diff --git a/.dockerignore b/.dockerignore index 34d9c074f7df..6afcdc010f73 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,4 @@ rasa/tests rasa/scripts data/ examples/ +docker-data/* diff --git a/.github/workflows/continous-integration.yml b/.github/workflows/continous-integration.yml index 3147c53c5688..8a8758a25e2c 100644 --- a/.github/workflows/continous-integration.yml +++ b/.github/workflows/continous-integration.yml @@ -352,9 +352,15 @@ jobs: (Get-ItemProperty "HKLM:System\CurrentControlSet\Control\FileSystem").LongPathsEnabled Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 0 - - name: Install ddtrace - if: needs.changes.outputs.backend == 'true' - run: poetry run pip install -U ddtrace + - name: Install ddtrace on Linux + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-22.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-2019' + run: | + .\.venv\Scripts\activate + py -m pip install -U 'ddtrace<2.0.0' - name: Test Code πŸ” (multi-process) if: needs.changes.outputs.backend == 'true' @@ -492,9 +498,15 @@ jobs: (Get-ItemProperty "HKLM:System\CurrentControlSet\Control\FileSystem").LongPathsEnabled Set-ItemProperty 'HKLM:\System\CurrentControlSet\Control\FileSystem' -Name 'LongPathsEnabled' -value 0 - - name: Install ddtrace - if: needs.changes.outputs.backend == 'true' - run: poetry run pip install -U ddtrace + - name: Install ddtrace on Linux + if: needs.changes.outputs.backend == 'true' && matrix.os == 'ubuntu-22.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-2019' + run: | + .\.venv\Scripts\activate + py -m pip install -U 'ddtrace<2.0.0' - name: Test Code πŸ” (multi-process) if: needs.changes.outputs.backend == 'true' @@ -1040,8 +1052,11 @@ jobs: run: | sudo swapoff -a sudo rm -f /swapfile + sudo rm -rf "$AGENT_TOOLSDIRECTORY" sudo apt clean - docker image prune -a + docker image prune -a -f + docker volume prune -f + docker container prune -f df -h - name: Read Poetry Version πŸ”’ @@ -1069,6 +1084,9 @@ jobs: 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: | diff --git a/LICENSE.txt b/LICENSE.txt deleted file mode 100644 index 0975a64b87b9..000000000000 --- a/LICENSE.txt +++ /dev/null @@ -1,201 +0,0 @@ - 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/README.md b/README.md index 7f7e569117e5..ac0d9d60ce5e 100644 --- a/README.md +++ b/README.md @@ -443,8 +443,7 @@ thus validating compatibility between Rasa and Rasa X/Enterprise. Please refer to the [Rasa Product Release and Maintenance Policy](https://rasa.com/rasa-product-release-and-maintenance-policy/) page. ## License -Licensed under the Apache License, Version 2.0. -Copyright 2022 Rasa Technologies GmbH. [Copy of the license](LICENSE.txt). +Copyright 2022 Rasa Technologies GmbH. A list of the Licenses of the dependencies of the project can be found at the bottom of the diff --git a/changelog/12901.improvement.md b/changelog/12901.improvement.md new file mode 100644 index 000000000000..663ab7ff7af4 --- /dev/null +++ b/changelog/12901.improvement.md @@ -0,0 +1 @@ +Added Schema file and schema validation for flows. \ No newline at end of file diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml index 67ecfdf27381..33edad5d1609 100644 --- a/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_predict_schema.yml @@ -1,4 +1,15 @@ nodes: + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider nlu_message_converter: needs: messages: __message__ diff --git a/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml b/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml index 5414f1831d64..52a3c3c03f9b 100644 --- a/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_train_schema.yml @@ -36,6 +36,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null provide_MitieNLP0: needs: {} uses: rasa.nlu.utils.mitie_utils.MitieNLP 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 index 07c51997f61c..388d65b56d73 100644 --- a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_predict_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: false resource: null + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider provide_MitieNLP0: needs: {} uses: rasa.nlu.utils.mitie_utils.MitieNLP 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 index 4d2db7495102..fc32637dcfd4 100644 --- a/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml +++ b/data/graph_schemas/config_pretrained_embeddings_mitie_zh_train_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null finetuning_validator: needs: importer: schema_validator 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 index 3d142a7bfb27..4413c598e2f3 100644 --- a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml +++ b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_predict_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: false resource: null + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider provide_SpacyNLP0: needs: {} uses: rasa.nlu.utils.spacy_utils.SpacyNLP 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 index e93831419e30..17aa1b17ef31 100644 --- a/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml +++ b/data/graph_schemas/config_pretrained_embeddings_spacy_duckling_train_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null finetuning_validator: needs: importer: schema_validator diff --git a/data/graph_schemas/default_config_core_predict_schema.yml b/data/graph_schemas/default_config_core_predict_schema.yml index a3c555008ceb..4aa0e1903b43 100644 --- a/data/graph_schemas/default_config_core_predict_schema.yml +++ b/data/graph_schemas/default_config_core_predict_schema.yml @@ -10,6 +10,30 @@ nodes: is_input: false resource: name: domain_provider + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider + command_processor: + needs: + tracker: __tracker__ + flows: flows_provider + uses: rasa.dialogue_understanding.processor.command_processor_component.CommandProcessorComponent + constructor_name: load + fn: execute_commands + config: { } + eager: true + is_target: false + is_input: false + resource: + name: command_processor run_MemoizationPolicy0: needs: domain: domain_provider diff --git a/data/graph_schemas/default_config_core_train_schema.yml b/data/graph_schemas/default_config_core_train_schema.yml index da7ca36746a6..31dda64461a9 100644 --- a/data/graph_schemas/default_config_core_train_schema.yml +++ b/data/graph_schemas/default_config_core_train_schema.yml @@ -45,6 +45,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null forms_provider: needs: domain: domain_provider diff --git a/data/graph_schemas/default_config_e2e_predict_schema.yml b/data/graph_schemas/default_config_e2e_predict_schema.yml index fe1cfee4f3c7..7861c3719d3e 100644 --- a/data/graph_schemas/default_config_e2e_predict_schema.yml +++ b/data/graph_schemas/default_config_e2e_predict_schema.yml @@ -232,6 +232,30 @@ nodes: is_target: false is_input: false resource: null + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider + command_processor: + needs: + tracker: __tracker__ + flows: flows_provider + uses: rasa.dialogue_understanding.processor.command_processor_component.CommandProcessorComponent + constructor_name: load + fn: execute_commands + config: { } + eager: true + is_target: false + is_input: false + resource: + name: command_processor run_MemoizationPolicy0: needs: tracker: __tracker__ diff --git a/data/graph_schemas/default_config_e2e_train_schema.yml b/data/graph_schemas/default_config_e2e_train_schema.yml index bc8261ec9389..b5b3ae434c08 100644 --- a/data/graph_schemas/default_config_e2e_train_schema.yml +++ b/data/graph_schemas/default_config_e2e_train_schema.yml @@ -1,392 +1,403 @@ 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 + 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 + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: { } + eager: false + is_target: true + 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 index d783a19fcd6f..a94d9b242734 100644 --- a/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml +++ b/data/graph_schemas/default_config_finetune_epoch_fraction_schema.yml @@ -206,6 +206,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null forms_provider: needs: domain: domain_provider diff --git a/data/graph_schemas/default_config_finetune_schema.yml b/data/graph_schemas/default_config_finetune_schema.yml index 5e36e5d64d6b..ba8ca7458379 100644 --- a/data/graph_schemas/default_config_finetune_schema.yml +++ b/data/graph_schemas/default_config_finetune_schema.yml @@ -204,6 +204,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null forms_provider: needs: domain: domain_provider diff --git a/data/graph_schemas/default_config_nlu_predict_schema.yml b/data/graph_schemas/default_config_nlu_predict_schema.yml index d1948cf6edc3..c7310f7286ca 100644 --- a/data/graph_schemas/default_config_nlu_predict_schema.yml +++ b/data/graph_schemas/default_config_nlu_predict_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: false resource: null + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider run_WhitespaceTokenizer0: needs: messages: nlu_message_converter diff --git a/data/graph_schemas/default_config_nlu_train_schema.yml b/data/graph_schemas/default_config_nlu_train_schema.yml index 2e39f1fc67c9..efd653fa9444 100644 --- a/data/graph_schemas/default_config_nlu_train_schema.yml +++ b/data/graph_schemas/default_config_nlu_train_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null finetuning_validator: needs: importer: schema_validator diff --git a/data/graph_schemas/default_config_predict_schema.yml b/data/graph_schemas/default_config_predict_schema.yml index 12ab1e8312cd..de5790c4d31f 100644 --- a/data/graph_schemas/default_config_predict_schema.yml +++ b/data/graph_schemas/default_config_predict_schema.yml @@ -148,6 +148,30 @@ nodes: is_input: false resource: name: domain_provider + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider + command_processor: + needs: + tracker: __tracker__ + flows: flows_provider + uses: rasa.dialogue_understanding.processor.command_processor_component.CommandProcessorComponent + constructor_name: load + fn: execute_commands + config: { } + eager: true + is_target: false + is_input: false + resource: + name: command_processor run_MemoizationPolicy0: needs: tracker: __tracker__ diff --git a/data/graph_schemas/default_config_train_schema.yml b/data/graph_schemas/default_config_train_schema.yml index a3b42882f9c3..c96973032466 100644 --- a/data/graph_schemas/default_config_train_schema.yml +++ b/data/graph_schemas/default_config_train_schema.yml @@ -204,6 +204,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null forms_provider: needs: domain: domain_provider diff --git a/data/graph_schemas/keyword_classifier_config_predict_schema.yml b/data/graph_schemas/keyword_classifier_config_predict_schema.yml index efbe223e5f46..cd2d7b49e4e0 100644 --- a/data/graph_schemas/keyword_classifier_config_predict_schema.yml +++ b/data/graph_schemas/keyword_classifier_config_predict_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: false resource: null + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider run_KeywordIntentClassifier0: needs: messages: nlu_message_converter diff --git a/data/graph_schemas/keyword_classifier_config_train_schema.yml b/data/graph_schemas/keyword_classifier_config_train_schema.yml index 328a3169a8ae..b1db33789e58 100644 --- a/data/graph_schemas/keyword_classifier_config_train_schema.yml +++ b/data/graph_schemas/keyword_classifier_config_train_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null finetuning_validator: needs: importer: schema_validator diff --git a/data/graph_schemas/max_hist_config_predict_schema.yml b/data/graph_schemas/max_hist_config_predict_schema.yml index 484bfa02309b..aed0dc4bdbed 100644 --- a/data/graph_schemas/max_hist_config_predict_schema.yml +++ b/data/graph_schemas/max_hist_config_predict_schema.yml @@ -10,6 +10,30 @@ nodes: is_input: false resource: name: domain_provider + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider + command_processor: + needs: + tracker: __tracker__ + flows: flows_provider + uses: rasa.dialogue_understanding.processor.command_processor_component.CommandProcessorComponent + constructor_name: load + fn: execute_commands + config: { } + eager: true + is_target: false + is_input: false + resource: + name: command_processor run_MemoizationPolicy0: needs: domain: domain_provider diff --git a/data/graph_schemas/max_hist_config_train_schema.yml b/data/graph_schemas/max_hist_config_train_schema.yml index 416e4ccff7c2..4c1a249753c1 100644 --- a/data/graph_schemas/max_hist_config_train_schema.yml +++ b/data/graph_schemas/max_hist_config_train_schema.yml @@ -10,6 +10,17 @@ nodes: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null finetuning_validator: needs: importer: schema_validator diff --git a/data/test_config/graph_config.yml b/data/test_config/graph_config.yml index e24aff3e5a37..a816e91ec000 100644 --- a/data/test_config/graph_config.yml +++ b/data/test_config/graph_config.yml @@ -36,6 +36,17 @@ train_schema: is_target: false is_input: true resource: null + flows_provider: + needs: + importer: finetuning_validator + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: create + fn: provide_train + config: {} + eager: false + is_target: true + is_input: true + resource: null nlu_training_data_provider: needs: importer: finetuning_validator @@ -330,6 +341,30 @@ predict_schema: is_target: false is_input: false resource: null + flows_provider: + needs: {} + uses: rasa.graph_components.providers.flows_provider.FlowsProvider + constructor_name: load + fn: provide_inference + config: {} + eager: true + is_target: false + is_input: false + resource: + name: flows_provider + command_processor: + needs: + tracker: __tracker__ + flows: flows_provider + uses: rasa.dialogue_understanding.processor.command_processor_component.CommandProcessorComponent + constructor_name: load + fn: execute_commands + config: { } + eager: true + is_target: false + is_input: false + resource: + name: command_processor run_WhitespaceTokenizer0: needs: messages: nlu_message_converter 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 index 042996ef9f0e..fa32d66500a4 100644 --- a/data/test_evaluations/test_end_to_end_trips_circuit_breaker.yml +++ b/data/test_evaluations/test_end_to_end_trips_circuit_breaker.yml @@ -14,3 +14,13 @@ stories: - 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 + - action: utter_greet + - action: utter_greet diff --git a/data/test_prompt_templates/test_prompt.jinja2 b/data/test_prompt_templates/test_prompt.jinja2 new file mode 100644 index 000000000000..34aaf4e27336 --- /dev/null +++ b/data/test_prompt_templates/test_prompt.jinja2 @@ -0,0 +1 @@ +This is a test prompt. diff --git a/data/test_trackers/tracker_moodbot.json b/data/test_trackers/tracker_moodbot.json index 6e206bd97ea4..7fc6db7830d7 100644 --- a/data/test_trackers/tracker_moodbot.json +++ b/data/test_trackers/tracker_moodbot.json @@ -24,13 +24,22 @@ ] }, "sender_id": "mysender", - "latest_action": {"action_name": "action_listen"}, + "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}, + "slots": { + "dialogue_stack": null, + "flow_hashes": null, + "name": null, + "requested_slot": null, + "return_value": null, + "session_started_metadata": null + }, "latest_input_channel": null, "events": [ { @@ -71,7 +80,9 @@ }, "event": "user", "text": "/greet", - "input_channel": null, "message_id": null, "metadata": {} + "input_channel": null, + "message_id": null, + "metadata": {} }, { "timestamp": 1517821726.200373, @@ -120,7 +131,9 @@ }, "event": "user", "text": "/mood_great", - "input_channel": null, "message_id": null, "metadata": {} + "input_channel": null, + "message_id": null, + "metadata": {} }, { "timestamp": 1517821726.209908, diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 000000000000..cfd46602c2a5 --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1 @@ +docker-data/* \ No newline at end of file diff --git a/docs/docs/action-server/knowledge-base-actions.mdx b/docs/docs/action-server/knowledge-base-actions.mdx index 538c79cdfdfc..956c854fe993 100644 --- a/docs/docs/action-server/knowledge-base-actions.mdx +++ b/docs/docs/action-server/knowledge-base-actions.mdx @@ -563,7 +563,7 @@ You can customize your `InMemoryKnowledgeBase` by overwriting the following func 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 +See the [example bot](https://github.com/RasaHQ/rasa/blob/main/examples/nlu_based/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 diff --git a/docs/docs/domain.mdx b/docs/docs/domain.mdx index 471768d440fd..512ef26de260 100644 --- a/docs/docs/domain.mdx +++ b/docs/docs/domain.mdx @@ -6,9 +6,9 @@ abstract: The domain defines the universe in which your assistant operates. It s --- Here is a full example of a domain, taken from the -[concertbot](https://github.com/RasaHQ/rasa/tree/main/examples/concertbot) example: +[concertbot](https://github.com/RasaHQ/rasa/tree/main/examples/nlu_based/concertbot) example: -```yaml-rasa (docs/sources/examples/concertbot/domain.yml) +```yaml-rasa (docs/sources/examples/nlu_based/concertbot/domain.yml) ``` ## Multiple Domain Files diff --git a/docs/docs/reaching-out-to-user.mdx b/docs/docs/reaching-out-to-user.mdx index 66dfe1627f13..79e14e7e980b 100644 --- a/docs/docs/reaching-out-to-user.mdx +++ b/docs/docs/reaching-out-to-user.mdx @@ -62,7 +62,7 @@ Sometimes you want an external device to change the course of an ongoing convers 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), +The examples below are from the [reminderbot example bot](https://github.com/RasaHQ/rasa/blob/main/examples/nlu_based/reminderbot), which includes both reminders and external events. ### 1. Trigger an Intent @@ -90,7 +90,7 @@ In a real-life scenario, your external device would get the conversation ID from 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. +the reminderbot [README](https://github.com/RasaHQ/rasa/blob/main/examples/nlu_based/reminderbot) for more information. ### 3. Add NLU Training Data @@ -159,7 +159,7 @@ External Events and Reminders don't work in request-response channels like the ` 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) +See the [reminderbot README](https://github.com/RasaHQ/rasa/blob/main/examples/nlu_based/reminderbot/README.md) for instructions on how to test your reminders locally. ::: @@ -180,7 +180,7 @@ You should see the bot respond in your channel: ## 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). +The examples below are from the [reminderbot example bot](https://github.com/RasaHQ/rasa/blob/main/examples/nlu_based/reminderbot). You can clone it and follow the instructions in `README` to try out the full version. ### Scheduling Reminders @@ -432,7 +432,7 @@ intents: 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. +See the [reminderbot README](https://github.com/RasaHQ/rasa/blob/main/examples/nlu_based/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/package.json b/docs/package.json index 2f45cb80ab85..8c1d8c01bf4c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,7 +16,7 @@ "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", + "program-outputs": "node scripts/compile_program_outputs.js --unhandled-rejections=strict", "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", @@ -115,7 +115,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$", - "https://docs-test-001.openai.azure.com" + "https://docs-test-001.openai.azure.com", + "^https://github.com/RasaHQ/rasa/tree/main/examples/*", + "^https://github.com/RasaHQ/rasa/blob/main/examples/*" ] } ] diff --git a/examples/money_transfer/actions.py b/examples/money_transfer/actions.py new file mode 100644 index 000000000000..508083283686 --- /dev/null +++ b/examples/money_transfer/actions.py @@ -0,0 +1,19 @@ +from typing import Any, Text, Dict, List +from rasa_sdk import Action, Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.events import SlotSet + +class ActionCheckSufficientFunds(Action): + def name(self) -> Text: + return "action_check_sufficient_funds" + + def run(self, dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any]) -> List[Dict[Text, Any]]: + balance = 1000 # hard-coded for tutorial purposes + # a real api call would look something like + # result = requests.get("https://example.com/api/balance") + # balance = result.json()["balance"] + transfer_amount = tracker.get_slot("amount") + has_sufficient_funds = transfer_amount <= balance + return [SlotSet("has_sufficient_funds", has_sufficient_funds)] diff --git a/examples/money_transfer/config.yml b/examples/money_transfer/config.yml new file mode 100644 index 000000000000..3801f4a2c879 --- /dev/null +++ b/examples/money_transfer/config.yml @@ -0,0 +1,13 @@ +recipe: default.v1 +language: en +pipeline: + - name: LLMCommandGenerator + llm: + model_name: gpt-4 + +policies: + - name: rasa.core.policies.flow_policy.FlowPolicy +# - name: rasa_plus.ml.DocsearchPolicy +# - name: RulePolicy + +assistant_id: 20230405-114328-tranquil-mustard diff --git a/examples/money_transfer/data/flows.yml b/examples/money_transfer/data/flows.yml new file mode 100644 index 000000000000..76ecae6e6e5d --- /dev/null +++ b/examples/money_transfer/data/flows.yml @@ -0,0 +1,23 @@ +flows: + transfer_money: + description: This flow lets users send money to friends and family. + steps: + - collect: recipient + - collect: amount + - action: action_check_sufficient_funds + next: + - if: not has_sufficient_funds + then: + - action: utter_insufficient_funds + next: END + - else: final_confirmation + - id: final_confirmation + collect: final_confirmation + next: + - if: not final_confirmation + then: + - action: utter_transfer_cancelled + next: END + - else: transfer_successful + - id: transfer_successful + action: utter_transfer_complete diff --git a/examples/money_transfer/domain.yml b/examples/money_transfer/domain.yml new file mode 100644 index 000000000000..a7b79ad598f0 --- /dev/null +++ b/examples/money_transfer/domain.yml @@ -0,0 +1,35 @@ +version: "3.1" + +slots: + recipient: + type: text + amount: + type: float + final_confirmation: + type: bool + has_sufficient_funds: + type: bool + +responses: + utter_ask_recipient: + - text: "Who would you like to send money to?" + + utter_ask_amount: + - text: "How much money would you like to send?" + + utter_transfer_complete: + - text: "All done. ${amount} has been sent to {recipient}." + + utter_ask_final_confirmation: + - text: "Please confirm: you want to transfer ${amount} to {recipient}?" + + utter_transfer_cancelled: + - text: "Your transfer has been cancelled." + + utter_insufficient_funds: + - text: "You do not have enough funds to make this transaction." + + + +actions: + - action_check_sufficient_funds diff --git a/examples/money_transfer/e2e_tests/complete_request.yml b/examples/money_transfer/e2e_tests/complete_request.yml new file mode 100644 index 000000000000..99072cb957c9 --- /dev/null +++ b/examples/money_transfer/e2e_tests/complete_request.yml @@ -0,0 +1,7 @@ +test_cases: + - test_case: user corrects recipient in the next message + steps: + - user: I want to send 400 dollars to James + - utter: utter_ask_final_confirmation + - user: yes + - utter: utter_transfer_complete diff --git a/examples/reminderbot/endpoints.yml b/examples/money_transfer/endpoints.yml similarity index 100% rename from examples/reminderbot/endpoints.yml rename to examples/money_transfer/endpoints.yml diff --git a/examples/concertbot/actions/__init__.py b/examples/nlu_based/__init__.py similarity index 100% rename from examples/concertbot/actions/__init__.py rename to examples/nlu_based/__init__.py diff --git a/examples/concertbot/README.md b/examples/nlu_based/concertbot/README.md similarity index 100% rename from examples/concertbot/README.md rename to examples/nlu_based/concertbot/README.md diff --git a/examples/formbot/actions/__init__.py b/examples/nlu_based/concertbot/actions/__init__.py similarity index 100% rename from examples/formbot/actions/__init__.py rename to examples/nlu_based/concertbot/actions/__init__.py diff --git a/examples/concertbot/actions/actions.py b/examples/nlu_based/concertbot/actions/actions.py similarity index 100% rename from examples/concertbot/actions/actions.py rename to examples/nlu_based/concertbot/actions/actions.py diff --git a/examples/concertbot/config.yml b/examples/nlu_based/concertbot/config.yml similarity index 100% rename from examples/concertbot/config.yml rename to examples/nlu_based/concertbot/config.yml diff --git a/examples/concertbot/data/nlu.yml b/examples/nlu_based/concertbot/data/nlu.yml similarity index 100% rename from examples/concertbot/data/nlu.yml rename to examples/nlu_based/concertbot/data/nlu.yml diff --git a/examples/concertbot/data/rules.yml b/examples/nlu_based/concertbot/data/rules.yml similarity index 100% rename from examples/concertbot/data/rules.yml rename to examples/nlu_based/concertbot/data/rules.yml diff --git a/examples/concertbot/data/stories.yml b/examples/nlu_based/concertbot/data/stories.yml similarity index 100% rename from examples/concertbot/data/stories.yml rename to examples/nlu_based/concertbot/data/stories.yml diff --git a/examples/concertbot/domain.yml b/examples/nlu_based/concertbot/domain.yml similarity index 100% rename from examples/concertbot/domain.yml rename to examples/nlu_based/concertbot/domain.yml diff --git a/examples/concertbot/endpoints.yml b/examples/nlu_based/concertbot/endpoints.yml similarity index 100% rename from examples/concertbot/endpoints.yml rename to examples/nlu_based/concertbot/endpoints.yml diff --git a/examples/e2ebot/config.yml b/examples/nlu_based/e2ebot/config.yml similarity index 100% rename from examples/e2ebot/config.yml rename to examples/nlu_based/e2ebot/config.yml diff --git a/examples/e2ebot/data/nlu.yml b/examples/nlu_based/e2ebot/data/nlu.yml similarity index 100% rename from examples/e2ebot/data/nlu.yml rename to examples/nlu_based/e2ebot/data/nlu.yml diff --git a/examples/e2ebot/data/stories.yml b/examples/nlu_based/e2ebot/data/stories.yml similarity index 100% rename from examples/e2ebot/data/stories.yml rename to examples/nlu_based/e2ebot/data/stories.yml diff --git a/examples/e2ebot/domain.yml b/examples/nlu_based/e2ebot/domain.yml similarity index 100% rename from examples/e2ebot/domain.yml rename to examples/nlu_based/e2ebot/domain.yml diff --git a/examples/e2ebot/tests/test_stories.yml b/examples/nlu_based/e2ebot/tests/test_stories.yml similarity index 100% rename from examples/e2ebot/tests/test_stories.yml rename to examples/nlu_based/e2ebot/tests/test_stories.yml diff --git a/examples/formbot/README.md b/examples/nlu_based/formbot/README.md similarity index 100% rename from examples/formbot/README.md rename to examples/nlu_based/formbot/README.md diff --git a/examples/knowledgebasebot/actions/__init__.py b/examples/nlu_based/formbot/actions/__init__.py similarity index 100% rename from examples/knowledgebasebot/actions/__init__.py rename to examples/nlu_based/formbot/actions/__init__.py diff --git a/examples/formbot/actions/actions.py b/examples/nlu_based/formbot/actions/actions.py similarity index 100% rename from examples/formbot/actions/actions.py rename to examples/nlu_based/formbot/actions/actions.py diff --git a/examples/formbot/config.yml b/examples/nlu_based/formbot/config.yml similarity index 100% rename from examples/formbot/config.yml rename to examples/nlu_based/formbot/config.yml diff --git a/examples/formbot/data/nlu.yml b/examples/nlu_based/formbot/data/nlu.yml similarity index 100% rename from examples/formbot/data/nlu.yml rename to examples/nlu_based/formbot/data/nlu.yml diff --git a/examples/formbot/data/rules.yml b/examples/nlu_based/formbot/data/rules.yml similarity index 100% rename from examples/formbot/data/rules.yml rename to examples/nlu_based/formbot/data/rules.yml diff --git a/examples/formbot/data/stories.yml b/examples/nlu_based/formbot/data/stories.yml similarity index 100% rename from examples/formbot/data/stories.yml rename to examples/nlu_based/formbot/data/stories.yml diff --git a/examples/formbot/domain.yml b/examples/nlu_based/formbot/domain.yml similarity index 100% rename from examples/formbot/domain.yml rename to examples/nlu_based/formbot/domain.yml diff --git a/examples/formbot/endpoints.yml b/examples/nlu_based/formbot/endpoints.yml similarity index 100% rename from examples/formbot/endpoints.yml rename to examples/nlu_based/formbot/endpoints.yml diff --git a/examples/formbot/tests/test_stories.yml b/examples/nlu_based/formbot/tests/test_stories.yml similarity index 100% rename from examples/formbot/tests/test_stories.yml rename to examples/nlu_based/formbot/tests/test_stories.yml diff --git a/examples/knowledgebasebot/README.md b/examples/nlu_based/knowledgebasebot/README.md similarity index 100% rename from examples/knowledgebasebot/README.md rename to examples/nlu_based/knowledgebasebot/README.md diff --git a/examples/reminderbot/actions/__init__.py b/examples/nlu_based/knowledgebasebot/actions/__init__.py similarity index 100% rename from examples/reminderbot/actions/__init__.py rename to examples/nlu_based/knowledgebasebot/actions/__init__.py diff --git a/examples/knowledgebasebot/actions/actions.py b/examples/nlu_based/knowledgebasebot/actions/actions.py similarity index 100% rename from examples/knowledgebasebot/actions/actions.py rename to examples/nlu_based/knowledgebasebot/actions/actions.py diff --git a/examples/knowledgebasebot/config.yml b/examples/nlu_based/knowledgebasebot/config.yml similarity index 100% rename from examples/knowledgebasebot/config.yml rename to examples/nlu_based/knowledgebasebot/config.yml diff --git a/examples/knowledgebasebot/data/nlu.yml b/examples/nlu_based/knowledgebasebot/data/nlu.yml similarity index 100% rename from examples/knowledgebasebot/data/nlu.yml rename to examples/nlu_based/knowledgebasebot/data/nlu.yml diff --git a/examples/knowledgebasebot/data/rules.yml b/examples/nlu_based/knowledgebasebot/data/rules.yml similarity index 100% rename from examples/knowledgebasebot/data/rules.yml rename to examples/nlu_based/knowledgebasebot/data/rules.yml diff --git a/examples/knowledgebasebot/data/stories.yml b/examples/nlu_based/knowledgebasebot/data/stories.yml similarity index 100% rename from examples/knowledgebasebot/data/stories.yml rename to examples/nlu_based/knowledgebasebot/data/stories.yml diff --git a/examples/knowledgebasebot/domain.yml b/examples/nlu_based/knowledgebasebot/domain.yml similarity index 100% rename from examples/knowledgebasebot/domain.yml rename to examples/nlu_based/knowledgebasebot/domain.yml diff --git a/examples/knowledgebasebot/endpoints.yml b/examples/nlu_based/knowledgebasebot/endpoints.yml similarity index 100% rename from examples/knowledgebasebot/endpoints.yml rename to examples/nlu_based/knowledgebasebot/endpoints.yml diff --git a/examples/knowledgebasebot/knowledge_base_data.json b/examples/nlu_based/knowledgebasebot/knowledge_base_data.json similarity index 100% rename from examples/knowledgebasebot/knowledge_base_data.json rename to examples/nlu_based/knowledgebasebot/knowledge_base_data.json diff --git a/examples/moodbot/README.md b/examples/nlu_based/moodbot/README.md similarity index 100% rename from examples/moodbot/README.md rename to examples/nlu_based/moodbot/README.md diff --git a/examples/moodbot/config.yml b/examples/nlu_based/moodbot/config.yml similarity index 100% rename from examples/moodbot/config.yml rename to examples/nlu_based/moodbot/config.yml diff --git a/examples/moodbot/credentials.yml b/examples/nlu_based/moodbot/credentials.yml similarity index 100% rename from examples/moodbot/credentials.yml rename to examples/nlu_based/moodbot/credentials.yml diff --git a/examples/moodbot/data/nlu.yml b/examples/nlu_based/moodbot/data/nlu.yml similarity index 100% rename from examples/moodbot/data/nlu.yml rename to examples/nlu_based/moodbot/data/nlu.yml diff --git a/examples/moodbot/data/rules.yml b/examples/nlu_based/moodbot/data/rules.yml similarity index 100% rename from examples/moodbot/data/rules.yml rename to examples/nlu_based/moodbot/data/rules.yml diff --git a/examples/moodbot/data/stories.yml b/examples/nlu_based/moodbot/data/stories.yml similarity index 100% rename from examples/moodbot/data/stories.yml rename to examples/nlu_based/moodbot/data/stories.yml diff --git a/examples/moodbot/domain.yml b/examples/nlu_based/moodbot/domain.yml similarity index 100% rename from examples/moodbot/domain.yml rename to examples/nlu_based/moodbot/domain.yml diff --git a/examples/reminderbot/README.md b/examples/nlu_based/reminderbot/README.md similarity index 100% rename from examples/reminderbot/README.md rename to examples/nlu_based/reminderbot/README.md diff --git a/examples/rules/actions/__init__.py b/examples/nlu_based/reminderbot/actions/__init__.py similarity index 100% rename from examples/rules/actions/__init__.py rename to examples/nlu_based/reminderbot/actions/__init__.py diff --git a/examples/reminderbot/actions/actions.py b/examples/nlu_based/reminderbot/actions/actions.py similarity index 100% rename from examples/reminderbot/actions/actions.py rename to examples/nlu_based/reminderbot/actions/actions.py diff --git a/examples/reminderbot/callback_server.py b/examples/nlu_based/reminderbot/callback_server.py similarity index 100% rename from examples/reminderbot/callback_server.py rename to examples/nlu_based/reminderbot/callback_server.py diff --git a/examples/reminderbot/config.yml b/examples/nlu_based/reminderbot/config.yml similarity index 100% rename from examples/reminderbot/config.yml rename to examples/nlu_based/reminderbot/config.yml diff --git a/examples/reminderbot/credentials.yml b/examples/nlu_based/reminderbot/credentials.yml similarity index 100% rename from examples/reminderbot/credentials.yml rename to examples/nlu_based/reminderbot/credentials.yml diff --git a/examples/reminderbot/data/nlu.yml b/examples/nlu_based/reminderbot/data/nlu.yml similarity index 100% rename from examples/reminderbot/data/nlu.yml rename to examples/nlu_based/reminderbot/data/nlu.yml diff --git a/examples/reminderbot/data/rules.yml b/examples/nlu_based/reminderbot/data/rules.yml similarity index 100% rename from examples/reminderbot/data/rules.yml rename to examples/nlu_based/reminderbot/data/rules.yml diff --git a/examples/reminderbot/data/stories.yml b/examples/nlu_based/reminderbot/data/stories.yml similarity index 100% rename from examples/reminderbot/data/stories.yml rename to examples/nlu_based/reminderbot/data/stories.yml diff --git a/examples/reminderbot/domain.yml b/examples/nlu_based/reminderbot/domain.yml similarity index 100% rename from examples/reminderbot/domain.yml rename to examples/nlu_based/reminderbot/domain.yml diff --git a/examples/nlu_based/reminderbot/endpoints.yml b/examples/nlu_based/reminderbot/endpoints.yml new file mode 100644 index 000000000000..5f65275b8802 --- /dev/null +++ b/examples/nlu_based/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: +# port: +# db: +# password: +# use_ssl: + +#tracker_store: +# type: mongod +# url: +# db: +# username: +# password: + +# 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/nlu_based/responseselectorbot/README.md similarity index 100% rename from examples/responseselectorbot/README.md rename to examples/nlu_based/responseselectorbot/README.md diff --git a/examples/responseselectorbot/config.yml b/examples/nlu_based/responseselectorbot/config.yml similarity index 100% rename from examples/responseselectorbot/config.yml rename to examples/nlu_based/responseselectorbot/config.yml diff --git a/examples/responseselectorbot/data/nlu.yml b/examples/nlu_based/responseselectorbot/data/nlu.yml similarity index 100% rename from examples/responseselectorbot/data/nlu.yml rename to examples/nlu_based/responseselectorbot/data/nlu.yml diff --git a/examples/responseselectorbot/data/rules.yml b/examples/nlu_based/responseselectorbot/data/rules.yml similarity index 100% rename from examples/responseselectorbot/data/rules.yml rename to examples/nlu_based/responseselectorbot/data/rules.yml diff --git a/examples/responseselectorbot/data/stories.yml b/examples/nlu_based/responseselectorbot/data/stories.yml similarity index 100% rename from examples/responseselectorbot/data/stories.yml rename to examples/nlu_based/responseselectorbot/data/stories.yml diff --git a/examples/responseselectorbot/domain.yml b/examples/nlu_based/responseselectorbot/domain.yml similarity index 100% rename from examples/responseselectorbot/domain.yml rename to examples/nlu_based/responseselectorbot/domain.yml diff --git a/rasa/cli/initial_project/actions/__init__.py b/examples/nlu_based/rules/actions/__init__.py similarity index 100% rename from rasa/cli/initial_project/actions/__init__.py rename to examples/nlu_based/rules/actions/__init__.py diff --git a/examples/rules/actions/actions.py b/examples/nlu_based/rules/actions/actions.py similarity index 100% rename from examples/rules/actions/actions.py rename to examples/nlu_based/rules/actions/actions.py diff --git a/examples/rules/config.yml b/examples/nlu_based/rules/config.yml similarity index 100% rename from examples/rules/config.yml rename to examples/nlu_based/rules/config.yml diff --git a/examples/rules/data/nlu.yml b/examples/nlu_based/rules/data/nlu.yml similarity index 100% rename from examples/rules/data/nlu.yml rename to examples/nlu_based/rules/data/nlu.yml diff --git a/examples/rules/data/rules.yml b/examples/nlu_based/rules/data/rules.yml similarity index 100% rename from examples/rules/data/rules.yml rename to examples/nlu_based/rules/data/rules.yml diff --git a/examples/rules/domain.yml b/examples/nlu_based/rules/domain.yml similarity index 100% rename from examples/rules/domain.yml rename to examples/nlu_based/rules/domain.yml diff --git a/examples/rules/endpoints.yml b/examples/nlu_based/rules/endpoints.yml similarity index 100% rename from examples/rules/endpoints.yml rename to examples/nlu_based/rules/endpoints.yml diff --git a/poetry.lock b/poetry.lock index 30c456cb7480..e1007fb635cf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -430,7 +430,7 @@ pytz = ">=2015.7" name = "backoff" version = "1.10.0" description = "Function decoration for backoff and retry" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1016,6 +1016,7 @@ files = [ {file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18a64814ae7bce73925131381603fff0116e2df25230dfc80d6d690aa6e20b37"}, {file = "contourpy-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c81f22b4f572f8a2110b0b741bb64e5a6427e0a198b2cdc1fbaf85f352a3aa"}, {file = "contourpy-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:53cc3a40635abedbec7f1bde60f8c189c49e84ac180c665f2cd7c162cc454baa"}, + {file = "contourpy-1.1.0-cp310-cp310-win32.whl", hash = "sha256:9b2dd2ca3ac561aceef4c7c13ba654aaa404cf885b187427760d7f7d4c57cff8"}, {file = "contourpy-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:1f795597073b09d631782e7245016a4323cf1cf0b4e06eef7ea6627e06a37ff2"}, {file = "contourpy-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0b7b04ed0961647691cfe5d82115dd072af7ce8846d31a5fac6c142dcce8b882"}, {file = "contourpy-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27bc79200c742f9746d7dd51a734ee326a292d77e7d94c8af6e08d1e6c15d545"}, @@ -1024,6 +1025,7 @@ files = [ {file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5cec36c5090e75a9ac9dbd0ff4a8cf7cecd60f1b6dc23a374c7d980a1cd710e"}, {file = "contourpy-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f0cbd657e9bde94cd0e33aa7df94fb73c1ab7799378d3b3f902eb8eb2e04a3a"}, {file = "contourpy-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:181cbace49874f4358e2929aaf7ba84006acb76694102e88dd15af861996c16e"}, + {file = "contourpy-1.1.0-cp311-cp311-win32.whl", hash = "sha256:edb989d31065b1acef3828a3688f88b2abb799a7db891c9e282df5ec7e46221b"}, {file = "contourpy-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb3b7d9e6243bfa1efb93ccfe64ec610d85cfe5aec2c25f97fbbd2e58b531256"}, {file = "contourpy-1.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bcb41692aa09aeb19c7c213411854402f29f6613845ad2453d30bf421fe68fed"}, {file = "contourpy-1.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5d123a5bc63cd34c27ff9c7ac1cd978909e9c71da12e05be0231c608048bb2ae"}, @@ -1032,6 +1034,7 @@ files = [ {file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:317267d915490d1e84577924bd61ba71bf8681a30e0d6c545f577363157e5e94"}, {file = "contourpy-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d551f3a442655f3dcc1285723f9acd646ca5858834efeab4598d706206b09c9f"}, {file = "contourpy-1.1.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e7a117ce7df5a938fe035cad481b0189049e8d92433b4b33aa7fc609344aafa1"}, + {file = "contourpy-1.1.0-cp38-cp38-win32.whl", hash = "sha256:108dfb5b3e731046a96c60bdc46a1a0ebee0760418951abecbe0fc07b5b93b27"}, {file = "contourpy-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:d4f26b25b4f86087e7d75e63212756c38546e70f2a92d2be44f80114826e1cd4"}, {file = "contourpy-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc00bb4225d57bff7ebb634646c0ee2a1298402ec10a5fe7af79df9a51c1bfd9"}, {file = "contourpy-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:189ceb1525eb0655ab8487a9a9c41f42a73ba52d6789754788d1883fb06b2d8a"}, @@ -1040,6 +1043,7 @@ files = [ {file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:143dde50520a9f90e4a2703f367cf8ec96a73042b72e68fcd184e1279962eb6f"}, {file = "contourpy-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e94bef2580e25b5fdb183bf98a2faa2adc5b638736b2c0a4da98691da641316a"}, {file = "contourpy-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ed614aea8462735e7d70141374bd7650afd1c3f3cb0c2dbbcbe44e14331bf002"}, + {file = "contourpy-1.1.0-cp39-cp39-win32.whl", hash = "sha256:71551f9520f008b2950bef5f16b0e3587506ef4f23c734b71ffb7b89f8721999"}, {file = "contourpy-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:438ba416d02f82b692e371858143970ed2eb6337d9cdbbede0d8ad9f3d7dd17d"}, {file = "contourpy-1.1.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a698c6a7a432789e587168573a864a7ea374c6be8d4f31f9d87c001d5a843493"}, {file = "contourpy-1.1.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b0ac8a12880412da3551a8cb5a187d3298a72802b45a3bd1805e204ad8439"}, @@ -1321,6 +1325,22 @@ files = [ "nr.util" = ">=0.8.3,<1.0.0" typing-extensions = ">=3.10.0" +[[package]] +name = "dataclasses-json" +version = "0.5.14" +description = "Easily serialize dataclasses to and from JSON." +category = "main" +optional = false +python-versions = ">=3.7,<3.13" +files = [ + {file = "dataclasses_json-0.5.14-py3-none-any.whl", hash = "sha256:5ec6fed642adb1dbdb4182badb01e0861badfd8fda82e3b67f44b2d1e9d10d21"}, + {file = "dataclasses_json-0.5.14.tar.gz", hash = "sha256:d82896a94c992ffaf689cd1fafc180164e2abdd415b8f94a7f78586af5886236"}, +] + +[package.dependencies] +marshmallow = ">=3.18.0,<4.0.0" +typing-inspect = ">=0.4.0,<1" + [[package]] name = "datadog" version = "0.45.0" @@ -1364,7 +1384,7 @@ zstandard = ["zstandard"] name = "deprecated" version = "1.2.14" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -2079,7 +2099,7 @@ requests = ["requests (>=2.18.0,<3.0.0dev)"] name = "googleapis-common-protos" version = "1.56.1" description = "Common protobufs used in Google APIs" -category = "main" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -2106,6 +2126,7 @@ files = [ {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"}, @@ -2114,6 +2135,7 @@ files = [ {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"}, @@ -2143,6 +2165,7 @@ files = [ {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"}, @@ -2151,6 +2174,7 @@ files = [ {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"}, @@ -2725,6 +2749,63 @@ files = [ {file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"}, ] +[[package]] +name = "langchain" +version = "0.0.223" +description = "Building applications with LLMs through composability" +category = "main" +optional = false +python-versions = ">=3.8.1,<4.0" +files = [ + {file = "langchain-0.0.223-py3-none-any.whl", hash = "sha256:d2af56be2591f1d8208011f63f1c4d8c208d79916d85304190bba0cc6f16e710"}, + {file = "langchain-0.0.223.tar.gz", hash = "sha256:4c6ffa022ad14ff7d8067207684b33eb79b4876509eafaeb1e99e95518d28520"}, +] + +[package.dependencies] +aiohttp = ">=3.8.3,<4.0.0" +async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""} +dataclasses-json = ">=0.5.7,<0.6.0" +langchainplus-sdk = ">=0.0.20,<0.0.21" +numexpr = ">=2.8.4,<3.0.0" +numpy = ">=1,<2" +openapi-schema-pydantic = ">=1.2,<2.0" +pydantic = ">=1,<2" +PyYAML = ">=5.4.1" +requests = ">=2,<3" +SQLAlchemy = ">=1.4,<3" +tenacity = ">=8.1.0,<9.0.0" + +[package.extras] +all = ["O365 (>=2.0.26,<3.0.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.2.6,<0.3.0)", "arxiv (>=1.4,<2.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "awadb (>=0.3.3,<0.4.0)", "azure-ai-formrecognizer (>=3.2.1,<4.0.0)", "azure-ai-vision (>=0.11.1b1,<0.12.0)", "azure-cognitiveservices-speech (>=1.28.0,<2.0.0)", "azure-cosmos (>=4.4.0b1,<5.0.0)", "azure-identity (>=1.12.0,<2.0.0)", "beautifulsoup4 (>=4,<5)", "clarifai (==9.1.0)", "clickhouse-connect (>=0.5.14,<0.6.0)", "cohere (>=3,<4)", "deeplake (>=3.6.2,<4.0.0)", "docarray[hnswlib] (>=0.32.0,<0.33.0)", "duckduckgo-search (>=3.8.3,<4.0.0)", "elasticsearch (>=8,<9)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "google-api-python-client (==2.70.0)", "google-auth (>=2.18.1,<3.0.0)", "google-search-results (>=2,<3)", "gptcache (>=0.1.7)", "html2text (>=2020.1.16,<2021.0.0)", "huggingface_hub (>=0,<1)", "jina (>=3.14,<4.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "lancedb (>=0.1,<0.2)", "langkit (>=0.0.1.dev3,<0.1.0)", "lark (>=1.1.5,<2.0.0)", "lxml (>=4.9.2,<5.0.0)", "manifest-ml (>=0.0.1,<0.0.2)", "momento (>=1.5.0,<2.0.0)", "nebula3-python (>=3.4.0,<4.0.0)", "neo4j (>=5.8.1,<6.0.0)", "networkx (>=2.6.3,<3.0.0)", "nlpcloud (>=1,<2)", "nltk (>=3,<4)", "nomic (>=1.0.43,<2.0.0)", "octoai-sdk (>=0.1.1,<0.2.0)", "openai (>=0,<1)", "openlm (>=0.0.5,<0.0.6)", "opensearch-py (>=2.0.0,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pexpect (>=4.8.0,<5.0.0)", "pgvector (>=0.1.6,<0.2.0)", "pinecone-client (>=2,<3)", "pinecone-text (>=0.4.2,<0.5.0)", "psycopg2-binary (>=2.9.5,<3.0.0)", "pymongo (>=4.3.3,<5.0.0)", "pyowm (>=3.3.0,<4.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pytesseract (>=0.3.10,<0.4.0)", "pyvespa (>=0.33.0,<0.34.0)", "qdrant-client (>=1.1.2,<2.0.0)", "redis (>=4,<5)", "requests-toolbelt (>=1.0.0,<2.0.0)", "sentence-transformers (>=2,<3)", "singlestoredb (>=0.7.1,<0.8.0)", "spacy (>=3,<4)", "steamship (>=2.16.9,<3.0.0)", "tensorflow-text (>=2.11.0,<3.0.0)", "tigrisdb (>=1.0.0b6,<2.0.0)", "tiktoken (>=0.3.2,<0.4.0)", "torch (>=1,<3)", "transformers (>=4,<5)", "weaviate-client (>=3,<4)", "wikipedia (>=1,<2)", "wolframalpha (==5.0.0)"] +azure = ["azure-ai-formrecognizer (>=3.2.1,<4.0.0)", "azure-ai-vision (>=0.11.1b1,<0.12.0)", "azure-cognitiveservices-speech (>=1.28.0,<2.0.0)", "azure-core (>=1.26.4,<2.0.0)", "azure-cosmos (>=4.4.0b1,<5.0.0)", "azure-identity (>=1.12.0,<2.0.0)", "azure-search-documents (==11.4.0a20230509004)", "openai (>=0,<1)"] +clarifai = ["clarifai (==9.1.0)"] +cohere = ["cohere (>=3,<4)"] +docarray = ["docarray[hnswlib] (>=0.32.0,<0.33.0)"] +embeddings = ["sentence-transformers (>=2,<3)"] +extended-testing = ["atlassian-python-api (>=3.36.0,<4.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.0.7,<0.0.8)", "chardet (>=5.1.0,<6.0.0)", "esprima (>=4.0.1,<5.0.0)", "gql (>=3.4.1,<4.0.0)", "html2text (>=2020.1.16,<2021.0.0)", "jq (>=1.4.1,<2.0.0)", "lxml (>=4.9.2,<5.0.0)", "openai (>=0,<1)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "streamlit (>=1.18.0,<2.0.0)", "telethon (>=1.28.5,<2.0.0)", "tqdm (>=4.48.0)", "zep-python (>=0.32)"] +javascript = ["esprima (>=4.0.1,<5.0.0)"] +llms = ["anthropic (>=0.2.6,<0.3.0)", "clarifai (==9.1.0)", "cohere (>=3,<4)", "huggingface_hub (>=0,<1)", "manifest-ml (>=0.0.1,<0.0.2)", "nlpcloud (>=1,<2)", "openai (>=0,<1)", "openllm (>=0.1.19)", "openlm (>=0.0.5,<0.0.6)", "torch (>=1,<3)", "transformers (>=4,<5)"] +openai = ["openai (>=0,<1)", "tiktoken (>=0.3.2,<0.4.0)"] +qdrant = ["qdrant-client (>=1.1.2,<2.0.0)"] +text-helpers = ["chardet (>=5.1.0,<6.0.0)"] + +[[package]] +name = "langchainplus-sdk" +version = "0.0.20" +description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform." +category = "main" +optional = false +python-versions = ">=3.8.1,<4.0" +files = [ + {file = "langchainplus_sdk-0.0.20-py3-none-any.whl", hash = "sha256:07a869d476755803aa04c4986ce78d00c2fe4ff584c0eaa57d7570c9664188db"}, + {file = "langchainplus_sdk-0.0.20.tar.gz", hash = "sha256:3d300e2e3290f68cc9d842c059f9458deba60e776c9e790309688cad1bfbb219"}, +] + +[package.dependencies] +pydantic = ">=1,<2" +requests = ">=2,<3" +tenacity = ">=8.1.0,<9.0.0" + [[package]] name = "langcodes" version = "3.3.0" @@ -2853,6 +2934,16 @@ files = [ {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"}, @@ -2885,6 +2976,27 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "marshmallow" +version = "3.20.1" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "marshmallow-3.20.1-py3-none-any.whl", hash = "sha256:684939db93e80ad3561392f47be0230743131560a41c5110684c16e21ade0a5c"}, + {file = "marshmallow-3.20.1.tar.gz", hash = "sha256:5d2371bbe42000f2b3fb5eaa065224df7d8f8597bc19a1bbfa5bfe7fba8da889"}, +] + +[package.dependencies] +packaging = ">=17.0" + +[package.extras] +dev = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)", "pytest", "pytz", "simplejson", "tox"] +docs = ["alabaster (==0.7.13)", "autodocsumm (==0.2.11)", "sphinx (==7.0.1)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"] +lint = ["flake8 (==6.0.0)", "flake8-bugbear (==23.7.10)", "mypy (==1.4.1)", "pre-commit (>=2.4,<4.0)"] +tests = ["pytest", "pytz", "simplejson"] + [[package]] name = "matplotlib" version = "3.7.2" @@ -3344,7 +3456,7 @@ reports = ["lxml"] name = "mypy-extensions" version = "0.4.4" description = "Experimental type system extensions for programs checked with the mypy typechecker." -category = "dev" +category = "main" optional = false python-versions = ">=2.7" files = [ @@ -3386,6 +3498,49 @@ files = [ deprecated = ">=1.2.0,<2.0.0" typing-extensions = ">=3.0.0" +[[package]] +name = "numexpr" +version = "2.8.6" +description = "Fast numerical expression evaluator for NumPy" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "numexpr-2.8.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80acbfefb68bd92e708e09f0a02b29e04d388b9ae72f9fcd57988aca172a7833"}, + {file = "numexpr-2.8.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6e884687da8af5955dc9beb6a12d469675c90b8fb38b6c93668c989cfc2cd982"}, + {file = "numexpr-2.8.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ef7e8aaa84fce3aba2e65f243d14a9f8cc92aafd5d90d67283815febfe43eeb"}, + {file = "numexpr-2.8.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dee04d72307c09599f786b9231acffb10df7d7a74b2ce3681d74a574880d13ce"}, + {file = "numexpr-2.8.6-cp310-cp310-win32.whl", hash = "sha256:211804ec25a9f6d188eadf4198dd1a92b2f61d7d20993c6c7706139bc4199c5b"}, + {file = "numexpr-2.8.6-cp310-cp310-win_amd64.whl", hash = "sha256:18b1804923cfa3be7bbb45187d01c0540c8f6df4928c22a0f786e15568e9ebc5"}, + {file = "numexpr-2.8.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:95b9da613761e4fc79748535b2a1f58cada22500e22713ae7d9571fa88d1c2e2"}, + {file = "numexpr-2.8.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47b45da5aa25600081a649f5e8b2aa640e35db3703f4631f34bb1f2f86d1b5b4"}, + {file = "numexpr-2.8.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84979bf14143351c2db8d9dd7fef8aca027c66ad9df9cb5e75c93bf5f7b5a338"}, + {file = "numexpr-2.8.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d36528a33aa9c23743b3ea686e57526a4f71e7128a1be66210e1511b09c4e4e9"}, + {file = "numexpr-2.8.6-cp311-cp311-win32.whl", hash = "sha256:681812e2e71ff1ba9145fac42d03f51ddf6ba911259aa83041323f68e7458002"}, + {file = "numexpr-2.8.6-cp311-cp311-win_amd64.whl", hash = "sha256:27782177a0081bd0aab229be5d37674e7f0ab4264ef576697323dd047432a4cd"}, + {file = "numexpr-2.8.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ef6e8896457a60a539cb6ba27da78315a9bb31edb246829b25b5b0304bfcee91"}, + {file = "numexpr-2.8.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e640bc0eaf1b59f3dde52bc02bbfda98e62f9950202b0584deba28baf9f36bbb"}, + {file = "numexpr-2.8.6-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d126938c2c3784673c9c58d94e00b1570aa65517d9c33662234d442fc9fb5795"}, + {file = "numexpr-2.8.6-cp37-cp37m-win32.whl", hash = "sha256:e93d64cd20940b726477c3cb64926e683d31b778a1e18f9079a5088fd0d8e7c8"}, + {file = "numexpr-2.8.6-cp37-cp37m-win_amd64.whl", hash = "sha256:31cf610c952eec57081171f0b4427f9bed2395ec70ec432bbf45d260c5c0cdeb"}, + {file = "numexpr-2.8.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5f96c89aa0b1f13685ec32fa3d71028db0b5981bfd99a0bbc271035949136b3"}, + {file = "numexpr-2.8.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c8f37f7a6af3bdd61f2efd1cafcc083a9525ab0aaf5dc641e7ec8fc0ae2d3aa1"}, + {file = "numexpr-2.8.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38b8b90967026bbc36c7aa6e8ca3b8906e1990914fd21f446e2a043f4ee3bc06"}, + {file = "numexpr-2.8.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1967c16f61c27df1cdc43ba3c0ba30346157048dd420b4259832276144d0f64e"}, + {file = "numexpr-2.8.6-cp38-cp38-win32.whl", hash = "sha256:15469dc722b5ceb92324ec8635411355ebc702303db901ae8cc87f47c5e3a124"}, + {file = "numexpr-2.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:95c09e814b0d6549de98b5ded7cdf7d954d934bb6b505432ff82e83a6d330bda"}, + {file = "numexpr-2.8.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:aa0f661f5f4872fd7350cc9895f5d2594794b2a7e7f1961649a351724c64acc9"}, + {file = "numexpr-2.8.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8e3e6f1588d6c03877cb3b3dcc3096482da9d330013b886b29cb9586af5af3eb"}, + {file = "numexpr-2.8.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8564186aad5a2c88d597ebc79b8171b52fd33e9b085013e1ff2208f7e4b387e3"}, + {file = "numexpr-2.8.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6a88d71c166e86b98d34701285d23e3e89d548d9f5ae3f4b60919ac7151949f"}, + {file = "numexpr-2.8.6-cp39-cp39-win32.whl", hash = "sha256:c48221b6a85494a7be5a022899764e58259af585dff031cecab337277278cc93"}, + {file = "numexpr-2.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:6d7003497d82ef19458dce380b36a99343b96a3bd5773465c2d898bf8f5a38f9"}, + {file = "numexpr-2.8.6.tar.gz", hash = "sha256:6336f8dba3f456e41a4ffc3c97eb63d89c73589ff6e1707141224b930263260d"}, +] + +[package.dependencies] +numpy = ">=1.13.3" + [[package]] name = "numpy" version = "1.22.3" @@ -3472,178 +3627,42 @@ signals = ["blinker (>=1.4.0)"] signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] [[package]] -name = "opentelemetry-api" -version = "1.15.0" -description = "OpenTelemetry Python API" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_api-1.15.0-py3-none-any.whl", hash = "sha256:e6c2d2e42140fd396e96edf75a7ceb11073f4efb4db87565a431cc9d0f93f2e0"}, - {file = "opentelemetry_api-1.15.0.tar.gz", hash = "sha256:79ab791b4aaad27acc3dc3ba01596db5b5aac2ef75c70622c6038051d6c2cded"}, -] - -[package.dependencies] -deprecated = ">=1.2.6" -setuptools = ">=16.0" - -[[package]] -name = "opentelemetry-exporter-jaeger" -version = "1.15.0" -description = "Jaeger Exporters for OpenTelemetry" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_exporter_jaeger-1.15.0-py3-none-any.whl", hash = "sha256:e8d1b8b95095736507fbef46eea4ee9472e9e7f415ee4461f9414d9d1590ac37"}, - {file = "opentelemetry_exporter_jaeger-1.15.0.tar.gz", hash = "sha256:5d0e5a1b37589a4d7eb67be90aa1fec45431565f8e84ae4960437e77b779002e"}, -] - -[package.dependencies] -opentelemetry-exporter-jaeger-proto-grpc = "1.15.0" -opentelemetry-exporter-jaeger-thrift = "1.15.0" - -[[package]] -name = "opentelemetry-exporter-jaeger-proto-grpc" -version = "1.15.0" -description = "Jaeger Protobuf Exporter for OpenTelemetry" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_exporter_jaeger_proto_grpc-1.15.0-py3-none-any.whl", hash = "sha256:78c46b8b8c9ceabd1107cc85a85b463bd50a049e980c370483d0c3c577632991"}, - {file = "opentelemetry_exporter_jaeger_proto_grpc-1.15.0.tar.gz", hash = "sha256:ff650cc786932cf0fce9809d18f680df7fb49955511009067322470a25b27c5c"}, -] - -[package.dependencies] -googleapis-common-protos = ">=1.52,<1.56.3" -grpcio = ">=1.0.0,<2.0.0" -opentelemetry-api = ">=1.3,<2.0" -opentelemetry-sdk = ">=1.11,<2.0" - -[[package]] -name = "opentelemetry-exporter-jaeger-thrift" -version = "1.15.0" -description = "Jaeger Thrift Exporter for OpenTelemetry" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_exporter_jaeger_thrift-1.15.0-py3-none-any.whl", hash = "sha256:a9d6dcdb203d10d6b0f72bfaeebf1e4822e2636d7d35ff67ed5a9fc672d76fc5"}, - {file = "opentelemetry_exporter_jaeger_thrift-1.15.0.tar.gz", hash = "sha256:2d85ad991c49f63f2397bcbae3881b9d58e51797d2f9c6fe4e02d6372e92b3ec"}, -] - -[package.dependencies] -opentelemetry-api = ">=1.3,<2.0" -opentelemetry-sdk = ">=1.11,<2.0" -thrift = ">=0.10.0" - -[[package]] -name = "opentelemetry-exporter-otlp" -version = "1.15.0" -description = "OpenTelemetry Collector Exporters" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_exporter_otlp-1.15.0-py3-none-any.whl", hash = "sha256:79f22748b6a54808a0448093dfa189c8490e729f67c134d4c992533d9393b33e"}, - {file = "opentelemetry_exporter_otlp-1.15.0.tar.gz", hash = "sha256:4f7c49751d9720e2e726e13b0bb958ccade4e29122c305d92c033da432c8d2c5"}, -] - -[package.dependencies] -opentelemetry-exporter-otlp-proto-grpc = "1.15.0" -opentelemetry-exporter-otlp-proto-http = "1.15.0" - -[[package]] -name = "opentelemetry-exporter-otlp-proto-grpc" -version = "1.15.0" -description = "OpenTelemetry Collector Protobuf over gRPC Exporter" +name = "openai" +version = "0.27.10" +description = "Python client library for the OpenAI API" category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.7.1" files = [ - {file = "opentelemetry_exporter_otlp_proto_grpc-1.15.0-py3-none-any.whl", hash = "sha256:c2a5492ba7d140109968135d641d06ce3c5bd73c50665f787526065d57d7fd1d"}, - {file = "opentelemetry_exporter_otlp_proto_grpc-1.15.0.tar.gz", hash = "sha256:844f2a4bb9bcda34e4eb6fe36765e5031aacb36dc60ed88c90fc246942ea26e7"}, + {file = "openai-0.27.10-py3-none-any.whl", hash = "sha256:beabd1757e3286fa166dde3b70ebb5ad8081af046876b47c14c41e203ed22a14"}, + {file = "openai-0.27.10.tar.gz", hash = "sha256:60e09edf7100080283688748c6803b7b3b52d5a55d21890f3815292a0552d83b"}, ] [package.dependencies] -backoff = {version = ">=1.10.0,<3.0.0", markers = "python_version >= \"3.7\""} -googleapis-common-protos = ">=1.52,<2.0" -grpcio = ">=1.0.0,<2.0.0" -opentelemetry-api = ">=1.12,<2.0" -opentelemetry-proto = "1.15.0" -opentelemetry-sdk = ">=1.12,<2.0" - -[package.extras] -test = ["pytest-grpc"] - -[[package]] -name = "opentelemetry-exporter-otlp-proto-http" -version = "1.15.0" -description = "OpenTelemetry Collector Protobuf over HTTP Exporter" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_exporter_otlp_proto_http-1.15.0-py3-none-any.whl", hash = "sha256:3ec2a02196c8a54bf5cbf7fe623a5238625638e83b6047a983bdf96e2bbb74c0"}, - {file = "opentelemetry_exporter_otlp_proto_http-1.15.0.tar.gz", hash = "sha256:11b2c814249a49b22f6cca7a06b05701f561d577b747f3660dfd67b6eb9daf9c"}, -] - -[package.dependencies] -backoff = {version = ">=1.10.0,<3.0.0", markers = "python_version >= \"3.7\""} -googleapis-common-protos = ">=1.52,<2.0" -opentelemetry-api = ">=1.12,<2.0" -opentelemetry-proto = "1.15.0" -opentelemetry-sdk = ">=1.12,<2.0" -requests = ">=2.7,<3.0" +aiohttp = "*" +requests = ">=2.20" +tqdm = "*" [package.extras] -test = ["responses (==0.22.0)"] - -[[package]] -name = "opentelemetry-proto" -version = "1.15.0" -description = "OpenTelemetry Python Proto" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_proto-1.15.0-py3-none-any.whl", hash = "sha256:044b6d044b4d10530f250856f933442b8753a17f94ae37c207607f733fb9a844"}, - {file = "opentelemetry_proto-1.15.0.tar.gz", hash = "sha256:9c4008e40ac8cab359daac283fbe7002c5c29c77ea2674ad5626a249e64e0101"}, -] - -[package.dependencies] -protobuf = ">=3.19,<5.0" +datalib = ["numpy", "openpyxl (>=3.0.7)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] +dev = ["black (>=21.6b0,<22.0)", "pytest (>=6.0.0,<7.0.0)", "pytest-asyncio", "pytest-mock"] +embeddings = ["matplotlib", "numpy", "openpyxl (>=3.0.7)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)", "plotly", "scikit-learn (>=1.0.2)", "scipy", "tenacity (>=8.0.1)"] +wandb = ["numpy", "openpyxl (>=3.0.7)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)", "wandb"] [[package]] -name = "opentelemetry-sdk" -version = "1.15.0" -description = "OpenTelemetry Python SDK" +name = "openapi-schema-pydantic" +version = "1.2.4" +description = "OpenAPI (v3) specification schema as pydantic class" category = "main" optional = false -python-versions = ">=3.7" +python-versions = ">=3.6.1" files = [ - {file = "opentelemetry_sdk-1.15.0-py3-none-any.whl", hash = "sha256:555c533e9837766119bbccc7a80458c9971d853a6f1da683a2246cd5e53b4645"}, - {file = "opentelemetry_sdk-1.15.0.tar.gz", hash = "sha256:98dbffcfeebcbff12c0c974292d6ea603180a145904cf838b1fe4d5c99078425"}, + {file = "openapi-schema-pydantic-1.2.4.tar.gz", hash = "sha256:3e22cf58b74a69f752cc7e5f1537f6e44164282db2700cbbcd3bb99ddd065196"}, + {file = "openapi_schema_pydantic-1.2.4-py3-none-any.whl", hash = "sha256:a932ecc5dcbb308950282088956e94dea069c9823c84e507d64f6b622222098c"}, ] [package.dependencies] -opentelemetry-api = "1.15.0" -opentelemetry-semantic-conventions = "0.36b0" -setuptools = ">=16.0" -typing-extensions = ">=3.7.4" - -[[package]] -name = "opentelemetry-semantic-conventions" -version = "0.36b0" -description = "OpenTelemetry Semantic Conventions" -category = "main" -optional = false -python-versions = ">=3.7" -files = [ - {file = "opentelemetry_semantic_conventions-0.36b0-py3-none-any.whl", hash = "sha256:adc05635e87b9d3e007c9f530eed487fc3ef2177d02f82f674f28ebf9aff8243"}, - {file = "opentelemetry_semantic_conventions-0.36b0.tar.gz", hash = "sha256:829dc221795467d98b773c04096e29be038d77526dc8d6ac76f546fb6279bf01"}, -] +pydantic = ">=1.8.2" [[package]] name = "opt-einsum" @@ -3890,6 +3909,18 @@ files = [ dev = ["pre-commit", "tox"] testing = ["pytest", "pytest-benchmark"] +[[package]] +name = "ply" +version = "3.11" +description = "Python Lex & Yacc" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "ply-3.11-py2.py3-none-any.whl", hash = "sha256:096f9b8350b65ebd2fd1346b12452efe5b9607f7482813ffca50c22722a807ce"}, + {file = "ply-3.11.tar.gz", hash = "sha256:00c7c1aaa88358b9c765b6d3000c6eec0ba42abca5351b095321aef446081da3"}, +] + [[package]] name = "portalocker" version = "2.7.0" @@ -4133,7 +4164,7 @@ name = "pydantic" version = "1.10.9" description = "Data validation and settings management using python type hints" category = "main" -optional = true +optional = false python-versions = ">=3.7" files = [ {file = "pydantic-1.10.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e692dec4a40bfb40ca530e07805b1208c1de071a18d26af4a2a0d79015b352ca"}, @@ -4386,6 +4417,20 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pypred" +version = "0.4.0" +description = "A Python library for simple evaluation of natural language predicates" +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "pypred-0.4.0.tar.gz", hash = "sha256:110d9807727d5b0f50994a9c220e6fc592a1eeed5652bed2c857c7941818aa80"}, +] + +[package.dependencies] +ply = ">=3.4" + [[package]] name = "pyreadline3" version = "3.4.1" @@ -4706,6 +4751,7 @@ files = [ {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"}, @@ -4713,8 +4759,15 @@ files = [ {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_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"}, @@ -4731,6 +4784,7 @@ files = [ {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"}, @@ -4738,6 +4792,7 @@ files = [ {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"}, @@ -4777,22 +4832,18 @@ fire = "*" [[package]] name = "rasa-sdk" -version = "3.7.0a1" +version = "3.8.0a1" 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" category = "main" optional = false python-versions = ">=3.8,<3.11" files = [ - {file = "rasa_sdk-3.7.0a1-py3-none-any.whl", hash = "sha256:1d5a2613c1e2e03dd5307dc86a59ce9a704bc7e8047076d993a2c38d5c9ad0bc"}, - {file = "rasa_sdk-3.7.0a1.tar.gz", hash = "sha256:9fda99c2bb3a609b93c352844ef390512e77ccc4d25d521feb128b796e11053b"}, + {file = "rasa_sdk-3.8.0a1-py3-none-any.whl", hash = "sha256:f32ef31f8b867c34d670c0f80be9d36c3fb1750491a5ab4242605f2a4e0071cc"}, + {file = "rasa_sdk-3.8.0a1.tar.gz", hash = "sha256:aa2e99cb6dba36e457de032997f70f170e9f17faaf4d864aacf9f6c59588ff81"}, ] [package.dependencies] coloredlogs = ">=10,<16" -opentelemetry-api = ">=1.15.0,<1.16.0" -opentelemetry-exporter-jaeger = ">=1.15.0,<1.16.0" -opentelemetry-exporter-otlp = ">=1.15.0,<1.16.0" -opentelemetry-sdk = ">=1.15.0,<1.16.0" pluggy = ">=1.0.0,<2.0.0" prompt-toolkit = ">=3.0,<3.0.29" "ruamel.yaml" = ">=0.16.5,<0.18.0" @@ -5959,6 +6010,21 @@ files = [ {file = "tarsafe-0.0.5.tar.gz", hash = "sha256:cbdffc260d8a33f0e35ed7b70b2e2f56ad40e77019e5384bbe1cfc1ccccac79a"}, ] +[[package]] +name = "tenacity" +version = "8.2.3" +description = "Retry code until it succeeds" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tenacity-8.2.3-py3-none-any.whl", hash = "sha256:ce510e327a630c9e1beaf17d42e6ffacc88185044ad85cf74c0a8887c6a0f88c"}, + {file = "tenacity-8.2.3.tar.gz", hash = "sha256:5398ef0d78e63f40007c1fb4c0bff96e1911394d2fa8d194f77619c05ff6cc8a"}, +] + +[package.extras] +doc = ["reno", "sphinx", "tornado (>=4.5)"] + [[package]] name = "tensorboard" version = "2.12.3" @@ -6425,25 +6491,6 @@ files = [ {file = "threadpoolctl-3.1.0.tar.gz", hash = "sha256:a335baacfaa4400ae1f0d8e3a58d6674d2f8828e3716bb2802c44955ad391380"}, ] -[[package]] -name = "thrift" -version = "0.16.0" -description = "Python bindings for the Apache Thrift RPC system" -category = "main" -optional = false -python-versions = "*" -files = [ - {file = "thrift-0.16.0.tar.gz", hash = "sha256:2b5b6488fcded21f9d312aa23c9ff6a0195d0f6ae26ddbd5ad9e3e25dfc14408"}, -] - -[package.dependencies] -six = ">=1.7.2" - -[package.extras] -all = ["tornado (>=4.0)", "twisted"] -tornado = ["tornado (>=4.0)"] -twisted = ["twisted"] - [[package]] name = "tokenizers" version = "0.13.3" @@ -6838,6 +6885,22 @@ files = [ {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +description = "Runtime inspection utilities for typing module." +category = "main" +optional = false +python-versions = "*" +files = [ + {file = "typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f"}, + {file = "typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78"}, +] + +[package.dependencies] +mypy-extensions = ">=0.3.0" +typing-extensions = ">=3.7.4" + [[package]] name = "typing-utils" version = "0.1.0" @@ -7469,5 +7532,5 @@ transformers = ["sentencepiece", "transformers"] [metadata] lock-version = "2.0" -python-versions = ">=3.8,<3.11" -content-hash = "7d061f86269fee0a2c1a29f1c801789cb43ce9cd62beea9012fcf0b69e2eb2e3" +python-versions = ">=3.8.1,<3.11" +content-hash = "2f9012af12d212e7f25f198271e17bc0091869d3b868ab8bb63a13bc2aa0357e" diff --git a/pyproject.toml b/pyproject.toml index a72afda3a0fd..18211d12110f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,18 +9,17 @@ exclude = "((.eggs | .git | .pytest_cache | build | dist))" [tool.poetry] name = "rasa" -version = "3.7.0a1" +version = "3.8.0a12" 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 ",] maintainers = [ "Tom Bocklisch ",] 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",] +classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "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",] +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", "rasa/dialogue_understanding/classifiers/command_prompt_template.jinja2",] 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/" @@ -87,7 +86,7 @@ line-length = 88 select = [ "D", "E", "F", "W", "RUF",] [tool.poetry.dependencies] -python = ">=3.8,<3.11" +python = ">=3.8.1,<3.11" boto3 = "^1.26.136" requests = "^2.23" matplotlib = ">=3.1,<3.8" @@ -111,7 +110,6 @@ 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.7.0a1" colorclass = "~2.2" terminaltables = "~3.1.0" sanic = "~21.12" @@ -149,10 +147,12 @@ pluggy = "^1.0.0" slack-sdk = "^3.19.2" confluent-kafka = ">=1.9.2,<3.0.0" portalocker = "^2.7.0" +pypred = "^0.4.0" structlog = "^23.1.0" structlog-sentry = "^2.0.2" -# pin dnspython to avoid dependency incompatibility -# in order to fix https://rasahq.atlassian.net/browse/ATO-1419 +langchain = "^0.0.223" +jinja2 = "^3.0.0" +openai = "^0.27.8" dnspython = "2.3.0" wheel = ">=0.38.1" certifi = ">=2023.7.22" @@ -248,6 +248,10 @@ timeout = 60 timeout_func_only = true asyncio_mode = "auto" +[tool.poetry.dependencies.rasa-sdk] +version = "~3.8.0a1" +allow-prereleases = true + [tool.poetry.dependencies.tensorflow] version = "2.12.0" markers = "sys_platform != 'darwin' or platform_machine != 'arm64'" diff --git a/rasa/cli/data.py b/rasa/cli/data.py index 06f912168c1d..4673a28e3783 100644 --- a/rasa/cli/data.py +++ b/rasa/cli/data.py @@ -144,6 +144,22 @@ def _add_data_validate_parsers( ) arguments.set_validator_arguments(story_structure_parser) + flows_structure_parser = validate_subparsers.add_parser( + "flows", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + parents=parents, + help="Checks for inconsistencies in the flows files.", + ) + flows_structure_parser.set_defaults( + func=lambda args: rasa.cli.utils.validate_files( + args.fail_on_warnings, + args.max_history, + _build_training_data_importer(args), + flows_only=True, + ) + ) + arguments.set_validator_arguments(flows_structure_parser) + def _build_training_data_importer(args: argparse.Namespace) -> "TrainingDataImporter": config = rasa.cli.utils.get_validated_path( diff --git a/rasa/cli/project_templates/__init__.py b/rasa/cli/project_templates/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/cli/project_templates/default/actions/__init__.py b/rasa/cli/project_templates/default/actions/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/cli/initial_project/actions/actions.py b/rasa/cli/project_templates/default/actions/actions.py similarity index 100% rename from rasa/cli/initial_project/actions/actions.py rename to rasa/cli/project_templates/default/actions/actions.py diff --git a/rasa/cli/initial_project/config.yml b/rasa/cli/project_templates/default/config.yml similarity index 100% rename from rasa/cli/initial_project/config.yml rename to rasa/cli/project_templates/default/config.yml diff --git a/rasa/cli/initial_project/credentials.yml b/rasa/cli/project_templates/default/credentials.yml similarity index 100% rename from rasa/cli/initial_project/credentials.yml rename to rasa/cli/project_templates/default/credentials.yml diff --git a/rasa/cli/initial_project/data/nlu.yml b/rasa/cli/project_templates/default/data/nlu.yml similarity index 100% rename from rasa/cli/initial_project/data/nlu.yml rename to rasa/cli/project_templates/default/data/nlu.yml diff --git a/rasa/cli/initial_project/data/rules.yml b/rasa/cli/project_templates/default/data/rules.yml similarity index 100% rename from rasa/cli/initial_project/data/rules.yml rename to rasa/cli/project_templates/default/data/rules.yml diff --git a/rasa/cli/initial_project/data/stories.yml b/rasa/cli/project_templates/default/data/stories.yml similarity index 100% rename from rasa/cli/initial_project/data/stories.yml rename to rasa/cli/project_templates/default/data/stories.yml diff --git a/rasa/cli/initial_project/domain.yml b/rasa/cli/project_templates/default/domain.yml similarity index 100% rename from rasa/cli/initial_project/domain.yml rename to rasa/cli/project_templates/default/domain.yml diff --git a/rasa/cli/initial_project/endpoints.yml b/rasa/cli/project_templates/default/endpoints.yml similarity index 100% rename from rasa/cli/initial_project/endpoints.yml rename to rasa/cli/project_templates/default/endpoints.yml diff --git a/rasa/cli/initial_project/tests/test_stories.yml b/rasa/cli/project_templates/default/tests/test_stories.yml similarity index 100% rename from rasa/cli/initial_project/tests/test_stories.yml rename to rasa/cli/project_templates/default/tests/test_stories.yml diff --git a/rasa/cli/project_templates/dm2/actions/__init__.py b/rasa/cli/project_templates/dm2/actions/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/cli/project_templates/dm2/actions/actions.py b/rasa/cli/project_templates/dm2/actions/actions.py new file mode 100644 index 000000000000..8bf1f757f851 --- /dev/null +++ b/rasa/cli/project_templates/dm2/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/project_templates/dm2/config.yml b/rasa/cli/project_templates/dm2/config.yml new file mode 100644 index 000000000000..b6638aca4dc7 --- /dev/null +++ b/rasa/cli/project_templates/dm2/config.yml @@ -0,0 +1,13 @@ +recipe: default.v1 +language: en +pipeline: + - name: LLMCommandGenerator + # llm: + # model_name: gpt-4 + +policies: + - name: rasa.core.policies.flow_policy.FlowPolicy +# - name: rasa_plus.ml.DocsearchPolicy +# - name: RulePolicy + +assistant_id: 20230405-114328-tranquil-mustard diff --git a/rasa/cli/project_templates/dm2/credentials.yml b/rasa/cli/project_templates/dm2/credentials.yml new file mode 100644 index 000000000000..e9f12911e3cf --- /dev/null +++ b/rasa/cli/project_templates/dm2/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: "" +# secret: "" +# page-access-token: "" + +#slack: +# slack_token: "" +# slack_channel: "" +# slack_signing_secret: "" + +#socketio: +# user_message_evt: +# bot_message_evt: +# session_persistence: + +#mattermost: +# url: "https:///api/v4" +# token: "" +# webhook_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/project_templates/dm2/data/flows.yml b/rasa/cli/project_templates/dm2/data/flows.yml new file mode 100644 index 000000000000..d4185318a853 --- /dev/null +++ b/rasa/cli/project_templates/dm2/data/flows.yml @@ -0,0 +1,43 @@ +flows: + say_goodbye: + description: say goodbye to the user + steps: + - id: "0" + action: utter_goodbye + bot_challenge: + description: explain to the user that they are talking to a bot, if they ask + steps: + - id: "0" + action: utter_iamabot + greet: + description: greet the user and ask how they are doing. cheer them up if needed. + steps: + - id: "0" + collect: good_mood + description: "can be true or false" + next: + - if: good_mood + then: "doing_great" + - else: "cheer_up" + - id: "doing_great" + action: utter_happy + - id: "cheer_up" + action: utter_cheer_up + next: "did_that_help" + - id: "did_that_help" + action: utter_did_that_help + recommend_restaurant: + description: This flow recommends a restaurant + steps: + - id: "0" + collect: cuisine + next: "1" + - id: "1" + collect: price_range + next: "2" + - id: "2" + collect: city + next: "3" + - id: "3" + action: utter_recommend_restaurant + diff --git a/rasa/cli/project_templates/dm2/domain.yml b/rasa/cli/project_templates/dm2/domain.yml new file mode 100644 index 000000000000..7953de14c297 --- /dev/null +++ b/rasa/cli/project_templates/dm2/domain.yml @@ -0,0 +1,60 @@ +version: "3.1" + +slots: + good_mood: + type: bool + mappings: + - type: custom + cuisine: + type: text + mappings: + - type: custom + price_range: + type: text + mappings: + - type: custom + city: + type: text + mappings: + - type: custom + + +responses: + utter_greet: + - text: "Hey!" + + utter_ask_good_mood: + - text: "How are you?" + + utter_ask_cuisine: + - text: "What kind of food are you looking for?" + + utter_ask_price_range: + - text: "in what price range?" + + utter_ask_city: + - text: "and in which city?" + + utter_recommend_restaurant: + - text: "Here's a recommendation ..." + + 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/project_templates/dm2/e2e_tests/complete_request.yml b/rasa/cli/project_templates/dm2/e2e_tests/complete_request.yml new file mode 100644 index 000000000000..b0a93f5bfe37 --- /dev/null +++ b/rasa/cli/project_templates/dm2/e2e_tests/complete_request.yml @@ -0,0 +1,5 @@ +test_cases: + - test_case: user corrects recipient in the next message + steps: + - user: I'm looking for a cheap Chinese restaurant in Amsterdam + - utter: utter_recommend_restaurant \ No newline at end of file diff --git a/rasa/cli/project_templates/dm2/e2e_tests/happy.yml b/rasa/cli/project_templates/dm2/e2e_tests/happy.yml new file mode 100644 index 000000000000..2181e9d2bb7d --- /dev/null +++ b/rasa/cli/project_templates/dm2/e2e_tests/happy.yml @@ -0,0 +1,11 @@ +test_cases: + - test_case: user corrects recipient in the next message + steps: + - user: Please recommend a restaurant + - utter: utter_ask_cuisine + - user: Indian food + - utter: utter_ask_price_range + - user: cheap + - utter: utter_ask_city + - user: Berlin + - utter: utter_recommend_restaurant \ No newline at end of file diff --git a/rasa/cli/project_templates/dm2/endpoints.yml b/rasa/cli/project_templates/dm2/endpoints.yml new file mode 100644 index 000000000000..5f65275b8802 --- /dev/null +++ b/rasa/cli/project_templates/dm2/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: +# port: +# db: +# password: +# use_ssl: + +#tracker_store: +# type: mongod +# url: +# db: +# username: +# password: + +# 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/project_templates/dm2/tests/test_stories.yml b/rasa/cli/project_templates/dm2/tests/test_stories.yml new file mode 100644 index 000000000000..d46e39b3ea06 --- /dev/null +++ b/rasa/cli/project_templates/dm2/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/project_templates/tutorial/actions.py b/rasa/cli/project_templates/tutorial/actions.py new file mode 100644 index 000000000000..312c3a47c5d5 --- /dev/null +++ b/rasa/cli/project_templates/tutorial/actions.py @@ -0,0 +1,22 @@ +from typing import Any, Text, Dict, List +from rasa_sdk import Action, Tracker +from rasa_sdk.executor import CollectingDispatcher +from rasa_sdk.events import SlotSet + + +class ActionCheckSufficientFunds(Action): + def name(self) -> Text: + return "action_check_sufficient_funds" + + def run( + self, + dispatcher: CollectingDispatcher, + tracker: Tracker, + domain: Dict[Text, Any], + ) -> List[Dict[Text, Any]]: + # hard-coded balance for tutorial purposes. in production this + # would be retrieved from a database or an API + balance = 1000 + transfer_amount = tracker.get_slot("amount") + has_sufficient_funds = transfer_amount <= balance + return [SlotSet("has_sufficient_funds", has_sufficient_funds)] diff --git a/rasa/cli/project_templates/tutorial/config.yml b/rasa/cli/project_templates/tutorial/config.yml new file mode 100644 index 000000000000..3801f4a2c879 --- /dev/null +++ b/rasa/cli/project_templates/tutorial/config.yml @@ -0,0 +1,13 @@ +recipe: default.v1 +language: en +pipeline: + - name: LLMCommandGenerator + llm: + model_name: gpt-4 + +policies: + - name: rasa.core.policies.flow_policy.FlowPolicy +# - name: rasa_plus.ml.DocsearchPolicy +# - name: RulePolicy + +assistant_id: 20230405-114328-tranquil-mustard diff --git a/rasa/cli/project_templates/tutorial/credentials.yml b/rasa/cli/project_templates/tutorial/credentials.yml new file mode 100644 index 000000000000..e9f12911e3cf --- /dev/null +++ b/rasa/cli/project_templates/tutorial/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: "" +# secret: "" +# page-access-token: "" + +#slack: +# slack_token: "" +# slack_channel: "" +# slack_signing_secret: "" + +#socketio: +# user_message_evt: +# bot_message_evt: +# session_persistence: + +#mattermost: +# url: "https:///api/v4" +# token: "" +# webhook_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/project_templates/tutorial/data/flows.yml b/rasa/cli/project_templates/tutorial/data/flows.yml new file mode 100644 index 000000000000..1ad56dc32fe0 --- /dev/null +++ b/rasa/cli/project_templates/tutorial/data/flows.yml @@ -0,0 +1,8 @@ +flows: + transfer_money: + description: This flow lets users send money to friends and family. + steps: + - collect: recipient + - collect: amount + description: the number of US dollars to send + - action: utter_transfer_complete diff --git a/rasa/cli/project_templates/tutorial/domain.yml b/rasa/cli/project_templates/tutorial/domain.yml new file mode 100644 index 000000000000..4c04450e3094 --- /dev/null +++ b/rasa/cli/project_templates/tutorial/domain.yml @@ -0,0 +1,17 @@ +version: "3.1" + +slots: + recipient: + type: text + amount: + type: float + +responses: + utter_ask_recipient: + - text: "Who would you like to send money to?" + + utter_ask_amount: + - text: "How much money would you like to send?" + + utter_transfer_complete: + - text: "All done. {amount} has been sent to {recipient}." diff --git a/rasa/cli/project_templates/tutorial/endpoints.yml b/rasa/cli/project_templates/tutorial/endpoints.yml new file mode 100644 index 000000000000..5f65275b8802 --- /dev/null +++ b/rasa/cli/project_templates/tutorial/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: +# port: +# db: +# password: +# use_ssl: + +#tracker_store: +# type: mongod +# url: +# db: +# username: +# password: + +# 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/scaffold.py b/rasa/cli/scaffold.py index b5f0f1e50f56..708f054e5e68 100644 --- a/rasa/cli/scaffold.py +++ b/rasa/cli/scaffold.py @@ -1,4 +1,5 @@ import argparse +from enum import Enum import os import sys from typing import List, Text @@ -16,6 +17,17 @@ ) +class ProjectTemplateName(Enum): + """Enum of the different project templates.""" + + DEFAULT = "default" + TUTORIAL = "tutorial" + DM2 = "dm2" + + def __str__(self) -> str: + return self.value + + def add_subparser( subparsers: SubParsersAction, parents: List[argparse.ArgumentParser] ) -> None: @@ -42,7 +54,13 @@ def add_subparser( default=None, help="Directory where your project should be initialized.", ) - + scaffold_parser.add_argument( + "--template", + type=ProjectTemplateName, + choices=list(ProjectTemplateName), + default=ProjectTemplateName.DEFAULT, + help="Select the template to use for the project.", + ) scaffold_parser.set_defaults(func=run) @@ -127,22 +145,27 @@ def print_run_or_instructions(args: argparse.Namespace) -> None: def init_project(args: argparse.Namespace, path: Text) -> None: """Inits project.""" os.chdir(path) - create_initial_project(".") + create_initial_project(".", args.template) print(f"Created project directory at '{os.getcwd()}'.") print_train_or_instructions(args) -def create_initial_project(path: Text) -> None: +def create_initial_project( + path: Text, template: ProjectTemplateName = ProjectTemplateName.DEFAULT +) -> None: """Creates directory structure and templates for initial project.""" from distutils.dir_util import copy_tree - copy_tree(scaffold_path(), path) + copy_tree(scaffold_path(template), path) -def scaffold_path() -> Text: +def scaffold_path(template: ProjectTemplateName) -> Text: import pkg_resources + import rasa.cli.project_templates + + template_module = rasa.cli.project_templates.__name__ - return pkg_resources.resource_filename(__name__, "initial_project") + return pkg_resources.resource_filename(template_module, template.value) def print_cancel() -> None: diff --git a/rasa/cli/utils.py b/rasa/cli/utils.py index a6c5e653868e..1778416fd1ac 100644 --- a/rasa/cli/utils.py +++ b/rasa/cli/utils.py @@ -217,6 +217,7 @@ def validate_files( max_history: Optional[int], importer: TrainingDataImporter, stories_only: bool = False, + flows_only: bool = False, ) -> None: """Validates either the story structure or the entire project. @@ -225,6 +226,7 @@ def validate_files( 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. + flows_only: If `True`, only the flows are validated. """ from rasa.validator import Validator @@ -232,6 +234,8 @@ def validate_files( if stories_only: all_good = _validate_story_structure(validator, max_history, fail_on_warnings) + elif flows_only: + all_good = validator.verify_flows() else: if importer.get_domain().is_empty(): rasa.shared.utils.cli.print_error_and_exit( @@ -243,8 +247,9 @@ def validate_files( valid_stories = _validate_story_structure( validator, max_history, fail_on_warnings ) + valid_flows = validator.verify_flows() - all_good = valid_domain and valid_nlu and valid_stories + all_good = valid_domain and valid_nlu and valid_stories and valid_flows validator.warn_if_config_mandatory_keys_are_not_set() diff --git a/rasa/constants.py b/rasa/constants.py index ef973613bc96..c982a5378400 100644 --- a/rasa/constants.py +++ b/rasa/constants.py @@ -18,7 +18,7 @@ CONFIG_TELEMETRY_ENABLED = "enabled" CONFIG_TELEMETRY_DATE = "date" -MINIMUM_COMPATIBLE_VERSION = "3.5.0" +MINIMUM_COMPATIBLE_VERSION = "3.7.0b3" GLOBAL_USER_CONFIG_PATH = os.path.expanduser("~/.config/rasa/global.yml") diff --git a/rasa/core/actions/action.py b/rasa/core/actions/action.py index 35ad3e2be2f6..e03ff75df311 100644 --- a/rasa/core/actions/action.py +++ b/rasa/core/actions/action.py @@ -33,9 +33,11 @@ DOCS_BASE_URL, DEFAULT_NLU_FALLBACK_INTENT_NAME, UTTER_PREFIX, + FLOW_PREFIX, ) from rasa.shared.core import events from rasa.shared.core.constants import ( + DIALOGUE_STACK_SLOT, USER_INTENT_OUT_OF_SCOPE, ACTION_LISTEN_NAME, ACTION_RESTART_NAME, @@ -56,6 +58,7 @@ ACTION_VALIDATE_SLOT_MAPPINGS, MAPPING_TYPE, SlotMappingType, + KNOWLEDGE_BASE_SLOT_NAMES, ) from rasa.shared.core.domain import Domain from rasa.shared.core.events import ( @@ -96,6 +99,13 @@ def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["Action"]: """List default actions.""" from rasa.core.actions.two_stage_fallback import TwoStageFallbackAction + from rasa.dialogue_understanding.patterns.correction import ActionCorrectFlowSlot + from rasa.dialogue_understanding.patterns.cancel import ActionCancelFlow + from rasa.dialogue_understanding.patterns.clarify import ActionClarifyFlows + from rasa.core.actions.action_run_slot_rejections import ActionRunSlotRejections + from rasa.core.actions.action_trigger_chitchat import ActionTriggerChitchat + from rasa.core.actions.action_trigger_search import ActionTriggerSearch + from rasa.core.actions.action_clean_stack import ActionCleanStack return [ ActionListen(), @@ -111,6 +121,13 @@ def default_actions(action_endpoint: Optional[EndpointConfig] = None) -> List["A ActionSendText(), ActionBack(), ActionExtractSlots(action_endpoint), + ActionCancelFlow(), + ActionCorrectFlowSlot(), + ActionClarifyFlows(), + ActionRunSlotRejections(), + ActionCleanStack(), + ActionTriggerSearch(), + ActionTriggerChitchat(), ] @@ -207,6 +224,10 @@ def action_for_name_or_text( return FormAction(action_name_or_text, action_endpoint) + if action_name_or_text.startswith(FLOW_PREFIX): + from rasa.core.actions.action_trigger_flow import ActionTriggerFlow + + return ActionTriggerFlow(action_name_or_text) return RemoteAction(action_name_or_text, action_endpoint) @@ -809,6 +830,13 @@ async def run( ) evts = events.deserialise_events(events_json) + # filter out `SlotSet` events for internal `dialogue_stack` slot + evts = [ + event + for event in evts + if not (isinstance(event, SlotSet) and event.key == DIALOGUE_STACK_SLOT) + ] + return cast(List[Event], bot_messages) + evts except ClientResponseError as e: @@ -1277,7 +1305,9 @@ async def run( executed_custom_actions: Set[Text] = set() user_slots = [ - slot for slot in domain.slots if slot.name not in DEFAULT_SLOT_NAMES + slot + for slot in domain.slots + if slot.name not in DEFAULT_SLOT_NAMES | KNOWLEDGE_BASE_SLOT_NAMES ] for slot in user_slots: diff --git a/rasa/core/actions/action_clean_stack.py b/rasa/core/actions/action_clean_stack.py new file mode 100644 index 000000000000..a885abbdbf8e --- /dev/null +++ b/rasa/core/actions/action_clean_stack.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from typing import Optional, Dict, Any, List + +from rasa.core.actions.action import Action +from rasa.core.channels import OutputChannel +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + BaseFlowStackFrame, + UserFlowStackFrame, +) +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import FlowStackFrameType +from rasa.shared.core.constants import ACTION_CLEAN_STACK, DIALOGUE_STACK_SLOT +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event, SlotSet +from rasa.shared.core.flows.flow import ContinueFlowStep, END_STEP +from rasa.shared.core.trackers import DialogueStateTracker + + +class ActionCleanStack(Action): + """Action which cancels a flow from the stack.""" + + def name(self) -> str: + """Return the flow name.""" + return ACTION_CLEAN_STACK + + async def run( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Event]: + """Clean the stack.""" + stack = DialogueStack.from_tracker(tracker) + + new_frames = [] + # Set all frames to their end step, filter out any non-BaseFlowStackFrames + for frame in stack.frames: + if isinstance(frame, BaseFlowStackFrame): + frame.step_id = ContinueFlowStep.continue_step_for_id(END_STEP) + if isinstance(frame, UserFlowStackFrame): + # Making sure there are no "continue interrupts" triggered + frame.frame_type = FlowStackFrameType.REGULAR + new_frames.append(frame) + new_stack = DialogueStack.from_dict([frame.as_dict() for frame in new_frames]) + + return [SlotSet(DIALOGUE_STACK_SLOT, new_stack.as_dict())] diff --git a/rasa/core/actions/action_run_slot_rejections.py b/rasa/core/actions/action_run_slot_rejections.py new file mode 100644 index 000000000000..3cbefe54173f --- /dev/null +++ b/rasa/core/actions/action_run_slot_rejections.py @@ -0,0 +1,131 @@ +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Text + +import structlog +from jinja2 import Template +from pypred import Predicate + +from rasa.core.actions.action import Action, create_bot_utterance +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.constants import ACTION_RUN_SLOT_REJECTIONS_NAME +from rasa.shared.core.events import Event, SlotSet + +if TYPE_CHECKING: + from rasa.core.nlg import NaturalLanguageGenerator + from rasa.core.channels.channel import OutputChannel + from rasa.shared.core.domain import Domain + from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger() + + +class ActionRunSlotRejections(Action): + """Action which evaluates the predicate checks under rejections.""" + + def name(self) -> Text: + """Return the name of the action.""" + return ACTION_RUN_SLOT_REJECTIONS_NAME + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + metadata: Optional[Dict[Text, Any]] = None, + ) -> List[Event]: + """Run the predicate checks.""" + events: List[Event] = [] + violation = False + utterance = None + internal_error = False + + dialogue_stack = DialogueStack.from_tracker(tracker) + top_frame = dialogue_stack.top() + if not isinstance(top_frame, CollectInformationPatternFlowStackFrame): + return [] + + if not top_frame.rejections: + return [] + + slot_name = top_frame.collect + slot_instance = tracker.slots.get(slot_name) + if slot_instance and not slot_instance.has_been_set: + # this is the first time the assistant asks for the slot value, + # therefore we skip the predicate validation because the slot + # value has not been provided + structlogger.debug( + "first.collect.slot.not.set", + slot_name=slot_name, + slot_value=slot_instance.value, + ) + return [] + + slot_value = tracker.get_slot(slot_name) + + current_context = dialogue_stack.current_context() + current_context[slot_name] = slot_value + + structlogger.debug("run.predicate.context", context=current_context) + document = current_context.copy() + + for rejection in top_frame.rejections: + condition = rejection.if_ + utterance = rejection.utter + + try: + rendered_template = Template(condition).render(current_context) + predicate = Predicate(rendered_template) + violation = predicate.evaluate(document) + structlogger.debug( + "run.predicate.result", + predicate=predicate.description(), + violation=violation, + ) + except (TypeError, Exception) as e: + structlogger.error( + "run.predicate.error", + predicate=condition, + document=document, + error=str(e), + ) + violation = True + internal_error = True + + if violation: + break + + if not violation: + return [] + + # reset slot value that was initially filled with an invalid value + events.append(SlotSet(top_frame.collect, None)) + + if internal_error: + utterance = "utter_internal_error_rasa" + + if not isinstance(utterance, str): + structlogger.error( + "run.rejection.missing.utter", + utterance=utterance, + ) + return events + + message = await nlg.generate( + utterance, + tracker, + output_channel.name(), + ) + + if message is None: + structlogger.error( + "run.rejection.failed.finding.utter", + utterance=utterance, + ) + else: + message["utter_action"] = utterance + events.append(create_bot_utterance(message)) + + return events diff --git a/rasa/core/actions/action_trigger_chitchat.py b/rasa/core/actions/action_trigger_chitchat.py new file mode 100644 index 000000000000..d57d27fe9e54 --- /dev/null +++ b/rasa/core/actions/action_trigger_chitchat.py @@ -0,0 +1,32 @@ +from typing import Optional, Dict, Any, List + +from rasa.core.actions.action import Action +from rasa.core.channels import OutputChannel +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ChitChatStackFrame +from rasa.shared.core.constants import ACTION_TRIGGER_CHITCHAT +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event +from rasa.shared.core.trackers import DialogueStateTracker + + +class ActionTriggerChitchat(Action): + """Action which triggers a chitchat answer.""" + + def name(self) -> str: + """Return the name of the action.""" + return ACTION_TRIGGER_CHITCHAT + + async def run( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Event]: + """Run the predicate checks.""" + dialogue_stack = DialogueStack.from_tracker(tracker) + dialogue_stack.push(ChitChatStackFrame()) + return [dialogue_stack.persist_as_event()] diff --git a/rasa/core/actions/action_trigger_flow.py b/rasa/core/actions/action_trigger_flow.py new file mode 100644 index 000000000000..a89481adf516 --- /dev/null +++ b/rasa/core/actions/action_trigger_flow.py @@ -0,0 +1,102 @@ +from typing import Any, Dict, Optional, Text, List + +import structlog +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import ( + FlowStackFrameType, + UserFlowStackFrame, +) +from rasa.core.actions import action +from rasa.core.channels import OutputChannel +from rasa.shared.constants import FLOW_PREFIX + +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + ActiveLoop, + Event, + SlotSet, +) +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger(__name__) + + +class ActionTriggerFlow(action.Action): + """Action which triggers a flow by putting it on the dialogue stack.""" + + def __init__(self, flow_action_name: Text) -> None: + """Creates a `ActionTriggerFlow`. + + Args: + flow_action_name: Name of the flow. + """ + super().__init__() + + if not flow_action_name.startswith(FLOW_PREFIX): + raise ValueError( + f"Flow action name '{flow_action_name}' needs to start with " + f"'{FLOW_PREFIX}'." + ) + + self._flow_name = flow_action_name[len(FLOW_PREFIX) :] + self._flow_action_name = flow_action_name + + def name(self) -> Text: + """Return the flow name.""" + return self._flow_action_name + + def create_event_to_start_flow(self, tracker: DialogueStateTracker) -> Event: + """Create an event to start the flow. + + Args: + tracker: The tracker to start the flow on. + + Returns: + The event to start the flow.""" + stack = DialogueStack.from_tracker(tracker) + frame_type = ( + FlowStackFrameType.REGULAR + if stack.is_empty() + else FlowStackFrameType.INTERRUPT + ) + + stack.push( + UserFlowStackFrame( + flow_id=self._flow_name, + frame_type=frame_type, + ) + ) + return stack.persist_as_event() + + def create_events_to_set_flow_slots(self, metadata: Dict[str, Any]) -> List[Event]: + """Create events to set the flow slots. + + Set additional slots to prefill information for the flow. + + Args: + metadata: The metadata to set the slots from. + + Returns: + The events to set the flow slots. + """ + slots_to_be_set = metadata.get("slots", {}) if metadata else {} + return [SlotSet(key, value) for key, value in slots_to_be_set.items()] + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + metadata: Optional[Dict[Text, Any]] = None, + ) -> List[Event]: + """Trigger the flow.""" + events: List[Event] = [self.create_event_to_start_flow(tracker)] + events.extend(self.create_events_to_set_flow_slots(metadata)) + + if tracker.active_loop_name: + # end any active loop to ensure we are progressing the started flow + events.append(ActiveLoop(None)) + + return events diff --git a/rasa/core/actions/action_trigger_search.py b/rasa/core/actions/action_trigger_search.py new file mode 100644 index 000000000000..8e5f16b5226b --- /dev/null +++ b/rasa/core/actions/action_trigger_search.py @@ -0,0 +1,32 @@ +from typing import Optional, Dict, Any, List + +from rasa.core.actions.action import Action +from rasa.core.channels import OutputChannel +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import SearchStackFrame +from rasa.shared.core.constants import ACTION_TRIGGER_SEARCH +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event +from rasa.shared.core.trackers import DialogueStateTracker + + +class ActionTriggerSearch(Action): + """Action which triggers a search""" + + def name(self) -> str: + """Return the name of the action.""" + return ACTION_TRIGGER_SEARCH + + async def run( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Event]: + """Run the predicate checks.""" + dialogue_stack = DialogueStack.from_tracker(tracker) + dialogue_stack.push(SearchStackFrame()) + return [dialogue_stack.persist_as_event()] diff --git a/rasa/core/channels/socketio.py b/rasa/core/channels/socketio.py index 4ae79a3e5afe..aa1eee17a647 100644 --- a/rasa/core/channels/socketio.py +++ b/rasa/core/channels/socketio.py @@ -15,6 +15,8 @@ class SocketBlueprint(Blueprint): + """Blueprint for socketio connections.""" + def __init__( self, sio: AsyncServer, socketio_path: Text, *args: Any, **kwargs: Any ) -> None: diff --git a/rasa/core/constants.py b/rasa/core/constants.py index 973e4e7b3a99..7270abfd1c39 100644 --- a/rasa/core/constants.py +++ b/rasa/core/constants.py @@ -79,3 +79,16 @@ COMPRESS_ACTION_SERVER_REQUEST_ENV_NAME = "COMPRESS_ACTION_SERVER_REQUEST" DEFAULT_COMPRESS_ACTION_SERVER_REQUEST = False + +# uses python like string formatting for the interpolation of templates +RASA_FORMAT_TEMPLATE_ENGINE = "format" + +# uses jinja for the interpolation of templates +JINJA2_TEMPLATE_ENGINE = "jinja" + +# default engine used if no engine is specified in the response +DEFAULT_TEMPLATE_ENGINE = RASA_FORMAT_TEMPLATE_ENGINE + +# configuration parameter used to specify the template engine to use +# for a response +TEMPLATE_ENGINE_CONFIG_KEY = "template" diff --git a/rasa/core/nlg/interpolator.py b/rasa/core/nlg/interpolator.py index 77d55b477785..a5d09fedb1f8 100644 --- a/rasa/core/nlg/interpolator.py +++ b/rasa/core/nlg/interpolator.py @@ -1,14 +1,18 @@ import copy import re import logging +from jinja2 import Template +import jinja2 import structlog from typing import Text, Dict, Union, Any, List +from rasa.core.constants import JINJA2_TEMPLATE_ENGINE, RASA_FORMAT_TEMPLATE_ENGINE + logger = logging.getLogger(__name__) structlogger = structlog.get_logger() -def interpolate_text(response: Text, values: Dict[Text, Text]) -> Text: +def interpolate_format_template(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: @@ -52,8 +56,38 @@ def interpolate_text(response: Text, values: Dict[Text, Text]) -> Text: return response +def interpolate_jinja_template(response: Text, values: Dict[Text, Any]) -> Text: + """Interpolate values into responses with placeholders using jinja. + + 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: + return Template(response).render(values) + except jinja2.exceptions.UndefinedError 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] + response: Union[List[Any], Dict[Text, Any], Text], + values: Dict[Text, Text], + method: str, ) -> Union[List[Any], Dict[Text, Any], Text]: """Recursively process response and interpolate any text keys. @@ -61,21 +95,29 @@ def interpolate( response: The response that should be interpolated. values: A dictionary of keys and the values that those keys should be replaced with. + method: The method to use for interpolation. If `None` or `"format"`, Returns: The response with any replacements made. """ + if method == RASA_FORMAT_TEMPLATE_ENGINE: + interpolator = interpolate_format_template + elif method == JINJA2_TEMPLATE_ENGINE: + interpolator = interpolate_jinja_template + else: + raise ValueError(f"Unknown interpolator implementation '{method}'") + if isinstance(response, str): - return interpolate_text(response, values) + return interpolator(response, values) elif isinstance(response, dict): for k, v in response.items(): if isinstance(v, dict): - interpolate(v, values) + interpolate(v, values, method) elif isinstance(v, list): - response[k] = [interpolate(i, values) for i in v] + response[k] = [interpolate(i, values, method) for i in v] elif isinstance(v, str): - response[k] = interpolate_text(v, values) + response[k] = interpolator(v, values) return response elif isinstance(response, list): - return [interpolate(i, values) for i in response] + return [interpolate(i, values, method) for i in response] return response diff --git a/rasa/core/nlg/response.py b/rasa/core/nlg/response.py index 987605baef5e..89072567d209 100644 --- a/rasa/core/nlg/response.py +++ b/rasa/core/nlg/response.py @@ -1,5 +1,7 @@ import copy import logging +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.core.constants import DEFAULT_TEMPLATE_ENGINE, TEMPLATE_ENGINE_CONFIG_KEY from rasa.shared.core.trackers import DialogueStateTracker from typing import Text, Any, Dict, Optional, List @@ -7,6 +9,7 @@ from rasa.core.nlg import interpolator from rasa.core.nlg.generator import NaturalLanguageGenerator, ResponseVariationFilter from rasa.shared.constants import RESPONSE_CONDITION +from rasa.shared.nlu.constants import METADATA logger = logging.getLogger(__name__) @@ -69,14 +72,16 @@ async def generate( ) -> Optional[Dict[Text, Any]]: """Generate a response for the requested utter action.""" filled_slots = tracker.current_slot_values() + stack_context = DialogueStack.from_tracker(tracker).current_context() return self.generate_from_slots( - utter_action, filled_slots, output_channel, **kwargs + utter_action, filled_slots, stack_context, output_channel, **kwargs ) def generate_from_slots( self, utter_action: Text, filled_slots: Dict[Text, Any], + stack_context: Dict[Text, Any], output_channel: Text, **kwargs: Any, ) -> Optional[Dict[Text, Any]]: @@ -87,19 +92,25 @@ def generate_from_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) + return self._fill_response(r, filled_slots, stack_context, **kwargs) else: return None def _fill_response( self, response: Dict[Text, Any], - filled_slots: Optional[Dict[Text, Any]] = None, + filled_slots: Dict[Text, Any], + stack_context: Dict[Text, Any], **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) + response_vars = self._response_variables(filled_slots, stack_context, kwargs) + + # template formatting method + method = response.get(METADATA, {}).get( + TEMPLATE_ENGINE_CONFIG_KEY, DEFAULT_TEMPLATE_ENGINE + ) keys_to_interpolate = [ "text", @@ -113,20 +124,26 @@ def _fill_response( for key in keys_to_interpolate: if key in response: response[key] = interpolator.interpolate( - response[key], response_vars + response[key], + response_vars, + method=method, ) return response @staticmethod def _response_variables( - filled_slots: Dict[Text, Any], kwargs: Dict[Text, Any] + filled_slots: Dict[Text, Any], + stack_context: 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 = {} + # copy in the context from the stack + response_vars = {"context": stack_context} # Copying the filled slots in the response variables. - response_vars = filled_slots.copy() + response_vars.update(filled_slots) response_vars.update(kwargs) return response_vars diff --git a/rasa/core/policies/flow_policy.py b/rasa/core/policies/flow_policy.py new file mode 100644 index 000000000000..fcd282e2233e --- /dev/null +++ b/rasa/core/policies/flow_policy.py @@ -0,0 +1,786 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Text, List, Optional + +from jinja2 import Template +from structlog.contextvars import ( + bound_contextvars, +) +from rasa.dialogue_understanding.patterns.internal_error import ( + InternalErrorPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + BaseFlowStackFrame, + DialogueStackFrame, + UserFlowStackFrame, +) +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.patterns.completed import ( + CompletedPatternFlowStackFrame, +) +from rasa.dialogue_understanding.patterns.continue_interrupted import ( + ContinueInterruptedPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import FlowStackFrameType +from rasa.dialogue_understanding.stack.utils import ( + end_top_user_flow, + top_user_flow_frame, +) + +from rasa.core.constants import ( + DEFAULT_POLICY_PRIORITY, + POLICY_MAX_HISTORY, + POLICY_PRIORITY, +) +from pypred import Predicate + +from rasa.shared.constants import FLOW_PREFIX +from rasa.shared.core.constants import ( + ACTION_LISTEN_NAME, + ACTION_SEND_TEXT_NAME, +) +from rasa.shared.core.events import Event, SlotSet +from rasa.shared.core.flows.flow import ( + END_STEP, + ActionFlowStep, + BranchFlowStep, + ContinueFlowStep, + ElseFlowLink, + EndFlowStep, + Flow, + FlowStep, + FlowsList, + GenerateResponseFlowStep, + IfFlowLink, + SlotRejection, + StepThatCanStartAFlow, + UserMessageStep, + LinkFlowStep, + SetSlotsFlowStep, + CollectInformationFlowStep, + StaticFlowLink, +) +from rasa.core.featurizers.tracker_featurizers import TrackerFeaturizer +from rasa.core.policies.policy import Policy, PolicyPrediction +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 Domain +from rasa.shared.core.generator import TrackerWithCachedStates +from rasa.shared.core.trackers import ( + DialogueStateTracker, +) +import structlog + +from rasa.shared.exceptions import RasaException + +structlogger = structlog.get_logger() + +MAX_NUMBER_OF_STEPS = 250 + + +class FlowException(RasaException): + """Exception that is raised when there is a problem with a flow.""" + + pass + + +class FlowCircuitBreakerTrippedException(FlowException): + """Exception that is raised when there is a problem with a flow.""" + + def __init__( + self, dialogue_stack: DialogueStack, number_of_steps_taken: int + ) -> None: + """Creates a `FlowCircuitBreakerTrippedException`. + + Args: + dialogue_stack: The dialogue stack. + number_of_steps_taken: The number of steps that were taken. + """ + super().__init__( + f"Flow circuit breaker tripped after {number_of_steps_taken} steps. " + "There appears to be an infinite loop in the flows." + ) + self.dialogue_stack = dialogue_stack + self.number_of_steps_taken = number_of_steps_taken + + +@DefaultV1Recipe.register( + DefaultV1Recipe.ComponentType.POLICY_WITHOUT_END_TO_END_SUPPORT, is_trainable=False +) +class FlowPolicy(Policy): + """A policy which handles the flow of the conversation based on flows. + + Flows are loaded from files during training. During prediction, + the flows are applied. + """ + + @staticmethod + def does_support_stack_frame(frame: DialogueStackFrame) -> bool: + """Checks if the policy supports the topmost frame on the dialogue stack. + + If `False` is returned, the policy will abstain from making a prediction. + + Args: + frame: The frame to check. + + Returns: + `True` if the policy supports the frame, `False` otherwise.""" + return isinstance(frame, BaseFlowStackFrame) + + @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 { + POLICY_PRIORITY: DEFAULT_POLICY_PRIORITY, + POLICY_MAX_HISTORY: None, + } + + 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.""" + super().__init__(config, model_storage, resource, execution_context, featurizer) + + self.max_history = self.config.get(POLICY_MAX_HISTORY) + self.resource = resource + + 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. + """ + return self.resource + + def predict_action_probabilities( + self, + tracker: DialogueStateTracker, + domain: Domain, + rule_only_data: Optional[Dict[Text, Any]] = None, + flows: Optional[FlowsList] = 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. + flows: The flows to use. + **kwargs: Depending on the specified `needs` section and the resulting + graph structure the policy can use different input to make predictions. + + Returns: + The prediction. + """ + if not self.supports_current_stack_frame(tracker): + # if the policy doesn't support the current stack frame, we'll abstain + return self._prediction(self._default_predictions(domain)) + + flows = flows or FlowsList([]) + executor = FlowExecutor.from_tracker(tracker, flows, domain) + + # create executor and predict next action + try: + prediction = executor.advance_flows(tracker) + return self._create_prediction_result( + prediction.action_name, + domain, + prediction.score, + prediction.events, + prediction.metadata, + ) + except FlowCircuitBreakerTrippedException as e: + structlogger.error( + "flow.circuit_breaker", + dialogue_stack=e.dialogue_stack, + number_of_steps_taken=e.number_of_steps_taken, + event_info=( + "The flow circuit breaker tripped. " + "There appears to be an infinite loop in the flows." + ), + ) + # end the current flow and start the internal error flow + end_top_user_flow(executor.dialogue_stack) + executor.dialogue_stack.push(InternalErrorPatternFlowStackFrame()) + # we retry, with the internal error frame on the stack + prediction = executor.advance_flows(tracker) + return self._create_prediction_result( + prediction.action_name, + domain, + prediction.score, + prediction.events, + prediction.metadata, + ) + + def _create_prediction_result( + self, + action_name: Optional[Text], + domain: Domain, + score: float = 1.0, + events: Optional[List[Event]] = None, + action_metadata: Optional[Dict[Text, Any]] = None, + ) -> PolicyPrediction: + """Creates a prediction result. + + Args: + action_name: The name of the predicted action. + domain: The model's domain. + score: The score of the predicted action. + + Returns: + The prediction result where the score is used for one hot encoding. + """ + result = self._default_predictions(domain) + if action_name: + result[domain.index_for_action(action_name)] = score + return self._prediction( + result, optional_events=events, action_metadata=action_metadata + ) + + +@dataclass +class ActionPrediction: + """Represents an action prediction.""" + + action_name: Optional[Text] + """The name of the predicted action.""" + score: float + """The score of the predicted action.""" + metadata: Optional[Dict[Text, Any]] = None + """The metadata of the predicted action.""" + events: Optional[List[Event]] = None + """The events attached to the predicted action.""" + + +class FlowExecutor: + """Executes a flow.""" + + def __init__( + self, dialogue_stack: DialogueStack, all_flows: FlowsList, domain: Domain + ) -> None: + """Initializes the `FlowExecutor`. + + Args: + dialogue_stack: State of the flow. + all_flows: All flows. + domain: The domain. + """ + self.dialogue_stack = dialogue_stack + self.all_flows = all_flows + self.domain = domain + + @staticmethod + def from_tracker( + tracker: DialogueStateTracker, flows: FlowsList, domain: Domain + ) -> FlowExecutor: + """Creates a `FlowExecutor` from a tracker. + + Args: + tracker: The tracker to create the `FlowExecutor` from. + flows: The flows to use. + domain: The domain to use. + + Returns: + The created `FlowExecutor`. + """ + dialogue_stack = DialogueStack.from_tracker(tracker) + return FlowExecutor(dialogue_stack, flows or FlowsList([]), domain) + + def find_startable_flow(self, tracker: DialogueStateTracker) -> Optional[Flow]: + """Finds a flow which can be started. + + Args: + tracker: The tracker containing the conversation history up to now. + + Returns: + The predicted action and the events to run. + """ + if ( + not tracker.latest_message + or tracker.latest_action_name != ACTION_LISTEN_NAME + ): + # flows can only be started automatically as a response to a user message + return None + + for flow in self.all_flows.underlying_flows: + first_step = flow.first_step_in_flow() + if not first_step or not isinstance(first_step, StepThatCanStartAFlow): + continue + + if first_step.is_triggered(tracker): + return flow + return None + + def is_condition_satisfied( + self, predicate: Text, tracker: "DialogueStateTracker" + ) -> bool: + """Evaluate a predicate condition.""" + + # attach context to the predicate evaluation to allow conditions using it + context = {"context": self.dialogue_stack.current_context()} + document: Dict[str, Any] = context.copy() + for slot in self.domain.slots: + document[slot.name] = tracker.get_slot(slot.name) + p = Predicate(self.render_template_variables(predicate, context)) + try: + return p.evaluate(document) + except (TypeError, Exception) as e: + structlogger.error( + "flow.predicate.error", + predicate=predicate, + document=document, + error=str(e), + ) + return False + + def _select_next_step_id( + self, current: FlowStep, tracker: "DialogueStateTracker" + ) -> Optional[Text]: + """Selects the next step id based on the current step.""" + next = current.next + if len(next.links) == 1 and isinstance(next.links[0], StaticFlowLink): + return next.links[0].target + + # evaluate if conditions + for link in next.links: + if isinstance(link, IfFlowLink) and link.condition: + if self.is_condition_satisfied(link.condition, tracker): + return link.target + + # evaluate else condition + for link in next.links: + if isinstance(link, ElseFlowLink): + return link.target + + if next.links: + structlogger.error( + "flow.link.failed_to_select_branch", + current=current, + links=next.links, + tracker=tracker, + ) + return None + if current.id == END_STEP: + # we are already at the very end of the flow. There is no next step. + return None + elif isinstance(current, LinkFlowStep): + # link steps don't have a next step, so we'll return the end step + return END_STEP + else: + structlogger.error( + "flow.step.failed_to_select_next_step", + step=current, + tracker=tracker, + ) + return None + + def _select_next_step( + self, + tracker: "DialogueStateTracker", + current_step: FlowStep, + flow: Flow, + ) -> Optional[FlowStep]: + """Get the next step to execute.""" + next_id = self._select_next_step_id(current_step, tracker) + step = flow.step_by_id(next_id) + structlogger.debug( + "flow.step.next", + next_id=step.id if step else None, + current_id=current_step.id, + flow_id=flow.id, + ) + return step + + @staticmethod + def render_template_variables(text: str, context: Dict[Text, Any]) -> str: + """Replace context variables in a text.""" + return Template(text).render(context) + + def _is_step_completed( + self, step: FlowStep, tracker: "DialogueStateTracker" + ) -> bool: + """Check if a step is completed.""" + if isinstance(step, CollectInformationFlowStep): + return tracker.get_slot(step.collect) is not None + else: + return True + + def consider_flow_switch(self, tracker: DialogueStateTracker) -> ActionPrediction: + """Consider switching to a new flow. + + Args: + tracker: The tracker to get the next action for. + + Returns: + The predicted action and the events to run. + """ + if new_flow := self.find_startable_flow(tracker): + # there are flows available, but we are not in a flow + # it looks like we can start a flow, so we'll predict the trigger action + structlogger.debug("flow.startable", flow_id=new_flow.id) + return ActionPrediction(FLOW_PREFIX + new_flow.id, 1.0) + else: + structlogger.debug("flow.nostartable") + return ActionPrediction(None, 0.0) + + def advance_flows(self, tracker: DialogueStateTracker) -> ActionPrediction: + """Advance the flows. + + Either start a new flow or advance the current flow. + + Args: + tracker: The tracker to get the next action for. + + Returns: + The predicted action and the events to run. + """ + prediction = self.consider_flow_switch(tracker) + + if prediction.action_name: + # if a flow can be started, we'll start it + return prediction + if self.dialogue_stack.is_empty(): + # if there are no flows, there is nothing to do + return ActionPrediction(None, 0.0) + else: + previous_stack = DialogueStack.get_persisted_stack(tracker) + prediction = self.select_next_action(tracker) + if previous_stack != self.dialogue_stack.as_dict(): + # we need to update dialogue stack to persist the state of the executor + if not prediction.events: + prediction.events = [] + prediction.events.append(self.dialogue_stack.persist_as_event()) + return prediction + + def select_next_action( + self, + tracker: DialogueStateTracker, + ) -> ActionPrediction: + """Select the next action to execute. + + Advances the current flow and returns the next action to execute. A flow + is advanced until it is completed or until it predicts an action. If + the flow is completed, the next flow is popped from the stack and + advanced. If there are no more flows, the action listen is predicted. + + Args: + tracker: The tracker to get the next action for. + + Returns: + The next action to execute, the events that should be applied to the + tracker and the confidence of the prediction. + """ + step_result: FlowStepResult = ContinueFlowWithNextStep() + + tracker = tracker.copy() + + number_of_initial_events = len(tracker.events) + + number_of_steps_taken = 0 + + while isinstance(step_result, ContinueFlowWithNextStep): + + number_of_steps_taken += 1 + if number_of_steps_taken > MAX_NUMBER_OF_STEPS: + raise FlowCircuitBreakerTrippedException( + self.dialogue_stack, number_of_steps_taken + ) + + active_frame = self.dialogue_stack.top() + if not isinstance(active_frame, BaseFlowStackFrame): + # If there is no current flow, we assume that all flows are done + # and there is nothing to do. The assumption here is that every + # flow ends with an action listen. + step_result = PauseFlowReturnPrediction( + ActionPrediction(ACTION_LISTEN_NAME, 1.0) + ) + else: + with bound_contextvars(flow_id=active_frame.flow_id): + structlogger.debug( + "flow.execution.loop", previous_step_id=active_frame.step_id + ) + current_flow = active_frame.flow(self.all_flows) + current_step = self._select_next_step( + tracker, active_frame.step(self.all_flows), current_flow + ) + + if current_step: + self._advance_top_flow_on_stack(current_step.id) + + with bound_contextvars(step_id=current_step.id): + step_result = self.run_step( + current_flow, current_step, tracker + ) + tracker.update_with_events(step_result.events, self.domain) + + gathered_events = list(tracker.events)[number_of_initial_events:] + if isinstance(step_result, PauseFlowReturnPrediction): + prediction = step_result.action_prediction + # make sure we really return all events that got created during the + # step execution of all steps (not only the last one) + prediction.events = gathered_events + return prediction + else: + structlogger.warning("flow.step.execution.no_action") + return ActionPrediction(None, 0.0) + + def _advance_top_flow_on_stack(self, updated_id: str) -> None: + if (top := self.dialogue_stack.top()) and isinstance(top, BaseFlowStackFrame): + top.step_id = updated_id + + def _reset_scoped_slots( + self, current_flow: Flow, tracker: DialogueStateTracker + ) -> List[Event]: + """Reset all scoped slots.""" + + def _reset_slot( + slot_name: Text, dialogue_tracker: DialogueStateTracker + ) -> None: + slot = dialogue_tracker.slots.get(slot_name, None) + initial_value = slot.initial_value if slot else None + events.append(SlotSet(slot_name, initial_value)) + + events: List[Event] = [] + + not_resettable_slot_names = set() + + for step in current_flow.steps: + if isinstance(step, CollectInformationFlowStep): + # reset all slots scoped to the flow + if step.reset_after_flow_ends: + _reset_slot(step.collect, tracker) + else: + not_resettable_slot_names.add(step.collect) + + # slots set by the set slots step should be reset after the flow ends + # unless they are also used in a collect step where `reset_after_flow_ends` + # is set to `False` + resettable_set_slots = [ + slot["key"] + for step in current_flow.steps + if isinstance(step, SetSlotsFlowStep) + for slot in step.slots + if slot["key"] not in not_resettable_slot_names + ] + + for name in resettable_set_slots: + _reset_slot(name, tracker) + + return events + + def run_step( + self, + flow: Flow, + step: FlowStep, + tracker: DialogueStateTracker, + ) -> FlowStepResult: + """Run a single step of a flow. + + Returns the predicted action and a list of events that were generated + during the step. The predicted action can be `None` if the step + doesn't generate an action. The list of events can be empty if the + step doesn't generate any events. + + Raises a `FlowException` if the step is invalid. + + Args: + flow: The flow that the step belongs to. + step: The step to run. + tracker: The tracker to run the step on. + + Returns: + A result of running the step describing where to transition to. + """ + if isinstance(step, CollectInformationFlowStep): + structlogger.debug("flow.step.run.collect") + self.trigger_pattern_ask_collect_information( + step.collect, step.rejections, step.utter + ) + + # reset the slot if its already filled and the collect information shouldn't + # be skipped + slot = tracker.slots.get(step.collect, None) + + if slot and slot.has_been_set and step.ask_before_filling: + events = [SlotSet(step.collect, slot.initial_value)] + else: + events = [] + + return ContinueFlowWithNextStep(events=events) + + elif isinstance(step, ActionFlowStep): + if not step.action: + raise FlowException(f"Action not specified for step {step}") + + context = {"context": self.dialogue_stack.current_context()} + action_name = self.render_template_variables(step.action, context) + + if action_name in self.domain.action_names_or_texts: + structlogger.debug("flow.step.run.action", context=context) + return PauseFlowReturnPrediction(ActionPrediction(action_name, 1.0)) + else: + structlogger.warning("flow.step.run.action.unknown", action=action_name) + return ContinueFlowWithNextStep() + + elif isinstance(step, LinkFlowStep): + structlogger.debug("flow.step.run.link") + self.dialogue_stack.push( + UserFlowStackFrame( + flow_id=step.link, + frame_type=FlowStackFrameType.LINK, + ), + # push this below the current stack frame so that we can + # complete the current flow first and then continue with the + # linked flow + index=-1, + ) + return ContinueFlowWithNextStep() + + elif isinstance(step, SetSlotsFlowStep): + structlogger.debug("flow.step.run.slot") + return ContinueFlowWithNextStep( + events=[SlotSet(slot["key"], slot["value"]) for slot in step.slots], + ) + + elif isinstance(step, UserMessageStep): + structlogger.debug("flow.step.run.user_message") + return ContinueFlowWithNextStep() + + elif isinstance(step, BranchFlowStep): + structlogger.debug("flow.step.run.branch") + return ContinueFlowWithNextStep() + + elif isinstance(step, GenerateResponseFlowStep): + structlogger.debug("flow.step.run.generate_response") + generated = step.generate(tracker) + return PauseFlowReturnPrediction( + ActionPrediction( + ACTION_SEND_TEXT_NAME, + 1.0, + metadata={"message": {"text": generated}}, + ) + ) + + elif isinstance(step, EndFlowStep): + # this is the end of the flow, so we'll pop it from the stack + structlogger.debug("flow.step.run.flow_end") + current_frame = self.dialogue_stack.pop() + self.trigger_pattern_continue_interrupted(current_frame) + self.trigger_pattern_completed(current_frame) + reset_events = self._reset_scoped_slots(flow, tracker) + return ContinueFlowWithNextStep(events=reset_events) + + else: + raise FlowException(f"Unknown flow step type {type(step)}") + + def trigger_pattern_continue_interrupted( + self, current_frame: DialogueStackFrame + ) -> None: + """Trigger the pattern to continue an interrupted flow if needed.""" + # get previously started user flow that will be continued + previous_user_flow_frame = top_user_flow_frame(self.dialogue_stack) + previous_user_flow_step = ( + previous_user_flow_frame.step(self.all_flows) + if previous_user_flow_frame + else None + ) + previous_user_flow = ( + previous_user_flow_frame.flow(self.all_flows) + if previous_user_flow_frame + else None + ) + + if ( + isinstance(current_frame, UserFlowStackFrame) + and previous_user_flow_step + and previous_user_flow + and current_frame.frame_type == FlowStackFrameType.INTERRUPT + and not self.is_step_end_of_flow(previous_user_flow_step) + ): + self.dialogue_stack.push( + ContinueInterruptedPatternFlowStackFrame( + previous_flow_name=previous_user_flow.readable_name(), + ) + ) + + def trigger_pattern_completed(self, current_frame: DialogueStackFrame) -> None: + """Trigger the pattern indicating that the stack is empty, if needed.""" + if self.dialogue_stack.is_empty() and isinstance( + current_frame, UserFlowStackFrame + ): + completed_flow = current_frame.flow(self.all_flows) + completed_flow_name = ( + completed_flow.readable_name() if completed_flow else None + ) + self.dialogue_stack.push( + CompletedPatternFlowStackFrame( + previous_flow_name=completed_flow_name, + ) + ) + + def trigger_pattern_ask_collect_information( + self, + collect: str, + rejections: List[SlotRejection], + utter: str, + ) -> None: + """Trigger the pattern to ask for a slot value.""" + self.dialogue_stack.push( + CollectInformationPatternFlowStackFrame( + collect=collect, + utter=utter, + rejections=rejections, + ) + ) + + @staticmethod + def is_step_end_of_flow(step: FlowStep) -> bool: + """Check if a step is the end of a flow.""" + return ( + step.id == END_STEP + or + # not quite at the end but almost, so we'll treat it as the end + step.id == ContinueFlowStep.continue_step_for_id(END_STEP) + ) + + +class FlowStepResult: + def __init__(self, events: Optional[List[Event]] = None) -> None: + self.events = events or [] + + +class ContinueFlowWithNextStep(FlowStepResult): + def __init__(self, events: Optional[List[Event]] = None) -> None: + super().__init__(events=events) + + +class PauseFlowReturnPrediction(FlowStepResult): + def __init__(self, action_prediction: ActionPrediction) -> None: + self.action_prediction = action_prediction + super().__init__(events=action_prediction.events) diff --git a/rasa/core/policies/policy.py b/rasa/core/policies/policy.py index 4156c5b54a3c..d74769d8f9b6 100644 --- a/rasa/core/policies/policy.py +++ b/rasa/core/policies/policy.py @@ -4,6 +4,10 @@ import logging from enum import Enum from pathlib import Path +from rasa.dialogue_understanding.stack.dialogue_stack import ( + DialogueStack, + DialogueStackFrame, +) from rasa.shared.core.events import Event from typing import ( Any, @@ -105,6 +109,24 @@ def supported_data() -> SupportedData: """ return SupportedData.ML_DATA + @staticmethod + def does_support_stack_frame(frame: DialogueStackFrame) -> bool: + """Returns the stack frames supported by the policy.""" + return False + + def supports_current_stack_frame( + self, tracker: DialogueStateTracker, only_after_user_message: bool = True + ) -> bool: + """Check whether the policy is allowed to act.""" + dialogue_stack = DialogueStack.from_tracker(tracker) + + if top_frame := dialogue_stack.top(): + return self.does_support_stack_frame(top_frame) + elif only_after_user_message and len(tracker.events) > 0: + return not tracker.has_action_after_latest_user_message() + else: + return True + def __init__( self, config: Dict[Text, Any], diff --git a/rasa/core/processor.py b/rasa/core/processor.py index f84cb1ab1e1f..0100b9597399 100644 --- a/rasa/core/processor.py +++ b/rasa/core/processor.py @@ -17,6 +17,7 @@ from rasa.engine.storage.storage import ModelMetadata from rasa.model import get_latest_model from rasa.plugin import plugin_manager +from rasa.shared.core.flows.flow import FlowsList from rasa.shared.data import TrainingType import rasa.shared.utils.io import rasa.core.actions.action @@ -79,7 +80,7 @@ logger = logging.getLogger(__name__) structlogger = structlog.get_logger() -MAX_NUMBER_OF_PREDICTIONS = int(os.environ.get("MAX_NUMBER_OF_PREDICTIONS", "10")) +MAX_NUMBER_OF_PREDICTIONS = int(os.environ.get("MAX_NUMBER_OF_PREDICTIONS", "20")) class MessageProcessor: @@ -651,13 +652,9 @@ async def trigger_external_user_uttered( @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) - ) + slots = {s.name: s.value for s in tracker.slots.values() if s.value is not None} + + structlogger.debug("processor.slots.log", slots=slots) def _check_for_unseen_features(self, parse_data: Dict[Text, Any]) -> None: """Warns the user if the NLU parse data contains unrecognized features. @@ -720,10 +717,10 @@ async def parse_message( if self.http_interpreter: parse_data = await self.http_interpreter.parse(message) else: + # Intent is not explicitly present. Pass message to graph. msg = YAMLStoryReader.unpack_regex_message( message=Message({TEXT: message.text}) ) - # Intent is not explicitly present. Pass message to graph. if msg.data.get(INTENT) is None: parse_data = self._parse_message_with_graph( message, tracker, only_output_properties @@ -854,6 +851,8 @@ async def _run_prediction_loop( # keep taking actions decided by the policy until it chooses to 'listen' should_predict_another_action = True + tracker = self.run_command_processor(tracker) + # 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 @@ -959,6 +958,23 @@ async def _cancel_reminders( ): scheduler.remove_job(scheduled_job.id) + def run_command_processor( + self, tracker: DialogueStateTracker + ) -> DialogueStateTracker: + target = "command_processor" + results = self.graph_runner.run( + inputs={PLACEHOLDER_TRACKER: tracker}, targets=[target] + ) + events = results[target] + tracker.update_with_events(events, self.domain) + return tracker + + def get_flows(self) -> FlowsList: + """Get the list of flows from the graph.""" + target = "flows_provider" + results = self.graph_runner.run(inputs={}, targets=[target]) + return results[target] + async def _run_action( self, action: rasa.core.actions.action.Action, @@ -1044,6 +1060,8 @@ def _log_action_on_tracker( structlogger.debug( "processor.actions.policy_prediction", prediction_events=copy.deepcopy(prediction.events), + policy_name=prediction.policy_name, + action_name=action.name(), ) tracker.update_with_events(prediction.events, self.domain) diff --git a/rasa/core/run.py b/rasa/core/run.py index 6c9463d2c515..a42a7a77876a 100644 --- a/rasa/core/run.py +++ b/rasa/core/run.py @@ -3,7 +3,7 @@ import uuid import os from functools import partial -from typing import Any, List, Optional, Text, Union, Dict +from typing import Any, Callable, List, Optional, Text, Tuple, Union, Dict import rasa.core.utils from rasa.plugin import plugin_manager @@ -96,6 +96,7 @@ def configure_app( syslog_port: Optional[int] = None, syslog_protocol: Optional[Text] = None, request_timeout: Optional[int] = None, + server_listeners: Optional[List[Tuple[Callable, Text]]] = None, ) -> Sanic: """Run the agent.""" rasa.core.utils.configure_file_logging( @@ -147,6 +148,10 @@ async def run_cmdline_io(running_app: Sanic) -> None: app.add_task(run_cmdline_io) + if server_listeners: + for (listener, event) in server_listeners: + app.register_listener(listener, event) + return app @@ -176,6 +181,7 @@ def serve_application( 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: @@ -201,6 +207,7 @@ def serve_application( syslog_port=syslog_port, syslog_protocol=syslog_protocol, request_timeout=request_timeout, + server_listeners=server_listeners, ) ssl_context = server.create_ssl_context( diff --git a/rasa/dialogue_understanding/__init__.py b/rasa/dialogue_understanding/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/dialogue_understanding/commands/__init__.py b/rasa/dialogue_understanding/commands/__init__.py new file mode 100644 index 000000000000..fd437043c2da --- /dev/null +++ b/rasa/dialogue_understanding/commands/__init__.py @@ -0,0 +1,42 @@ +from rasa.dialogue_understanding.commands.command import Command +from rasa.dialogue_understanding.commands.free_form_answer_command import ( + FreeFormAnswerCommand, +) +from rasa.dialogue_understanding.commands.cancel_flow_command import CancelFlowCommand +from rasa.dialogue_understanding.commands.knowledge_answer_command import ( + KnowledgeAnswerCommand, +) +from rasa.dialogue_understanding.commands.chit_chat_answer_command import ( + ChitChatAnswerCommand, +) +from rasa.dialogue_understanding.commands.can_not_handle_command import ( + CannotHandleCommand, +) +from rasa.dialogue_understanding.commands.clarify_command import ClarifyCommand +from rasa.dialogue_understanding.commands.error_command import ErrorCommand +from rasa.dialogue_understanding.commands.set_slot_command import SetSlotCommand +from rasa.dialogue_understanding.commands.start_flow_command import StartFlowCommand +from rasa.dialogue_understanding.commands.human_handoff_command import ( + HumanHandoffCommand, +) +from rasa.dialogue_understanding.commands.correct_slots_command import ( + CorrectSlotsCommand, + CorrectedSlot, +) + + +__all__ = [ + "Command", + "FreeFormAnswerCommand", + "CancelFlowCommand", + "KnowledgeAnswerCommand", + "ChitChatAnswerCommand", + "CannotHandleCommand", + "ClarifyCommand", + "ErrorCommand", + "SetSlotCommand", + "StartFlowCommand", + "HumanHandoffCommand", + "CorrectSlotsCommand", + "CorrectedSlot", +] diff --git a/rasa/dialogue_understanding/commands/can_not_handle_command.py b/rasa/dialogue_understanding/commands/can_not_handle_command.py new file mode 100644 index 000000000000..631cbe378ec4 --- /dev/null +++ b/rasa/dialogue_understanding/commands/can_not_handle_command.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List +from rasa.dialogue_understanding.commands import Command +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + + +@dataclass +class CannotHandleCommand(Command): + """A command to indicate that the bot can't handle the user's input.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "cannot handle" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> CannotHandleCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return CannotHandleCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + return [] diff --git a/rasa/dialogue_understanding/commands/cancel_flow_command.py b/rasa/dialogue_understanding/commands/cancel_flow_command.py new file mode 100644 index 000000000000..904fdc775b82 --- /dev/null +++ b/rasa/dialogue_understanding/commands/cancel_flow_command.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog + +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.patterns.cancel import CancelPatternFlowStackFrame +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import UserFlowStackFrame +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.dialogue_understanding.stack.utils import top_user_flow_frame + +structlogger = structlog.get_logger() + + +@dataclass +class CancelFlowCommand(Command): + """A command to cancel the current flow.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "cancel flow" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> CancelFlowCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return CancelFlowCommand() + + @staticmethod + def select_canceled_frames(stack: DialogueStack) -> List[str]: + """Selects the frames that were canceled. + + Args: + dialogue_stack: The dialogue stack. + current_flow: The current flow. + + Returns: + The frames that were canceled.""" + canceled_frames = [] + # we need to go through the original stack dump in reverse order + # to find the frames that were canceled. we cancel everything from + # the top of the stack until we hit the user flow that was canceled. + # this will also cancel any patterns put on top of that user flow, + # e.g. corrections. + for frame in reversed(stack.frames): + canceled_frames.append(frame.frame_id) + if isinstance(frame, UserFlowStackFrame): + return canceled_frames + else: + # we should never get here as we should always find the user flow + # that was canceled. + raise ValueError( + f"Could not find a user flow frame to cancel. " + f"Current stack: {stack}." + ) + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + + stack = DialogueStack.from_tracker(tracker) + original_stack = DialogueStack.from_tracker(original_tracker) + user_frame = top_user_flow_frame(original_stack) + current_flow = user_frame.flow(all_flows) if user_frame else None + + if not current_flow: + structlogger.debug( + "command_executor.skip_cancel_flow.no_active_flow", command=self + ) + return [] + + # we pass in the original dialogue stack (before any of the currently + # predicted commands were applied) to make sure we don't cancel any + # frames that were added by the currently predicted commands. + canceled_frames = self.select_canceled_frames(original_stack) + + stack.push( + CancelPatternFlowStackFrame( + canceled_name=current_flow.readable_name(), + canceled_frames=canceled_frames, + ) + ) + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/commands/chit_chat_answer_command.py b/rasa/dialogue_understanding/commands/chit_chat_answer_command.py new file mode 100644 index 000000000000..e8559e46c820 --- /dev/null +++ b/rasa/dialogue_understanding/commands/chit_chat_answer_command.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List +from rasa.dialogue_understanding.commands import FreeFormAnswerCommand +from rasa.dialogue_understanding.patterns.chitchat import ChitchatPatternFlowStackFrame +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + + +@dataclass +class ChitChatAnswerCommand(FreeFormAnswerCommand): + """A command to indicate a chitchat style free-form answer by the bot.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "chitchat" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> ChitChatAnswerCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return ChitChatAnswerCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + stack = DialogueStack.from_tracker(tracker) + stack.push(ChitchatPatternFlowStackFrame()) + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/commands/clarify_command.py b/rasa/dialogue_understanding/commands/clarify_command.py new file mode 100644 index 000000000000..21bbd9ec6f51 --- /dev/null +++ b/rasa/dialogue_understanding/commands/clarify_command.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.patterns.clarify import ClarifyPatternFlowStackFrame +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger() + + +@dataclass +class ClarifyCommand(Command): + """A command to indicate that the bot should ask for clarification.""" + + options: List[str] + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "clarify" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> ClarifyCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + try: + return ClarifyCommand(options=data["options"]) + except KeyError as e: + raise ValueError( + f"Missing parameter '{e}' while parsing ClarifyCommand." + ) from e + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + + flows = [all_flows.flow_by_id(opt) for opt in self.options] + clean_options = [flow.id for flow in flows if flow is not None] + if len(clean_options) != len(self.options): + structlogger.debug( + "command_executor.altered_command.dropped_clarification_options", + command=self, + original_options=self.options, + cleaned_options=clean_options, + ) + if len(clean_options) == 0: + structlogger.debug( + "command_executor.skip_command.empty_clarification", command=self + ) + return [] + + stack = DialogueStack.from_tracker(tracker) + relevant_flows = [all_flows.flow_by_id(opt) for opt in clean_options] + names = [flow.readable_name() for flow in relevant_flows if flow is not None] + stack.push(ClarifyPatternFlowStackFrame(names=names)) + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/commands/command.py b/rasa/dialogue_understanding/commands/command.py new file mode 100644 index 000000000000..23485c072d8f --- /dev/null +++ b/rasa/dialogue_understanding/commands/command.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +import dataclasses +from typing import Any, Dict, List +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +import rasa.shared.utils.common + + +@dataclass +class Command: + """A command that can be executed on a tracker.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the command.""" + raise NotImplementedError() + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + raise NotImplementedError() + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Command: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + raise NotImplementedError() + + @staticmethod + def command_from_json(data: Dict[str, Any]) -> "Command": + """Converts a dictionary to a command object. + + First, resolves the command type and then converts the dictionary to + the corresponding command object. + + Args: + data: The dictionary to convert. + + Returns: + The converted command object. + """ + for cls in rasa.shared.utils.common.all_subclasses(Command): + try: + if data.get("command") == cls.command(): + return cls.from_dict(data) + except NotImplementedError: + # we don't want to raise an error if the frame type is not + # implemented, as this is ok to be raised by an abstract class + pass + else: + raise ValueError(f"Unknown command type: {data}") + + def as_dict(self) -> Dict[str, Any]: + """Converts the command to a dictionary. + + Returns: + The converted dictionary. + """ + data = dataclasses.asdict(self) + data["command"] = self.command() + return data + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + raise NotImplementedError() diff --git a/rasa/dialogue_understanding/commands/correct_slots_command.py b/rasa/dialogue_understanding/commands/correct_slots_command.py new file mode 100644 index 000000000000..bc29b90b6a9f --- /dev/null +++ b/rasa/dialogue_understanding/commands/correct_slots_command.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import structlog + +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.patterns.correction import ( + FLOW_PATTERN_CORRECTION_ID, + CorrectionPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import ( + BaseFlowStackFrame, + UserFlowStackFrame, +) +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep, FlowStep, FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +import rasa.dialogue_understanding.stack.utils as utils + +structlogger = structlog.get_logger() + + +@dataclass +class CorrectedSlot: + """A slot that was corrected.""" + + name: str + value: Any + + +@dataclass +class CorrectSlotsCommand(Command): + """A command to correct the value of a slot.""" + + corrected_slots: List[CorrectedSlot] + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "correct slot" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> CorrectSlotsCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + try: + return CorrectSlotsCommand( + corrected_slots=[ + CorrectedSlot(s["name"], value=s["value"]) + for s in data["corrected_slots"] + ] + ) + except KeyError as e: + raise ValueError( + f"Missing key when parsing CorrectSlotsCommand: {e}" + ) from e + + @staticmethod + def are_all_slots_reset_only( + proposed_slots: Dict[str, Any], all_flows: FlowsList + ) -> bool: + """Checks if all slots are reset only. + + A slot is reset only if the `collect` step it gets filled by + has the `ask_before_filling` flag set to `True`. This means, the slot + shouldn't be filled if the question isn't asked. + + If such a slot gets corrected, we don't want to correct the slot but + instead reset the flow to the question where the slot was asked. + + Args: + proposed_slots: The proposed slots. + all_flows: All flows in the assistant. + + Returns: + `True` if all slots are reset only, `False` otherwise. + """ + return all( + collect_step.collect not in proposed_slots + or collect_step.ask_before_filling + for flow in all_flows.underlying_flows + for collect_step in flow.get_collect_steps() + ) + + @staticmethod + def find_earliest_updated_collect_info( + user_frame: UserFlowStackFrame, + updated_slots: List[str], + all_flows: FlowsList, + ) -> Optional[FlowStep]: + """Find the earliest collect information step that fills one of the slots. + + When we update slots, we need to reset a flow to the question when the slot + was asked. This function finds the earliest collect information step that + fills one of the slots - with the idea being that we afterwards go through + the other updated slots. + + Args: + user_frame: The current user flow frame. + updated_slots: The slots that were updated. + all_flows: All flows. + + Returns: + The earliest collect information step that fills one of the slots. + """ + flow = user_frame.flow(all_flows) + step = user_frame.step(all_flows) + # TODO: DM2 rethink the jumping back behaviour we use for corrections. + # Currently we move backwards from a given step id but this could + # technically result in wrong results in cases of branches merging + # again. If you call this method on a merge step or after it, you'll + # get all slots from both branches, although there was only one taken. + # You could get this in our verify account flow if you call this on the + # final confirm step. I think you'll get the income question even + # if you went the not based in ca route. Maybe it's not a problem. + # The way to get the exact set of slots would probably simulate the + # flow forwards from the starting step. Given the current slots you + # could chart the path to the current step id. + asked_collect_steps = flow.previous_collect_steps(step.id) + + for collect_step in reversed(asked_collect_steps): + if collect_step.collect in updated_slots: + return collect_step + return None + + def corrected_slots_dict(self, tracker: DialogueStateTracker) -> Dict[str, Any]: + """Returns the slots that should be corrected. + + Filters out slots, that are already set to the correct value. + + Args: + tracker: The tracker. + + Returns: + A dict with the slots and their values that should be corrected. + """ + proposed_slots = {} + for corrected_slot in self.corrected_slots: + if tracker.get_slot(corrected_slot.name) != corrected_slot.value: + proposed_slots[corrected_slot.name] = corrected_slot.value + else: + structlogger.debug( + "command_executor.skip_correction.slot_already_set", command=self + ) + return proposed_slots + + @staticmethod + def index_for_correction_frame( + top_flow_frame: BaseFlowStackFrame, stack: DialogueStack + ) -> int: + """Returns the index for the correction frame. + + Args: + top_flow_frame: The top flow frame. + stack: The stack. + + Returns: + The index for the correction frame. + """ + if top_flow_frame.flow_id != FLOW_PATTERN_CORRECTION_ID: + # we are not in a correction flow, so we can just push the correction + # frame on top of the stack + return len(stack.frames) + else: + # we allow the previous correction to finish first before + # starting the new one. that's why we insert the new correction below + # the previous one. + for i, frame in enumerate(stack.frames): + if frame.frame_id == top_flow_frame.frame_id: + return i + else: + # we should never get here as we should always find the previous + # correction frame + raise ValueError( + f"Could not find the previous correction frame " + f"{top_flow_frame.frame_id} on the stack {stack}." + ) + + @staticmethod + def end_previous_correction( + top_flow_frame: BaseFlowStackFrame, stack: DialogueStack + ) -> None: + """Ends the previous correction. + + If the top flow frame is already a correction, we wrap up the previous + correction before starting the new one. All frames that were added + after that correction and the correction itself will be set to continue + at the END step. + + Args: + top_flow_frame: The top flow frame. + stack: The stack. + """ + if top_flow_frame.flow_id != FLOW_PATTERN_CORRECTION_ID: + # only need to end something if we are already in a correction + return + + for frame in reversed(stack.frames): + if isinstance(frame, BaseFlowStackFrame): + frame.step_id = ContinueFlowStep.continue_step_for_id(END_STEP) + if frame.frame_id == top_flow_frame.frame_id: + break + + @classmethod + def create_correction_frame( + cls, + user_frame: Optional[BaseFlowStackFrame], + proposed_slots: Dict[str, Any], + all_flows: FlowsList, + ) -> CorrectionPatternFlowStackFrame: + """Creates a correction frame. + + Args: + user_frame: The user frame. + proposed_slots: The proposed slots. + all_flows: All flows in the assistant. + + Returns: + The correction frame. + """ + if user_frame: + # check if all corrected slots have ask_before_filling=True + # if this is a case, we are not correcting a value but we + # are resetting the slots and jumping back to the first question + is_reset_only = cls.are_all_slots_reset_only(proposed_slots, all_flows) + + reset_step = cls.find_earliest_updated_collect_info( + user_frame, proposed_slots, all_flows + ) + return CorrectionPatternFlowStackFrame( + is_reset_only=is_reset_only, + corrected_slots=proposed_slots, + reset_flow_id=user_frame.flow_id, + reset_step_id=reset_step.id if reset_step else None, + ) + else: + return CorrectionPatternFlowStackFrame( + corrected_slots=proposed_slots, + ) + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + stack = DialogueStack.from_tracker(tracker) + user_frame = utils.top_user_flow_frame(stack) + + top_flow_frame = utils.top_flow_frame(stack) + if not top_flow_frame: + # we shouldn't end up here as a correction shouldn't be triggered + # if we are not in any flow. but just in case we do, we + # just skip the command. + structlogger.warning( + "command_executor.correct_slots.no_active_flow", command=self + ) + return [] + + structlogger.debug("command_executor.correct_slots", command=self) + proposed_slots = self.corrected_slots_dict(tracker) + + correction_frame = self.create_correction_frame( + user_frame, proposed_slots, all_flows + ) + insertion_index = self.index_for_correction_frame(top_flow_frame, stack) + self.end_previous_correction(top_flow_frame, stack) + + stack.push(correction_frame, index=insertion_index) + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/commands/error_command.py b/rasa/dialogue_understanding/commands/error_command.py new file mode 100644 index 000000000000..da5b3fbaf393 --- /dev/null +++ b/rasa/dialogue_understanding/commands/error_command.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.patterns.internal_error import ( + InternalErrorPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger() + + +@dataclass +class ErrorCommand(Command): + """A command to indicate that the bot failed to handle the dialogue.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "error" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> ErrorCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return ErrorCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + dialogue_stack = DialogueStack.from_tracker(tracker) + structlogger.debug("command_executor.error", command=self) + dialogue_stack.push(InternalErrorPatternFlowStackFrame()) + return [dialogue_stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/commands/free_form_answer_command.py b/rasa/dialogue_understanding/commands/free_form_answer_command.py new file mode 100644 index 000000000000..13bac7e5ff5c --- /dev/null +++ b/rasa/dialogue_understanding/commands/free_form_answer_command.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from rasa.dialogue_understanding.commands import Command + + +@dataclass +class FreeFormAnswerCommand(Command): + """A command to indicate a free-form answer by the bot.""" + + pass diff --git a/rasa/dialogue_understanding/commands/handle_code_change_command.py b/rasa/dialogue_understanding/commands/handle_code_change_command.py new file mode 100644 index 000000000000..c54bce685f17 --- /dev/null +++ b/rasa/dialogue_understanding/commands/handle_code_change_command.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog + +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.patterns.code_change import CodeChangeFlowStackFrame +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import Event, SlotSet +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.dialogue_understanding.stack.utils import top_user_flow_frame + +structlogger = structlog.get_logger() + + +@dataclass +class HandleCodeChangeCommand(Command): + """A that is executed when the flows have changed.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "handle code change" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> HandleCodeChangeCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return HandleCodeChangeCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + + stack = DialogueStack.from_tracker(tracker) + original_stack = DialogueStack.from_tracker(original_tracker) + user_frame = top_user_flow_frame(original_stack) + current_flow = user_frame.flow(all_flows) if user_frame else None + + if not current_flow: + structlogger.debug( + "handle_code_change_command.skip.no_active_flow", command=self + ) + return [] + + stack.push(CodeChangeFlowStackFrame()) + return [SlotSet(DIALOGUE_STACK_SLOT, stack.as_dict())] diff --git a/rasa/dialogue_understanding/commands/human_handoff_command.py b/rasa/dialogue_understanding/commands/human_handoff_command.py new file mode 100644 index 000000000000..a91630018c50 --- /dev/null +++ b/rasa/dialogue_understanding/commands/human_handoff_command.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List +from rasa.dialogue_understanding.commands import Command +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + + +@dataclass +class HumanHandoffCommand(Command): + """A command to indicate that the bot should handoff to a human.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "human handoff" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> HumanHandoffCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return HumanHandoffCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + return [] diff --git a/rasa/dialogue_understanding/commands/knowledge_answer_command.py b/rasa/dialogue_understanding/commands/knowledge_answer_command.py new file mode 100644 index 000000000000..bcac001b2c57 --- /dev/null +++ b/rasa/dialogue_understanding/commands/knowledge_answer_command.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List +from rasa.dialogue_understanding.commands import FreeFormAnswerCommand +from rasa.dialogue_understanding.patterns.search import SearchPatternFlowStackFrame +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + + +@dataclass +class KnowledgeAnswerCommand(FreeFormAnswerCommand): + """A command to indicate a knowledge-based free-form answer by the bot.""" + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "knowledge" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> KnowledgeAnswerCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + return KnowledgeAnswerCommand() + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + dialogue_stack = DialogueStack.from_tracker(tracker) + dialogue_stack.push(SearchPatternFlowStackFrame()) + return [dialogue_stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/commands/set_slot_command.py b/rasa/dialogue_understanding/commands/set_slot_command.py new file mode 100644 index 000000000000..b9f9de59ca7b --- /dev/null +++ b/rasa/dialogue_understanding/commands/set_slot_command.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.utils import filled_slots_for_active_flow +from rasa.shared.core.events import Event, SlotSet +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger() + + +@dataclass +class SetSlotCommand(Command): + """A command to set a slot.""" + + name: str + value: Any + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "set slot" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> SetSlotCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + try: + return SetSlotCommand(name=data["name"], value=data["value"]) + except KeyError as e: + raise ValueError(f"Missing key when parsing SetSlotCommand: {e}") from e + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + stack = DialogueStack.from_tracker(tracker) + slots_so_far = filled_slots_for_active_flow(stack, all_flows) + if tracker.get_slot(self.name) == self.value: + # value hasn't changed, skip this one + structlogger.debug( + "command_executor.skip_command.slot_already_set", command=self + ) + return [] + if self.name not in slots_so_far: + # only fill slots that belong to a collect infos that can be asked + use_slot_fill = any( + step.collect == self.name and not step.ask_before_filling + for flow in all_flows.underlying_flows + for step in flow.get_collect_steps() + ) + + if not use_slot_fill: + structlogger.debug( + "command_executor.skip_command.slot_not_asked_for", command=self + ) + return [] + + structlogger.debug("command_executor.set_slot", command=self) + return [SlotSet(self.name, self.value)] diff --git a/rasa/dialogue_understanding/commands/start_flow_command.py b/rasa/dialogue_understanding/commands/start_flow_command.py new file mode 100644 index 000000000000..3604d46afff5 --- /dev/null +++ b/rasa/dialogue_understanding/commands/start_flow_command.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List + +import structlog +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import ( + FlowStackFrameType, + UserFlowStackFrame, +) +from rasa.dialogue_understanding.stack.utils import ( + top_user_flow_frame, + user_flows_on_the_stack, +) +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + +structlogger = structlog.get_logger() + + +@dataclass +class StartFlowCommand(Command): + """A command to start a flow.""" + + flow: str + + @classmethod + def command(cls) -> str: + """Returns the command type.""" + return "start flow" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> StartFlowCommand: + """Converts the dictionary to a command. + + Returns: + The converted dictionary. + """ + try: + return StartFlowCommand(flow=data["flow"]) + except KeyError as e: + raise ValueError( + f"Missing parameter '{e}' while parsing StartFlowCommand." + ) from e + + def run_command_on_tracker( + self, + tracker: DialogueStateTracker, + all_flows: FlowsList, + original_tracker: DialogueStateTracker, + ) -> List[Event]: + """Runs the command on the tracker. + + Args: + tracker: The tracker to run the command on. + all_flows: All flows in the assistant. + original_tracker: The tracker before any command was executed. + + Returns: + The events to apply to the tracker. + """ + stack = DialogueStack.from_tracker(tracker) + original_stack = DialogueStack.from_tracker(original_tracker) + + if self.flow in user_flows_on_the_stack(stack): + structlogger.debug( + "command_executor.skip_command.already_started_flow", command=self + ) + return [] + elif self.flow not in all_flows.user_flow_ids: + structlogger.debug( + "command_executor.skip_command.start_invalid_flow_id", command=self + ) + return [] + + original_user_frame = top_user_flow_frame(original_stack) + original_top_flow = ( + original_user_frame.flow(all_flows) if original_user_frame else None + ) + frame_type = ( + FlowStackFrameType.INTERRUPT + if original_top_flow + else FlowStackFrameType.REGULAR + ) + structlogger.debug("command_executor.start_flow", command=self) + stack.push(UserFlowStackFrame(flow_id=self.flow, frame_type=frame_type)) + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/generator/__init__.py b/rasa/dialogue_understanding/generator/__init__.py new file mode 100644 index 000000000000..8d4efbb2bc73 --- /dev/null +++ b/rasa/dialogue_understanding/generator/__init__.py @@ -0,0 +1,6 @@ +from rasa.dialogue_understanding.generator.command_generator import CommandGenerator +from rasa.dialogue_understanding.generator.llm_command_generator import ( + LLMCommandGenerator, +) + +__all__ = ["CommandGenerator", "LLMCommandGenerator"] diff --git a/rasa/dialogue_understanding/generator/command_generator.py b/rasa/dialogue_understanding/generator/command_generator.py new file mode 100644 index 000000000000..44dee5058c94 --- /dev/null +++ b/rasa/dialogue_understanding/generator/command_generator.py @@ -0,0 +1,66 @@ +from typing import List, Optional +import structlog +from rasa.dialogue_understanding.commands import Command +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.training_data.message import Message +from rasa.shared.nlu.constants import COMMANDS + +structlogger = structlog.get_logger() + + +class CommandGenerator: + """A command generator. + + Parses a message and returns a list of commands. The commands are then + executed and will lead to tracker state modifications and action + predictions.""" + + def process( + self, + messages: List[Message], + flows: FlowsList, + tracker: Optional[DialogueStateTracker] = None, + ) -> List[Message]: + """Process a list of messages. For each message predict commands. + + The result of the generation is added to the message as a list of + commands. + + Args: + messages: The messages to process. + tracker: The tracker containing the conversation history up to now. + flows: The flows to use for command prediction. + + Returns: + The processed messages (usually this is just one during prediction). + """ + for message in messages: + try: + commands = self.predict_commands(message, flows, tracker) + except Exception as e: + if isinstance(e, NotImplementedError): + raise e + structlogger.error("command_generator.predict.error", error=e) + commands = [] + commands_dicts = [command.as_dict() for command in commands] + message.set(COMMANDS, commands_dicts, add_to_output=True) + return messages + + def predict_commands( + self, + message: Message, + flows: FlowsList, + tracker: Optional[DialogueStateTracker] = None, + ) -> List[Command]: + """Predict commands for a single message. + + Args: + message: The message to predict commands for. + flows: The flows to use for command prediction. + tracker: The tracker containing the conversation history up to now. + + Returns: + The predicted commands. + """ + raise NotImplementedError() diff --git a/rasa/dialogue_understanding/generator/command_prompt_template.jinja2 b/rasa/dialogue_understanding/generator/command_prompt_template.jinja2 new file mode 100644 index 000000000000..f68e5d3125f2 --- /dev/null +++ b/rasa/dialogue_understanding/generator/command_prompt_template.jinja2 @@ -0,0 +1,55 @@ +Your task is to analyze the current conversation context and generate a list of actions to start new business processes that we call flows, to extract slots, or respond to small talk and knowledge requests. + +These are the flows that can be started, with their description and slots: +{% for flow in available_flows %} +{{ flow.name }}: {{ flow.description }} + {% for slot in flow.slots -%} + slot: {{ slot.name }}{% if slot.description %} ({{ slot.description }}){% endif %}{% if slot.allowed_values %}, allowed values: {{ slot.allowed_values }}{% endif %} + {% endfor %} +{%- endfor %} + +=== +Here is what happened previously in the conversation: +{{ current_conversation }} + +=== +{% if current_flow != None %} +You are currently in the flow "{{ current_flow }}". +You have just asked the user for the slot "{{ current_slot }}"{% if current_slot_description %} ({{ current_slot_description }}){% endif %}. + +{% if flow_slots|length > 0 %} +Here are the slots of the currently active flow: +{% for slot in flow_slots -%} +- name: {{ slot.name }}, value: {{ slot.value }}, type: {{ slot.type }}, description: {{ slot.description}}{% if slot.allowed_values %}, allowed values: {{ slot.allowed_values }}{% endif %} +{% endfor %} +{% endif %} +{% else %} +You are currently not in any flow and so there are no active slots. +This means you can only set a slot if you first start a flow that requires that slot. +{% endif %} +If you start a flow, first start the flow and then optionally fill that flow's slots with information the user provided in their message. + +The user just said """{{ user_message }}""". + +=== +Based on this information generate a list of actions you want to take. Your job is to start flows and to fill slots where appropriate. Any logic of what happens afterwards is handled by the flow engine. These are your available actions: +* Slot setting, described by "SetSlot(slot_name, slot_value)". An example would be "SetSlot(recipient, Freddy)" +* Starting another flow, described by "StartFlow(flow_name)". An example would be "StartFlow(transfer_money)" +* Cancelling the current flow, described by "CancelFlow()" +* Clarifying which flow should be started. An example would be Clarify(list_contacts, add_contact, remove_contact) if the user just wrote "contacts" and there are multiple potential candidates. It also works with a single flow name to confirm you understood correctly, as in Clarify(transfer_money). +* Responding to knowledge-oriented user messages, described by "SearchAndReply()" +* Responding to a casual, non-task-oriented user message, described by "ChitChat()". +* Handing off to a human, in case the user seems frustrated or explicitly asks to speak to one, described by "HumanHandoff()". + +=== +Write out the actions you want to take, one per line, in the order they should take place. +Do not fill slots with abstract values or placeholders. +Only use information provided by the user. +Only start a flow if it's completely clear what the user wants. Imagine you were a person reading this message. If it's not 100% clear, clarify the next step. +Don't be overly confident. Take a conservative approach and clarify before proceeding. +If the user asks for two things which seem contradictory, clarify before starting a flow. +Strictly adhere to the provided action types listed above. +Focus on the last message and take it one step at a time. +Use the previous conversation steps only to aid understanding. + +Your action list: diff --git a/rasa/dialogue_understanding/generator/llm_command_generator.py b/rasa/dialogue_understanding/generator/llm_command_generator.py new file mode 100644 index 000000000000..42d7667fb476 --- /dev/null +++ b/rasa/dialogue_understanding/generator/llm_command_generator.py @@ -0,0 +1,469 @@ +import importlib.resources +import re +from typing import Dict, Any, List, Optional, Tuple, Union + +from jinja2 import Template +import structlog + +from rasa.dialogue_understanding.stack.utils import top_flow_frame +from rasa.dialogue_understanding.generator import CommandGenerator +from rasa.dialogue_understanding.commands import ( + Command, + ErrorCommand, + SetSlotCommand, + CancelFlowCommand, + StartFlowCommand, + HumanHandoffCommand, + ChitChatAnswerCommand, + KnowledgeAnswerCommand, + ClarifyCommand, +) +from rasa.core.policies.flow_policy import DialogueStack +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.core.flows.flow import ( + Flow, + FlowStep, + FlowsList, + CollectInformationFlowStep, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.slots import ( + BooleanSlot, + CategoricalSlot, + FloatSlot, + Slot, + bool_from_any, +) +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.shared.utils.llm import ( + DEFAULT_OPENAI_CHAT_MODEL_NAME_ADVANCED, + get_prompt_template, + llm_factory, + tracker_as_readable_transcript, + sanitize_message_for_prompt, +) + +DEFAULT_COMMAND_PROMPT_TEMPLATE = importlib.resources.read_text( + "rasa.dialogue_understanding.generator", "command_prompt_template.jinja2" +) + +DEFAULT_LLM_CONFIG = { + "_type": "openai", + "request_timeout": 7, + "temperature": 0.0, + "model_name": DEFAULT_OPENAI_CHAT_MODEL_NAME_ADVANCED, +} + +LLM_CONFIG_KEY = "llm" + +structlogger = structlog.get_logger() + + +@DefaultV1Recipe.register( + [ + DefaultV1Recipe.ComponentType.COMMAND_GENERATOR, + ], + is_trainable=True, +) +class LLMCommandGenerator(GraphComponent, CommandGenerator): + """An LLM-based command generator.""" + + @staticmethod + def get_default_config() -> Dict[str, Any]: + """The component's default config (see parent class for full docstring).""" + return {"prompt": None, LLM_CONFIG_KEY: None} + + def __init__( + self, + config: Dict[str, Any], + model_storage: ModelStorage, + resource: Resource, + ) -> None: + self.config = {**self.get_default_config(), **config} + self.prompt_template = get_prompt_template( + config.get("prompt"), + DEFAULT_COMMAND_PROMPT_TEMPLATE, + ) + self._model_storage = model_storage + self._resource = resource + + @classmethod + def create( + cls, + config: Dict[str, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> "LLMCommandGenerator": + """Creates a new untrained component (see parent class for full docstring).""" + return cls(config, model_storage, resource) + + @classmethod + def load( + cls, + config: Dict[str, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> "LLMCommandGenerator": + """Loads trained component (see parent class for full docstring).""" + return cls(config, model_storage, resource) + + def persist(self) -> None: + pass + + def train(self, training_data: TrainingData) -> Resource: + """Train the intent classifier on a data set.""" + self.persist() + return self._resource + + def predict_commands( + self, + message: Message, + flows: FlowsList, + tracker: Optional[DialogueStateTracker] = None, + ) -> List[Command]: + """Predict commands using the LLM. + + Args: + message: The message from the user. + flows: The flows available to the user. + tracker: The tracker containing the current state of the conversation. + + Returns: + The commands generated by the llm. + """ + if tracker is None or flows.is_empty(): + # cannot do anything if there are no flows or no tracker + return [] + flow_prompt = self.render_template(message, tracker, flows) + structlogger.info( + "llm_command_generator.predict_commands.prompt_rendered", prompt=flow_prompt + ) + action_list = self._generate_action_list_using_llm(flow_prompt) + structlogger.info( + "llm_command_generator.predict_commands.actions_generated", + action_list=action_list, + ) + commands = self.parse_commands(action_list, tracker) + structlogger.info( + "llm_command_generator.predict_commands.finished", + commands=commands, + ) + + return commands + + def render_template( + self, message: Message, tracker: DialogueStateTracker, flows: FlowsList + ) -> str: + """Render the jinja template to create the prompt for the LLM. + + Args: + message: The current message from the user. + tracker: The tracker containing the current state of the conversation. + flows: The flows available to the user. + + Returns: + The rendered prompt template. + """ + top_relevant_frame = top_flow_frame(DialogueStack.from_tracker(tracker)) + top_flow = top_relevant_frame.flow(flows) if top_relevant_frame else None + current_step = top_relevant_frame.step(flows) if top_relevant_frame else None + + flow_slots = self.prepare_current_flow_slots_for_template( + top_flow, current_step, tracker + ) + current_slot, current_slot_description = self.prepare_current_slot_for_template( + current_step + ) + current_conversation = tracker_as_readable_transcript(tracker) + latest_user_message = sanitize_message_for_prompt(message.get(TEXT)) + current_conversation += f"\nUSER: {latest_user_message}" + + inputs = { + "available_flows": self.prepare_flows_for_template(flows, tracker), + "current_conversation": current_conversation, + "flow_slots": flow_slots, + "current_flow": top_flow.id if top_flow is not None else None, + "current_slot": current_slot, + "current_slot_description": current_slot_description, + "user_message": latest_user_message, + } + + return Template(self.prompt_template).render(**inputs) + + def _generate_action_list_using_llm(self, prompt: str) -> Optional[str]: + """Use LLM to generate a response. + + Args: + prompt: The prompt to send to the LLM. + + Returns: + The generated text. + """ + llm = llm_factory(self.config.get(LLM_CONFIG_KEY), DEFAULT_LLM_CONFIG) + + try: + return llm(prompt) + except Exception as e: + # unfortunately, langchain does not wrap LLM exceptions which means + # we have to catch all exceptions here + structlogger.error("llm_command_generator.llm.error", error=e) + return None + + @classmethod + def parse_commands( + cls, actions: Optional[str], tracker: DialogueStateTracker + ) -> List[Command]: + """Parse the actions returned by the llm into intent and entities. + + Args: + actions: The actions returned by the llm. + tracker: The tracker containing the current state of the conversation. + + Returns: + The parsed commands. + """ + if not actions: + return [ErrorCommand()] + + commands: List[Command] = [] + + slot_set_re = re.compile( + r"""SetSlot\(([a-zA-Z_][a-zA-Z0-9_-]*?), ?\"?([^)]*?)\"?\)""" + ) + start_flow_re = re.compile(r"StartFlow\(([a-zA-Z_][a-zA-Z0-9_-]*?)\)") + cancel_flow_re = re.compile(r"CancelFlow\(\)") + chitchat_re = re.compile(r"ChitChat\(\)") + knowledge_re = re.compile(r"SearchAndReply\(\)") + humand_handoff_re = re.compile(r"HumanHandoff\(\)") + clarify_re = re.compile(r"Clarify\(([a-zA-Z0-9_, ]+)\)") + + for action in actions.strip().splitlines(): + if match := slot_set_re.search(action): + slot_name = match.group(1).strip() + slot_value = cls.clean_extracted_value(match.group(2)) + # error case where the llm tries to start a flow using a slot set + if slot_name == "flow_name": + commands.append(StartFlowCommand(flow=slot_value)) + else: + typed_slot_value = cls.coerce_slot_value( + slot_value, slot_name, tracker + ) + commands.append( + SetSlotCommand(name=slot_name, value=typed_slot_value) + ) + elif match := start_flow_re.search(action): + commands.append(StartFlowCommand(flow=match.group(1).strip())) + elif cancel_flow_re.search(action): + commands.append(CancelFlowCommand()) + elif chitchat_re.search(action): + commands.append(ChitChatAnswerCommand()) + elif knowledge_re.search(action): + commands.append(KnowledgeAnswerCommand()) + elif humand_handoff_re.search(action): + commands.append(HumanHandoffCommand()) + elif match := clarify_re.search(action): + options = [opt.strip() for opt in match.group(1).split(",")] + commands.append(ClarifyCommand(options)) + + return commands + + @staticmethod + def is_none_value(value: str) -> bool: + """Check if the value is a none value.""" + return value in { + "[missing information]", + "[missing]", + "None", + "undefined", + "null", + } + + @staticmethod + def clean_extracted_value(value: str) -> str: + """Clean up the extracted value from the llm.""" + # replace any combination of single quotes, double quotes, and spaces + # from the beginning and end of the string + return value.strip("'\" ") + + @classmethod + def coerce_slot_value( + cls, slot_value: str, slot_name: str, tracker: DialogueStateTracker + ) -> Union[str, bool, float, None]: + """Coerce the slot value to the correct type. + + Tries to coerce the slot value to the correct type. If the + conversion fails, `None` is returned. + + Args: + value: The value to coerce. + slot_name: The name of the slot. + tracker: The tracker containing the current state of the conversation. + + Returns: + The coerced value or `None` if the conversion failed. + """ + nullable_value = slot_value if not cls.is_none_value(slot_value) else None + if slot_name not in tracker.slots: + return nullable_value + + slot = tracker.slots[slot_name] + if isinstance(slot, BooleanSlot): + try: + return bool_from_any(nullable_value) + except (ValueError, TypeError): + return None + elif isinstance(slot, FloatSlot): + try: + return float(nullable_value) + except (ValueError, TypeError): + return None + else: + return nullable_value + + def prepare_flows_for_template( + self, flows: FlowsList, tracker: DialogueStateTracker + ) -> List[Dict[str, Any]]: + """Format data on available flows for insertion into the prompt template. + + Args: + flows: The flows available to the user. + tracker: The tracker containing the current state of the conversation. + + Returns: + The inputs for the prompt template. + """ + result = [] + for flow in flows.user_flows: + slots_with_info = [ + { + "name": q.collect, + "description": q.description, + "allowed_values": self.allowed_values_for_slot( + tracker.slots[q.collect] + ), + } + for q in flow.get_collect_steps() + if self.is_extractable(q, tracker) + ] + result.append( + { + "name": flow.id, + "description": flow.description, + "slots": slots_with_info, + } + ) + return result + + @staticmethod + def is_extractable( + collect_step: CollectInformationFlowStep, + tracker: DialogueStateTracker, + current_step: Optional[FlowStep] = None, + ) -> bool: + """Check if the `collect` can be filled. + + A collect slot can only be filled if the slot exist + and either the collect has been asked already or the + slot has been filled already. + + Args: + collect_step: The collect_information step. + tracker: The tracker containing the current state of the conversation. + current_step: The current step in the flow. + + Returns: + `True` if the slot can be filled, `False` otherwise. + """ + slot = tracker.slots.get(collect_step.collect) + if slot is None: + return False + + return ( + # we can fill because this is a slot that can be filled ahead of time + not collect_step.ask_before_filling + # we can fill because the slot has been filled already + or slot.has_been_set + # we can fill because the is currently getting asked + or ( + current_step is not None + and isinstance(current_step, CollectInformationFlowStep) + and current_step.collect == collect_step.collect + ) + ) + + def allowed_values_for_slot(self, slot: Slot) -> Union[str, None]: + """Get the allowed values for a slot.""" + if isinstance(slot, BooleanSlot): + return str([True, False]) + if isinstance(slot, CategoricalSlot): + return str([v for v in slot.values if v != "__other__"]) + else: + return None + + @staticmethod + def get_slot_value(tracker: DialogueStateTracker, slot_name: str) -> str: + """Get the slot value from the tracker. + + Args: + tracker: The tracker containing the current state of the conversation. + slot_name: The name of the slot. + + Returns: + The slot value as a string. + """ + slot_value = tracker.get_slot(slot_name) + if slot_value is None: + return "undefined" + else: + return str(slot_value) + + def prepare_current_flow_slots_for_template( + self, top_flow: Flow, current_step: FlowStep, tracker: DialogueStateTracker + ) -> List[Dict[str, Any]]: + """Prepare the current flow slots for the template. + + Args: + top_flow: The top flow. + current_step: The current step in the flow. + tracker: The tracker containing the current state of the conversation. + + Returns: + The slots with values, types, allowed values and a description. + """ + if top_flow is not None: + flow_slots = [ + { + "name": collect_step.collect, + "value": self.get_slot_value(tracker, collect_step.collect), + "type": tracker.slots[collect_step.collect].type_name, + "allowed_values": self.allowed_values_for_slot( + tracker.slots[collect_step.collect] + ), + "description": collect_step.description, + } + for collect_step in top_flow.get_collect_steps() + if self.is_extractable(collect_step, tracker, current_step) + ] + else: + flow_slots = [] + return flow_slots + + def prepare_current_slot_for_template( + self, current_step: FlowStep + ) -> Tuple[Union[str, None], Union[str, None]]: + """Prepare the current slot for the template.""" + return ( + (current_step.collect, current_step.description) + if isinstance(current_step, CollectInformationFlowStep) + else (None, None) + ) diff --git a/rasa/dialogue_understanding/generator/nlu_command_adapter.py b/rasa/dialogue_understanding/generator/nlu_command_adapter.py new file mode 100644 index 000000000000..405657ec5a9d --- /dev/null +++ b/rasa/dialogue_understanding/generator/nlu_command_adapter.py @@ -0,0 +1,19 @@ +import structlog + +from rasa.engine.graph import GraphComponent +from rasa.engine.recipes.default_recipe import DefaultV1Recipe + +structlogger = structlog.get_logger() + +# TODO: check if the original inhertance from IntentClassifier and EntityExtractorMixin +# is still needed or what benefits that provided. +@DefaultV1Recipe.register( + [ + DefaultV1Recipe.ComponentType.INTENT_CLASSIFIER, + DefaultV1Recipe.ComponentType.ENTITY_EXTRACTOR, + ], + is_trainable=False, +) +class NLUCommandAdapter(GraphComponent): + # TODO: implement + pass diff --git a/rasa/dialogue_understanding/patterns/__init__.py b/rasa/dialogue_understanding/patterns/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/dialogue_understanding/patterns/cancel.py b/rasa/dialogue_understanding/patterns/cancel.py new file mode 100644 index 000000000000..b60df2e004cc --- /dev/null +++ b/rasa/dialogue_understanding/patterns/cancel.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import structlog +from rasa.dialogue_understanding.stack.dialogue_stack import ( + DialogueStack, +) +from rasa.dialogue_understanding.stack.frames import ( + PatternFlowStackFrame, + BaseFlowStackFrame, +) +from rasa.core.actions import action +from rasa.core.channels.channel import OutputChannel +from rasa.core.nlg.generator import NaturalLanguageGenerator +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.shared.core.constants import ACTION_CANCEL_FLOW +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep +from rasa.shared.core.trackers import DialogueStateTracker + + +structlogger = structlog.get_logger() + +FLOW_PATTERN_CANCEL_ID = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "cancel_flow" + + +@dataclass +class CancelPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which cancels a flow. + + The frame contains the information about the stack frames that should + be canceled.""" + + flow_id: str = FLOW_PATTERN_CANCEL_ID + """The ID of the flow.""" + canceled_name: str = "" + """The name of the flow that should be canceled.""" + canceled_frames: List[str] = field(default_factory=list) + """The stack frames that should be canceled. These can be multiple + frames since the user frame that is getting canceled might have + created patterns that should be canceled as well.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_cancel_flow" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> CancelPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return CancelPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + canceled_name=data["canceled_name"], + canceled_frames=data["canceled_frames"], + ) + + +class ActionCancelFlow(action.Action): + """Action which cancels a flow from the stack.""" + + def __init__(self) -> None: + """Creates a `ActionCancelFlow`.""" + super().__init__() + + def name(self) -> str: + """Return the flow name.""" + return ACTION_CANCEL_FLOW + + async def run( + self, + output_channel: OutputChannel, + nlg: NaturalLanguageGenerator, + tracker: DialogueStateTracker, + domain: Domain, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Event]: + """Cancel the flow.""" + stack = DialogueStack.from_tracker(tracker) + if not (top := stack.top()): + structlogger.warning("action.cancel_flow.no_active_flow") + return [] + + if not isinstance(top, CancelPatternFlowStackFrame): + structlogger.warning("action.cancel_flow.no_cancel_frame", top=top) + return [] + + for canceled_frame_id in top.canceled_frames: + for frame in stack.frames: + if frame.frame_id == canceled_frame_id and isinstance( + frame, BaseFlowStackFrame + ): + # Setting the stack frame to the end step so it is properly + # wrapped up by the flow policy + frame.step_id = ContinueFlowStep.continue_step_for_id(END_STEP) + break + else: + structlogger.warning( + "action.cancel_flow.frame_not_found", + stack=stack, + frame_id=canceled_frame_id, + ) + + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/patterns/chitchat.py b/rasa/dialogue_understanding/patterns/chitchat.py new file mode 100644 index 000000000000..d4f78a6270f7 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/chitchat.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame + + +FLOW_PATTERN_CHITCHAT = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "chitchat" + + +@dataclass +class ChitchatPatternFlowStackFrame(PatternFlowStackFrame): + """A flow stack frame that gets added to respond to Chitchat.""" + + flow_id: str = FLOW_PATTERN_CHITCHAT + """The ID of the flow.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return FLOW_PATTERN_CHITCHAT + + @staticmethod + def from_dict(data: Dict[str, Any]) -> ChitchatPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return ChitchatPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + ) diff --git a/rasa/dialogue_understanding/patterns/clarify.py b/rasa/dialogue_understanding/patterns/clarify.py new file mode 100644 index 000000000000..db877bf5ef66 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/clarify.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import structlog +from rasa.dialogue_understanding.stack.dialogue_stack import ( + DialogueStack, +) +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame +from rasa.core.actions import action +from rasa.core.channels.channel import OutputChannel +from rasa.core.nlg.generator import NaturalLanguageGenerator +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.shared.core.constants import ACTION_CLARIFY_FLOWS +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import Event +from rasa.shared.core.trackers import DialogueStateTracker + + +structlogger = structlog.get_logger() + +FLOW_PATTERN_CLARIFICATION = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "clarification" + + +@dataclass +class ClarifyPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which helps the user clarify their action.""" + + flow_id: str = FLOW_PATTERN_CLARIFICATION + """The ID of the flow.""" + names: List[str] = field(default_factory=list) + """The names of the flows that the user can choose from.""" + clarification_options: str = "" + """The options that the user can choose from as a string.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_clarification" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> ClarifyPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return ClarifyPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + names=data["names"], + clarification_options=data["clarification_options"], + ) + + +class ActionClarifyFlows(action.Action): + """Action which clarifies which flow to start.""" + + def name(self) -> str: + """Return the flow name.""" + return ACTION_CLARIFY_FLOWS + + @staticmethod + def assemble_options_string(names: List[str]) -> str: + """Concatenate options to a human-readable string.""" + clarification_message = "" + for i, name in enumerate(names): + if i == 0: + clarification_message += name + elif i == len(names) - 1: + clarification_message += f" or {name}" + else: + clarification_message += f", {name}" + return clarification_message + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + metadata: Optional[Dict[str, Any]] = None, + ) -> List[Event]: + """Correct the slots.""" + stack = DialogueStack.from_tracker(tracker) + if not (top := stack.top()): + structlogger.warning("action.clarify_flows.no_active_flow") + return [] + + if not isinstance(top, ClarifyPatternFlowStackFrame): + structlogger.warning("action.clarify_flows.no_correction_frame", top=top) + return [] + + options_string = self.assemble_options_string(top.names) + top.clarification_options = options_string + # since we modified the stack frame, we need to update the stack + return [stack.persist_as_event()] diff --git a/rasa/dialogue_understanding/patterns/code_change.py b/rasa/dialogue_understanding/patterns/code_change.py new file mode 100644 index 000000000000..4a0ebf12ebeb --- /dev/null +++ b/rasa/dialogue_understanding/patterns/code_change.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict + +import structlog +from rasa.dialogue_understanding.stack.frames import ( + PatternFlowStackFrame, +) +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX + +structlogger = structlog.get_logger() + +FLOW_PATTERN_CODE_CHANGE_ID = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "code_change" + + +@dataclass +class CodeChangeFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which cleans the stack after a bot update.""" + + flow_id: str = FLOW_PATTERN_CODE_CHANGE_ID + """The ID of the flow.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return FLOW_PATTERN_CODE_CHANGE_ID + + @staticmethod + def from_dict(data: Dict[str, Any]) -> CodeChangeFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return CodeChangeFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + ) diff --git a/rasa/dialogue_understanding/patterns/collect_information.py b/rasa/dialogue_understanding/patterns/collect_information.py new file mode 100644 index 000000000000..9aa35824e888 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/collect_information.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, List, Optional +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStackFrame +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame +from rasa.shared.core.flows.flow import SlotRejection + +FLOW_PATTERN_COLLECT_INFORMATION = ( + RASA_DEFAULT_FLOW_PATTERN_PREFIX + "collect_information" +) + + +@dataclass +class CollectInformationPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which collects information from the user.""" + + flow_id: str = FLOW_PATTERN_COLLECT_INFORMATION + """The ID of the flow.""" + collect: str = "" + """The information that should be collected from the user. + this corresponds to the slot that will be filled.""" + utter: str = "" + """The utter action that should be executed to ask the user for the + information.""" + rejections: Optional[List[SlotRejection]] = None + """The predicate check that should be applied to the collected information. + If a predicate check fails, its `utter` action indicated under rejections + will be executed. + """ + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_collect_information" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> CollectInformationPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + rejections = data.get("rejections") + if rejections is not None: + rejections = [ + SlotRejection.from_dict(rejection) for rejection in rejections + ] + + return CollectInformationPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + collect=data["collect"], + utter=data["utter"], + rejections=rejections, + ) + + def context_as_dict( + self, underlying_frames: List[DialogueStackFrame] + ) -> Dict[str, Any]: + """Returns the context of the frame as a dictionary. + + The collect information frame needs a special implementation as + it includes the context of the underlying frame in its context. + + This corresponds to the user expectation when e.g. using templates + in a collect information node. + """ + context = super().context_as_dict(underlying_frames) + + if underlying_frames: + underlying_context = underlying_frames[-1].context_as_dict( + underlying_frames[:-1] + ) + else: + underlying_context = {} + + # the collect information frame is a special case, as it is not + # a regular frame, but a frame that is used to collect information + + context.update(underlying_context) + return context diff --git a/rasa/dialogue_understanding/patterns/completed.py b/rasa/dialogue_understanding/patterns/completed.py new file mode 100644 index 000000000000..1392a403804a --- /dev/null +++ b/rasa/dialogue_understanding/patterns/completed.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame + + +FLOW_PATTERN_COMPLETED = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "completed" + + +@dataclass +class CompletedPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which gets added if all prior flows are completed.""" + + flow_id: str = FLOW_PATTERN_COMPLETED + """The ID of the flow.""" + previous_flow_name: str = "" + """The name of the last flow that was completed.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_completed" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> CompletedPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return CompletedPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + previous_flow_name=data["previous_flow_name"], + ) diff --git a/rasa/dialogue_understanding/patterns/continue_interrupted.py b/rasa/dialogue_understanding/patterns/continue_interrupted.py new file mode 100644 index 000000000000..7a45f9e677b2 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/continue_interrupted.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame + + +FLOW_PATTERN_CONTINUE_INTERRUPTED = ( + RASA_DEFAULT_FLOW_PATTERN_PREFIX + "continue_interrupted" +) + + +@dataclass +class ContinueInterruptedPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame that gets added if an interruption is completed.""" + + flow_id: str = FLOW_PATTERN_CONTINUE_INTERRUPTED + """The ID of the flow.""" + previous_flow_name: str = "" + """The name of the flow that was interrupted.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_continue_interrupted" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> ContinueInterruptedPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return ContinueInterruptedPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + previous_flow_name=data["previous_flow_name"], + ) diff --git a/rasa/dialogue_understanding/patterns/correction.py b/rasa/dialogue_understanding/patterns/correction.py new file mode 100644 index 000000000000..1409bba8fba1 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/correction.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, Text, List, Optional +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.shared.core.flows.flow import ( + START_STEP, +) +from rasa.shared.core.trackers import ( + DialogueStateTracker, +) +import structlog +from rasa.core.actions import action +from rasa.core.channels import OutputChannel +from rasa.dialogue_understanding.stack.frames import ( + BaseFlowStackFrame, + PatternFlowStackFrame, +) + +from rasa.shared.core.constants import ( + ACTION_CORRECT_FLOW_SLOT, +) +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ( + Event, + SlotSet, +) +from rasa.core.nlg import NaturalLanguageGenerator +from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep + +structlogger = structlog.get_logger() + +FLOW_PATTERN_CORRECTION_ID = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "correction" + + +@dataclass +class CorrectionPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame which gets added if a slot value is corrected.""" + + flow_id: str = FLOW_PATTERN_CORRECTION_ID + """The ID of the flow.""" + is_reset_only: bool = False + """Whether the correction is only a reset of the flow. + + This is the case if all corrected slots have `ask_before_filling=True`. + In this case, we do not set their value directly but rather reset the flow + to the position where the first question is asked to fill the slot.""" + corrected_slots: Dict[str, Any] = field(default_factory=dict) + """The slots that were corrected.""" + reset_flow_id: Optional[str] = None + """The ID of the flow to reset to.""" + reset_step_id: Optional[str] = None + """The ID of the step to reset to.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_correction" + + @staticmethod + def from_dict(data: Dict[Text, Any]) -> CorrectionPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return CorrectionPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + is_reset_only=data["is_reset_only"], + corrected_slots=data["corrected_slots"], + reset_flow_id=data["reset_flow_id"], + reset_step_id=data["reset_step_id"], + ) + + +class ActionCorrectFlowSlot(action.Action): + """Action which corrects a slots value in a flow.""" + + def __init__(self) -> None: + """Creates a `ActionCorrectFlowSlot`.""" + super().__init__() + + def name(self) -> Text: + """Return the flow name.""" + return ACTION_CORRECT_FLOW_SLOT + + async def run( + self, + output_channel: "OutputChannel", + nlg: "NaturalLanguageGenerator", + tracker: "DialogueStateTracker", + domain: "Domain", + metadata: Optional[Dict[Text, Any]] = None, + ) -> List[Event]: + """Correct the slots.""" + stack = DialogueStack.from_tracker(tracker) + if not (top := stack.top()): + structlogger.warning("action.correct_flow_slot.no_active_flow") + return [] + + if not isinstance(top, CorrectionPatternFlowStackFrame): + structlogger.warning( + "action.correct_flow_slot.no_correction_frame", top=top + ) + return [] + + for idx_of_flow_to_cancel, frame in enumerate(stack.frames): + if ( + isinstance(frame, BaseFlowStackFrame) + and frame.flow_id == top.reset_flow_id + ): + frame.step_id = ( + ContinueFlowStep.continue_step_for_id(top.reset_step_id) + if top.reset_step_id + else START_STEP + ) + break + + # also need to end any running collect information + if len(stack.frames) > idx_of_flow_to_cancel + 1: + frame_ontop_of_user_frame = stack.frames[idx_of_flow_to_cancel + 1] + # if the frame on top of the user frame is a collect information frame, + # we need to end it as well + if isinstance( + frame_ontop_of_user_frame, CollectInformationPatternFlowStackFrame + ): + frame_ontop_of_user_frame.step_id = ( + ContinueFlowStep.continue_step_for_id(END_STEP) + ) + + events: List[Event] = [stack.persist_as_event()] + + events.extend([SlotSet(k, v) for k, v in top.corrected_slots.items()]) + + return events diff --git a/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml new file mode 100644 index 000000000000..983f4154b7b6 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/default_flows_for_patterns.yml @@ -0,0 +1,143 @@ +version: "3.1" +responses: + utter_flow_continue_interrupted: + - text: "Let's continue with {{ context.previous_flow_name }}." + metadata: + rephrase: True + template: jinja + + utter_corrected_previous_input: + - text: "Ok, I am updating {{ context.corrected_slots.keys()|join(', ') }} to {{ context.corrected_slots.values()|join(', ') }} respectively." + metadata: + rephrase: True + template: jinja + + utter_flow_cancelled_rasa: + - text: "Okay, stopping {{ context.canceled_name }}." + metadata: + rephrase: True + template: jinja + + utter_can_do_something_else: + - text: "What else I can help you with?" + metadata: + rephrase: True + + utter_internal_error_rasa: + - text: Sorry, I am having trouble with that. Please try again in a few minutes. + + utter_clarification_options_rasa: + - text: "I can help, but I need more information. Which of these would you like to do: {{context.clarification_options}}?" + metadata: + rephrase: True + template: jinja + + utter_inform_code_change: + - text: There has been an update to my code. I need to wrap up our running dialogue and start from scratch. + metadata: + rephrase: True + + utter_no_knowledge_base: + - text: I am afraid, I don't know the answer. At this point, I don't have access to a knowledge base. + metadata: + rephrase: True + +slots: + confirm_correction: + type: bool + mappings: + - type: custom + +flows: + pattern_continue_interrupted: + description: A flow that should will be started to continue an interrupted flow. + name: pattern continue interrupted + + steps: + - action: utter_flow_continue_interrupted + + pattern_correction: + description: Handle a correction of a slot value. + name: pattern correction + steps: + - action: action_correct_flow_slot + next: + - if: not context.is_reset_only + then: + - action: utter_corrected_previous_input + next: "END" + - else: "END" + + pattern_cancel_flow: + description: A meta flow that's started when a flow is cancelled. + name: pattern_cancel_flow + + steps: + - id: "cancel_flow" + action: action_cancel_flow + next: "inform_user" + - id: "inform_user" + action: utter_flow_cancelled_rasa + + pattern_internal_error: + description: internal error + name: pattern internal error + steps: + - action: utter_internal_error_rasa + + pattern_completed: + description: a flow has been completed and there is nothing else to be done + name: pattern completed + steps: + - action: utter_can_do_something_else + + pattern_chitchat: + description: handle interactions with the user that are not task-oriented + name: pattern chitchat + steps: + - generation_prompt: | + You are an incredibly friendly assistant. Generate a short + response to the user's comment in simple english. + + User: {{latest_user_message}} + Response: + + pattern_search: + description: handle a knowledge-based question or request + name: pattern search + steps: + - action: utter_no_knowledge_base + # - action: action_trigger_search to use doc search policy if present + + pattern_clarification: + description: handle clarifications with the user + name: pattern clarification + steps: + - action: action_clarify_flows + - action: utter_clarification_options_rasa + + pattern_collect_information: + description: flow used to fill a slot + name: pattern collect information + steps: + - id: "start" + action: action_run_slot_rejections + - action: validate_{{context.collect}} + next: + - if: "{{context.collect}} is not null" + then: "END" + - else: "ask_collect" + - id: "ask_collect" + action: "{{context.utter}}" + next: "listen" + - id: "listen" + action: action_listen + next: "start" + + + pattern_code_change: + description: flow used to clean the stack after a bot update + name: pattern code change + steps: + - action: utter_inform_code_change + - action: action_clean_stack diff --git a/rasa/dialogue_understanding/patterns/internal_error.py b/rasa/dialogue_understanding/patterns/internal_error.py new file mode 100644 index 000000000000..405953d9cf06 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/internal_error.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from dataclasses import dataclass +from typing import Any, Dict +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame + + +FLOW_PATTERN_INTERNAL_ERROR_ID = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "internal_error" + + +@dataclass +class InternalErrorPatternFlowStackFrame(PatternFlowStackFrame): + """A pattern flow stack frame that gets added if an internal error occurs.""" + + flow_id: str = FLOW_PATTERN_INTERNAL_ERROR_ID + """The ID of the flow.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "pattern_internal_error" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> InternalErrorPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return InternalErrorPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + ) diff --git a/rasa/dialogue_understanding/patterns/search.py b/rasa/dialogue_understanding/patterns/search.py new file mode 100644 index 000000000000..d0adb09b8883 --- /dev/null +++ b/rasa/dialogue_understanding/patterns/search.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX +from rasa.dialogue_understanding.stack.frames import PatternFlowStackFrame + + +FLOW_PATTERN_SEARCH = RASA_DEFAULT_FLOW_PATTERN_PREFIX + "search" + + +@dataclass +class SearchPatternFlowStackFrame(PatternFlowStackFrame): + """A stack frame that gets added to respond to knowledge-oriented questions.""" + + flow_id: str = FLOW_PATTERN_SEARCH + """The ID of the flow.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return FLOW_PATTERN_SEARCH + + @staticmethod + def from_dict(data: Dict[str, Any]) -> SearchPatternFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return SearchPatternFlowStackFrame( + frame_id=data["frame_id"], + step_id=data["step_id"], + ) diff --git a/rasa/dialogue_understanding/processor/__init__.py b/rasa/dialogue_understanding/processor/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/dialogue_understanding/processor/command_processor.py b/rasa/dialogue_understanding/processor/command_processor.py new file mode 100644 index 000000000000..e80a9c0998b2 --- /dev/null +++ b/rasa/dialogue_understanding/processor/command_processor.py @@ -0,0 +1,350 @@ +from typing import List, Optional, Type, Set, Dict + +import structlog +from rasa.dialogue_understanding.commands import ( + CancelFlowCommand, + Command, + CorrectSlotsCommand, + CorrectedSlot, + SetSlotCommand, + FreeFormAnswerCommand, +) +from rasa.dialogue_understanding.commands.handle_code_change_command import ( + HandleCodeChangeCommand, +) +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.patterns.correction import ( + CorrectionPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + BaseFlowStackFrame, +) +from rasa.dialogue_understanding.stack.utils import ( + filled_slots_for_active_flow, + top_flow_frame, +) +from rasa.shared.core.constants import FLOW_HASHES_SLOT +from rasa.shared.core.events import Event, SlotSet +from rasa.shared.core.flows.flow import ( + FlowsList, + CollectInformationFlowStep, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import COMMANDS + + +structlogger = structlog.get_logger() + + +def contains_command(commands: List[Command], typ: Type[Command]) -> bool: + """Check if a list of commands contains a command of a given type. + + Example: + >>> contains_command([SetSlotCommand("foo", "bar")], SetSlotCommand) + True + + Args: + commands: The commands to check. + typ: The type of command to check for. + + Returns: + `True` if the list of commands contains a command of the given type. + """ + return any(isinstance(command, typ) for command in commands) + + +def _get_commands_from_tracker(tracker: DialogueStateTracker) -> List[Command]: + """Extracts the commands from the tracker. + + Args: + tracker: The tracker containing the conversation history up to now. + + Returns: + The commands. + """ + if tracker.latest_message: + dumped_commands = tracker.latest_message.parse_data.get(COMMANDS) or [] + assert isinstance(dumped_commands, list) + return [Command.command_from_json(command) for command in dumped_commands] + else: + return [] + + +def validate_state_of_commands(commands: List[Command]) -> None: + """Validates the state of the commands. + + We have some invariants that should always hold true. This function + checks if they do. Executing the commands relies on these invariants. + + We cleanup the commands before executing them, so the cleanup should + always make sure that these invariants hold true - no matter the commands + that are provided. + + Args: + commands: The commands to validate. + """ + # assert that there is only at max one cancel flow command + assert sum(isinstance(c, CancelFlowCommand) for c in commands) <= 1 + + # assert that free form answer commands are only at the beginning of the list + free_form_answer_commands = [ + c for c in commands if isinstance(c, FreeFormAnswerCommand) + ] + assert free_form_answer_commands == commands[: len(free_form_answer_commands)] + + # assert that there is at max only one correctslots command + assert sum(isinstance(c, CorrectSlotsCommand) for c in commands) <= 1 + + +def find_updated_flows(tracker: DialogueStateTracker, all_flows: FlowsList) -> Set[str]: + """Find the set of updated flows. + + Run through the current dialogue stack and compare the flow hashes of the + flows on the stack with those stored in the tracker. + + Args: + tracker: The tracker. + all_flows: All flows. + + Returns: + A set of flow ids of those flows that have changed + """ + stored_fingerprints: Dict[str, str] = tracker.get_slot(FLOW_HASHES_SLOT) or {} + dialogue_stack = DialogueStack.from_tracker(tracker) + + changed_flows = set() + for frame in dialogue_stack.frames: + if isinstance(frame, BaseFlowStackFrame): + flow = all_flows.flow_by_id(frame.flow_id) + if flow is None or ( + flow.id in stored_fingerprints + and flow.fingerprint != stored_fingerprints[flow.id] + ): + changed_flows.add(frame.flow_id) + return changed_flows + + +def calculate_flow_fingerprints(all_flows: FlowsList) -> Dict[str, str]: + """Calculate fingerprints for all flows.""" + return {flow.id: flow.fingerprint for flow in all_flows.underlying_flows} + + +def execute_commands( + tracker: DialogueStateTracker, all_flows: FlowsList +) -> List[Event]: + """Executes a list of commands. + + Args: + commands: The commands to execute. + tracker: The tracker to execute the commands on. + all_flows: All flows. + + Returns: + A tuple of the action to execute and the events that were created. + """ + commands: List[Command] = _get_commands_from_tracker(tracker) + original_tracker = tracker.copy() + + commands = clean_up_commands(commands, tracker, all_flows) + + updated_flows = find_updated_flows(tracker, all_flows) + if updated_flows: + # Override commands + structlogger.debug( + "command_executor.running_flows_were_updated", + updated_flow_ids=updated_flows, + ) + commands = [HandleCodeChangeCommand()] + + # store current flow hashes if they changed + new_hashes = calculate_flow_fingerprints(all_flows) + flow_hash_events: List[Event] = [] + if new_hashes != (tracker.get_slot(FLOW_HASHES_SLOT) or {}): + flow_hash_events.append(SlotSet(FLOW_HASHES_SLOT, new_hashes)) + tracker.update_with_events(flow_hash_events, None) + + events: List[Event] = flow_hash_events + + # commands need to be reversed to make sure they end up in the right order + # on the stack. e.g. if there multiple start flow commands, the first one + # should be on top of the stack. this is achieved by reversing the list + # and then pushing the commands onto the stack in the reversed order. + reversed_commands = list(reversed(commands)) + + validate_state_of_commands(commands) + + for command in reversed_commands: + new_events = command.run_command_on_tracker( + tracker, all_flows, original_tracker + ) + events.extend(new_events) + tracker.update_with_events(new_events, None) + + return remove_duplicated_set_slots(events) + + +def remove_duplicated_set_slots(events: List[Event]) -> List[Event]: + """Removes duplicated set slot events. + + This can happen if a slot is set multiple times in a row. We only want to + keep the last one. + + Args: + events: The events to optimize. + + Returns: + The optimized events. + """ + slots_so_far = set() + + optimized_events: List[Event] = [] + + for event in reversed(events): + if isinstance(event, SlotSet) and event.key in slots_so_far: + # slot will be overwritten, no need to set it + continue + elif isinstance(event, SlotSet): + slots_so_far.add(event.key) + + optimized_events.append(event) + + # since we reversed the original events, we need to reverse the optimized + # events again to get them in the right order + return list(reversed(optimized_events)) + + +def get_current_collect_step( + dialogue_stack: DialogueStack, all_flows: FlowsList +) -> Optional[CollectInformationFlowStep]: + """Get the current collect information if the conversation is currently in one. + + If we are currently in a collect information step, the stack should have at least + two frames. The top frame is the collect information pattern and the frame below + is the flow that triggered the collect information pattern. We can use the flow + id to get the collect information step from the flow. + + Args: + dialogue_stack: The dialogue stack. + all_flows: All flows. + + Returns: + The current collect information if the conversation is currently in one, + `None` otherwise. + """ + if not (top_frame := dialogue_stack.top()): + # we are currently not in a flow + return None + + if not isinstance(top_frame, CollectInformationPatternFlowStackFrame): + # we are currently not in a collect information + return None + + if len(dialogue_stack.frames) <= 1: + # for some reason only the collect information pattern step is on the stack + # but no flow that triggered it. this should never happen. + structlogger.warning( + "command_executor.get_current_collect_step.no_flow_on_stack", + stack=dialogue_stack, + ) + return None + + frame_that_triggered_collect_infos = dialogue_stack.frames[-2] + if not isinstance(frame_that_triggered_collect_infos, BaseFlowStackFrame): + # this is a failure, if there is a frame, we should be able to get the + # step from it + structlogger.warning( + "command_executor.get_current_collect_step.no_step_for_frame", + frame=frame_that_triggered_collect_infos, + ) + return None + + step = frame_that_triggered_collect_infos.step(all_flows) + if isinstance(step, CollectInformationFlowStep): + # we found it! + return step + else: + # this should never happen as we only push collect information patterns + # onto the stack if there is a collect information step + structlogger.warning( + "command_executor.get_current_collect_step.step_not_collect", + step=step, + ) + return None + + +def clean_up_commands( + commands: List[Command], tracker: DialogueStateTracker, all_flows: FlowsList +) -> List[Command]: + """Clean up a list of commands. + + This will remove commands that are not necessary anymore, e.g. because the slot + they set is already set to the same value. It will also remove commands that + start a flow that is already on the stack. + + Args: + commands: The commands to clean up. + tracker: The tracker to clean up the commands for. + all_flows: All flows. + + Returns: + The cleaned up commands. + """ + dialogue_stack = DialogueStack.from_tracker(tracker) + slots_so_far = filled_slots_for_active_flow(dialogue_stack, all_flows) + + clean_commands: List[Command] = [] + + for command in commands: + if isinstance(command, SetSlotCommand) and command.name in slots_so_far: + current_collect_info = get_current_collect_step(dialogue_stack, all_flows) + + if current_collect_info and current_collect_info.collect == command.name: + # not a correction but rather an answer to the current collect info + clean_commands.append(command) + continue + + structlogger.debug( + "command_executor.convert_command.correction", command=command + ) + top = top_flow_frame(dialogue_stack) + if isinstance(top, CorrectionPatternFlowStackFrame): + already_corrected_slots = top.corrected_slots + else: + already_corrected_slots = {} + + if ( + command.name in already_corrected_slots + and already_corrected_slots[command.name] == command.value + ): + structlogger.debug( + "command_executor.skip_command.slot_already_corrected", + command=command, + ) + continue + + corrected_slot = CorrectedSlot(command.name, command.value) + for c in clean_commands: + if isinstance(c, CorrectSlotsCommand): + c.corrected_slots.append(corrected_slot) + break + else: + clean_commands.append( + CorrectSlotsCommand(corrected_slots=[corrected_slot]) + ) + elif isinstance(command, CancelFlowCommand) and contains_command( + clean_commands, CancelFlowCommand + ): + structlogger.debug( + "command_executor.skip_command.already_cancelled_flow", command=command + ) + elif isinstance(command, FreeFormAnswerCommand): + structlogger.debug( + "command_executor.prepend_command.free_form_answer", command=command + ) + clean_commands.insert(0, command) + else: + clean_commands.append(command) + return clean_commands diff --git a/rasa/dialogue_understanding/processor/command_processor_component.py b/rasa/dialogue_understanding/processor/command_processor_component.py new file mode 100644 index 000000000000..e557ab2c83c8 --- /dev/null +++ b/rasa/dialogue_understanding/processor/command_processor_component.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Text +from rasa.dialogue_understanding.processor.command_processor import execute_commands + +from rasa.engine.graph import ExecutionContext, GraphComponent +from rasa.engine.storage.resource import Resource +from rasa.engine.storage.storage import ModelStorage +from rasa.shared.core.events import Event +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker + + +class CommandProcessorComponent(GraphComponent): + """Processes commands by issuing events to modify a tracker. + + Minimal component that applies commands to a tracker.""" + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> CommandProcessorComponent: + """Creates component (see parent class for full docstring).""" + return cls() + + def execute_commands( + self, tracker: DialogueStateTracker, flows: FlowsList + ) -> List[Event]: + """Excute commands to update tracker state.""" + return execute_commands(tracker, flows) diff --git a/rasa/dialogue_understanding/stack/__init__.py b/rasa/dialogue_understanding/stack/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/dialogue_understanding/stack/dialogue_stack.py b/rasa/dialogue_understanding/stack/dialogue_stack.py new file mode 100644 index 000000000000..45911b0207c6 --- /dev/null +++ b/rasa/dialogue_understanding/stack/dialogue_stack.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Dict, List, Optional +from rasa.dialogue_understanding.stack.frames import DialogueStackFrame +from rasa.shared.core.constants import ( + DIALOGUE_STACK_SLOT, +) +from rasa.shared.core.events import Event, SlotSet +from rasa.shared.core.trackers import ( + DialogueStateTracker, +) +import structlog + +structlogger = structlog.get_logger() + + +@dataclass +class DialogueStack: + """Represents the current dialogue stack.""" + + frames: List[DialogueStackFrame] + + @staticmethod + def from_dict(data: List[Dict[str, Any]]) -> DialogueStack: + """Creates a `DialogueStack` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStack` from. + + Returns: + The created `DialogueStack`. + """ + return DialogueStack( + [DialogueStackFrame.create_typed_frame(frame) for frame in data] + ) + + def as_dict(self) -> List[Dict[str, Any]]: + """Returns the `DialogueStack` as a dictionary. + + Returns: + The `DialogueStack` as a dictionary. + """ + return [frame.as_dict() for frame in self.frames] + + def push(self, frame: DialogueStackFrame, index: Optional[int] = None) -> None: + """Pushes a new frame onto the stack. + + If the frame shouldn't be put on top of the stack, the index can be + specified. Not specifying an index equals `push(frame, index=len(frames))`. + + Args: + frame: The frame to push onto the stack. + index: The index to insert the frame at. If `None`, the frame + is put on top of the stack. + """ + if index is None: + self.frames.append(frame) + else: + self.frames.insert(index, frame) + + def update(self, frame: DialogueStackFrame) -> None: + """Updates the topmost frame. + + Args: + frame: The frame to update. + """ + if not self.is_empty(): + self.pop() + + self.push(frame) + + def pop(self) -> DialogueStackFrame: + """Pops the topmost frame from the stack. + + Returns: + The popped frame. + """ + return self.frames.pop() + + def current_context(self) -> Dict[str, Any]: + """Returns the context of the topmost frame. + + Returns: + The context of the topmost frame. + """ + if self.is_empty(): + return {} + + return self.frames[-1].context_as_dict(self.frames[:-1]) + + def top( + self, + ignore: Optional[Callable[[DialogueStackFrame], bool]] = None, + ) -> Optional[DialogueStackFrame]: + """Returns the topmost frame from the stack. + + Args: + ignore_frame: The ID of the flow to ignore. Picks the top most + frame that has a different flow ID. + + Returns: + The topmost frame. + """ + for frame in reversed(self.frames): + if ignore and ignore(frame): + continue + return frame + return None + + def is_empty(self) -> bool: + """Checks if the stack is empty. + + Returns: + `True` if the stack is empty, `False` otherwise. + """ + return len(self.frames) == 0 + + @staticmethod + def get_persisted_stack(tracker: DialogueStateTracker) -> List[Dict[str, Any]]: + """Returns the persisted stack from the tracker. + + The stack is stored on a slot on the tracker. If the slot is not set, + an empty list is returned. + + Args: + tracker: The tracker to get the stack from. + + Returns: + The persisted stack as a dictionary.""" + return tracker.get_slot(DIALOGUE_STACK_SLOT) or [] + + def persist_as_event(self) -> Event: + """Returns the stack as a slot set event.""" + return SlotSet(DIALOGUE_STACK_SLOT, self.as_dict()) + + @staticmethod + def from_tracker(tracker: DialogueStateTracker) -> DialogueStack: + """Creates a `DialogueStack` from a tracker. + + The stack is read from a slot on the tracker. If the slot is not set, + an empty stack is returned. + + Args: + tracker: The tracker to create the `DialogueStack` from. + + Returns: + The created `DialogueStack`. + """ + return DialogueStack.from_dict(DialogueStack.get_persisted_stack(tracker)) diff --git a/rasa/dialogue_understanding/stack/frames/__init__.py b/rasa/dialogue_understanding/stack/frames/__init__.py new file mode 100644 index 000000000000..57bdfa9ce178 --- /dev/null +++ b/rasa/dialogue_understanding/stack/frames/__init__.py @@ -0,0 +1,19 @@ +from rasa.dialogue_understanding.stack.frames.dialogue_stack_frame import ( + DialogueStackFrame, +) +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import ( + UserFlowStackFrame, + BaseFlowStackFrame, +) +from rasa.dialogue_understanding.stack.frames.pattern_frame import PatternFlowStackFrame +from rasa.dialogue_understanding.stack.frames.search_frame import SearchStackFrame +from rasa.dialogue_understanding.stack.frames.chit_chat_frame import ChitChatStackFrame + +__all__ = [ + "DialogueStackFrame", + "BaseFlowStackFrame", + "PatternFlowStackFrame", + "UserFlowStackFrame", + "SearchStackFrame", + "ChitChatStackFrame", +] diff --git a/rasa/dialogue_understanding/stack/frames/chit_chat_frame.py b/rasa/dialogue_understanding/stack/frames/chit_chat_frame.py new file mode 100644 index 000000000000..27e05583066b --- /dev/null +++ b/rasa/dialogue_understanding/stack/frames/chit_chat_frame.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict +from rasa.dialogue_understanding.stack.frames import DialogueStackFrame + + +@dataclass +class ChitChatStackFrame(DialogueStackFrame): + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "chitchat" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> ChitChatStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return ChitChatStackFrame( + frame_id=data["frame_id"], + ) diff --git a/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py b/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py new file mode 100644 index 000000000000..46e55bc02f29 --- /dev/null +++ b/rasa/dialogue_understanding/stack/frames/dialogue_stack_frame.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +import dataclasses +from enum import Enum +from typing import Any, Dict, List, Tuple + +import structlog +from rasa.shared.exceptions import RasaException + +import rasa.shared.utils.common +from rasa.shared.utils.io import random_string + + +structlogger = structlog.get_logger() + + +def generate_stack_frame_id() -> str: + """Generates a stack frame ID. + + Returns: + The generated stack frame ID. + """ + return random_string(8) + + +class InvalidStackFrameType(RasaException): + """Raised if a stack frame type is invalid.""" + + def __init__(self, frame_type: str) -> None: + """Creates a `InvalidStackFrameType`. + + Args: + frame_type: The invalid frame type. + """ + super().__init__(f"Invalid stack frame type '{frame_type}'.") + + +@dataclass +class DialogueStackFrame: + """Represents the current flow step.""" + + frame_id: str = field(default_factory=generate_stack_frame_id) + """The ID of the current frame.""" + + def as_dict(self) -> Dict[str, Any]: + """Returns the `DialogueStackFrame` as a dictionary. + + Returns: + The `DialogueStackFrame` as a dictionary. + """ + + def custom_asdict_factory(fields: List[Tuple[str, Any]]) -> Dict[str, Any]: + """Converts enum values to their value.""" + + def rename_internal(field_name: str) -> str: + return field_name[:-1] if field_name.endswith("_") else field_name + + return { + rename_internal(field): value.value + if isinstance(value, Enum) + else value + for field, value in fields + } + + data = dataclasses.asdict(self, dict_factory=custom_asdict_factory) + data["type"] = self.type() + return data + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + raise NotImplementedError + + @staticmethod + def from_dict(data: Dict[str, Any]) -> DialogueStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + raise NotImplementedError + + def context_as_dict( + self, underlying_frames: List[DialogueStackFrame] + ) -> Dict[str, Any]: + """Returns the context of the frame.""" + return self.as_dict() + + @staticmethod + def create_typed_frame(data: Dict[str, Any]) -> DialogueStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + typ = data.get("type") + for clazz in rasa.shared.utils.common.all_subclasses(DialogueStackFrame): + try: + if typ == clazz.type(): + return clazz.from_dict(data) + except NotImplementedError: + # we don't want to raise an error if the frame type is not + # implemented, as this is ok to be raised by an abstract class + pass + else: + structlogger.warning("dialogue_stack.frame.unknown_type", data=data) + raise InvalidStackFrameType(typ) diff --git a/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py new file mode 100644 index 000000000000..20b7cfc6b4be --- /dev/null +++ b/rasa/dialogue_understanding/stack/frames/flow_stack_frame.py @@ -0,0 +1,150 @@ +from __future__ import annotations +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Optional + +from rasa.dialogue_understanding.stack.frames import DialogueStackFrame +from rasa.shared.core.flows.flow import START_STEP, Flow, FlowStep, FlowsList +from rasa.shared.exceptions import RasaException + + +class InvalidFlowStackFrameType(RasaException): + """Raised if the stack frame type is invalid.""" + + def __init__(self, frame_type: Optional[str]) -> None: + """Creates a `InvalidFlowStackFrameType`. + + Args: + frame_type: The invalid stack frame type. + """ + super().__init__(f"Invalid stack frame type '{frame_type}'.") + + +class FlowStackFrameType(str, Enum): + INTERRUPT = "interrupt" + """The frame is an interrupt frame. + + This means that the previous flow was interrupted by this flow. An + interrupt should be used for frames that span multiple turns and + where we expect the user needing help to get back to the previous + flow.""" + LINK = "link" + """The frame is a link frame. + + This means that the previous flow linked to this flow.""" + REGULAR = "regular" + """The frame is a regular frame. + + In all other cases, this is the case.""" + + @staticmethod + def from_str(typ: Optional[str]) -> "FlowStackFrameType": + """Creates a `FlowStackFrameType` from a string. + + Args: + typ: The string to create the `FlowStackFrameType` from. + + Returns: + The created `FlowStackFrameType`.""" + if typ is None: + return FlowStackFrameType.REGULAR + elif typ == FlowStackFrameType.INTERRUPT.value: + return FlowStackFrameType.INTERRUPT + elif typ == FlowStackFrameType.LINK.value: + return FlowStackFrameType.LINK + elif typ == FlowStackFrameType.REGULAR.value: + return FlowStackFrameType.REGULAR + else: + raise InvalidFlowStackFrameType(typ) + + +class InvalidFlowIdException(Exception): + """Raised if the flow ID is invalid.""" + + def __init__(self, flow_id: str) -> None: + """Creates a `InvalidFlowIdException`. + + Args: + flow_id: The invalid flow ID. + """ + super().__init__(f"Invalid flow ID '{flow_id}'.") + + +class InvalidFlowStepIdException(Exception): + """Raised if the flow step ID is invalid.""" + + def __init__(self, flow_id: str, step_id: str) -> None: + """Creates a `InvalidFlowStepIdException`. + + Args: + flow_id: The invalid flow ID. + step_id: The invalid flow step ID. + """ + super().__init__(f"Invalid flow step ID '{step_id}' for flow '{flow_id}'.") + + +@dataclass +class BaseFlowStackFrame(DialogueStackFrame): + flow_id: str = "" # needed to avoid "default arg before non-default" error + """The ID of the current flow.""" + step_id: str = START_STEP + """The ID of the current step.""" + + def flow(self, all_flows: FlowsList) -> Flow: + """Returns the current flow. + + Args: + all_flows: All flows in the assistant. + + Returns: + The current flow.""" + flow = all_flows.flow_by_id(self.flow_id) + if not flow: + # we shouldn't ever end up with a frame that belongs to a non + # existing flow, but if we do, we should raise an error + raise InvalidFlowIdException(self.flow_id) + return flow + + def step(self, all_flows: FlowsList) -> FlowStep: + """Returns the current flow step. + + Args: + all_flows: All flows in the assistant. + + Returns: + The current flow step.""" + flow = self.flow(all_flows) + step = flow.step_by_id(self.step_id) + if not step: + # we shouldn't ever end up with a frame that belongs to a non + # existing step, but if we do, we should raise an error + raise InvalidFlowStepIdException(self.flow_id, self.step_id) + return step + + +@dataclass +class UserFlowStackFrame(BaseFlowStackFrame): + frame_type: FlowStackFrameType = FlowStackFrameType.REGULAR + """The type of the frame. Defaults to `StackFrameType.REGULAR`.""" + + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "flow" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> UserFlowStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return UserFlowStackFrame( + frame_id=data["frame_id"], + flow_id=data["flow_id"], + step_id=data["step_id"], + frame_type=FlowStackFrameType.from_str(data.get("frame_type")), + ) diff --git a/rasa/dialogue_understanding/stack/frames/pattern_frame.py b/rasa/dialogue_understanding/stack/frames/pattern_frame.py new file mode 100644 index 000000000000..54e10f2f688e --- /dev/null +++ b/rasa/dialogue_understanding/stack/frames/pattern_frame.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from rasa.dialogue_understanding.stack.frames import BaseFlowStackFrame + + +@dataclass +class PatternFlowStackFrame(BaseFlowStackFrame): + """A stack frame that represents a pattern flow.""" + + pass diff --git a/rasa/dialogue_understanding/stack/frames/search_frame.py b/rasa/dialogue_understanding/stack/frames/search_frame.py new file mode 100644 index 000000000000..efa7aa0bd14e --- /dev/null +++ b/rasa/dialogue_understanding/stack/frames/search_frame.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Dict + +from rasa.dialogue_understanding.stack.frames import DialogueStackFrame + + +@dataclass +class SearchStackFrame(DialogueStackFrame): + @classmethod + def type(cls) -> str: + """Returns the type of the frame.""" + return "search" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> SearchStackFrame: + """Creates a `DialogueStackFrame` from a dictionary. + + Args: + data: The dictionary to create the `DialogueStackFrame` from. + + Returns: + The created `DialogueStackFrame`. + """ + return SearchStackFrame( + frame_id=data["frame_id"], + ) diff --git a/rasa/dialogue_understanding/stack/utils.py b/rasa/dialogue_understanding/stack/utils.py new file mode 100644 index 000000000000..71e59b90d4ba --- /dev/null +++ b/rasa/dialogue_understanding/stack/utils.py @@ -0,0 +1,126 @@ +from typing import Optional, Set +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.frames import BaseFlowStackFrame +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import UserFlowStackFrame +from rasa.shared.core.flows.flow import END_STEP, ContinueFlowStep, FlowsList + + +def top_flow_frame( + dialogue_stack: DialogueStack, ignore_collect_information_pattern: bool = True +) -> Optional[BaseFlowStackFrame]: + """Returns the topmost flow frame from the tracker. + + By default, the topmost flow frame is ignored if it is the + `pattern_collect_information`. This is because the `pattern_collect_information` + is a special flow frame that is used to collect information from the user + and commonly, is not what you are looking for when you want the topmost frame. + + Args: + dialogue_stack: The dialogue stack to use. + ignore_collect_information_pattern: Whether to ignore the + `pattern_collect_information` frame. + + Returns: + The topmost flow frame from the tracker. `None` if there + is no frame on the stack. + """ + + for frame in reversed(dialogue_stack.frames): + if ignore_collect_information_pattern and isinstance( + frame, CollectInformationPatternFlowStackFrame + ): + continue + if isinstance(frame, BaseFlowStackFrame): + return frame + return None + + +def top_user_flow_frame(dialogue_stack: DialogueStack) -> Optional[UserFlowStackFrame]: + """Returns the topmost user flow frame from the tracker. + + A user flow frame is a flow defined by a bot builder. Other frame types + (e.g. patterns, search frames, chitchat, ...) are ignored when looking + for the topmost frame. + + Args: + tracker: The tracker to use. + + + Returns: + The topmost user flow frame from the tracker.""" + for frame in reversed(dialogue_stack.frames): + if isinstance(frame, UserFlowStackFrame): + return frame + return None + + +def filled_slots_for_active_flow( + dialogue_stack: DialogueStack, all_flows: FlowsList +) -> Set[str]: + """Get all slots that have been filled for the 'current user flow'. + + The 'current user flow' is the top-most flow that is user created. All + patterns that sit ontop of that user flow, are also included. So any + collect information step that is part of a pattern that is part of the + current user flow is also included. + + Args: + tracker: The tracker to get the filled slots from. + all_flows: All flows. + + Returns: + All slots that have been filled for the current flow. + """ + filled_slots = set() + + for frame in reversed(dialogue_stack.frames): + if not isinstance(frame, BaseFlowStackFrame): + # we skip all frames that are not flows, e.g. chitchat / search + # frames, because they don't have slots. + continue + flow = frame.flow(all_flows) + for q in flow.previous_collect_steps(frame.step_id): + filled_slots.add(q.collect) + + if isinstance(frame, UserFlowStackFrame): + # as soon as we hit the first stack frame that is a "normal" + # user defined flow we stop looking for previously asked collect infos + # because we only want to ask collect infos that are part of the + # current flow. + break + + return filled_slots + + +def user_flows_on_the_stack(dialogue_stack: DialogueStack) -> Set[str]: + """Get all user flows that are currently on the stack. + + Args: + dialogue_stack: The dialogue stack. + + Returns: + All user flows that are currently on the stack.""" + return { + f.flow_id for f in dialogue_stack.frames if isinstance(f, UserFlowStackFrame) + } + + +def end_top_user_flow(stack: DialogueStack) -> None: + """Ends all frames on top of the stack including the topmost user frame. + + Ends all flows until the next user flow is reached. This is useful + if you want to end all flows that are currently on the stack and + the user flow that triggered them. + + Args: + stack: The dialogue stack. + """ + + for frame in reversed(stack.frames): + if isinstance(frame, BaseFlowStackFrame): + frame.step_id = ContinueFlowStep.continue_step_for_id(END_STEP) + if isinstance(frame, UserFlowStackFrame): + break diff --git a/rasa/engine/caching.py b/rasa/engine/caching.py index b1e250f46471..d9e73122e43f 100644 --- a/rasa/engine/caching.py +++ b/rasa/engine/caching.py @@ -2,7 +2,6 @@ import abc import logging -import os import shutil from datetime import datetime from pathlib import Path @@ -24,17 +23,14 @@ from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta from rasa.engine.storage.storage import ModelStorage +from rasa.shared.engine.caching import ( + get_local_cache_location, + get_max_cache_size, + get_cache_database_name, +) 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. @@ -170,13 +166,9 @@ def __init__(self) -> None: """ self._cache_location = LocalTrainingCache._get_cache_location() - self._max_cache_size = float( - os.environ.get(CACHE_SIZE_ENV, DEFAULT_CACHE_SIZE_MB) - ) + self._max_cache_size = get_max_cache_size() - self._cache_database_name = os.environ.get( - CACHE_DB_NAME_ENV, DEFAULT_CACHE_NAME - ) + self._cache_database_name = get_cache_database_name() if not self._cache_location.exists() and not self._is_disabled(): logger.debug( @@ -191,7 +183,7 @@ def __init__(self) -> None: @staticmethod def _get_cache_location() -> Path: - return Path(os.environ.get(CACHE_LOCATION_ENV, DEFAULT_CACHE_LOCATION)) + return get_local_cache_location() def _create_database(self) -> sqlalchemy.orm.sessionmaker: if self._is_disabled(): diff --git a/rasa/engine/graph.py b/rasa/engine/graph.py index ad0ab228989c..7dbe3a519d83 100644 --- a/rasa/engine/graph.py +++ b/rasa/engine/graph.py @@ -3,9 +3,10 @@ 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 +import structlog + from rasa.engine.exceptions import ( GraphComponentException, GraphRunError, @@ -19,7 +20,7 @@ from rasa.shared.exceptions import InvalidConfigException, RasaException from rasa.shared.data import TrainingType -logger = logging.getLogger(__name__) +structlogger = structlog.get_logger() @dataclass @@ -392,10 +393,12 @@ def __init__( 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}'." + structlogger.debug( + "graph.node.loading_component", + node_name=self._node_name, + clazz=self._component_class.__name__, + constructor=self._constructor_name, + kwargs=kwargs, ) constructor = getattr(self._component_class, self._constructor_name) @@ -417,8 +420,9 @@ def _load_component(self, **kwargs: Any) -> None: 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}." + structlogger.error( + "graph.node.error_loading_component", + node_name=self._node_name, ) raise @@ -458,10 +462,14 @@ def __call__( 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." + structlogger.warning( + "graph.node.input_not_resolved", + node_name=self._node_name, + input_name=i, + event_info=( + "Node input was not resolved, there is no putput. " + "Another component should have provided this as an output." + ), ) kwargs = {} @@ -487,9 +495,11 @@ def __call__( else: run_kwargs = kwargs - logger.debug( - f"Node '{self._node_name}' running " - f"'{self._component_class.__name__}.{self._fn_name}'." + structlogger.debug( + "graph.node.running_component", + node_name=self._node_name, + clazz=self._component_class.__name__, + fn=self._fn_name, ) try: @@ -504,8 +514,9 @@ def __call__( 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}." + structlogger.error( + "graph.node.error_running_component", + node_name=self._node_name, ) raise @@ -516,9 +527,10 @@ def __call__( 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}'." + structlogger.debug( + "graph.node.hook.on_after_node", + node_name=self._node_name, + hook_name=hook.__class__.__name__, ) hook.on_after_node( node_name=self._node_name, @@ -536,9 +548,10 @@ 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}'." + structlogger.debug( + "graph.node.hook.on_before_node", + node_name=self._node_name, + hook_name=hook.__class__.__name__, ) hook_output = hook.on_before_node( node_name=self._node_name, diff --git a/rasa/engine/recipes/default_components.py b/rasa/engine/recipes/default_components.py index 5ddbea3e45d0..11c2212cc41f 100644 --- a/rasa/engine/recipes/default_components.py +++ b/rasa/engine/recipes/default_components.py @@ -1,6 +1,9 @@ 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.dialogue_understanding.generator.llm_command_generator import ( + LLMCommandGenerator, +) from rasa.nlu.classifiers.logistic_regression_classifier import ( LogisticRegressionClassifier, ) @@ -45,6 +48,7 @@ MitieIntentClassifier, SklearnIntentClassifier, LogisticRegressionClassifier, + LLMCommandGenerator, # Response Selectors ResponseSelector, # Message Entity Extractors diff --git a/rasa/engine/recipes/default_recipe.py b/rasa/engine/recipes/default_recipe.py index cc48db4e53df..49de80c4ee80 100644 --- a/rasa/engine/recipes/default_recipe.py +++ b/rasa/engine/recipes/default_recipe.py @@ -13,6 +13,10 @@ CoreFeaturizationInputConverter, CoreFeaturizationCollector, ) +from rasa.graph_components.providers.flows_provider import FlowsProvider +from rasa.dialogue_understanding.processor.command_processor_component import ( + CommandProcessorComponent, +) from rasa.plugin import plugin_manager from rasa.shared.exceptions import FileNotFoundException from rasa.core.policies.ensemble import DefaultPolicyPredictionEnsemble @@ -97,6 +101,7 @@ class ComponentType(Enum): POLICY_WITHOUT_END_TO_END_SUPPORT = 4 POLICY_WITH_END_TO_END_SUPPORT = 5 MODEL_LOADER = 6 + COMMAND_GENERATOR = 7 name = "default.v1" _registered_components: Dict[Text, RegisteredComponent] = {} # noqa: RUF012 @@ -287,7 +292,17 @@ def _add_nlu_train_nodes( train_nodes=train_nodes, cli_parameters=cli_parameters, ) - + train_nodes["flows_provider"] = SchemaNode( + needs={ + "importer": "finetuning_validator", + }, + uses=FlowsProvider, + constructor_name="create", + fn="provide_train", + config={}, + is_target=True, + is_input=True, + ) persist_nlu_data = bool(cli_parameters.get("persist_nlu_training_data")) train_nodes["nlu_training_data_provider"] = SchemaNode( needs={"importer": "finetuning_validator"}, @@ -581,6 +596,17 @@ def _add_core_train_nodes( config={"exclusion_percentage": cli_parameters.get("exclusion_percentage")}, is_input=True, ) + train_nodes["flows_provider"] = SchemaNode( + needs={ + "importer": "finetuning_validator", + }, + uses=FlowsProvider, + constructor_name="create", + fn="provide_train", + config={}, + is_target=True, + is_input=True, + ) train_nodes["training_tracker_provider"] = SchemaNode( needs={ "story_graph": "story_graph_provider", @@ -719,6 +745,15 @@ def _add_nlu_predict_nodes( plugin_manager().hook.modify_default_recipe_graph_predict_nodes( predict_nodes=predict_nodes ) + predict_nodes["flows_provider"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={}, + uses=FlowsProvider, + fn="provide_inference", + config={}, + resource=Resource("flows_provider"), + ) + for idx, config in enumerate(predict_config["pipeline"]): component_name = config.pop("name") component = self._from_registry(component_name) @@ -750,6 +785,7 @@ def _add_nlu_predict_nodes( { self.ComponentType.INTENT_CLASSIFIER, self.ComponentType.ENTITY_EXTRACTOR, + self.ComponentType.COMMAND_GENERATOR, } ): if component.is_trainable: @@ -839,6 +875,14 @@ def _add_core_predict_nodes( config={}, resource=Resource("domain_provider"), ) + predict_nodes["flows_provider"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs={}, + uses=FlowsProvider, + fn="provide_inference", + config={}, + resource=Resource("flows_provider"), + ) node_with_e2e_features = None @@ -847,6 +891,17 @@ def _add_core_predict_nodes( predict_nodes, preprocessors ) + predict_nodes["command_processor"] = SchemaNode( + **DEFAULT_PREDICT_KWARGS, + needs=self._get_needs_from_args( + CommandProcessorComponent, "execute_commands" + ), + uses=CommandProcessorComponent, + fn="execute_commands", + config={}, + resource=Resource("command_processor"), + ) + rule_policy_resource = None policies: List[Text] = [] diff --git a/rasa/engine/runner/dask.py b/rasa/engine/runner/dask.py index 2b17d5e2a9d6..a339e5601313 100644 --- a/rasa/engine/runner/dask.py +++ b/rasa/engine/runner/dask.py @@ -100,6 +100,12 @@ def run( try: dask_result = dask.get(run_graph, run_targets) return dict(dask_result) + except KeyError as e: + raise GraphRunError( + f"Could not find key {e} in the graph. Error running runner. " + f"Please check that you are running bot developed with CALM instead " + f"of bot developed with previous version of dialog management (DM1)." + ) from e except RuntimeError as e: raise GraphRunError("Error running runner.") from e diff --git a/rasa/graph_components/providers/flows_provider.py b/rasa/graph_components/providers/flows_provider.py new file mode 100644 index 000000000000..647249c69c50 --- /dev/null +++ b/rasa/graph_components/providers/flows_provider.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any, Dict, Text + +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.importer import TrainingDataImporter +from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader, YamlFlowsWriter + +from rasa.shared.core.flows.flow import FlowsList + +FLOWS_PERSITENCE_FILE_NAME = "flows.yml" + + +class FlowsProvider(GraphComponent): + """Provides flows information during training and inference time.""" + + def __init__( + self, + model_storage: ModelStorage, + resource: Resource, + flows: FlowsList, + ) -> None: + """Creates flows provider.""" + self._model_storage = model_storage + self._resource = resource + self._flows = flows + + @classmethod + def create( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + ) -> FlowsProvider: + """Creates component (see parent class for full docstring).""" + return cls(model_storage, resource, flows=FlowsList([])) + + @classmethod + def load( + cls, + config: Dict[Text, Any], + model_storage: ModelStorage, + resource: Resource, + execution_context: ExecutionContext, + **kwargs: Any, + ) -> FlowsProvider: + """Creates provider using a persisted version of itself.""" + with model_storage.read_from(resource) as resource_directory: + flows = YAMLFlowsReader.read_from_file( + resource_directory / FLOWS_PERSITENCE_FILE_NAME + ) + return cls(model_storage, resource, flows) + + def _persist(self, flows: FlowsList) -> None: + """Persists flows to model storage.""" + with self._model_storage.write_to(self._resource) as resource_directory: + YamlFlowsWriter.dump( + flows.underlying_flows, + resource_directory / FLOWS_PERSITENCE_FILE_NAME, + ) + + def provide_train(self, importer: TrainingDataImporter) -> FlowsList: + """Provides flows configuration from training data during training.""" + self._flows = importer.get_flows() + self._persist(self._flows) + return self._flows + + def provide_inference(self) -> FlowsList: + """Provides the flows configuration during inference.""" + return self._flows diff --git a/rasa/graph_components/validators/default_recipe_validator.py b/rasa/graph_components/validators/default_recipe_validator.py index 1d2aab15f702..9aa18c32794a 100644 --- a/rasa/graph_components/validators/default_recipe_validator.py +++ b/rasa/graph_components/validators/default_recipe_validator.py @@ -31,7 +31,7 @@ DOCS_URL_POLICIES, DOCS_URL_RULES, ) -from rasa.shared.core.domain import Domain, InvalidDomain +from rasa.shared.core.domain import Domain from rasa.shared.core.constants import ( ACTION_BACK_NAME, ACTION_RESTART_NAME, @@ -383,7 +383,7 @@ def _validate_core(self, story_graph: StoryGraph, domain: Domain) -> None: 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._warn_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) @@ -400,14 +400,10 @@ def _warn_if_no_rule_policy_is_contained(self) -> None: docs=DOCS_URL_DEFAULT_ACTIONS, ) - def _raise_if_domain_contains_form_names_but_no_rule_policy_given( + def _warn_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 - """ + """Validates that there exists a rule policy if forms are defined.""" contains_rule_policy = any( schema_node for schema_node in self._graph_schema.nodes.values() @@ -415,7 +411,7 @@ def _raise_if_domain_contains_form_names_but_no_rule_policy_given( ) if domain.form_names and not contains_rule_policy: - raise InvalidDomain( + rasa.shared.utils.io.raise_warning( "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 " diff --git a/rasa/model_training.py b/rasa/model_training.py index 435825d4e261..1ef47ca675bc 100644 --- a/rasa/model_training.py +++ b/rasa/model_training.py @@ -24,6 +24,7 @@ import rasa.shared.exceptions import rasa.shared.utils.io import rasa.shared.constants +from rasa.shared.constants import CONTEXT import rasa.model CODE_NEEDS_TO_BE_RETRAINED = 0b0001 @@ -124,6 +125,28 @@ def _check_unresolved_slots(domain: Domain, stories: StoryGraph) -> None: return None +def _check_restricted_slots(domain: Domain) -> None: + """Checks if there are any restricted slots. + + Args: + domain: The domain. + + Raises: + Warn user if there are any restricted slots. + + Returns: + `None` if there are no restricted slots. + """ + restricted_slot_names = [CONTEXT] + for slot in domain.slots: + if slot.name in restricted_slot_names: + rasa.shared.utils.cli.print_warning( + f"Slot name - '{slot.name}' is reserved and can not be used. " + f"Please use another slot name." + ) + return None + + def train( domain: Text, config: Text, @@ -167,6 +190,7 @@ def train( ) stories = file_importer.get_stories() + flows = file_importer.get_flows() nlu_data = file_importer.get_nlu_data() training_type = TrainingType.BOTH @@ -175,9 +199,9 @@ def train( 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(): + if stories.is_empty() and nlu_data.contains_no_pure_nlu_data() and flows.is_empty(): rasa.shared.utils.cli.print_error( - "No training data given. Please provide stories and NLU data in " + "No training data given. Please provide stories, flows or NLU data in " "order to train a Rasa model using the '--data' argument." ) return TrainingResult(code=1) @@ -191,20 +215,25 @@ def train( ) training_type = TrainingType.NLU - elif stories.is_empty(): + elif stories.is_empty() and flows.is_empty(): rasa.shared.utils.cli.print_warning( - "No stories present. Just a Rasa NLU model will be trained." + "No stories or flows 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(): + elif ( + nlu_data.contains_no_pure_nlu_data() + and not nlu_data.has_e2e_examples() + and flows.is_empty() + ): rasa.shared.utils.cli.print_warning( - "No NLU data present. Just a Rasa Core model will be trained." + "No NLU data present. No NLU model will be trained." ) training_type = TrainingType.CORE _check_unresolved_slots(domain_object, stories) + _check_restricted_slots(domain_object) with telemetry.track_model_training(file_importer, model_type="rasa"): return _train_graph( @@ -388,6 +417,7 @@ def train_core( return None _check_unresolved_slots(domain, stories_data) + _check_restricted_slots(domain) return _train_graph( file_importer, diff --git a/rasa/server.py b/rasa/server.py index d8bc94e8e127..33d621457895 100644 --- a/rasa/server.py +++ b/rasa/server.py @@ -327,11 +327,6 @@ async def get_test_stories( # 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 @@ -719,12 +714,20 @@ async def retrieve_tracker(request: Request, conversation_id: Text) -> HTTPRespo """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(), + should_start_session = rasa.utils.endpoints.bool_arg( + request, "start_session", default=True ) + if should_start_session: + tracker = ( + await app.ctx.agent.processor.fetch_full_tracker_with_initial_session( + conversation_id, + output_channel=CollectingOutputChannel(), + ) + ) + else: + tracker = await app.ctx.agent.processor.get_tracker(conversation_id) + try: if until_time is not None: tracker = tracker.travel_back_in_time(until_time) @@ -828,7 +831,6 @@ async def replace_events(request: Request, conversation_id: Text) -> HTTPRespons @app.get("/conversations//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") @@ -1370,6 +1372,14 @@ async def unload_model(request: Request) -> HTTPResponse: logger.debug(f"Successfully unloaded model '{model_file}'.") return response.json(None, status=HTTPStatus.NO_CONTENT) + @app.get("/flows") + @requires_auth(app, auth_token) + async def get_flows(request: Request) -> HTTPResponse: + """Get all the flows currently stored by the agent.""" + processor = app.ctx.agent.processor + flows = processor.get_flows() + return response.json(flows.as_json()) + @app.get("/domain") @requires_auth(app, auth_token) @ensure_loaded_agent(app) diff --git a/rasa/shared/constants.py b/rasa/shared/constants.py index 91664cdcc959..8049e4b14692 100644 --- a/rasa/shared/constants.py +++ b/rasa/shared/constants.py @@ -62,6 +62,8 @@ DEFAULT_SENDER_ID = "default" UTTER_PREFIX = "utter_" +UTTER_ASK_PREFIX = "utter_ask_" +FLOW_PREFIX = "flow_" ASSISTANT_ID_KEY = "assistant_id" ASSISTANT_ID_DEFAULT_VALUE = "placeholder_default" @@ -108,3 +110,6 @@ CHANNEL = "channel" OPENAI_API_KEY_ENV_VAR = "OPENAI_API_KEY" + +RASA_DEFAULT_FLOW_PATTERN_PREFIX = "pattern_" +CONTEXT = "context" diff --git a/rasa/shared/core/constants.py b/rasa/shared/core/constants.py index 182ffe672112..368d1dff1176 100644 --- a/rasa/shared/core/constants.py +++ b/rasa/shared/core/constants.py @@ -37,6 +37,14 @@ RULE_SNIPPET_ACTION_NAME = "..." ACTION_EXTRACT_SLOTS = "action_extract_slots" ACTION_VALIDATE_SLOT_MAPPINGS = "action_validate_slot_mappings" +ACTION_CANCEL_FLOW = "action_cancel_flow" +ACTION_CLARIFY_FLOWS = "action_clarify_flows" +ACTION_CORRECT_FLOW_SLOT = "action_correct_flow_slot" +ACTION_RUN_SLOT_REJECTIONS_NAME = "action_run_slot_rejections" +ACTION_CLEAN_STACK = "action_clean_stack" +ACTION_TRIGGER_SEARCH = "action_trigger_search" +ACTION_TRIGGER_CHITCHAT = "action_trigger_chitchat" + DEFAULT_ACTION_NAMES = [ ACTION_LISTEN_NAME, @@ -53,6 +61,13 @@ ACTION_SEND_TEXT_NAME, RULE_SNIPPET_ACTION_NAME, ACTION_EXTRACT_SLOTS, + ACTION_CANCEL_FLOW, + ACTION_CORRECT_FLOW_SLOT, + ACTION_CLARIFY_FLOWS, + ACTION_RUN_SLOT_REJECTIONS_NAME, + ACTION_CLEAN_STACK, + ACTION_TRIGGER_SEARCH, + ACTION_TRIGGER_CHITCHAT, ] ACTION_SHOULD_SEND_DOMAIN = "send_domain" @@ -78,6 +93,11 @@ ACTION_NAME_SENDER_ID_CONNECTOR_STR = "__sender_id:" REQUESTED_SLOT = "requested_slot" +DIALOGUE_STACK_SLOT = "dialogue_stack" +RETURN_VALUE_SLOT = "return_value" +FLOW_HASHES_SLOT = "flow_hashes" + +FLOW_SLOT_NAMES = [DIALOGUE_STACK_SLOT, RETURN_VALUE_SLOT, FLOW_HASHES_SLOT] # slots for knowledge base SLOT_LISTED_ITEMS = "knowledge_base_listed_objects" @@ -85,14 +105,20 @@ 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, +KNOWLEDGE_BASE_SLOT_NAMES = { SLOT_LISTED_ITEMS, SLOT_LAST_OBJECT, SLOT_LAST_OBJECT_TYPE, } +DEFAULT_SLOT_NAMES = { + REQUESTED_SLOT, + DIALOGUE_STACK_SLOT, + SESSION_START_METADATA_SLOT, + RETURN_VALUE_SLOT, + FLOW_HASHES_SLOT, +} + SLOT_MAPPINGS = "mappings" MAPPING_CONDITIONS = "conditions" diff --git a/rasa/shared/core/domain.py b/rasa/shared/core/domain.py index e56540799091..73531d08271d 100644 --- a/rasa/shared/core/domain.py +++ b/rasa/shared/core/domain.py @@ -37,12 +37,13 @@ IGNORED_INTENTS, RESPONSE_CONDITION, ) -import rasa.shared.core.constants from rasa.shared.core.constants import ( ACTION_SHOULD_SEND_DOMAIN, + SLOT_MAPPINGS, SlotMappingType, MAPPING_TYPE, MAPPING_CONDITIONS, + KNOWLEDGE_BASE_SLOT_NAMES, ) from rasa.shared.exceptions import ( RasaException, @@ -54,7 +55,13 @@ 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.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, @@ -484,6 +491,13 @@ def collect_slots(slot_dict: Dict[Text, Any]) -> List[Slot]: slot_type = slot_dict[slot_name].pop("type", None) slot_class = Slot.resolve_by_type(slot_type) + if SLOT_MAPPINGS not in slot_dict[slot_name]: + logger.warning( + f"Slot '{slot_name}' has no mappings defined. " + f"We will continue with an empty list of mappings." + ) + slot_dict[slot_name][SLOT_MAPPINGS] = [] + slot = slot_class(slot_name, **slot_dict[slot_name]) slots.append(slot) return slots @@ -765,6 +779,7 @@ def __init__( self.form_names, self.forms, overridden_form_actions = self._initialize_forms( forms ) + action_names += overridden_form_actions self.responses = responses @@ -951,6 +966,7 @@ def is_retrieval_intent_response( def _add_default_slots(self) -> None: """Sets up the default slots and slot values for the domain.""" self._add_requested_slot() + self._add_flow_slots() self._add_knowledge_base_slots() self._add_categorical_slot_default_value() self._add_session_metadata_slot() @@ -964,6 +980,33 @@ def _add_categorical_slot_default_value(self) -> None: for slot in [s for s in self.slots if isinstance(s, CategoricalSlot)]: slot.add_default_value() + def _add_flow_slots(self) -> None: + """Adds the slots needed for the conversation flows. + + Add a slot called `dialogue_stack_slot` to the list of slots. The value of + this slot will be a call stack of the flow ids. + """ + from rasa.shared.core.constants import FLOW_SLOT_NAMES + + slot_names = [slot.name for slot in self.slots] + + for flow_slot in FLOW_SLOT_NAMES: + if flow_slot not in slot_names: + self.slots.append( + AnySlot( + flow_slot, + mappings=[], + influence_conversation=False, + is_builtin=True, + ) + ) + else: + # TODO: figure out what to do here. + logger.warning( + f"Slot {flow_slot} is reserved for Rasa internal usage, " + f"but it already exists. πŸ€”" + ) + def _add_requested_slot(self) -> None: """Add a slot called `requested_slot` to the list of slots. @@ -978,6 +1021,7 @@ def _add_requested_slot(self) -> None: rasa.shared.core.constants.REQUESTED_SLOT, mappings=[], influence_conversation=False, + is_builtin=True, ) ) @@ -1000,20 +1044,24 @@ def _add_knowledge_base_slots(self) -> None: ) ) 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: + for slot in KNOWLEDGE_BASE_SLOT_NAMES: if slot not in slot_names: self.slots.append( - TextSlot(slot, mappings=[], influence_conversation=False) + TextSlot( + slot, + mappings=[], + influence_conversation=False, + is_builtin=True, + ) ) def _add_session_metadata_slot(self) -> None: self.slots.append( - AnySlot(rasa.shared.core.constants.SESSION_START_METADATA_SLOT, mappings=[]) + AnySlot( + rasa.shared.core.constants.SESSION_START_METADATA_SLOT, + mappings=[], + is_builtin=True, + ) ) def index_for_action(self, action_name: Text) -> int: diff --git a/rasa/shared/core/flows/__init__.py b/rasa/shared/core/flows/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/shared/core/flows/flow.py b/rasa/shared/core/flows/flow.py new file mode 100644 index 000000000000..656309c23564 --- /dev/null +++ b/rasa/shared/core/flows/flow.py @@ -0,0 +1,1539 @@ +from __future__ import annotations + +from dataclasses import dataclass +from functools import cached_property +from typing import ( + Any, + Dict, + Generator, + List, + Optional, + Protocol, + Set, + Text, + Union, + runtime_checkable, +) +import structlog + +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.constants import RASA_DEFAULT_FLOW_PATTERN_PREFIX, UTTER_PREFIX +from rasa.shared.exceptions import RasaException +from rasa.shared.nlu.constants import ENTITY_ATTRIBUTE_TYPE, INTENT_NAME_KEY + +import rasa.shared.utils.io +from rasa.shared.utils.llm import ( + DEFAULT_OPENAI_GENERATE_MODEL_NAME, + DEFAULT_OPENAI_TEMPERATURE, +) + +structlogger = structlog.get_logger() + +START_STEP = "START" + +END_STEP = "END" + +DEFAULT_STEPS = {END_STEP, START_STEP} + + +class UnreachableFlowStepException(RasaException): + """Raised when a flow step is unreachable.""" + + def __init__(self, step: FlowStep, flow: Flow) -> None: + """Initializes the exception.""" + self.step = step + self.flow = flow + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step.id}' in flow '{self.flow.id}' can not be reached " + f"from the start step. Please make sure that all steps can be reached " + f"from the start step, e.g. by " + f"checking that another step points to this step." + ) + + +class MissingNextLinkException(RasaException): + """Raised when a flow step is missing a next link.""" + + def __init__(self, step: FlowStep, flow: Flow) -> None: + """Initializes the exception.""" + self.step = step + self.flow = flow + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step.id}' in flow '{self.flow.id}' is missing a `next`. " + f"As a last step of a branch, it is required to have one. " + ) + + +class ReservedFlowStepIdException(RasaException): + """Raised when a flow step is using a reserved id.""" + + def __init__(self, step: FlowStep, flow: Flow) -> None: + """Initializes the exception.""" + self.step = step + self.flow = flow + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step.id}' in flow '{self.flow.id}' is using the reserved id " + f"'{self.step.id}'. Please use a different id for your step." + ) + + +class MissingElseBranchException(RasaException): + """Raised when a flow step is missing an else branch.""" + + def __init__(self, step: FlowStep, flow: Flow) -> None: + """Initializes the exception.""" + self.step = step + self.flow = flow + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Step '{self.step.id}' in flow '{self.flow.id}' is missing an `else` " + f"branch. If a steps `next` statement contains an `if` it always " + f"also needs an `else` branch. Please add the missing `else` branch." + ) + + +class NoNextAllowedForLinkException(RasaException): + """Raised when a flow step has a next link but is not allowed to have one.""" + + def __init__(self, step: FlowStep, flow: Flow) -> None: + """Initializes the exception.""" + self.step = step + self.flow = flow + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Link step '{self.step.id}' in flow '{self.flow.id}' has a `next` but " + f"as a link step is not allowed to have one." + ) + + +class UnresolvedFlowStepIdException(RasaException): + """Raised when a flow step is referenced, but its id can not be resolved.""" + + def __init__( + self, step_id: Text, flow: Flow, referenced_from: Optional[FlowStep] + ) -> None: + """Initializes the exception.""" + self.step_id = step_id + self.flow = flow + self.referenced_from = referenced_from + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + if self.referenced_from: + exception_message = ( + f"Step with id '{self.step_id}' could not be resolved. " + f"'Step '{self.referenced_from.id}' in flow '{self.flow.id}' " + f"referenced this step but it does not exist. " + ) + else: + exception_message = ( + f"Step '{self.step_id}' in flow '{self.flow.id}' can not be resolved. " + ) + + return exception_message + ( + "Please make sure that the step is defined in the same flow." + ) + + +class UnresolvedFlowException(RasaException): + """Raised when a flow is referenced but it's id can not be resolved.""" + + def __init__(self, flow_id: Text) -> None: + """Initializes the exception.""" + self.flow_id = flow_id + + def __str__(self) -> Text: + """Return a string representation of the exception.""" + return ( + f"Flow '{self.flow_id}' can not be resolved. " + f"Please make sure that the flow is defined." + ) + + +class FlowsList: + """Represents the configuration of a list of flow. + + We need this class to be able to fingerprint the flows configuration. + Fingerprinting is needed to make sure that the model is retrained if the + flows configuration changes. + """ + + def __init__(self, flows: List[Flow]) -> None: + """Initializes the configuration of flows. + + Args: + flows: The flows to be configured. + """ + self.underlying_flows = flows + + def __iter__(self) -> Generator[Flow, None, None]: + """Iterates over the flows.""" + yield from self.underlying_flows + + def __eq__(self, other: Any) -> bool: + """Compares the flows.""" + return ( + isinstance(other, FlowsList) + and self.underlying_flows == other.underlying_flows + ) + + def is_empty(self) -> bool: + """Returns whether the flows list is empty.""" + return len(self.underlying_flows) == 0 + + @classmethod + def from_json( + cls, flows_configs: Optional[Dict[Text, Dict[Text, Any]]] + ) -> FlowsList: + """Used to read flows from parsed YAML. + + Args: + flows_configs: The parsed YAML as a dictionary. + + Returns: + The parsed flows. + """ + if not flows_configs: + return cls([]) + + return cls( + [ + Flow.from_json(flow_id, flow_config) + for flow_id, flow_config in flows_configs.items() + ] + ) + + def as_json(self) -> List[Dict[Text, Any]]: + """Returns the flows as a dictionary. + + Returns: + The flows as a dictionary. + """ + return [flow.as_json() for flow in self.underlying_flows] + + def fingerprint(self) -> str: + """Creates a fingerprint of the flows configuration. + + Returns: + The fingerprint of the flows configuration. + """ + flow_dicts = [flow.as_json() for flow in self.underlying_flows] + return rasa.shared.utils.io.get_list_fingerprint(flow_dicts) + + def merge(self, other: FlowsList) -> FlowsList: + """Merges two lists of flows together.""" + return FlowsList(self.underlying_flows + other.underlying_flows) + + def flow_by_id(self, id: Optional[Text]) -> Optional[Flow]: + """Return the flow with the given id.""" + if not id: + return None + + for flow in self.underlying_flows: + if flow.id == id: + return flow + else: + return None + + def step_by_id(self, step_id: Text, flow_id: Text) -> FlowStep: + """Return the step with the given id.""" + flow = self.flow_by_id(flow_id) + if not flow: + raise UnresolvedFlowException(flow_id) + + step = flow.step_by_id(step_id) + if not step: + raise UnresolvedFlowStepIdException(step_id, flow, referenced_from=None) + + return step + + def validate(self) -> None: + """Validate the flows.""" + for flow in self.underlying_flows: + flow.validate() + + @property + def user_flow_ids(self) -> List[str]: + """Get all ids of flows that can be started by a user. + + Returns: + The ids of all flows that can be started by a user.""" + return [f.id for f in self.user_flows] + + @property + def user_flows(self) -> FlowsList: + """Get all flows that can be started by a user. + + Returns: + All flows that can be started by a user.""" + return FlowsList( + [f for f in self.underlying_flows if not f.is_rasa_default_flow] + ) + + @property + def utterances(self) -> Set[str]: + """Retrieve all utterances of all flows""" + return set().union(*[flow.utterances for flow in self.underlying_flows]) + + +@dataclass +class Flow: + """Represents the configuration of a flow.""" + + id: Text + """The id of the flow.""" + name: Text + """The human-readable name of the flow.""" + description: Optional[Text] + """The description of the flow.""" + step_sequence: StepSequence + """The steps of the flow.""" + + @staticmethod + def from_json(flow_id: Text, flow_config: Dict[Text, Any]) -> Flow: + """Used to read flows from parsed YAML. + + Args: + flow_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow. + """ + step_sequence = StepSequence.from_json(flow_config.get("steps")) + + return Flow( + id=flow_id, + name=flow_config.get("name", Flow.create_default_name(flow_id)), + description=flow_config.get("description"), + step_sequence=Flow.resolve_default_ids(step_sequence), + ) + + @staticmethod + def create_default_name(flow_id: str) -> str: + """Create a default flow name for when it is missing.""" + return flow_id.replace("_", " ").replace("-", " ") + + @staticmethod + def resolve_default_ids(step_sequence: StepSequence) -> StepSequence: + """Resolves the default ids of all steps in the sequence. + + If a step does not have an id, a default id is assigned to it based + on the type of the step and its position in the flow. + + Similarly, if a step doesn't have an explicit next assigned we resolve + the default next step id. + + Args: + step_sequence: The step sequence to resolve the default ids for. + + Returns: + The step sequence with the default ids resolved. + """ + # assign an index to all steps + for idx, step in enumerate(step_sequence.steps): + step.idx = idx + + def resolve_default_next(steps: List[FlowStep], is_root_sequence: bool) -> None: + for i, step in enumerate(steps): + if step.next.no_link_available(): + if i == len(steps) - 1: + # can't attach end to link step + if is_root_sequence and not isinstance(step, LinkFlowStep): + # if this is the root sequence, we need to add an end step + # to the end of the sequence. other sequences, e.g. + # in branches need to explicitly add a next step. + step.next.links.append(StaticFlowLink(target=END_STEP)) + else: + step.next.links.append(StaticFlowLink(target=steps[i + 1].id)) + for link in step.next.links: + if sub_steps := link.child_steps(): + resolve_default_next(sub_steps, is_root_sequence=False) + + resolve_default_next(step_sequence.child_steps, is_root_sequence=True) + return step_sequence + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow as a dictionary. + + Returns: + The flow as a dictionary. + """ + return { + "id": self.id, + "name": self.name, + "description": self.description, + "steps": self.step_sequence.as_json(), + } + + def readable_name(self) -> str: + """Returns the name of the flow or its id if no name is set.""" + return self.name or self.id + + def validate(self) -> None: + """Validates the flow configuration. + + This ensures that the flow semantically makes sense. E.g. it + checks: + - whether all next links point to existing steps + - whether all steps can be reached from the start step + """ + self._validate_all_steps_next_property() + self._validate_all_next_ids_are_availble_steps() + self._validate_all_steps_can_be_reached() + self._validate_all_branches_have_an_else() + self._validate_not_using_buildin_ids() + + def _validate_not_using_buildin_ids(self) -> None: + """Validates that the flow does not use any of the build in ids.""" + for step in self.steps: + if step.id in DEFAULT_STEPS or step.id.startswith(CONTINUE_STEP_PREFIX): + raise ReservedFlowStepIdException(step, self) + + def _validate_all_branches_have_an_else(self) -> None: + """Validates that all branches have an else link.""" + for step in self.steps: + links = step.next.links + + has_an_if = any(isinstance(link, IfFlowLink) for link in links) + has_an_else = any(isinstance(link, ElseFlowLink) for link in links) + + if has_an_if and not has_an_else: + raise MissingElseBranchException(step, self) + + def _validate_all_steps_next_property(self) -> None: + """Validates that every step has a next link.""" + for step in self.steps: + if isinstance(step, LinkFlowStep): + # link steps can't have a next link! + if not step.next.no_link_available(): + raise NoNextAllowedForLinkException(step, self) + elif step.next.no_link_available(): + # all other steps should have a next link + raise MissingNextLinkException(step, self) + + def _validate_all_next_ids_are_availble_steps(self) -> None: + """Validates that all next links point to existing steps.""" + available_steps = {step.id for step in self.steps} | DEFAULT_STEPS + for step in self.steps: + for link in step.next.links: + if link.target not in available_steps: + raise UnresolvedFlowStepIdException(link.target, self, step) + + def _validate_all_steps_can_be_reached(self) -> None: + """Validates that all steps can be reached from the start step.""" + + def _reachable_steps( + step: Optional[FlowStep], reached_steps: Set[Text] + ) -> Set[Text]: + """Validates that the given step can be reached from the start step.""" + if step is None or step.id in reached_steps: + return reached_steps + + reached_steps.add(step.id) + for link in step.next.links: + reached_steps = _reachable_steps( + self.step_by_id(link.target), reached_steps + ) + return reached_steps + + reached_steps = _reachable_steps(self.first_step_in_flow(), set()) + + for step in self.steps: + if step.id not in reached_steps: + raise UnreachableFlowStepException(step, self) + + def step_by_id(self, step_id: Optional[Text]) -> Optional[FlowStep]: + """Returns the step with the given id.""" + if not step_id: + return None + + if step_id == START_STEP: + first_step_in_flow = self.first_step_in_flow() + return StartFlowStep(first_step_in_flow.id if first_step_in_flow else None) + + if step_id == END_STEP: + return EndFlowStep() + + if step_id.startswith(CONTINUE_STEP_PREFIX): + return ContinueFlowStep(step_id[len(CONTINUE_STEP_PREFIX) :]) + + for step in self.steps: + if step.id == step_id: + return step + + return None + + def first_step_in_flow(self) -> Optional[FlowStep]: + """Returns the start step of this flow.""" + if len(self.steps) == 0: + return None + return self.steps[0] + + def previous_collect_steps( + self, step_id: Optional[str] + ) -> List[CollectInformationFlowStep]: + """Returns the collect informations asked before the given step. + + CollectInformations are returned roughly in reverse order, i.e. the first + collect information in the list is the one asked last. But due to circles + in the flow the order is not guaranteed to be exactly reverse. + """ + + def _previously_asked_collect( + current_step_id: str, visited_steps: Set[str] + ) -> List[CollectInformationFlowStep]: + """Returns the collect informations asked before the given step. + + Keeps track of the steps that have been visited to avoid circles. + """ + current_step = self.step_by_id(current_step_id) + + collects: List[CollectInformationFlowStep] = [] + + if not current_step: + return collects + + if isinstance(current_step, CollectInformationFlowStep): + collects.append(current_step) + + visited_steps.add(current_step.id) + + for previous_step in self.steps: + for next_link in previous_step.next.links: + if next_link.target != current_step_id: + continue + if previous_step.id in visited_steps: + continue + collects.extend( + _previously_asked_collect(previous_step.id, visited_steps) + ) + return collects + + return _previously_asked_collect(step_id or START_STEP, set()) + + def get_trigger_intents(self) -> Set[str]: + """Returns the trigger intents of the flow""" + results: Set[str] = set() + if len(self.steps) == 0: + return results + + first_step = self.steps[0] + + if not isinstance(first_step, UserMessageStep): + return results + + for condition in first_step.trigger_conditions: + results.add(condition.intent) + + return results + + def is_user_triggerable(self) -> bool: + """Test whether a user can trigger the flow with an intent.""" + return len(self.get_trigger_intents()) > 0 + + @property + def is_rasa_default_flow(self) -> bool: + """Test whether something is a rasa default flow.""" + return self.id.startswith(RASA_DEFAULT_FLOW_PATTERN_PREFIX) + + def get_collect_steps(self) -> List[CollectInformationFlowStep]: + """Return the collect information steps of the flow.""" + collect_steps = [] + for step in self.steps: + if isinstance(step, CollectInformationFlowStep): + collect_steps.append(step) + return collect_steps + + @property + def steps(self) -> List[FlowStep]: + """Returns the steps of the flow.""" + return self.step_sequence.steps + + @cached_property + def fingerprint(self) -> str: + """Create a fingerprint identifying this step sequence.""" + return rasa.shared.utils.io.deep_container_fingerprint(self.as_json()) + + @property + def utterances(self) -> Set[str]: + """Retrieve all utterances of this flow""" + return set().union(*[step.utterances for step in self.step_sequence.steps]) + + +@dataclass +class StepSequence: + child_steps: List[FlowStep] + + @staticmethod + def from_json(steps_config: List[Dict[Text, Any]]) -> StepSequence: + """Used to read steps from parsed YAML. + + Args: + steps_config: The parsed YAML as a dictionary. + + Returns: + The parsed steps. + """ + + flow_steps: List[FlowStep] = [step_from_json(config) for config in steps_config] + + return StepSequence(child_steps=flow_steps) + + def as_json(self) -> List[Dict[Text, Any]]: + """Returns the steps as a dictionary. + + Returns: + The steps as a dictionary. + """ + return [ + step.as_json() + for step in self.child_steps + if not isinstance(step, InternalFlowStep) + ] + + @property + def steps(self) -> List[FlowStep]: + """Returns the steps of the flow.""" + return [ + step + for child_step in self.child_steps + for step in child_step.steps_in_tree() + ] + + def first(self) -> Optional[FlowStep]: + """Returns the first step of the sequence.""" + if len(self.child_steps) == 0: + return None + return self.child_steps[0] + + +def step_from_json(flow_step_config: Dict[Text, Any]) -> FlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + if "action" in flow_step_config: + return ActionFlowStep.from_json(flow_step_config) + if "intent" in flow_step_config: + return UserMessageStep.from_json(flow_step_config) + if "collect" in flow_step_config: + return CollectInformationFlowStep.from_json(flow_step_config) + if "link" in flow_step_config: + return LinkFlowStep.from_json(flow_step_config) + if "set_slots" in flow_step_config: + return SetSlotsFlowStep.from_json(flow_step_config) + if "generation_prompt" in flow_step_config: + return GenerateResponseFlowStep.from_json(flow_step_config) + else: + return BranchFlowStep.from_json(flow_step_config) + + +@dataclass +class FlowStep: + """Represents the configuration of a flow step.""" + + custom_id: Optional[Text] + """The id of the flow step.""" + idx: int + """The index of the step in the flow.""" + description: Optional[Text] + """The description of the flow step.""" + metadata: Dict[Text, Any] + """Additional, unstructured information about this flow step.""" + next: "FlowLinks" + """The next steps of the flow step.""" + + @classmethod + def _from_json(cls, flow_step_config: Dict[Text, Any]) -> FlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + return FlowStep( + # the idx is set later once the flow is created that contains + # this step + idx=-1, + custom_id=flow_step_config.get("id"), + description=flow_step_config.get("description"), + metadata=flow_step_config.get("metadata", {}), + next=FlowLinks.from_json(flow_step_config.get("next", [])), + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = {"next": self.next.as_json(), "id": self.id} + + if self.description: + dump["description"] = self.description + if self.metadata: + dump["metadata"] = self.metadata + return dump + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow step.""" + yield self + yield from self.next.steps_in_tree() + + @property + def id(self) -> Text: + """Returns the id of the flow step.""" + return self.custom_id or self.default_id() + + def default_id(self) -> str: + """Returns the default id of the flow step.""" + return f"{self.idx}_{self.default_id_postfix()}" + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + raise NotImplementedError() + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return set() + + +class InternalFlowStep(FlowStep): + """Represents the configuration of a built-in flow step. + + Built in flow steps are required to manage the lifecycle of a + flow and are not intended to be used by users. + """ + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: + """Used to read flow steps from parsed JSON. + + Args: + flow_step_config: The parsed JSON as a dictionary. + + Returns: + The parsed flow step. + """ + raise ValueError("A start step cannot be parsed.") + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + raise ValueError("A start step cannot be dumped.") + + +@dataclass +class StartFlowStep(InternalFlowStep): + """Represents the configuration of a start flow step.""" + + def __init__(self, start_step_id: Optional[Text]) -> None: + """Initializes a start flow step. + + Args: + start_step: The step to start the flow from. + """ + if start_step_id is not None: + links: List[FlowLink] = [StaticFlowLink(target=start_step_id)] + else: + links = [] + + super().__init__( + idx=0, + custom_id=START_STEP, + description=None, + metadata={}, + next=FlowLinks(links=links), + ) + + +@dataclass +class EndFlowStep(InternalFlowStep): + """Represents the configuration of an end to a flow.""" + + def __init__(self) -> None: + """Initializes an end flow step.""" + super().__init__( + idx=0, + custom_id=END_STEP, + description=None, + metadata={}, + next=FlowLinks(links=[]), + ) + + +CONTINUE_STEP_PREFIX = "NEXT:" + + +@dataclass +class ContinueFlowStep(InternalFlowStep): + """Represents the configuration of a continue-step flow step.""" + + def __init__(self, next: str) -> None: + """Initializes a continue-step flow step.""" + super().__init__( + idx=0, + custom_id=CONTINUE_STEP_PREFIX + next, + description=None, + metadata={}, + # The continue step links to the step that should be continued. + # The flow policy in a sense only "runs" the logic of a step + # when it transitions to that step, once it is there it will use + # the next link to transition to the next step. This means that + # if we want to "re-run" a step, we need to link to it again. + # This is why the continue step links to the step that should be + # continued. + next=FlowLinks(links=[StaticFlowLink(target=next)]), + ) + + @staticmethod + def continue_step_for_id(step_id: str) -> str: + return CONTINUE_STEP_PREFIX + step_id + + +@dataclass +class ActionFlowStep(FlowStep): + """Represents the configuration of an action flow step.""" + + action: Text + """The action of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> ActionFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return ActionFlowStep( + action=flow_step_config.get("action", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["action"] = self.action + return dump + + def default_id_postfix(self) -> str: + return self.action + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return {self.action} if self.action.startswith(UTTER_PREFIX) else set() + + +@dataclass +class BranchFlowStep(FlowStep): + """Represents the configuration of a branch flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> BranchFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return BranchFlowStep(**base.__dict__) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "branch" + + +@dataclass +class LinkFlowStep(FlowStep): + """Represents the configuration of a link flow step.""" + + link: Text + """The link of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> LinkFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return LinkFlowStep( + link=flow_step_config.get("link", ""), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["link"] = self.link + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return f"link_{self.link}" + + +@dataclass +class TriggerCondition: + """Represents the configuration of a trigger condition.""" + + intent: Text + """The intent to trigger the flow.""" + entities: List[Text] + """The entities to trigger the flow.""" + + def is_triggered(self, intent: Text, entities: List[Text]) -> bool: + """Check if condition is triggered by the given intent and entities. + + Args: + intent: The intent to check. + entities: The entities to check. + + Returns: + Whether the trigger condition is triggered by the given intent and entities. + """ + if self.intent != intent: + return False + if len(self.entities) == 0: + return True + return all(entity in entities for entity in self.entities) + + +@runtime_checkable +class StepThatCanStartAFlow(Protocol): + """Represents a step that can start a flow.""" + + def is_triggered(self, tracker: DialogueStateTracker) -> bool: + """Check if a flow should be started for the tracker + + Args: + tracker: The tracker to check. + + Returns: + Whether a flow should be started for the tracker. + """ + ... + + +@dataclass +class UserMessageStep(FlowStep, StepThatCanStartAFlow): + """Represents the configuration of an intent flow step.""" + + trigger_conditions: List[TriggerCondition] + """The trigger conditions of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> UserMessageStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + + trigger_conditions = [] + if "intent" in flow_step_config: + trigger_conditions.append( + TriggerCondition( + intent=flow_step_config["intent"], + entities=flow_step_config.get("entities", []), + ) + ) + elif "or" in flow_step_config: + for trigger_condition in flow_step_config["or"]: + trigger_conditions.append( + TriggerCondition( + intent=trigger_condition.get("intent", ""), + entities=trigger_condition.get("entities", []), + ) + ) + + return UserMessageStep( + trigger_conditions=trigger_conditions, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + + if len(self.trigger_conditions) == 1: + dump["intent"] = self.trigger_conditions[0].intent + if self.trigger_conditions[0].entities: + dump["entities"] = self.trigger_conditions[0].entities + elif len(self.trigger_conditions) > 1: + dump["or"] = [ + { + "intent": trigger_condition.intent, + "entities": trigger_condition.entities, + } + for trigger_condition in self.trigger_conditions + ] + + return dump + + def is_triggered(self, tracker: DialogueStateTracker) -> bool: + """Returns whether the flow step is triggered by the given intent and entities. + + Args: + intent: The intent to check. + entities: The entities to check. + + Returns: + Whether the flow step is triggered by the given intent and entities. + """ + if not tracker.latest_message: + return False + + intent: Text = tracker.latest_message.intent.get(INTENT_NAME_KEY, "") + entities: List[Text] = [ + e.get(ENTITY_ATTRIBUTE_TYPE, "") for e in tracker.latest_message.entities + ] + return any( + trigger_condition.is_triggered(intent, entities) + for trigger_condition in self.trigger_conditions + ) + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "intent" + + +DEFAULT_LLM_CONFIG = { + "_type": "openai", + "request_timeout": 5, + "temperature": DEFAULT_OPENAI_TEMPERATURE, + "model_name": DEFAULT_OPENAI_GENERATE_MODEL_NAME, +} + + +@dataclass +class GenerateResponseFlowStep(FlowStep): + """Represents the configuration of a step prompting an LLM.""" + + generation_prompt: Text + """The prompt template of the flow step.""" + llm_config: Optional[Dict[Text, Any]] = None + """The LLM configuration of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> GenerateResponseFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return GenerateResponseFlowStep( + generation_prompt=flow_step_config.get("generation_prompt", ""), + llm_config=flow_step_config.get("llm", None), + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["generation_prompt"] = self.generation_prompt + if self.llm_config: + dump["llm"] = self.llm_config + + return dump + + def generate(self, tracker: DialogueStateTracker) -> Optional[Text]: + """Generates a response for the given tracker. + + Args: + tracker: The tracker to generate a response for. + + Returns: + The generated response. + """ + from rasa.shared.utils.llm import llm_factory, tracker_as_readable_transcript + from jinja2 import Template + + context = { + "history": tracker_as_readable_transcript(tracker, max_turns=5), + "latest_user_message": tracker.latest_message.text + if tracker.latest_message + else "", + } + context.update(tracker.current_slot_values()) + + llm = llm_factory(self.llm_config, DEFAULT_LLM_CONFIG) + prompt = Template(self.generation_prompt).render(context) + + try: + return llm(prompt) + except Exception as e: + # unfortunately, langchain does not wrap LLM exceptions which means + # we have to catch all exceptions here + structlogger.error( + "flow.generate_step.llm.error", error=e, step=self.id, prompt=prompt + ) + return None + + def default_id_postfix(self) -> str: + return "generate" + + +@dataclass +class SlotRejection: + """A slot rejection.""" + + if_: str + """The condition that should be checked.""" + utter: str + """The utterance that should be executed if the condition is met.""" + + @staticmethod + def from_dict(rejection_config: Dict[Text, Any]) -> SlotRejection: + """Used to read slot rejections from parsed YAML. + + Args: + rejection_config: The parsed YAML as a dictionary. + + Returns: + The parsed slot rejection. + """ + return SlotRejection( + if_=rejection_config["if"], + utter=rejection_config["utter"], + ) + + def as_dict(self) -> Dict[Text, Any]: + """Returns the slot rejection as a dictionary. + + Returns: + The slot rejection as a dictionary. + """ + return { + "if": self.if_, + "utter": self.utter, + } + + +@dataclass +class CollectInformationFlowStep(FlowStep): + """Represents the configuration of a collect information flow step.""" + + collect: Text + """The collect information of the flow step.""" + utter: Text + """The utterance that the assistant uses to ask for the slot.""" + rejections: List[SlotRejection] + """how the slot value is validated using predicate evaluation.""" + ask_before_filling: bool = False + """Whether to always ask the question even if the slot is already filled.""" + reset_after_flow_ends: bool = True + """Determines whether to reset the slot value at the end of the flow.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> CollectInformationFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + return CollectInformationFlowStep( + collect=flow_step_config["collect"], + utter=flow_step_config.get( + "utter", f"utter_ask_{flow_step_config['collect']}" + ), + ask_before_filling=flow_step_config.get("ask_before_filling", False), + reset_after_flow_ends=flow_step_config.get("reset_after_flow_ends", True), + rejections=[ + SlotRejection.from_dict(rejection) + for rejection in flow_step_config.get("rejections", []) + ], + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["collect"] = self.collect + dump["utter"] = self.utter + dump["ask_before_filling"] = self.ask_before_filling + dump["reset_after_flow_ends"] = self.reset_after_flow_ends + dump["rejections"] = [rejection.as_dict() for rejection in self.rejections] + + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return f"collect_{self.collect}" + + @property + def utterances(self) -> Set[str]: + """Return all the utterances used in this step""" + return {self.utter} | {r.utter for r in self.rejections} + + +@dataclass +class SetSlotsFlowStep(FlowStep): + """Represents the configuration of a set_slots flow step.""" + + slots: List[Dict[str, Any]] + """Slots to set of the flow step.""" + + @classmethod + def from_json(cls, flow_step_config: Dict[Text, Any]) -> SetSlotsFlowStep: + """Used to read flow steps from parsed YAML. + + Args: + flow_step_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow step. + """ + base = super()._from_json(flow_step_config) + slots = [ + {"key": k, "value": v} + for slot in flow_step_config.get("set_slots", []) + for k, v in slot.items() + ] + return SetSlotsFlowStep( + slots=slots, + **base.__dict__, + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow step as a dictionary. + + Returns: + The flow step as a dictionary. + """ + dump = super().as_json() + dump["set_slots"] = [{slot["key"]: slot["value"]} for slot in self.slots] + return dump + + def default_id_postfix(self) -> str: + """Returns the default id postfix of the flow step.""" + return "set_slots" + + +@dataclass +class FlowLinks: + """Represents the configuration of a list of flow links.""" + + links: List[FlowLink] + + @staticmethod + def from_json(flow_links_config: List[Dict[Text, Any]]) -> FlowLinks: + """Used to read flow links from parsed YAML. + + Args: + flow_links_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow links. + """ + if not flow_links_config: + return FlowLinks(links=[]) + + if isinstance(flow_links_config, str): + return FlowLinks(links=[StaticFlowLink.from_json(flow_links_config)]) + + return FlowLinks( + links=[ + FlowLinks.link_from_json(link_config) + for link_config in flow_links_config + if link_config + ] + ) + + @staticmethod + def link_from_json(link_config: Dict[Text, Any]) -> FlowLink: + """Used to read a single flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if "if" in link_config: + return IfFlowLink.from_json(link_config) + elif "else" in link_config: + return ElseFlowLink.from_json(link_config) + else: + raise Exception("Invalid flow link") + + def as_json(self) -> Any: + """Returns the flow links as a dictionary. + + Returns: + The flow links as a dictionary. + """ + if not self.links: + return None + + if len(self.links) == 1 and isinstance(self.links[0], StaticFlowLink): + return self.links[0].as_json() + + return [link.as_json() for link in self.links] + + def no_link_available(self) -> bool: + """Returns whether no link is available.""" + return len(self.links) == 0 + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow links.""" + for link in self.links: + yield from link.steps_in_tree() + + +class FlowLink(Protocol): + """Represents a flow link.""" + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link. + + Returns: + The target of the flow link. + """ + ... + + def as_json(self) -> Any: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + ... + + @staticmethod + def from_json(link_config: Any) -> FlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + ... + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + ... + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + ... + + +@dataclass +class BranchBasedLink: + target_reference: Union[Text, StepSequence] + """The id of the linked flow.""" + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + if isinstance(self.target_reference, StepSequence): + yield from self.target_reference.steps + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + if isinstance(self.target_reference, StepSequence): + return self.target_reference.child_steps + else: + return [] + + @property + def target(self) -> Optional[Text]: + """Returns the target of the flow link.""" + if isinstance(self.target_reference, StepSequence): + if first := self.target_reference.first(): + return first.id + else: + return None + else: + return self.target_reference + + +@dataclass +class IfFlowLink(BranchBasedLink): + """Represents the configuration of an if flow link.""" + + condition: Optional[Text] + """The condition of the linked flow.""" + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> IfFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if isinstance(link_config["then"], str): + return IfFlowLink( + target_reference=link_config["then"], condition=link_config.get("if") + ) + else: + return IfFlowLink( + target_reference=StepSequence.from_json(link_config["then"]), + condition=link_config.get("if"), + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return { + "if": self.condition, + "then": self.target_reference.as_json() + if isinstance(self.target_reference, StepSequence) + else self.target_reference, + } + + +@dataclass +class ElseFlowLink(BranchBasedLink): + """Represents the configuration of an else flow link.""" + + @staticmethod + def from_json(link_config: Dict[Text, Any]) -> ElseFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + if isinstance(link_config["else"], str): + return ElseFlowLink(target_reference=link_config["else"]) + else: + return ElseFlowLink( + target_reference=StepSequence.from_json(link_config["else"]) + ) + + def as_json(self) -> Dict[Text, Any]: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return { + "else": self.target_reference.as_json() + if isinstance(self.target_reference, StepSequence) + else self.target_reference + } + + +@dataclass +class StaticFlowLink: + """Represents the configuration of a static flow link.""" + + target: Text + """The id of the linked flow.""" + + @staticmethod + def from_json(link_config: Text) -> StaticFlowLink: + """Used to read flow links from parsed YAML. + + Args: + link_config: The parsed YAML as a dictionary. + + Returns: + The parsed flow link. + """ + return StaticFlowLink(target=link_config) + + def as_json(self) -> Text: + """Returns the flow link as a dictionary. + + Returns: + The flow link as a dictionary. + """ + return self.target + + def steps_in_tree(self) -> Generator[FlowStep, None, None]: + """Returns the steps in the tree of the flow link.""" + # static links do not have any child steps + yield from [] + + def child_steps(self) -> List[FlowStep]: + """Returns the child steps of the flow link.""" + return [] diff --git a/rasa/shared/core/flows/flows_yaml_schema.json b/rasa/shared/core/flows/flows_yaml_schema.json new file mode 100644 index 000000000000..3435b991310d --- /dev/null +++ b/rasa/shared/core/flows/flows_yaml_schema.json @@ -0,0 +1,269 @@ +{ + "type": "object", + "required": [ + "flows" + ], + "properties": { + "version": { + "type": "string" + }, + "flows": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "$ref": "#$defs/flow" + } + } + } + }, + "$defs": { + "steps": { + "type": "array", + "minContains": 1, + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "action" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "action": { + "type": "string" + }, + "next": { + "$ref": "#$defs/next" + } + } + }, + { + "required": [ + "collect" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "description":{ + "type": "string" + }, + "collect": { + "type": "string" + }, + "ask_before_filling": { + "type": "boolean" + }, + "reset_after_flow_ends": { + "type": "boolean" + }, + "utter": { + "type": "string" + }, + "rejections": { + "type": "array", + "items": { + "type": "object", + "required": [ + "if", + "utter" + ], + "properties": { + "if": { + "type": "string" + }, + "utter": { + "type": "string" + } + } + } + }, + "next": { + "$ref": "#$defs/next" + } + } + }, + { + "required": [ + "link" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "link": { + "type": "string" + }, + "next": { + "type": "null" + } + } + }, + { + "required": [ + "set_slots" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "set_slots": { + "$ref": "#$defs/set_slots" + }, + "next": { + "$ref": "#$defs/next" + } + } + }, + { + "required": [ + "next" + ], + "additionalProperties": false, + "properties": { + "next": { + "$ref": "#$defs/next" + }, + "id": { + "type": "string" + } + } + }, + { + "required": [ + "generation_prompt" + ], + "additionalProperties": false, + "properties": { + "generation_prompt": { + "type": "string" + }, + "id": { + "type": "string" + }, + "next": { + "$ref": "#$defs/next" + } + } + } + ] + } + }, + "flow": { + "required": [ + "steps" + ], + "type": "object", + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "if": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nlu_trigger": { + "type": "array", + "items": { + "required": [ + "intent" + ], + "type": "object", + "additionalProperties": false, + "properties": { + "intent": { + "type": "object", + "properties": { + "confidence_threshold": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + } + } + } + }, + "steps": { + "$ref": "#$defs/steps" + } + } + }, + "next": { + "anyOf": [ + { + "type": "array", + "minContains": 1, + "items": { + "type": "object", + "oneOf": [ + { + "required": [ + "if", + "then" + ] + }, + { + "required": [ + "else" + ] + } + ], + "properties": { + "else": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#$defs/steps" + } + ] + }, + "if": { + "type": "string" + }, + "then": { + "oneOf": [ + { + "$ref": "#$defs/steps" + }, + { + "type": "string" + } + ] + } + } + } + }, + { + "type": "string" + } + ] + }, + "set_slots": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "type": ["string", "null", "boolean", "number"] + } + } + } + } + } +} diff --git a/rasa/shared/core/flows/utils.py b/rasa/shared/core/flows/utils.py new file mode 100644 index 000000000000..250efb93720c --- /dev/null +++ b/rasa/shared/core/flows/utils.py @@ -0,0 +1,25 @@ +from pathlib import Path +from typing import Text, Union +import rasa.shared.data +import rasa.shared.utils.io + +KEY_FLOWS = "flows" + + +def is_flows_file(file_path: Union[Text, Path]) -> bool: + """Check if file contains Flow training data. + + Args: + file_path: Path of the file to check. + + Returns: + `True` in case the file is a flows YAML training 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_FLOWS) diff --git a/rasa/shared/core/flows/yaml_flows_io.py b/rasa/shared/core/flows/yaml_flows_io.py new file mode 100644 index 000000000000..14cf7da82557 --- /dev/null +++ b/rasa/shared/core/flows/yaml_flows_io.py @@ -0,0 +1,102 @@ +import textwrap +from pathlib import Path +from typing import List, Text, Union + +from rasa.shared.core.flows.utils import KEY_FLOWS + +import rasa.shared.utils.io +import rasa.shared.utils.validation +from rasa.shared.exceptions import YamlException + +from rasa.shared.core.flows.flow import Flow, FlowsList + +FLOWS_SCHEMA_FILE = "shared/core/flows/flows_yaml_schema.json" + + +class YAMLFlowsReader: + """Class that reads flows information in YAML format.""" + + @classmethod + def read_from_file( + cls, filename: Union[Text, Path], skip_validation: bool = False + ) -> FlowsList: + """Read flows from file. + + Args: + filename: Path to the flows file. + skip_validation: `True` if the file was already validated + e.g. when it was stored in the database. + + Returns: + `Flow`s read from `filename`. + """ + try: + return cls.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 + + @classmethod + def read_from_string(cls, string: Text, skip_validation: bool = False) -> FlowsList: + """Read flows 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: + `Flow`s read from `string`. + """ + if not skip_validation: + rasa.shared.utils.validation.validate_yaml_with_jsonschema( + string, FLOWS_SCHEMA_FILE + ) + + yaml_content = rasa.shared.utils.io.read_yaml(string) + + flows = FlowsList.from_json(yaml_content.get(KEY_FLOWS, {})) + if not skip_validation: + flows.validate() + return flows + + +class YamlFlowsWriter: + """Class that writes flows information in YAML format.""" + + @staticmethod + def dumps(flows: List[Flow]) -> Text: + """Dump `Flow`s to YAML. + + Args: + flows: The `Flow`s to dump. + + Returns: + The dumped YAML. + """ + dump = {} + for flow in flows: + dumped_flow = flow.as_json() + del dumped_flow["id"] + dump[flow.id] = dumped_flow + return rasa.shared.utils.io.dump_obj_as_yaml_to_string({KEY_FLOWS: dump}) + + @staticmethod + def dump(flows: List[Flow], filename: Union[Text, Path]) -> None: + """Dump `Flow`s to YAML file. + + Args: + flows: The `Flow`s to dump. + filename: The path to the file to write to. + """ + rasa.shared.utils.io.write_text_file(YamlFlowsWriter.dumps(flows), filename) + + +def flows_from_str(yaml_str: str) -> FlowsList: + """Reads flows from a YAML string.""" + return YAMLFlowsReader.read_from_string(textwrap.dedent(yaml_str)) diff --git a/rasa/shared/core/slot_mappings.py b/rasa/shared/core/slot_mappings.py index 44d19a4cd5c7..57130cccb61d 100644 --- a/rasa/shared/core/slot_mappings.py +++ b/rasa/shared/core/slot_mappings.py @@ -229,7 +229,7 @@ def validate_slot_mappings(domain_slots: Dict[Text, Any]) -> None: ) for slot_name, properties in domain_slots.items(): - mappings = properties.get(SLOT_MAPPINGS) + 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 index d31629f78ad9..9630d00d7f76 100644 --- a/rasa/shared/core/slots.py +++ b/rasa/shared/core/slots.py @@ -35,6 +35,7 @@ def __init__( initial_value: Any = None, value_reset_delay: Optional[int] = None, influence_conversation: bool = True, + is_builtin: bool = False, ) -> None: """Create a Slot. @@ -46,6 +47,9 @@ def __init__( 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. + is_builtin: `True` if the slot is a built-in slot, `False` otherwise. + Built-in slots also encompass user writable slots (via custom actions), + such as `return_value`. """ self.name = name self.mappings = mappings @@ -54,6 +58,7 @@ def __init__( self._value_reset_delay = value_reset_delay self.influence_conversation = influence_conversation self._has_been_set = False + self.is_builtin = is_builtin def feature_dimensionality(self) -> int: """How many features this single slot creates. @@ -180,6 +185,7 @@ def __init__( max_value: float = 1.0, min_value: float = 0.0, influence_conversation: bool = True, + is_builtin: bool = False, ) -> None: """Creates a FloatSlot. @@ -188,7 +194,12 @@ def __init__( UserWarning, if initial_value is outside the min-max range. """ super().__init__( - name, mappings, initial_value, value_reset_delay, influence_conversation + name, + mappings, + initial_value, + value_reset_delay, + influence_conversation, + is_builtin, ) self.max_value = max_value self.min_value = min_value @@ -260,8 +271,12 @@ def bool_from_any(x: Any) -> bool: return float(x) == 1.0 elif x.strip().lower() == "true": return True + elif x.strip().lower() == "no": + return False elif x.strip().lower() == "false": return False + elif x.strip().lower() == "yes": + return True else: raise ValueError("Cannot convert string to bool") else: @@ -314,10 +329,16 @@ def __init__( initial_value: Any = None, value_reset_delay: Optional[int] = None, influence_conversation: bool = True, + is_builtin: bool = False, ) -> None: """Creates a `Categorical Slot` (see parent class for detailed docstring).""" super().__init__( - name, mappings, initial_value, value_reset_delay, influence_conversation + name, + mappings, + initial_value, + value_reset_delay, + influence_conversation, + is_builtin, ) if values and None in values: rasa.shared.utils.io.raise_warning( @@ -413,6 +434,7 @@ def __init__( initial_value: Any = None, value_reset_delay: Optional[int] = None, influence_conversation: bool = False, + is_builtin: bool = False, ) -> None: """Creates an `Any Slot` (see parent class for detailed docstring). @@ -429,7 +451,12 @@ def __init__( ) super().__init__( - name, mappings, initial_value, value_reset_delay, influence_conversation + name, + mappings, + initial_value, + value_reset_delay, + influence_conversation, + is_builtin, ) def __eq__(self, other: Any) -> bool: diff --git a/rasa/shared/core/trackers.py b/rasa/shared/core/trackers.py index d0498bc2a925..415dbe897448 100644 --- a/rasa/shared/core/trackers.py +++ b/rasa/shared/core/trackers.py @@ -198,10 +198,10 @@ def __init__( # id of the source of the messages self.sender_id = sender_id # slots that can be filled in this domain + self.slots: Dict[str, Slot] = AnySlotDict() 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 diff --git a/rasa/shared/engine/__init__.py b/rasa/shared/engine/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/rasa/shared/engine/caching.py b/rasa/shared/engine/caching.py new file mode 100644 index 000000000000..64f794933587 --- /dev/null +++ b/rasa/shared/engine/caching.py @@ -0,0 +1,26 @@ +import os +from pathlib import Path + + +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" + + +def get_local_cache_location() -> Path: + """Returns the location of the local cache.""" + return Path(os.environ.get(CACHE_LOCATION_ENV, DEFAULT_CACHE_LOCATION)) + + +def get_max_cache_size() -> float: + """Returns the maximum cache size.""" + return float(os.environ.get(CACHE_SIZE_ENV, DEFAULT_CACHE_SIZE_MB)) + + +def get_cache_database_name() -> str: + """Returns the database name in the cache.""" + return os.environ.get(CACHE_DB_NAME_ENV, DEFAULT_CACHE_NAME) diff --git a/rasa/shared/importers/importer.py b/rasa/shared/importers/importer.py index 6a181a5d4720..9cd5904d43ee 100644 --- a/rasa/shared/importers/importer.py +++ b/rasa/shared/importers/importer.py @@ -3,7 +3,10 @@ from typing import Text, Optional, List, Dict, Set, Any, Tuple, Type, Union, cast import logging +import pkg_resources + import rasa.shared.constants +from rasa.shared.core.flows.flow import FlowsList import rasa.shared.utils.common import rasa.shared.core.constants import rasa.shared.utils.io @@ -59,6 +62,17 @@ def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: """ ... + def get_flows(self) -> FlowsList: + """Retrieves the flows that should be used for training. + + Default implementation returns an empty `FlowsList`. The default + implementation is required because of backwards compatibility. + + Returns: + `FlowsList` containing all loaded flows. + """ + return FlowsList(flows=[]) + def get_conversation_tests(self) -> StoryGraph: """Retrieves end-to-end conversation stories for testing. @@ -140,7 +154,7 @@ def load_nlu_importer_from_config( 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 + importer = importer._importer return NluDataImporter(importer) @@ -169,7 +183,9 @@ def load_from_dict( RasaFileImporter(config_path, domain_path, training_data_paths) ] - return E2EImporter(ResponsesSyncImporter(CombinedDataImporter(importers))) + return E2EImporter( + FlowSyncImporter(ResponsesSyncImporter(CombinedDataImporter(importers))) + ) @staticmethod def _importer_from_dict( @@ -288,6 +304,15 @@ def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: lambda merged, other: merged.merge(other), stories, StoryGraph([]) ) + @rasa.shared.utils.common.cached_method + def get_flows(self) -> FlowsList: + """Retrieves training stories / rules (see parent class for full docstring).""" + flow_lists = [importer.get_flows() for importer in self._importers] + + return reduce( + lambda merged, other: merged.merge(other), flow_lists, FlowsList(flows=[]) + ) + @rasa.shared.utils.common.cached_method def get_conversation_tests(self) -> StoryGraph: """Retrieves conversation test stories (see parent class for full docstring).""" @@ -318,27 +343,131 @@ def get_config_file_for_auto_config(self) -> Optional[Text]: 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. - """ +class PassThroughImporter(TrainingDataImporter): + """Importer that passes through all calls to the actual importer.""" def __init__(self, importer: TrainingDataImporter): - """Initializes the ResponsesSyncImporter.""" + """Initializes the FlowSyncImporter.""" 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_flows(self) -> FlowsList: + """Retrieves model flows (see parent class for full docstring).""" + return self._importer.get_flows() + 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() + def get_domain(self) -> Domain: + """Retrieves model domain (see parent class for full docstring).""" + return self._importer.get_domain() + + 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() + + def get_nlu_data(self, language: Optional[Text] = "en") -> TrainingData: + """Updates NLU data with responses for retrieval intents from domain.""" + return self._importer.get_nlu_data(language) + + +DEFAULT_PATTERN_FLOWS_FILE_NAME = "default_flows_for_patterns.yml" + + +class FlowSyncImporter(PassThroughImporter): + """Importer that syncs `flows` between Domain and flow training data.""" + + @staticmethod + def load_default_pattern_flows() -> FlowsList: + """Loads the default flows from the file system.""" + from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader + + default_flows_file = pkg_resources.resource_filename( + "rasa.dialogue_understanding.patterns", DEFAULT_PATTERN_FLOWS_FILE_NAME + ) + + return YAMLFlowsReader.read_from_file(default_flows_file) + + @staticmethod + def load_default_pattern_flows_domain() -> Domain: + """Loads the default flows from the file system.""" + default_flows_file = pkg_resources.resource_filename( + "rasa.dialogue_understanding.patterns", DEFAULT_PATTERN_FLOWS_FILE_NAME + ) + + return Domain.from_path(default_flows_file) + + @classmethod + def merge_with_default_flows(cls, flows: FlowsList) -> FlowsList: + """Merges the passed flows with the default flows. + + If a user defined flow contains a flow with an id of a default flow, + it will overwrite the default flow. + + Args: + flows: user defined flows. + + Returns: + Merged flows.""" + default_flows = cls.load_default_pattern_flows() + + user_flow_ids = [flow.id for flow in flows.underlying_flows] + missing_default_flows = [ + default_flow + for default_flow in default_flows.underlying_flows + if default_flow.id not in user_flow_ids + ] + + return flows.merge(FlowsList(missing_default_flows)) + + @rasa.shared.utils.common.cached_method + def get_flows(self) -> FlowsList: + flows = self._importer.get_flows() + + if flows.is_empty(): + # if there are no flows, we don't need to add the default flows either + return flows + + return self.merge_with_default_flows(flows) + + @rasa.shared.utils.common.cached_method + def get_domain(self) -> Domain: + """Merge existing domain with properties of flows.""" + domain = self._importer.get_domain() + + flows = self.get_flows() + + if flows.is_empty(): + # if there are no flows, we don't need to add the default flows either + return domain + + default_flows_domain = self.load_default_pattern_flows_domain() + + flow_names = [ + rasa.shared.constants.FLOW_PREFIX + flow.id + for flow in flows.underlying_flows + ] + + flow_domain = Domain.from_dict({KEY_ACTIONS: flow_names}) + return domain.merge(flow_domain.merge(default_flows_domain)) + + +class ResponsesSyncImporter(PassThroughImporter): + """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. + """ + @rasa.shared.utils.common.cached_method def get_domain(self) -> Domain: """Merge existing domain with properties of retrieval intents in NLU data.""" @@ -421,14 +550,6 @@ def _get_domain_with_retrieval_intents( } ) - 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.""" @@ -457,21 +578,17 @@ def _get_nlu_data_with_responses( return TrainingData(responses=responses) -class E2EImporter(TrainingDataImporter): +class E2EImporter(PassThroughImporter): """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() + original = self._importer.get_domain() e2e_domain = self._get_domain_with_e2e_actions() return original.merge(e2e_domain) @@ -492,32 +609,14 @@ def _get_domain_with_e2e_actions(self) -> Domain: 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() + 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._importer.get_nlu_data(language), self._additional_training_data_from_stories(), ] diff --git a/rasa/shared/importers/rasa.py b/rasa/shared/importers/rasa.py index 53ba35b5ee80..355f913aedd4 100644 --- a/rasa/shared/importers/rasa.py +++ b/rasa/shared/importers/rasa.py @@ -1,6 +1,7 @@ import logging import os from typing import Dict, List, Optional, Text, Union +from rasa.shared.core.flows.flow import FlowsList import rasa.shared.data import rasa.shared.utils.common @@ -10,6 +11,7 @@ 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 +import rasa.shared.core.flows.utils from rasa.shared.core.training_data.story_reader.yaml_story_reader import ( YAMLStoryReader, ) @@ -35,6 +37,9 @@ def __init__( self._story_files = rasa.shared.data.get_data_files( training_data_paths, YAMLStoryReader.is_stories_file ) + self._flow_files = rasa.shared.data.get_data_files( + training_data_paths, rasa.shared.core.flows.utils.is_flows_file + ) self._conversation_test_files = rasa.shared.data.get_data_files( training_data_paths, YAMLStoryReader.is_test_stories_file ) @@ -61,6 +66,10 @@ def get_stories(self, exclusion_percentage: Optional[int] = None) -> StoryGraph: self._story_files, self.get_domain(), exclusion_percentage ) + def get_flows(self) -> FlowsList: + """Retrieves training stories / rules (see parent class for full docstring).""" + return utils.flows_from_paths(self._flow_files) + def get_conversation_tests(self) -> StoryGraph: """Retrieves conversation test stories (see parent class for full docstring).""" return utils.story_graph_from_paths( diff --git a/rasa/shared/importers/utils.py b/rasa/shared/importers/utils.py index 40e5bb21e51b..019363ba77d6 100644 --- a/rasa/shared/importers/utils.py +++ b/rasa/shared/importers/utils.py @@ -1,6 +1,7 @@ from typing import Iterable, Text, Optional, List from rasa.shared.core.domain import Domain +from rasa.shared.core.flows.flow import FlowsList from rasa.shared.core.training_data.structures import StoryGraph from rasa.shared.nlu.training_data.training_data import TrainingData @@ -20,3 +21,13 @@ def story_graph_from_paths( story_steps = loading.load_data_from_files(files, domain, exclusion_percentage) return StoryGraph(story_steps) + + +def flows_from_paths(files: List[Text]) -> FlowsList: + """Returns the flows from paths.""" + from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader + + flows = FlowsList(flows=[]) + for file in files: + flows = flows.merge(YAMLFlowsReader.read_from_file(file)) + return flows diff --git a/rasa/shared/nlu/constants.py b/rasa/shared/nlu/constants.py index 1f0b9b865c36..2c57c436de31 100644 --- a/rasa/shared/nlu/constants.py +++ b/rasa/shared/nlu/constants.py @@ -1,6 +1,7 @@ TEXT = "text" TEXT_TOKENS = "text_tokens" INTENT = "intent" +COMMANDS = "commands" NOT_INTENT = "not_intent" RESPONSE = "response" RESPONSE_SELECTOR = "response_selector" diff --git a/rasa/shared/utils/llm.py b/rasa/shared/utils/llm.py new file mode 100644 index 000000000000..aa2be1f99fac --- /dev/null +++ b/rasa/shared/utils/llm.py @@ -0,0 +1,234 @@ +from typing import Any, Dict, Optional, Text, Type, TYPE_CHECKING +import warnings + +import structlog + +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.events import BotUttered, UserUttered +from rasa.shared.engine.caching import get_local_cache_location +import rasa.shared.utils.io + +if TYPE_CHECKING: + from langchain.embeddings.base import Embeddings + from langchain.llms.base import BaseLLM + + +structlogger = structlog.get_logger() + +USER = "USER" + +AI = "AI" + +DEFAULT_OPENAI_GENERATE_MODEL_NAME = "text-davinci-003" + +DEFAULT_OPENAI_CHAT_MODEL_NAME = "gpt-3.5-turbo" + +DEFAULT_OPENAI_CHAT_MODEL_NAME_ADVANCED = "gpt-4" + +DEFAULT_OPENAI_EMBEDDING_MODEL_NAME = "text-embedding-ada-002" + +DEFAULT_OPENAI_TEMPERATURE = 0.7 + + +def tracker_as_readable_transcript( + tracker: DialogueStateTracker, + human_prefix: str = USER, + ai_prefix: str = AI, + max_turns: Optional[int] = 20, +) -> str: + """Creates a readable dialogue from a tracker. + + Args: + tracker: the tracker to convert + human_prefix: the prefix to use for human utterances + ai_prefix: the prefix to use for ai utterances + max_turns: the maximum number of turns to include in the transcript + + Example: + >>> tracker = Tracker( + ... sender_id="test", + ... slots=[], + ... events=[ + ... UserUttered("hello"), + ... BotUttered("hi"), + ... ], + ... ) + >>> tracker_as_readable_transcript(tracker) + USER: hello + AI: hi + + Returns: + A string representing the transcript of the tracker + """ + transcript = [] + + for event in tracker.events: + if isinstance(event, UserUttered): + transcript.append( + f"{human_prefix}: {sanitize_message_for_prompt(event.text)}" + ) + elif isinstance(event, BotUttered): + transcript.append(f"{ai_prefix}: {sanitize_message_for_prompt(event.text)}") + + if max_turns: + transcript = transcript[-max_turns:] + return "\n".join(transcript) + + +def sanitize_message_for_prompt(text: Optional[str]) -> str: + """Removes new lines from a string. + + Args: + text: the text to sanitize + + Returns: + A string with new lines removed. + """ + return text.replace("\n", " ") if text else "" + + +def combine_custom_and_default_config( + custom_config: Optional[Dict[Text, Any]], default_config: Dict[Text, Any] +) -> Dict[Text, Any]: + """Merges the given llm config with the default config. + + Only uses the default configuration arguments, if the type set in the + custom config matches the type in the default config. Otherwise, only + the custom config is used. + + Args: + custom_config: The custom config containing values to overwrite defaults + default_config: The default config. + + Returns: + The merged config. + """ + if custom_config is None: + return default_config + + if "type" in custom_config: + # rename type to _type as "type" is the convention we use + # across the different components in config files. + # langchain expects "_type" as the key though + custom_config["_type"] = custom_config.pop("type") + + if "_type" in custom_config and custom_config["_type"] != default_config.get( + "_type" + ): + return custom_config + return {**default_config, **custom_config} + + +def ensure_cache() -> None: + """Ensures that the cache is initialized.""" + import langchain + from langchain.cache import SQLiteCache + + # ensure the cache directory exists + cache_location = get_local_cache_location() + cache_location.mkdir(parents=True, exist_ok=True) + + db_location = cache_location / "rasa-llm-cache.db" + langchain.llm_cache = SQLiteCache(database_path=str(db_location)) + + +def llm_factory( + custom_config: Optional[Dict[str, Any]], default_config: Dict[str, Any] +) -> "BaseLLM": + """Creates an LLM from the given config. + + Args: + custom_config: The custom config containing values to overwrite defaults + default_config: The default config. + + + Returns: + Instantiated LLM based on the configuration. + """ + from langchain.llms.loading import load_llm_from_config + + ensure_cache() + + config = combine_custom_and_default_config(custom_config, default_config) + + # need to create a copy as the langchain function modifies the + # config in place... + structlogger.debug("llmfactory.create.llm", config=config) + # langchain issues a user warning when using chat models. at the same time + # it doesn't provide a way to instantiate a chat model directly using the + # config. so for now, we need to suppress the warning here. Original + # warning: + # packages/langchain/llms/openai.py:189: UserWarning: You are trying to + # use a chat model. This way of initializing it is no longer supported. + # Instead, please use: `from langchain.chat_models import ChatOpenAI + with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=UserWarning) + return load_llm_from_config(config.copy()) + + +def embedder_factory( + custom_config: Optional[Dict[str, Any]], default_config: Dict[str, Any] +) -> "Embeddings": + """Creates an Embedder from the given config. + + Args: + custom_config: The custom config containing values to overwrite defaults + default_config: The default config. + + + Returns: + Instantiated Embedder based on the configuration. + """ + from langchain.embeddings.base import Embeddings + from langchain.embeddings import ( + CohereEmbeddings, + HuggingFaceHubEmbeddings, + HuggingFaceInstructEmbeddings, + LlamaCppEmbeddings, + OpenAIEmbeddings, + SpacyEmbeddings, + VertexAIEmbeddings, + ) + + type_to_embedding_cls_dict: Dict[str, Type[Embeddings]] = { + "openai": OpenAIEmbeddings, + "cohere": CohereEmbeddings, + "spacy": SpacyEmbeddings, + "vertexai": VertexAIEmbeddings, + "huggingface_instruct": HuggingFaceInstructEmbeddings, + "huggingface_hub": HuggingFaceHubEmbeddings, + "llamacpp": LlamaCppEmbeddings, + } + + config = combine_custom_and_default_config(custom_config, default_config) + typ = config.get("_type") + + structlogger.debug("llmfactory.create.embedder", config=config) + + if not typ: + return OpenAIEmbeddings() + elif embeddings_cls := type_to_embedding_cls_dict.get(typ): + parameters = config.copy() + parameters.pop("_type") + return embeddings_cls(**parameters) + else: + raise ValueError(f"Unsupported embeddings type '{typ}'") + + +def get_prompt_template( + jinja_file_path: Optional[Text], default_prompt_template: Text +) -> Text: + """Returns the prompt template. + + Args: + jinja_file_path: the path to the jinja file + default_prompt_template: the default prompt template + + Returns: + The prompt template. + """ + return ( + rasa.shared.utils.io.read_file(jinja_file_path) + if jinja_file_path is not None + else default_prompt_template + ) diff --git a/rasa/shared/utils/schemas/domain.yml b/rasa/shared/utils/schemas/domain.yml index bd615c9b9161..134512b25596 100644 --- a/rasa/shared/utils/schemas/domain.yml +++ b/rasa/shared/utils/schemas/domain.yml @@ -77,7 +77,7 @@ mapping: required: False mappings: type: "seq" - required: True + required: False allowempty: False sequence: - type: "map" diff --git a/rasa/shared/utils/validation.py b/rasa/shared/utils/validation.py index 04a0d5b43da6..a91ff4c7a37a 100644 --- a/rasa/shared/utils/validation.py +++ b/rasa/shared/utils/validation.py @@ -289,3 +289,44 @@ def validate_training_data_format_version( docs=DOCS_URL_TRAINING_DATA, ) return False + + +def validate_yaml_with_jsonschema( + yaml_file_content: Text, schema_path: Text, package_name: Text = PACKAGE_NAME +) -> None: + """Validate data format. + + 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`. + + Raises: + YamlSyntaxException: if the yaml file is not valid. + SchemaValidationError: if validation fails. + """ + from jsonschema import validate, ValidationError + from ruamel.yaml import YAMLError + import pkg_resources + + schema_file = pkg_resources.resource_filename(package_name, schema_path) + schema_content = rasa.shared.utils.io.read_json_file(schema_file) + + 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) + + try: + validate(source_data, schema_content) + except ValidationError as error: + error.message += ". Failed to validate data, make sure your data is valid." + raise SchemaValidationError.create_from(error) from error diff --git a/rasa/utils/common.py b/rasa/utils/common.py index 164c709d58e4..59d3e6bd3905 100644 --- a/rasa/utils/common.py +++ b/rasa/utils/common.py @@ -83,6 +83,10 @@ DeprecationWarning, "non-integer arguments to randrange\\(\\) have been deprecated since", ), + ( + UserWarning, + "The utterance 'utter_flow_continue_interrupted' is not used*", + ), ] EXPECTED_WARNINGS.extend(EXPECTED_PILLOW_DEPRECATION_WARNINGS) diff --git a/rasa/utils/llm.py b/rasa/utils/llm.py deleted file mode 100644 index 39058230498c..000000000000 --- a/rasa/utils/llm.py +++ /dev/null @@ -1,76 +0,0 @@ -from typing import Optional -import structlog -from rasa.shared.core.events import BotUttered, UserUttered - -from rasa.shared.core.trackers import DialogueStateTracker - -structlogger = structlog.get_logger() - -USER = "USER" - -AI = "AI" - -DEFAULT_OPENAI_GENERATE_MODEL_NAME = "text-davinci-003" - -DEFAULT_OPENAI_CHAT_MODEL_NAME = "gpt-3.5-turbo" - -DEFAULT_OPENAI_EMBEDDING_MODEL_NAME = "text-embedding-ada-002" - -DEFAULT_OPENAI_TEMPERATURE = 0.7 - - -def tracker_as_readable_transcript( - tracker: DialogueStateTracker, - human_prefix: str = USER, - ai_prefix: str = AI, - max_turns: Optional[int] = 20, -) -> str: - """Creates a readable dialogue from a tracker. - - Args: - tracker: the tracker to convert - human_prefix: the prefix to use for human utterances - ai_prefix: the prefix to use for ai utterances - max_turns: the maximum number of turns to include in the transcript - - Example: - >>> tracker = Tracker( - ... sender_id="test", - ... slots=[], - ... events=[ - ... UserUttered("hello"), - ... BotUttered("hi"), - ... ], - ... ) - >>> tracker_as_readable_transcript(tracker) - USER: hello - AI: hi - - Returns: - A string representing the transcript of the tracker - """ - transcript = [] - - for event in tracker.events: - if isinstance(event, UserUttered): - transcript.append( - f"{human_prefix}: {sanitize_message_for_prompt(event.text)}" - ) - elif isinstance(event, BotUttered): - transcript.append(f"{ai_prefix}: {sanitize_message_for_prompt(event.text)}") - - if max_turns: - transcript = transcript[-max_turns:] - return "\n".join(transcript) - - -def sanitize_message_for_prompt(text: Optional[str]) -> str: - """Removes new lines from a string. - - Args: - text: the text to sanitize - - Returns: - A string with new lines removed. - """ - return text.replace("\n", " ") if text else "" diff --git a/rasa/utils/log_utils.py b/rasa/utils/log_utils.py index 5852a1ba3e45..5a530e391790 100644 --- a/rasa/utils/log_utils.py +++ b/rasa/utils/log_utils.py @@ -36,7 +36,7 @@ def _anonymizer( "text", "response_text", "user_text", - "slot_values", + "slots", "parse_data_text", "parse_data_entities", "prediction_events", diff --git a/rasa/validator.py b/rasa/validator.py index 5ed0117a69dc..285afdf640c7 100644 --- a/rasa/validator.py +++ b/rasa/validator.py @@ -1,8 +1,20 @@ import logging +import re +import string from collections import defaultdict -from typing import Set, Text, Optional, Dict, Any, List +from typing import Set, Text, Optional, Dict, Any, List, Tuple + +from pypred import Predicate import rasa.core.training.story_conflict +from rasa.shared.core.flows.flow import ( + ActionFlowStep, + BranchFlowStep, + CollectInformationFlowStep, + FlowsList, + IfFlowLink, + SetSlotsFlowStep, +) import rasa.shared.nlu.constants from rasa.shared.constants import ( ASSISTANT_ID_DEFAULT_VALUE, @@ -21,6 +33,7 @@ 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.slots import ListSlot, Slot 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 @@ -37,6 +50,7 @@ def __init__( domain: Domain, intents: TrainingData, story_graph: StoryGraph, + flows: FlowsList, config: Optional[Dict[Text, Any]], ) -> None: """Initializes the Validator object. @@ -50,6 +64,7 @@ def __init__( self.domain = domain self.intents = intents self.story_graph = story_graph + self.flows = flows self.config = config or {} @classmethod @@ -59,8 +74,9 @@ def from_importer(cls, importer: TrainingDataImporter) -> "Validator": story_graph = importer.get_stories() intents = importer.get_nlu_data() config = importer.get_config() + flows = importer.get_flows() - return cls(domain, intents, story_graph, config) + return cls(domain, intents, story_graph, flows, config) def _non_default_intents(self) -> List[Text]: return [ @@ -171,15 +187,25 @@ def _gather_utterance_actions(self) -> Set[Text]: } 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 + def _does_story_only_use_valid_actions( + self, used_utterances_in_stories: Set[str], utterance_actions: List[str] + ) -> bool: + """Checks if all utterances used in stories are valid.""" + has_no_warnings = True + for used_utterance in used_utterances_in_stories: + if used_utterance not in utterance_actions: + rasa.shared.utils.io.raise_warning( + f"The action '{used_utterance}' 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", + ) + has_no_warnings = False + return has_no_warnings - utterance_actions = self._gather_utterance_actions() + def _utterances_used_in_stories(self) -> Set[str]: + """Return all utterances which are used in stories.""" stories_utterances = set() for story in self.story_graph.story_steps: @@ -198,21 +224,34 @@ def verify_utterances_in_stories(self, ignore_warnings: bool = True) -> bool: # 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) + return stories_utterances + + def verify_utterances_in_dialogues(self, ignore_warnings: bool = True) -> bool: + """Verifies usage of utterances in stories or flows. + + Checks whether utterances used in the stories are valid, + and whether all valid utterances are used in stories. + """ + utterance_actions = self._gather_utterance_actions() + + stories_utterances = self._utterances_used_in_stories() + flow_utterances = self.flows.utterances + + all_used_utterances = flow_utterances.union(stories_utterances) + + everything_is_alright = ( + ignore_warnings + or self._does_story_only_use_valid_actions( + stories_utterances, list(utterance_actions) + ) + ) for utterance in utterance_actions: - if utterance not in stories_utterances: + if utterance not in all_used_utterances: rasa.shared.utils.io.raise_warning( - f"The utterance '{utterance}' is not used in any story or rule." + f"The utterance '{utterance}' is not used in " + f"any story, rule or flow." ) everything_is_alright = ignore_warnings or everything_is_alright @@ -329,7 +368,7 @@ def verify_nlu(self, ignore_warnings: bool = True) -> bool: ) logger.info("Validating utterances...") - stories_are_valid = self.verify_utterances_in_stories(ignore_warnings) + stories_are_valid = self.verify_utterances_in_dialogues(ignore_warnings) return intents_are_valid and stories_are_valid and there_is_no_duplication def verify_form_slots(self) -> bool: @@ -432,3 +471,216 @@ def warn_if_config_mandatory_keys_are_not_set(self) -> None: f"'{ASSISTANT_ID_KEY}' mandatory key. Please replace the default " f"placeholder value with a unique identifier." ) + + @staticmethod + def _log_error_if_slot_not_in_domain( + slot_name: str, + domain_slots: Dict[Text, Slot], + step_id: str, + flow_id: str, + all_good: bool, + ) -> bool: + if slot_name not in domain_slots: + logger.error( + f"The slot '{slot_name}' is used in the " + f"step '{step_id}' of flow id '{flow_id}', but it " + f"is not listed in the domain slots. " + f"You should add it to your domain file!", + ) + all_good = False + + return all_good + + @staticmethod + def _log_error_if_list_slot( + slot: Slot, step_id: str, flow_id: str, all_good: bool + ) -> bool: + if isinstance(slot, ListSlot): + logger.error( + f"The slot '{slot.name}' is used in the " + f"step '{step_id}' of flow id '{flow_id}', but it " + f"is a list slot. List slots are currently not " + f"supported in flows. You should change it to a " + f"text, boolean or float slot in your domain file!", + ) + all_good = False + + return all_good + + @staticmethod + def _log_error_if_dialogue_stack_slot( + slot: Slot, step_id: str, flow_id: str, all_good: bool + ) -> bool: + if slot.name == constants.DIALOGUE_STACK_SLOT: + logger.error( + f"The slot '{constants.DIALOGUE_STACK_SLOT}' is used in the " + f"step '{step_id}' of flow id '{flow_id}', but it " + f"is a reserved slot. You must not use reserved slots in " + f"your flows.", + ) + all_good = False + + return all_good + + def verify_flows_steps_against_domain(self) -> bool: + """Checks flows steps' references against the domain file.""" + all_good = True + domain_slots = {slot.name: slot for slot in self.domain.slots} + for flow in self.flows.underlying_flows: + for step in flow.steps: + if isinstance(step, CollectInformationFlowStep): + all_good = self._log_error_if_slot_not_in_domain( + step.collect, domain_slots, step.id, flow.id, all_good + ) + current_slot = domain_slots.get(step.collect) + if not current_slot: + continue + + all_good = self._log_error_if_list_slot( + current_slot, step.id, flow.id, all_good + ) + all_good = self._log_error_if_dialogue_stack_slot( + current_slot, step.id, flow.id, all_good + ) + + elif isinstance(step, SetSlotsFlowStep): + for slot in step.slots: + slot_name = slot["key"] + all_good = self._log_error_if_slot_not_in_domain( + slot_name, domain_slots, step.id, flow.id, all_good + ) + current_slot = domain_slots.get(slot_name) + if not current_slot: + continue + + all_good = self._log_error_if_list_slot( + current_slot, step.id, flow.id, all_good + ) + all_good = self._log_error_if_dialogue_stack_slot( + current_slot, step.id, flow.id, all_good + ) + + elif isinstance(step, ActionFlowStep): + regex = r"{context\..+?}" + matches = re.findall(regex, step.action) + if matches: + logger.warning( + f"An interpolated action name '{step.action}' was " + f"found at step '{step.id}' of flow id '{flow.id}'. " + f"Skipping validation for this step. " + f"Please make sure that the action name is " + f"listed in your domain responses or actions." + ) + elif step.action not in self.domain.action_names_or_texts: + logger.error( + f"The action '{step.action}' is used in the step " + f"'{step.id}' of flow id '{flow.id}', but it " + f"is not listed in the domain file. " + f"You should add it to your domain file!", + ) + all_good = False + return all_good + + def verify_unique_flows(self) -> bool: + """Checks if all flows have unique names and descriptions.""" + all_good = True + flow_names = set() + flow_descriptions = set() + punctuation_table = str.maketrans({i: "" for i in string.punctuation}) + + for flow in self.flows.underlying_flows: + flow_description = flow.description + cleaned_description = flow_description.translate(punctuation_table) # type: ignore[union-attr] # noqa: E501 + if cleaned_description in flow_descriptions: + logger.error( + f"Detected duplicate flow description for flow id '{flow.id}'. " + f"Flow descriptions must be unique. " + f"Please make sure that all flows have different descriptions." + ) + all_good = False + + if not flow.name: + logger.error(f"Flow with flow id '{flow.id}' has an empty name.") + all_good = False + + if flow.name in flow_names: + logger.error( + f"Detected duplicate flow name '{flow.name}' for flow " + f"id '{flow.id}'. Flow names must be unique. " + f"Please make sure that all flows have different names." + ) + all_good = False + + flow_names.add(flow.name) + flow_descriptions.add(cleaned_description) + + return all_good + + @staticmethod + def _construct_predicate( + predicate: Optional[str], step_id: str, all_good: bool = True + ) -> Tuple[Optional[Predicate], bool]: + try: + pred = Predicate(predicate) + except (TypeError, Exception) as exception: + logger.error( + f"Could not initialize the predicate found under step " + f"'{step_id}': {exception}." + ) + pred = None + all_good = False + + return pred, all_good + + def verify_predicates(self) -> bool: + """Checks that predicates used in branch flow steps or `collect` steps are valid.""" # noqa: E501 + all_good = True + for flow in self.flows.underlying_flows: + for step in flow.steps: + if isinstance(step, BranchFlowStep): + for link in step.next.links: + if isinstance(link, IfFlowLink): + predicate, all_good = Validator._construct_predicate( + link.condition, step.id + ) + if predicate and not predicate.is_valid(): + logger.error( + f"Detected invalid condition '{link.condition}' " + f"at step '{step.id}' for flow id '{flow.id}'. " + f"Please make sure that all conditions are valid." + ) + all_good = False + elif isinstance(step, CollectInformationFlowStep): + predicates = [predicate.if_ for predicate in step.rejections] + for predicate in predicates: + pred, all_good = Validator._construct_predicate( + predicate, step.id + ) + if pred and not pred.is_valid(): + logger.error( + f"Detected invalid rejection '{predicate}' " + f"at `collect` step '{step.id}' " + f"for flow id '{flow.id}'. " + f"Please make sure that all conditions are valid." + ) + all_good = False + return all_good + + def verify_flows(self) -> bool: + """Checks for inconsistencies across flows.""" + logger.info("Validating flows...") + + if self.flows.is_empty(): + logger.warning( + "No flows were found in the data files. " + "Will not proceed with flow validation.", + ) + return True + + condition_one = self.verify_flows_steps_against_domain() + condition_two = self.verify_unique_flows() + condition_three = self.verify_predicates() + + all_good = all([condition_one, condition_two, condition_three]) + + return all_good diff --git a/rasa/version.py b/rasa/version.py index 45324effe832..0a2e252183fd 100644 --- a/rasa/version.py +++ b/rasa/version.py @@ -1,3 +1,3 @@ # this file will automatically be changed, # do not add anything but the version number here! -__version__ = "3.7.0a1" +__version__ = "3.8.0a12" diff --git a/scripts/lint_python_docstrings.sh b/scripts/lint_python_docstrings.sh index 6255de2c21c0..0b19359c0a72 100755 --- a/scripts/lint_python_docstrings.sh +++ b/scripts/lint_python_docstrings.sh @@ -17,7 +17,7 @@ else fi # Diff of uncommitted changes for running locally -DEV_FILES_WITH_DIFF=`git diff HEAD --name-only -- rasa | xargs echo -n` +DEV_FILES_WITH_DIFF=`git diff HEAD --name-only -- rasa/**/*.py | xargs echo -n` if [ ! -z "$DEV_FILES_WITH_DIFF" ] then diff --git a/tests/cli/test_rasa_data.py b/tests/cli/test_rasa_data.py index aecbc3d2f65b..b9644a7ab5c8 100644 --- a/tests/cli/test_rasa_data.py +++ b/tests/cli/test_rasa_data.py @@ -4,6 +4,8 @@ from _pytest.fixtures import FixtureRequest from _pytest.pytester import RunResult + +from rasa.shared.constants import LATEST_TRAINING_DATA_FORMAT_VERSION from rasa.shared.nlu.training_data.formats import RasaYAMLReader import rasa.shared.utils.io @@ -139,7 +141,7 @@ def test_data_validate_help(run: Callable[..., RunResult]): [--max-history MAX_HISTORY] [-c CONFIG] [--fail-on-warnings] [-d DOMAIN] [--data DATA [DATA ...]] - {{stories}} ...""" + {{stories,flows}} ...""" lines = help_text.split("\n") # expected help text lines should appear somewhere in the output @@ -204,7 +206,7 @@ def test_data_validate_not_used_warning( 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.", + "The utterance 'utter_chatter' is not used in any story, rule or flow.", ]: assert warning in str(result.stderr) @@ -255,3 +257,41 @@ def test_data_split_stories(run_in_simple_project: Callable[..., RunResult]): 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" + + +def test_rasa_data_validate_flows_success( + run_in_simple_project: Callable[..., RunResult] +) -> None: + flows_yaml = f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +flows: + transfer_money: + description: This flow lets users send money. + name: transfer money + steps: + - id: "ask_recipient" + collect: "transfer_recipient" + next: "ask_amount" + - id: "ask_amount" + collect: "transfer_amount" + next: "execute_transfer" + - id: "execute_transfer" + action: action_transfer_money""" + + Path("data/flows.yml").write_text(flows_yaml) + + domain_yaml = """ + actions: + - action_transfer_money + intents: + - transfer_money + slots: + transfer_recipient: + type: text + mappings: [] + transfer_amount: + type: float + mappings: []""" + Path("domain.yml").write_text(domain_yaml) + result = run_in_simple_project("data", "validate", "flows") + assert result.ret == 0 diff --git a/tests/cli/test_rasa_init.py b/tests/cli/test_rasa_init.py index b7f45cb856da..c53c864a1c13 100644 --- a/tests/cli/test_rasa_init.py +++ b/tests/cli/test_rasa_init.py @@ -4,6 +4,7 @@ from typing import Callable from _pytest.pytester import RunResult from _pytest.monkeypatch import MonkeyPatch +import pytest from rasa.cli import scaffold from tests.conftest import enable_cache @@ -46,7 +47,7 @@ def test_init_help(run: Callable[..., RunResult]): help_text = f"""usage: {RASA_EXE} init [-h] [-v] [-vv] [--quiet] [--logging-config-file LOGGING_CONFIG_FILE] [--no-prompt] - [--init-dir INIT_DIR]""" + [--init-dir INIT_DIR] [--template {{default,tutorial,dm2}}]""" lines = help_text.split("\n") # expected help text lines should appear somewhere in the output @@ -101,3 +102,18 @@ def mock_get_config(*args): scaffold.init_project(args, str(new_project_folder_path)) assert os.getcwd() == str(new_project_folder_path) assert os.path.exists(".rasa/cache") + + +@pytest.mark.parametrize("template", ["default", "tutorial", "dm2"]) +def test_train_data_non_default_template( + run_with_stdin: Callable[..., RunResult], + tmp_path: Path, + monkeypatch: MonkeyPatch, + template: str, +): + run_with_stdin( + "init", "--quiet", "--init-dir", str(tmp_path), stdin=b"N" + ) # avoid training an initial model + + # picking domain as it is present in all templates + assert (tmp_path / "domain.yml").exists() diff --git a/tests/cli/test_rasa_train.py b/tests/cli/test_rasa_train.py index 1436f6f7d13a..769a28c6b8ef 100644 --- a/tests/cli/test_rasa_train.py +++ b/tests/cli/test_rasa_train.py @@ -531,7 +531,7 @@ def test_train_validation_warnings( 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.", + "The utterance 'utter_chatter' is not used in any story, rule or flow.", ]: assert warning in str(result.stderr) diff --git a/tests/core/actions/test_action_run_slot_rejections.py b/tests/core/actions/test_action_run_slot_rejections.py new file mode 100644 index 000000000000..9e535f2c9406 --- /dev/null +++ b/tests/core/actions/test_action_run_slot_rejections.py @@ -0,0 +1,569 @@ +import uuid +from typing import Optional, Text + +import pytest +from pytest import CaptureFixture + +from rasa.core.actions.action_run_slot_rejections import ActionRunSlotRejections +from rasa.core.channels import OutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import BotUttered, SlotSet, UserUttered +from rasa.shared.core.slots import AnySlot, FloatSlot, TextSlot +from rasa.shared.core.trackers import DialogueStateTracker + + +@pytest.fixture +def rejection_test_nlg() -> TemplatedNaturalLanguageGenerator: + return TemplatedNaturalLanguageGenerator( + { + "utter_ask_recurrent_payment_type": [ + {"text": "What type of recurrent payment do you want to setup?"} + ], + "utter_invalid_recurrent_payment_type": [ + {"text": "Sorry, you requested an invalid recurrent payment type."} + ], + "utter_internal_error_rasa": [{"text": "Sorry, something went wrong."}], + "utter_ask_payment_amount": [{"text": "What amount do you want to pay?"}], + "utter_payment_too_high": [ + {"text": "Sorry, the amount is above the maximum Β£1,000 allowed."} + ], + "utter_payment_negative": [ + {"text": "Sorry, the amount cannot be negative."} + ], + } + ) + + +@pytest.fixture +def rejection_test_domain() -> Domain: + return Domain.from_yaml( + """ + slots: + recurrent_payment_type: + type: text + mappings: [] + payment_recipient: + type: text + mappings: [] + payment_amount: + type: float + mappings: [] + responses: + utter_ask_recurrent_payment_type: + - text: "What type of recurrent payment do you want to setup?" + utter_invalid_recurrent_payment_type: + - text: "Sorry, you requested an invalid recurrent payment type." + utter_internal_error_rasa: + - text: "Sorry, something went wrong." + utter_ask_payment_amount: + - text: "What amount do you want to pay?" + utter_payment_too_high: + - text: "Sorry, the amount is above the maximum Β£1,000 allowed." + utter_payment_negative: + - text: "Sorry, the amount cannot be negative." + """ + ) + + +async def test_action_run_slot_rejections_top_frame_not_collect_information( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + ] + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + + +async def test_action_run_slot_rejections_top_frame_none_rejections( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_recipient", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "payment_recipient", + "utter": "utter_ask_payment_recipient", + "rejections": [], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("I want to make a payment."), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("payment_recipient", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + + +async def test_action_run_slot_rejections_top_frame_slot_not_been_set( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": "utter_invalid_recurrent_payment_type", + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + out = capsys.readouterr().out + assert "[debug ] first.collect.slot.not.set" in out + + +async def test_action_run_slot_rejections_run_success( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": "utter_invalid_recurrent_payment_type", + } + ], + "type": "pattern_collect_information", + }, + ] + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup an international transfer."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [ + SlotSet("recurrent_payment_type", None), + BotUttered( + "Sorry, you requested an invalid recurrent payment type.", + metadata={"utter_action": "utter_invalid_recurrent_payment_type"}, + ), + ] + + +@pytest.mark.parametrize( + "predicate", [None, "recurrent_payment_type in {'direct debit', 'standing order'}"] +) +async def test_action_run_slot_rejections_internal_error( + predicate: Optional[Text], + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + """Test that an invalid or None predicate dispatches an internal error utterance.""" + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", + "rejections": [ + { + "if": predicate, + "utter": "utter_invalid_recurrent_payment_type", + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events[0] == SlotSet("recurrent_payment_type", None) + assert isinstance(events[1], BotUttered) + assert events[1].text == "Sorry, something went wrong." + assert events[1].metadata == {"utter_action": "utter_internal_error_rasa"} + + out = capsys.readouterr().out + assert "[error ] run.predicate.error" in out + assert f"predicate={predicate}" in out + + +async def test_action_run_slot_rejections_collect_missing_utter( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": None, + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [SlotSet("recurrent_payment_type", None)] + + out = capsys.readouterr().out + assert "[error ] run.rejection.missing.utter" in out + assert "utterance=None" in out + + +async def test_action_run_slot_rejections_not_found_utter( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_type", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "recurrent_payment_type", + "utter": "utter_ask_recurrent_payment_type", + "rejections": [ + { + "if": 'not ({"direct debit" "standing order"} contains recurrent_payment_type)', # noqa: E501 + "utter": "utter_not_found", + } + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to setup a new recurrent payment."), + SlotSet("recurrent_payment_type", "international transfer"), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + TextSlot("recurrent_payment_type", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [SlotSet("recurrent_payment_type", None)] + + out = capsys.readouterr().out + assert "[error ] run.rejection.failed.finding.utter" in out + assert "utterance=utter_not_found" in out + + +async def test_action_run_slot_rejections_pass_multiple_rejection_checks( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_amount", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "payment_amount", + "utter": "utter_ask_payment_amount", + "rejections": [ + { + "if": "payment_amount > 1000", + "utter": "utter_payment_too_high", + }, + { + "if": "payment_amount < 0", + "utter": "utter_payment_negative", + }, + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to transfer Β£500."), + SlotSet("payment_amount", 500), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + FloatSlot("payment_amount", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [] + assert tracker.get_slot("payment_amount") == 500 + + +async def test_action_run_slot_rejections_fails_multiple_rejection_checks( + default_channel: OutputChannel, + rejection_test_nlg: TemplatedNaturalLanguageGenerator, + rejection_test_domain: Domain, + capsys: CaptureFixture, +) -> None: + dialogue_stack = [ + { + "frame_id": "4YL3KDBR", + "flow_id": "setup_recurrent_payment", + "step_id": "ask_payment_amount", + "frame_type": "regular", + "type": "flow", + }, + { + "frame_id": "6Z7PSTRM", + "flow_id": "pattern_collect_information", + "step_id": "start", + "collect": "payment_amount", + "utter": "utter_ask_payment_amount", + "rejections": [ + { + "if": "payment_amount > 1000", + "utter": "utter_payment_too_high", + }, + { + "if": "payment_amount < 0", + "utter": "utter_payment_negative", + }, + ], + "type": "pattern_collect_information", + }, + ] + + tracker = DialogueStateTracker.from_events( + sender_id=uuid.uuid4().hex, + evts=[ + UserUttered("i want to transfer $-100."), + SlotSet("payment_amount", -100), + SlotSet("dialogue_stack", dialogue_stack), + ], + slots=[ + FloatSlot("payment_amount", mappings=[]), + AnySlot("dialogue_stack", mappings=[]), + ], + ) + + action_run_slot_rejections = ActionRunSlotRejections() + events = await action_run_slot_rejections.run( + output_channel=default_channel, + nlg=rejection_test_nlg, + tracker=tracker, + domain=rejection_test_domain, + ) + + assert events == [ + SlotSet("payment_amount", None), + BotUttered( + "Sorry, the amount cannot be negative.", + metadata={"utter_action": "utter_payment_negative"}, + ), + ] diff --git a/tests/core/actions/test_action_trigger_chitchat.py b/tests/core/actions/test_action_trigger_chitchat.py new file mode 100644 index 000000000000..98f58ac31836 --- /dev/null +++ b/tests/core/actions/test_action_trigger_chitchat.py @@ -0,0 +1,22 @@ +from rasa.core.actions.action_trigger_chitchat import ActionTriggerChitchat +from rasa.core.channels import CollectingOutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.dialogue_understanding.stack.frames import ChitChatStackFrame +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import SlotSet +from rasa.shared.core.trackers import DialogueStateTracker + + +async def test_action_trigger_chitchat(): + tracker = DialogueStateTracker.from_events("test", []) + action = ActionTriggerChitchat() + channel = CollectingOutputChannel() + nlg = TemplatedNaturalLanguageGenerator({}) + events = await action.run(channel, nlg, tracker, Domain.empty()) + assert len(events) == 1 + event = events[0] + assert isinstance(event, SlotSet) + assert event.key == DIALOGUE_STACK_SLOT + assert len(event.value) == 1 + assert event.value[0]["type"] == ChitChatStackFrame.type() diff --git a/tests/core/actions/test_action_trigger_flow.py b/tests/core/actions/test_action_trigger_flow.py new file mode 100644 index 000000000000..652007f7fd38 --- /dev/null +++ b/tests/core/actions/test_action_trigger_flow.py @@ -0,0 +1,92 @@ +import pytest +from rasa.core.actions.action_trigger_flow import ActionTriggerFlow +from rasa.core.channels import CollectingOutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import ( + FlowStackFrameType, + UserFlowStackFrame, +) +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import ActiveLoop, SlotSet +from rasa.shared.core.trackers import DialogueStateTracker + + +async def test_action_trigger_flow(): + tracker = DialogueStateTracker.from_events("test", []) + action = ActionTriggerFlow("flow_foo") + channel = CollectingOutputChannel() + nlg = TemplatedNaturalLanguageGenerator({}) + events = await action.run(channel, nlg, tracker, Domain.empty()) + assert len(events) == 1 + event = events[0] + assert isinstance(event, SlotSet) + assert event.key == DIALOGUE_STACK_SLOT + assert len(event.value) == 1 + assert event.value[0]["type"] == UserFlowStackFrame.type() + assert event.value[0]["flow_id"] == "foo" + assert event.value[0]["frame_type"] == FlowStackFrameType.REGULAR.value + + +async def test_action_trigger_flow_with_slots(): + tracker = DialogueStateTracker.from_events("test", []) + action = ActionTriggerFlow("flow_foo") + channel = CollectingOutputChannel() + nlg = TemplatedNaturalLanguageGenerator({}) + events = await action.run( + channel, nlg, tracker, Domain.empty(), metadata={"slots": {"foo": "bar"}} + ) + + event = events[0] + assert isinstance(event, SlotSet) + assert event.key == DIALOGUE_STACK_SLOT + assert len(event.value) == 1 + assert event.value[0]["type"] == UserFlowStackFrame.type() + assert event.value[0]["flow_id"] == "foo" + + assert len(events) == 2 + event = events[1] + assert isinstance(event, SlotSet) + assert event.key == "foo" + assert event.value == "bar" + + +async def test_action_trigger_fails_if_name_is_invalid(): + with pytest.raises(ValueError): + ActionTriggerFlow("foo") + + +async def test_action_trigger_ends_an_active_loop_on_the_tracker(): + tracker = DialogueStateTracker.from_events("test", [ActiveLoop("loop_foo")]) + action = ActionTriggerFlow("flow_foo") + channel = CollectingOutputChannel() + nlg = TemplatedNaturalLanguageGenerator({}) + events = await action.run(channel, nlg, tracker, Domain.empty()) + + assert len(events) == 2 + assert isinstance(events[1], ActiveLoop) + assert events[1].name is None + + +async def test_action_trigger_uses_interrupt_flow_type_if_stack_already_contains_flow(): + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[user_frame]) + tracker = DialogueStateTracker.from_events("test", [stack.persist_as_event()]) + + action = ActionTriggerFlow("flow_foo") + channel = CollectingOutputChannel() + nlg = TemplatedNaturalLanguageGenerator({}) + + events = await action.run(channel, nlg, tracker, Domain.empty()) + + assert len(events) == 1 + event = events[0] + assert isinstance(event, SlotSet) + assert event.key == DIALOGUE_STACK_SLOT + assert len(event.value) == 2 + assert event.value[1]["type"] == UserFlowStackFrame.type() + assert event.value[1]["flow_id"] == "foo" + assert event.value[1]["frame_type"] == FlowStackFrameType.INTERRUPT.value diff --git a/tests/core/actions/test_action_trigger_search.py b/tests/core/actions/test_action_trigger_search.py new file mode 100644 index 000000000000..fc9545c84b4f --- /dev/null +++ b/tests/core/actions/test_action_trigger_search.py @@ -0,0 +1,22 @@ +from rasa.core.actions.action_trigger_search import ActionTriggerSearch +from rasa.core.channels import CollectingOutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.dialogue_understanding.stack.frames import SearchStackFrame +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import SlotSet +from rasa.shared.core.trackers import DialogueStateTracker + + +async def test_action_trigger_search(): + tracker = DialogueStateTracker.from_events("test", []) + action = ActionTriggerSearch() + channel = CollectingOutputChannel() + nlg = TemplatedNaturalLanguageGenerator({}) + events = await action.run(channel, nlg, tracker, Domain.empty()) + assert len(events) == 1 + event = events[0] + assert isinstance(event, SlotSet) + assert event.key == DIALOGUE_STACK_SLOT + assert len(event.value) == 1 + assert event.value[0]["type"] == SearchStackFrame.type() diff --git a/tests/core/conftest.py b/tests/core/conftest.py index ff69c4acec19..1da3c380e10d 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -135,11 +135,11 @@ def default_tracker(domain: Domain) -> DialogueStateTracker: @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", + domain="examples/nlu_based/formbot/domain.yml", + config="examples/nlu_based/formbot/config.yml", training_files=[ - "examples/formbot/data/rules.yml", - "examples/formbot/data/stories.yml", + "examples/nlu_based/formbot/data/rules.yml", + "examples/nlu_based/formbot/data/stories.yml", ], ) diff --git a/tests/core/featurizers/test_tracker_featurizer.py b/tests/core/featurizers/test_tracker_featurizer.py index d0b6b73b5907..2066a65d6cf4 100644 --- a/tests/core/featurizers/test_tracker_featurizer.py +++ b/tests/core/featurizers/test_tracker_featurizer.py @@ -179,14 +179,25 @@ def test_featurize_trackers_with_full_dialogue_tracker_featurizer( }, ] ] - 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, 17, 0, 14, 15, 0, 16]]) + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -255,7 +266,19 @@ def test_trackers_ignore_action_unlikely_intent_with_full_dialogue_tracker_featu for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 17, 0, 14, 15, 0, 16]]) + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -324,7 +347,22 @@ def test_trackers_keep_action_unlikely_intent_with_full_dialogue_tracker_featuri for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 9, 17, 0, 9, 14, 15, 0, 9, 16]]) + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ) assert actual_labels is not None assert len(actual_labels) == 1 for actual, expected in zip(actual_labels, expected_labels): @@ -832,7 +870,19 @@ def test_featurize_trackers_with_max_history_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 17, 0, 14, 15, 0, 16]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ).T assert actual_labels is not None assert actual_labels.shape == expected_labels.shape @@ -899,7 +949,15 @@ def test_featurize_trackers_ignore_action_unlikely_intent_max_history_featurizer for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 17, 0]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + ] + ] + ).T assert actual_labels.shape == expected_labels.shape for actual, expected in zip(actual_labels, expected_labels): assert np.all(actual == expected) @@ -971,7 +1029,16 @@ def test_featurize_trackers_keep_action_unlikely_intent_max_history_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 9, 17, 0]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("action_unlikely_intent"), + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + ] + ] + ).T assert actual_labels is not None assert actual_labels.shape == expected_labels.shape for actual, expected in zip(actual_labels, expected_labels): @@ -1088,7 +1155,19 @@ def test_deduplicate_featurize_trackers_with_max_history_tracker_featurizer( for actual, expected in zip(actual_features, expected_features): assert compare_featurized_states(actual, expected) - expected_labels = np.array([[0, 17, 0, 14, 15, 0, 16]]).T + expected_labels = np.array( + [ + [ + 0, + moodbot_domain.action_names_or_texts.index("utter_greet"), + 0, + moodbot_domain.action_names_or_texts.index("utter_cheer_up"), + moodbot_domain.action_names_or_texts.index("utter_did_that_help"), + 0, + moodbot_domain.action_names_or_texts.index("utter_goodbye"), + ] + ] + ).T if not remove_duplicates: expected_labels = np.vstack([expected_labels] * 2) diff --git a/tests/core/flows/__init__.py b/tests/core/flows/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/core/flows/test_flow.py b/tests/core/flows/test_flow.py new file mode 100644 index 000000000000..87f4e2551b86 --- /dev/null +++ b/tests/core/flows/test_flow.py @@ -0,0 +1,107 @@ +import pytest + +from rasa.shared.core.flows.flow import Flow, FlowsList +from rasa.shared.importers.importer import FlowSyncImporter +from tests.utilities import flows_from_str + + +@pytest.fixture +def user_flows_and_patterns() -> FlowsList: + return flows_from_str( + """ + flows: + foo: + steps: + - id: first + action: action_listen + pattern_bar: + steps: + - id: first + action: action_listen + """ + ) + + +@pytest.fixture +def only_patterns() -> FlowsList: + return flows_from_str( + """ + flows: + pattern_bar: + steps: + - id: first + action: action_listen + """ + ) + + +@pytest.fixture +def empty_flowlist() -> FlowsList: + return FlowsList(flows=[]) + + +def test_user_flow_ids(user_flows_and_patterns: FlowsList): + assert user_flows_and_patterns.user_flow_ids == ["foo"] + + +def test_user_flow_ids_handles_empty(empty_flowlist: FlowsList): + assert empty_flowlist.user_flow_ids == [] + + +def test_user_flow_ids_handles_patterns_only(only_patterns: FlowsList): + assert only_patterns.user_flow_ids == [] + + +def test_user_flows(user_flows_and_patterns: FlowsList): + user_flows = user_flows_and_patterns.user_flows + expected_user_flows = FlowsList( + [Flow.from_json("foo", {"steps": [{"id": "first", "action": "action_listen"}]})] + ) + assert user_flows == expected_user_flows + + +def test_user_flows_handles_empty(empty_flowlist: FlowsList): + assert empty_flowlist.user_flows == empty_flowlist + + +def test_user_flows_handles_patterns_only( + only_patterns: FlowsList, empty_flowlist: FlowsList +): + assert only_patterns.user_flows == empty_flowlist + + +def test_collecting_flow_utterances(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - action: utter_welcome + - action: setup + - collect: age + rejections: + - if: age<18 + utter: utter_too_young + - if: age>100 + utter: utter_too_old + bar: + steps: + - action: utter_hello + - collect: income + utter: utter_ask_income_politely + """ + ) + assert all_flows.utterances == { + "utter_ask_age", + "utter_ask_income_politely", + "utter_hello", + "utter_welcome", + "utter_too_young", + "utter_too_old", + } + + +def test_default_flows_have_non_empty_names(): + default_flows = FlowSyncImporter.load_default_pattern_flows() + for flow in default_flows.underlying_flows: + assert flow.name diff --git a/tests/core/nlg/test_response.py b/tests/core/nlg/test_response.py index 7bbccb45d61d..9c0583b3f7bd 100644 --- a/tests/core/nlg/test_response.py +++ b/tests/core/nlg/test_response.py @@ -9,6 +9,7 @@ from rasa.shared.core.domain import Domain from rasa.shared.core.slots import TextSlot, AnySlot, CategoricalSlot, BooleanSlot from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.utils.validation import YamlValidationException async def test_nlg_conditional_response_variations_with_no_slots(): @@ -625,3 +626,93 @@ async def test_nlg_conditional_response_variations_condition_logging( "[condition 2] type: slot | name: test_B | value: B" in message for message in caplog.messages ) + + +async def test_nlg_response_with_no_text(): + with pytest.raises(YamlValidationException): + Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_flow_xyz: + - buttons: + - payload: "yes" + title: Yes + - payload: "no" + title: No + + """ + ) + + +async def test_nlg_response_with_default_template_engine(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_flow_xyz: + - text: "Do you want to update the values?" + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + r = t.generate_from_slots( + "utter_flow_xyz", + {"tm": "50"}, + { + "frame_id": "XYYZABCD", + "corrected_slots": {"tm": "100"}, + }, + "", + ) + assert r.get("text") == "Do you want to update the values?" + + +async def test_nlg_response_with_jinja_template(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_flow_xyz: + - text: "Do you want to update the + {{{{ context.corrected_slots.keys()|join(', ') }}}}?" + metadata: + rephrase: true + template: jinja + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + r = t.generate_from_slots( + "utter_flow_xyz", + {"tm": "50"}, + { + "frame_id": "XYYZABCD", + "corrected_slots": {"tm": "100"}, + }, + "", + ) + assert r.get("text") == "Do you want to update the tm?" + + +async def test_nlg_response_with_unknown_var_jinja_template(): + domain = Domain.from_yaml( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_flow_xyz: + - text: "Do you want to update the {{{{ context.unknown_key }}}}?" + metadata: + rephrase: true + template: jinja + """ + ) + t = TemplatedNaturalLanguageGenerator(domain.responses) + r = t.generate_from_slots( + "utter_flow_xyz", + {"tm": "50"}, + { + "frame_id": "XYYZABCD", + "corrected_slots": {"tm": "100"}, + }, + "", + ) + assert r.get("text") == "Do you want to update the ?" diff --git a/tests/core/policies/test_flow_policy.py b/tests/core/policies/test_flow_policy.py new file mode 100644 index 000000000000..5a84a9dcca1c --- /dev/null +++ b/tests/core/policies/test_flow_policy.py @@ -0,0 +1,416 @@ +import textwrap +from typing import List, Optional, Text, Tuple + +import pytest + +from rasa.core.policies.flow_policy import ( + FlowCircuitBreakerTrippedException, + FlowExecutor, + FlowPolicy, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +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.events import ActionExecuted, Event, SlotSet +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.yaml_flows_io import YAMLFlowsReader +from rasa.shared.core.slots import TextSlot +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.dialogue_understanding.stack.frames import ( + UserFlowStackFrame, + SearchStackFrame, +) +from tests.utilities import ( + flows_default_domain, + flows_from_str, + flows_from_str_with_defaults, +) + + +@pytest.fixture() +def resource() -> Resource: + return Resource("flow_policy") + + +@pytest.fixture() +def default_flow_policy( + resource: Resource, + default_model_storage: ModelStorage, + default_execution_context: ExecutionContext, +) -> FlowPolicy: + return FlowPolicy( + config={}, + model_storage=default_model_storage, + resource=resource, + execution_context=default_execution_context, + ) + + +@pytest.fixture() +def default_flows() -> FlowsList: + return flows_from_str( + """ + flows: + foo_flow: + steps: + - id: "1" + action: action_listen + next: "2" + - id: "2" + action: action_unlikely_intent # some action that exists by default + bar_flow: + steps: + - id: first_step + action: action_listen + """ + ) + + +def _run_flow_until_listen( + executor: FlowExecutor, tracker: DialogueStateTracker, domain: Domain +) -> Tuple[List[Optional[Text]], List[Event]]: + # Run the flow until we reach a listen action. + # Collect and return all events and intermediate actions. + events = [] + actions = [] + while True: + action_prediction = executor.advance_flows(tracker) + if not action_prediction: + break + + events.extend(action_prediction.events or []) + actions.append(action_prediction.action_name) + tracker.update_with_events(action_prediction.events or [], domain) + if action_prediction.action_name: + tracker.update(ActionExecuted(action_prediction.action_name), domain) + if action_prediction.action_name == "action_listen": + break + if action_prediction.action_name is None and not action_prediction.events: + # No action was executed and no events were generated. This means that + # the flow isn't doing anything anymore + break + return actions, events + + +@pytest.mark.skip(reason="Skip until intent gets replaced by nlu_trigger") +def test_select_next_action() -> None: + flows = YAMLFlowsReader.read_from_string( + textwrap.dedent( + """ + flows: + test_flow: + description: Test flow + steps: + - id: "1" + intent: transfer_money + next: "2" + - id: "2" + action: utter_ask_name + """ + ) + ) + tracker = DialogueStateTracker.from_dict( + "test", + [ + {"event": "action", "name": "action_listen"}, + {"event": "user", "parse_data": {"intent": {"name": "transfer_money"}}}, + ], + ) + domain = Domain.empty() + executor = FlowExecutor.from_tracker(tracker, flows, domain) + + actions, events = _run_flow_until_listen(executor, tracker, domain) + + assert actions == ["flow_test_flow", None] + assert events == [] + + +def test_flow_policy_does_support_user_flowstack_frame(): + frame = UserFlowStackFrame(flow_id="foo", step_id="first_step", frame_id="some-id") + assert FlowPolicy.does_support_stack_frame(frame) + + +def test_flow_policy_does_not_support_search_frame(): + frame = SearchStackFrame( + frame_id="some-id", + ) + assert not FlowPolicy.does_support_stack_frame(frame) + + +def test_get_default_config(): + assert FlowPolicy.get_default_config() == {"priority": 1, "max_history": None} + + +def test_predict_action_probabilities_abstains_from_unsupported_frame( + default_flow_policy: FlowPolicy, +): + domain = Domain.empty() + + stack = DialogueStack(frames=[SearchStackFrame(frame_id="some-id")]) + # create a tracker with the stack set + tracker = DialogueStateTracker.from_events( + "test abstain", + domain=domain, + slots=domain.slots, + evts=[ActionExecuted(action_name="action_listen"), stack.persist_as_event()], + ) + + prediction = default_flow_policy.predict_action_probabilities( + tracker=tracker, + domain=Domain.empty(), + ) + + # check that the policy didn't predict anything + assert prediction.max_confidence == 0.0 + + +def test_predict_action_probabilities_advances_topmost_flow( + default_flow_policy: FlowPolicy, default_flows: FlowsList +): + domain = Domain.empty() + + stack = DialogueStack( + frames=[UserFlowStackFrame(flow_id="foo_flow", step_id="1", frame_id="some-id")] + ) + + tracker = DialogueStateTracker.from_events( + "test abstain", + domain=domain, + slots=domain.slots, + evts=[ActionExecuted(action_name="action_listen"), stack.persist_as_event()], + ) + + prediction = default_flow_policy.predict_action_probabilities( + tracker=tracker, domain=Domain.empty(), flows=default_flows + ) + + assert prediction.max_confidence == 1.0 + + predicted_idx = prediction.max_confidence_index + assert domain.action_names_or_texts[predicted_idx] == "action_unlikely_intent" + # check that the stack was updated + assert prediction.optional_events == [ + SlotSet( + "dialogue_stack", + [ + { + "frame_id": "some-id", + "flow_id": "foo_flow", + "step_id": "2", + "frame_type": "regular", + "type": "flow", + } + ], + ) + ] + + +def test_executor_trips_internal_circuit_breaker(): + flow_with_loop = flows_from_str( + """ + flows: + foo_flow: + steps: + - id: "1" + set_slots: + - foo: bar + next: "2" + - id: "2" + set_slots: + - foo: barbar + next: "1" + """ + ) + + domain = Domain.empty() + + stack = DialogueStack( + frames=[UserFlowStackFrame(flow_id="foo_flow", step_id="1", frame_id="some-id")] + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ActionExecuted(action_name="action_listen"), stack.persist_as_event()], + domain=domain, + slots=domain.slots, + ) + + executor = FlowExecutor.from_tracker(tracker, flow_with_loop, domain) + + with pytest.raises(FlowCircuitBreakerTrippedException): + executor.select_next_action(tracker) + + +def test_policy_triggers_error_pattern_if_internal_circuit_breaker_is_tripped( + default_flow_policy: FlowPolicy, +): + flow_with_loop = flows_from_str_with_defaults( + """ + flows: + foo_flow: + steps: + - id: "1" + set_slots: + - foo: bar + next: "2" + - id: "2" + set_slots: + - foo: barbar + next: "1" + """ + ) + + domain = flows_default_domain() + + stack = DialogueStack( + frames=[UserFlowStackFrame(flow_id="foo_flow", step_id="1", frame_id="some-id")] + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ActionExecuted(action_name="action_listen"), stack.persist_as_event()], + domain=domain, + slots=domain.slots, + ) + + prediction = default_flow_policy.predict_action_probabilities( + tracker=tracker, domain=domain, flows=flow_with_loop + ) + + assert prediction.max_confidence == 1.0 + + predicted_idx = prediction.max_confidence_index + assert domain.action_names_or_texts[predicted_idx] == "utter_internal_error_rasa" + # check that the stack was updated. + assert len(prediction.optional_events) == 1 + assert isinstance(prediction.optional_events[0], SlotSet) + + assert prediction.optional_events[0].key == "dialogue_stack" + # the user flow should be on the stack as well as the error pattern + assert len(prediction.optional_events[0].value) == 2 + # the user flow should be about to end + assert prediction.optional_events[0].value[0]["step_id"] == "NEXT:END" + # the pattern should be the other frame + assert prediction.optional_events[0].value[1]["flow_id"] == "pattern_internal_error" + + +def test_executor_does_not_get_tripped_if_an_action_is_predicted_in_loop(): + flow_with_loop = flows_from_str( + """ + flows: + foo_flow: + steps: + - id: "1" + set_slots: + - foo: bar + next: "2" + - id: "2" + action: action_listen + next: "1" + """ + ) + + domain = Domain.empty() + + stack = DialogueStack( + frames=[UserFlowStackFrame(flow_id="foo_flow", step_id="1", frame_id="some-id")] + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ActionExecuted(action_name="action_listen"), stack.persist_as_event()], + domain=domain, + slots=domain.slots, + ) + + executor = FlowExecutor.from_tracker(tracker, flow_with_loop, domain) + + selection = executor.select_next_action(tracker) + assert selection.action_name == "action_listen" + + +def test_flow_policy_resets_all_slots_after_flow_ends() -> None: + flows = flows_from_str( + """ + flows: + foo_flow: + steps: + - id: "1" + collect: my_slot + - id: "2" + set_slots: + - foo: bar + - other_slot: other_value + - id: "3" + action: action_listen + """ + ) + tracker = DialogueStateTracker.from_events( + "test", + [ + SlotSet("my_slot", "my_value"), + SlotSet("foo", "bar"), + SlotSet("other_slot", "other_value"), + ActionExecuted("action_listen"), + ], + slots=[ + TextSlot("my_slot", mappings=[], initial_value="initial_value"), + TextSlot("foo", mappings=[]), + TextSlot("other_slot", mappings=[]), + ], + ) + + domain = Domain.empty() + executor = FlowExecutor.from_tracker(tracker, flows, domain) + + current_flow = flows.flow_by_id("foo_flow") + events = executor._reset_scoped_slots(current_flow, tracker) + assert events == [ + SlotSet("my_slot", "initial_value"), + SlotSet("foo", None), + SlotSet("other_slot", None), + ] + + +def test_flow_policy_set_slots_inherit_reset_from_collect_step() -> None: + """Test that `reset_after_flow_ends` is inherited from the collect step.""" + slot_name = "my_slot" + flows = flows_from_str( + f""" + flows: + foo_flow: + steps: + - id: "1" + collect: {slot_name} + reset_after_flow_ends: false + - id: "2" + set_slots: + - foo: bar + - {slot_name}: my_value + - id: "3" + action: action_listen + """ + ) + tracker = DialogueStateTracker.from_events( + "test123", + [ + SlotSet("my_slot", "my_value"), + SlotSet("foo", "bar"), + ActionExecuted("action_listen"), + ], + slots=[ + TextSlot("my_slot", mappings=[], initial_value="initial_value"), + TextSlot("foo", mappings=[]), + ], + ) + + domain = Domain.empty() + executor = FlowExecutor.from_tracker(tracker, flows, domain) + + current_flow = flows.flow_by_id("foo_flow") + events = executor._reset_scoped_slots(current_flow, tracker) + assert events == [ + SlotSet("foo", None), + ] diff --git a/tests/core/policies/test_rule_policy.py b/tests/core/policies/test_rule_policy.py index 945a6717a826..9fa5cdee415c 100644 --- a/tests/core/policies/test_rule_policy.py +++ b/tests/core/policies/test_rule_policy.py @@ -758,7 +758,7 @@ def test_rule_policy_finetune( ) original_data = training.load_data( - "examples/rules/data/rules.yml", trained_rule_policy_domain + "examples/nlu_based/rules/data/rules.yml", trained_rule_policy_domain ) loaded_policy.train(original_data + [new_rule], trained_rule_policy_domain) @@ -805,7 +805,7 @@ def test_rule_policy_contradicting_rule_finetune( ) original_data = training.load_data( - "examples/rules/data/rules.yml", trained_rule_policy_domain + "examples/nlu_based/rules/data/rules.yml", trained_rule_policy_domain ) with pytest.raises(InvalidRule) as execinfo: @@ -1847,7 +1847,7 @@ def test_immediate_submit(policy: RulePolicy): @pytest.fixture() def trained_rule_policy_domain() -> Domain: - return Domain.load("examples/rules/domain.yml") + return Domain.load("examples/nlu_based/rules/domain.yml") @pytest.fixture() @@ -1855,7 +1855,7 @@ 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 + "examples/nlu_based/rules/data/rules.yml", trained_rule_policy_domain ) policy.train(trackers, trained_rule_policy_domain) diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index e3ecc4fdf432..61a3638dab62 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -26,6 +26,7 @@ ActionSessionStart, ActionEndToEndResponse, ActionExtractSlots, + default_actions, ) from rasa.core.actions.forms import FormAction from rasa.core.channels import CollectingOutputChannel, OutputChannel @@ -74,23 +75,15 @@ 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, - ACTION_SEND_TEXT_NAME, ACTIVE_LOOP, FOLLOWUP_ACTION, REQUESTED_SLOT, SESSION_START_METADATA_SLOT, - ACTION_EXTRACT_SLOTS, + DIALOGUE_STACK_SLOT, + RETURN_VALUE_SLOT, + FLOW_HASHES_SLOT, + DEFAULT_ACTION_NAMES, + RULE_SNIPPET_ACTION_NAME, ) from rasa.shared.core.trackers import DialogueStateTracker from rasa.shared.exceptions import RasaException @@ -139,25 +132,15 @@ def test_domain_action_instantiation(): action.action_for_name_or_text(action_name, domain, None) for action_name in domain.action_names_or_texts ] - - assert len(instantiated_actions) == 17 + expected_action_names = DEFAULT_ACTION_NAMES + [ + "my_module.ActionTest", + "utter_test", + "utter_chitchat", + ] + assert len(instantiated_actions) == len(expected_action_names) + for i, instantiated_action in enumerate(instantiated_actions): + assert instantiated_action.name() == expected_action_names[i] 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() == ACTION_SEND_TEXT_NAME - assert instantiated_actions[12].name() == RULE_SNIPPET_ACTION_NAME - assert instantiated_actions[13].name() == ACTION_EXTRACT_SLOTS - assert instantiated_actions[14].name() == "my_module.ActionTest" - assert instantiated_actions[15].name() == "utter_test" - assert instantiated_actions[16].name() == "utter_chitchat" @pytest.mark.parametrize( @@ -238,7 +221,10 @@ async def test_remote_action_runs( "slots": { "name": None, REQUESTED_SLOT: None, + FLOW_HASHES_SLOT: None, SESSION_START_METADATA_SLOT: None, + DIALOGUE_STACK_SLOT: None, + RETURN_VALUE_SLOT: None, }, "events": [], "latest_input_channel": None, @@ -300,7 +286,10 @@ async def test_remote_action_logs_events( "slots": { "name": None, REQUESTED_SLOT: None, + FLOW_HASHES_SLOT: None, SESSION_START_METADATA_SLOT: None, + DIALOGUE_STACK_SLOT: None, + RETURN_VALUE_SLOT: None, }, "events": [], "latest_input_channel": None, @@ -3042,3 +3031,33 @@ async def test_action_send_text_handles_missing_metadata( ) assert events == [BotUttered("")] + + +def test_default_actions_and_names_consistency(): + names_of_default_actions = {action.name() for action in default_actions()} + names_of_executable_actions_in_constants = set(DEFAULT_ACTION_NAMES) - { + RULE_SNIPPET_ACTION_NAME + } + assert names_of_default_actions == names_of_executable_actions_in_constants + + +async def test_filter_out_dialogue_stack_slot_set_in_a_custom_action( + default_channel: OutputChannel, + default_nlg: NaturalLanguageGenerator, + default_tracker: DialogueStateTracker, + domain: Domain, +) -> None: + endpoint = EndpointConfig("https://example.com/webhooks/actions") + remote_action = action.RemoteAction("my_action", endpoint) + events = [SlotSet(DIALOGUE_STACK_SLOT, {}), SlotSet("some_slot", "some_value")] + events_as_dict = [event.as_dict() for event in events] + response = {"events": events_as_dict, "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 + assert events[0] == SlotSet("some_slot", "some_value") diff --git a/tests/core/test_evaluation.py b/tests/core/test_evaluation.py index 30a91941f6a5..531fc3c25111 100644 --- a/tests/core/test_evaluation.py +++ b/tests/core/test_evaluation.py @@ -188,7 +188,7 @@ async def test_end_to_evaluation_trips_circuit_breaker( assistant_id: placeholder_default policies: - name: MemoizationPolicy - max_history: 11 + max_history: 21 pipeline: [] """ @@ -215,6 +215,16 @@ async def test_end_to_evaluation_trips_circuit_breaker( ) circuit_trip_predicted = [ + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", + "utter_greet", "utter_greet", "utter_greet", "utter_greet", diff --git a/tests/core/test_nlg.py b/tests/core/test_nlg.py index fccc1e8d81de..87a785a7578d 100644 --- a/tests/core/test_nlg.py +++ b/tests/core/test_nlg.py @@ -122,7 +122,9 @@ def test_nlg_schema_validation_empty_custom_dict(): 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}) + result = t._fill_response( + response=response, filled_slots={slot_name: slot_value}, stack_context=dict() + ) assert result == {"text": str(slot_value)} @@ -134,7 +136,9 @@ 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} + response=response, + filled_slots={img_slot_name: img_slot_value}, + stack_context=dict(), ) assert result == {"image": str(img_slot_value)} @@ -169,7 +173,11 @@ def test_nlg_fill_response_custom(slot_name: Text, slot_value: Any): } } t = TemplatedNaturalLanguageGenerator(responses=dict()) - result = t._fill_response(response=response, filled_slots={slot_name: slot_value}) + result = t._fill_response( + response=response, + filled_slots={slot_name: slot_value}, + stack_context=dict(), + ) assert result == { "custom": { @@ -190,7 +198,11 @@ def test_nlg_fill_response_custom_with_list(): } } t = TemplatedNaturalLanguageGenerator(responses=dict()) - result = t._fill_response(response=response, filled_slots={"test": 5}) + result = t._fill_response( + response=response, + filled_slots={"test": 5}, + stack_context=dict(), + ) assert result == { "custom": { "blocks": [{"fields": [{"text": "*Departure date:*\n5"}]}], @@ -213,7 +225,9 @@ 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"} + response=response, + filled_slots={"slot_1": "foo", "slot_2": "bar"}, + stack_context=dict(), ) assert result == {"text": expected} @@ -223,7 +237,9 @@ 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} + response={"text": response_text}, + filled_slots={slot_name: slot_value}, + stack_context=dict(), ) assert result["text"] == response_text @@ -243,6 +259,7 @@ def test_nlg_fill_response_image_and_text( result = t._fill_response( response=response, filled_slots={text_slot_name: text_slot_value, img_slot_name: img_slot_value}, + stack_context=dict(), ) assert result == {"text": str(text_slot_value), "image": str(img_slot_value)} @@ -268,6 +285,7 @@ def test_nlg_fill_response_text_and_custom( result = t._fill_response( response=response, filled_slots={text_slot_name: text_slot_value, cust_slot_name: cust_slot_value}, + stack_context=dict(), ) assert result == { "text": str(text_slot_value), @@ -285,7 +303,9 @@ 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} + response=response, + filled_slots={attach_slot_name: attach_slot_value}, + stack_context=dict(), ) assert result == {"attachment": str(attach_slot_value)} @@ -304,7 +324,9 @@ def test_nlg_fill_response_button(button_slot_name, button_slot_value): } t = TemplatedNaturalLanguageGenerator(responses=dict()) result = t._fill_response( - response=response, filled_slots={button_slot_name: button_slot_value} + response=response, + filled_slots={button_slot_name: button_slot_value}, + stack_context=dict(), ) assert result == { "buttons": [ @@ -327,5 +349,6 @@ def test_nlg_fill_response_quick_replies( result = t._fill_response( response=response, filled_slots={quick_replies_slot_name: quick_replies_slot_value}, + stack_context=dict(), ) assert result == {"quick_replies": str(quick_replies_slot_value)} diff --git a/tests/dialogue_understanding/__init__.py b/tests/dialogue_understanding/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/dialogue_understanding/commands/__init__.py b/tests/dialogue_understanding/commands/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/dialogue_understanding/commands/conftest.py b/tests/dialogue_understanding/commands/conftest.py new file mode 100644 index 000000000000..7741b820865b --- /dev/null +++ b/tests/dialogue_understanding/commands/conftest.py @@ -0,0 +1,43 @@ +import pytest + +from rasa.dialogue_understanding.commands import StartFlowCommand +from rasa.dialogue_understanding.processor.command_processor import execute_commands +from rasa.shared.core.events import UserUttered +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import COMMANDS +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +@pytest.fixture +def all_flows() -> FlowsList: + return flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + bar: + steps: + - id: also_first_step + action: action_listen + """ + ) + + +start_foo_user_uttered = UserUttered( + "start foo", None, None, {COMMANDS: [StartFlowCommand("foo").as_dict()]} +) + +start_bar_user_uttered = UserUttered( + "start bar", None, None, {COMMANDS: [StartFlowCommand("bar").as_dict()]} +) + + +@pytest.fixture +def tracker(all_flows: FlowsList) -> DialogueStateTracker: + # Creates a useful tracker that has a started flow and the current flows hashed + tracker = DialogueStateTracker.from_events("test", evts=[start_foo_user_uttered]) + execute_commands(tracker, all_flows) + return tracker diff --git a/tests/dialogue_understanding/commands/test_can_not_handle_command.py b/tests/dialogue_understanding/commands/test_can_not_handle_command.py new file mode 100644 index 000000000000..d0aa676f020a --- /dev/null +++ b/tests/dialogue_understanding/commands/test_can_not_handle_command.py @@ -0,0 +1,27 @@ +from rasa.dialogue_understanding.commands.can_not_handle_command import ( + CannotHandleCommand, +) +from rasa.shared.core.events import UserUttered +from rasa.shared.core.trackers import DialogueStateTracker + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert CannotHandleCommand.command() == "cannot handle" + + +def test_from_dict(): + assert CannotHandleCommand.from_dict({}) == CannotHandleCommand() + + +def test_run_command_on_tracker(): + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + UserUttered("hi", {"name": "greet"}), + ], + ) + command = CannotHandleCommand() + + assert command.run_command_on_tracker(tracker, [], tracker) == [] diff --git a/tests/dialogue_understanding/commands/test_cancel_flow_command.py b/tests/dialogue_understanding/commands/test_cancel_flow_command.py new file mode 100644 index 000000000000..890fb889222c --- /dev/null +++ b/tests/dialogue_understanding/commands/test_cancel_flow_command.py @@ -0,0 +1,131 @@ +import pytest +from rasa.dialogue_understanding.commands.cancel_flow_command import CancelFlowCommand +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import UserFlowStackFrame +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import SlotSet +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_command_name(): + # names of commands should not change as they are part of persisted + # trackers + assert CancelFlowCommand.command() == "cancel flow" + + +def test_from_dict(): + assert CancelFlowCommand.from_dict({}) == CancelFlowCommand() + + +def test_run_command_on_tracker_without_flows(): + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = CancelFlowCommand() + + assert command.run_command_on_tracker(tracker, [], tracker) == [] + + +def test_run_command_on_tracker(): + all_flows = flows_from_str( + """ + flows: + foo: + name: foo flow + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "frame_type": "regular", + "flow_id": "foo", + "step_id": "first_step", + "frame_id": "some-frame-id", + } + ], + ) + ], + ) + command = CancelFlowCommand() + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + # flow should still be on the stack and a cancel flow should have been added + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 2 + + assert dialogue_stack_dump[1]["type"] == "pattern_cancel_flow" + assert dialogue_stack_dump[1]["flow_id"] == "pattern_cancel_flow" + assert dialogue_stack_dump[1]["step_id"] == "START" + assert dialogue_stack_dump[1]["canceled_name"] == "foo flow" + assert dialogue_stack_dump[1]["canceled_frames"] == ["some-frame-id"] + + +def test_select_canceled_frames_cancels_patterns(): + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ), + CollectInformationPatternFlowStackFrame( + collect="bar", frame_id="some-other-id" + ), + ] + ) + + canceled_frames = CancelFlowCommand.select_canceled_frames(stack) + assert len(canceled_frames) == 2 + assert canceled_frames[0] == "some-other-id" + assert canceled_frames[1] == "some-frame-id" + + +def test_select_canceled_frames_cancels_only_top_user_flow(): + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="bar", step_id="first_step", frame_id="some-bar-id" + ), + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-foo-id" + ), + ] + ) + + canceled_frames = CancelFlowCommand.select_canceled_frames(stack) + assert len(canceled_frames) == 1 + assert canceled_frames[0] == "some-foo-id" + + +def test_select_canceled_frames_empty_stack(): + stack = DialogueStack(frames=[]) + + with pytest.raises(ValueError): + # this shouldn't actually, happen. if the stack is empty we shouldn't + # try to cancel anything. + CancelFlowCommand.select_canceled_frames(stack) + + +def test_select_canceled_frames_raises_if_frame_not_found(): + stack = DialogueStack(frames=[]) + + with pytest.raises(ValueError): + # can't cacenl if there is no user flow on the stack. in reality + # this should never happen as the flow should always be on the stack + # when this command is executed. + CancelFlowCommand.select_canceled_frames(stack) diff --git a/tests/dialogue_understanding/commands/test_chit_chat_answer_command.py b/tests/dialogue_understanding/commands/test_chit_chat_answer_command.py new file mode 100644 index 000000000000..d30983bcea68 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_chit_chat_answer_command.py @@ -0,0 +1,35 @@ +from rasa.dialogue_understanding.commands.chit_chat_answer_command import ( + ChitChatAnswerCommand, +) +from rasa.shared.core.events import SlotSet, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert ChitChatAnswerCommand.command() == "chitchat" + + +def test_from_dict(): + assert ChitChatAnswerCommand.from_dict({}) == ChitChatAnswerCommand() + + +def test_run_command_on_tracker(): + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + UserUttered("hi", {"name": "greet"}), + ], + ) + command = ChitChatAnswerCommand() + + events = command.run_command_on_tracker(tracker, [], tracker) + assert len(events) == 1 + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == "dialogue_stack" + assert len(dialogue_stack_event.value) == 1 + + frame = dialogue_stack_event.value[0] + assert frame["type"] == "pattern_chitchat" diff --git a/tests/dialogue_understanding/commands/test_clarify_command.py b/tests/dialogue_understanding/commands/test_clarify_command.py new file mode 100644 index 000000000000..b9630a5a618a --- /dev/null +++ b/tests/dialogue_understanding/commands/test_clarify_command.py @@ -0,0 +1,103 @@ +import pytest +from rasa.dialogue_understanding.commands.clarify_command import ClarifyCommand +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import SlotSet +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_command_name(): + # names of commands should not change as they are part of persisted + # trackers + assert ClarifyCommand.command() == "clarify" + + +def test_from_dict(): + assert ClarifyCommand.from_dict({"options": ["foo", "bar"]}) == ClarifyCommand( + options=["foo", "bar"] + ) + + +def test_from_dict_fails_if_options_is_missing(): + with pytest.raises(ValueError): + ClarifyCommand.from_dict({}) + + +def test_run_command_skips_if_no_options(): + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = ClarifyCommand(options=[]) + + assert command.run_command_on_tracker(tracker, [], tracker) == [] + + +def test_run_command_skips_if_only_non_existant_flows(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = ClarifyCommand(options=["does-not-exist"]) + + assert command.run_command_on_tracker(tracker, all_flows, tracker) == [] + + +def test_run_command_ignores_non_existant_flows(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = ClarifyCommand(options=["does-not-exist", "foo"]) + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + dialogue_stack_dump = events[0] + assert isinstance(dialogue_stack_dump, SlotSet) + assert dialogue_stack_dump.key == DIALOGUE_STACK_SLOT + assert len(dialogue_stack_dump.value) == 1 + + frame = dialogue_stack_dump.value[0] + assert frame["type"] == "pattern_clarification" + assert frame["flow_id"] == "pattern_clarification" + assert frame["step_id"] == "START" + assert frame["names"] == ["foo"] + assert frame["clarification_options"] == "" + + +def test_run_command_uses_name_of_flow(): + all_flows = flows_from_str( + """ + flows: + foo: + name: some foo + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = ClarifyCommand(options=["foo"]) + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + dialogue_stack_dump = events[0] + assert isinstance(dialogue_stack_dump, SlotSet) + assert dialogue_stack_dump.key == DIALOGUE_STACK_SLOT + assert len(dialogue_stack_dump.value) == 1 + + frame = dialogue_stack_dump.value[0] + assert frame["type"] == "pattern_clarification" + assert frame["names"] == ["some foo"] diff --git a/tests/dialogue_understanding/commands/test_command.py b/tests/dialogue_understanding/commands/test_command.py new file mode 100644 index 000000000000..7235e23af7ca --- /dev/null +++ b/tests/dialogue_understanding/commands/test_command.py @@ -0,0 +1,19 @@ +import pytest +from rasa.dialogue_understanding.commands.command import Command +from rasa.dialogue_understanding.commands.set_slot_command import SetSlotCommand + + +def test_command_from_json(): + data = {"command": "set slot", "name": "foo", "value": "bar"} + assert Command.command_from_json(data) == SetSlotCommand(name="foo", value="bar") + + +def test_command_from_dict_handles_unknown_commands(): + data = {"command": "unknown"} + with pytest.raises(ValueError): + Command.command_from_json(data) + + +def test_command_as_dict(): + command = SetSlotCommand(name="foo", value="bar") + assert command.as_dict() == {"command": "set slot", "name": "foo", "value": "bar"} diff --git a/tests/dialogue_understanding/commands/test_command_processor.py b/tests/dialogue_understanding/commands/test_command_processor.py new file mode 100644 index 000000000000..0e623c57c2a4 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_command_processor.py @@ -0,0 +1,128 @@ +import pytest + +from rasa.dialogue_understanding.patterns.code_change import FLOW_PATTERN_CODE_CHANGE_ID +from rasa.dialogue_understanding.processor.command_processor import ( + execute_commands, + find_updated_flows, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + UserFlowStackFrame, + PatternFlowStackFrame, +) +from rasa.shared.core.constants import FLOW_HASHES_SLOT +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.flows.yaml_flows_io import flows_from_str +from tests.dialogue_understanding.commands.conftest import start_bar_user_uttered + + +def test_properly_prepared_tracker(tracker: DialogueStateTracker): + # flow hashes have been initialized + assert "foo" in tracker.get_slot(FLOW_HASHES_SLOT) + + # foo flow is on the stack + dialogue_stack = DialogueStack.from_tracker(tracker) + assert (top_frame := dialogue_stack.top()) + assert isinstance(top_frame, UserFlowStackFrame) + assert top_frame.flow_id == "foo" + + +def test_detects_no_changes_when_nothing_changed( + tracker: DialogueStateTracker, all_flows: FlowsList +): + assert find_updated_flows(tracker, all_flows) == set() + + +def test_detects_no_changes_for_not_started_flows( + tracker: DialogueStateTracker, +): + bar_changed_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + bar: + steps: + - id: also_first_step_BUT_CHANGED + action: action_listen + """ + ) + assert find_updated_flows(tracker, bar_changed_flows) == set() + + +change_cases = { + "step_id_changed": """ + flows: + foo: + steps: + - id: first_step_id_BUT_CHANGED + action: action_listen + bar: + steps: + - id: also_first_step + action: action_listen + """, + "action_changed": """ + flows: + foo: + steps: + - id: first_step_id + action: action_CHANGED + bar: + steps: + - id: also_first_step + action: action_listen + """, + "new_step": """ + flows: + foo: + steps: + - id: first_step_id + action: action_listen + next: second_step_id + - id: second_step_id + action: action_cool_stuff + bar: + steps: + - id: also_first_step + action: action_listen + """, + "flow_removed": """ + flows: + bar: + steps: + - id: also_first_step + action: action_listen + """, +} + + +@pytest.mark.parametrize("case, flow_yaml", list(change_cases.items())) +def test_detects_changes(case: str, flow_yaml: str, tracker: DialogueStateTracker): + all_flows = flows_from_str(flow_yaml) + assert find_updated_flows(tracker, all_flows) == {"foo"} + + +def test_starting_of_another_flow(tracker: DialogueStateTracker, all_flows: FlowsList): + """Tests that commands are not discarded when there is no change.""" + tracker.update_with_events([start_bar_user_uttered], None) + execute_commands(tracker, all_flows) + dialogue_stack = DialogueStack.from_tracker(tracker) + assert len(dialogue_stack.frames) == 2 + assert (top_frame := dialogue_stack.top()) + assert isinstance(top_frame, UserFlowStackFrame) + assert top_frame.flow_id == "bar" + + +def test_stack_cleaning_command_is_applied_on_changes(tracker: DialogueStateTracker): + all_flows = flows_from_str(change_cases["step_id_changed"]) + tracker.update_with_events([start_bar_user_uttered], None) + execute_commands(tracker, all_flows) + dialogue_stack = DialogueStack.from_tracker(tracker) + assert len(dialogue_stack.frames) == 2 + assert (top_frame := dialogue_stack.top()) + assert isinstance(top_frame, PatternFlowStackFrame) + assert top_frame.flow_id == FLOW_PATTERN_CODE_CHANGE_ID diff --git a/tests/dialogue_understanding/commands/test_correct_slots_command.py b/tests/dialogue_understanding/commands/test_correct_slots_command.py new file mode 100644 index 000000000000..72234cbd13a2 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_correct_slots_command.py @@ -0,0 +1,375 @@ +from typing import Any, Dict, List +import pytest +from rasa.dialogue_understanding.commands.correct_slots_command import ( + CorrectSlotsCommand, + CorrectedSlot, +) +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.patterns.correction import ( + CorrectionPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import UserFlowStackFrame +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import SlotSet +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_command_name(): + # names of commands should not change as they are part of persisted + # trackers + assert CorrectSlotsCommand.command() == "correct slot" + + +def test_from_dict(): + assert CorrectSlotsCommand.from_dict( + {"corrected_slots": [{"name": "foo", "value": "bar"}]} + ) == CorrectSlotsCommand(corrected_slots=[CorrectedSlot(name="foo", value="bar")]) + + +def test_from_dict_fails_if_missing_name_parameter(): + with pytest.raises(ValueError): + CorrectSlotsCommand.from_dict({"corrected_slots": [{"value": "bar"}]}) + + +def test_from_dict_fails_if_missing_value_parameter(): + with pytest.raises(ValueError): + CorrectSlotsCommand.from_dict({"corrected_slots": [{"name": "foo"}]}) + + +def test_from_dict_fails_if_missing_corrected_slots_parameter(): + with pytest.raises(ValueError): + CorrectSlotsCommand.from_dict({}) + + +def test_run_command_on_tracker_without_flows(): + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = CorrectSlotsCommand(corrected_slots=[]) + + assert command.run_command_on_tracker(tracker, [], tracker) == [] + + +def test_run_command_on_tracker_correcting_previous_flow(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet("foo", "foofoo"), + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "frame_type": "regular", + "flow_id": "my_flow", + "step_id": "collect_bar", + "frame_id": "some-frame-id", + } + ], + ), + ], + ) + command = CorrectSlotsCommand( + corrected_slots=[CorrectedSlot(name="foo", value="not-foofoo")] + ) + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + # flow should still be on the stack and a correction pattern should have been added + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 2 + + assert dialogue_stack_dump[1]["type"] == "pattern_correction" + assert dialogue_stack_dump[1]["flow_id"] == "pattern_correction" + assert dialogue_stack_dump[1]["step_id"] == "START" + assert dialogue_stack_dump[1]["corrected_slots"] == {"foo": "not-foofoo"} + assert dialogue_stack_dump[1]["reset_flow_id"] == "my_flow" + assert dialogue_stack_dump[1]["reset_step_id"] == "collect_foo" + assert dialogue_stack_dump[1]["is_reset_only"] is False + + +def test_run_command_on_tracker_correcting_current_flow(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet("foo", "foofoo"), + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "frame_type": "regular", + "flow_id": "my_flow", + "step_id": "collect_bar", + "frame_id": "some-frame-id", + } + ], + ), + ], + ) + command = CorrectSlotsCommand( + corrected_slots=[CorrectedSlot(name="bar", value="barbar")] + ) + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + # flow should still be on the stack and a correction flow should have been added + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 2 + + assert dialogue_stack_dump[1]["type"] == "pattern_correction" + assert dialogue_stack_dump[1]["flow_id"] == "pattern_correction" + assert dialogue_stack_dump[1]["step_id"] == "START" + assert dialogue_stack_dump[1]["corrected_slots"] == {"bar": "barbar"} + assert dialogue_stack_dump[1]["reset_flow_id"] == "my_flow" + assert dialogue_stack_dump[1]["reset_step_id"] == "collect_bar" + assert dialogue_stack_dump[1]["is_reset_only"] is False + + +def test_run_command_on_tracker_correcting_during_a_correction(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet("foo", "foofoo"), + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "frame_type": "regular", + "flow_id": "my_flow", + "step_id": "collect_bar", + "frame_id": "some-frame-id", + }, + { + "type": "pattern_correction", + "flow_id": "pattern_correction", + "step_id": "collect_bar", + "frame_id": "some-other-id", + "corrected_slots": {"foo": "not-foofoo"}, + "reset_flow_id": "my_flow", + "reset_step_id": "collect_foo", + "is_reset_only": False, + }, + ], + ), + ], + ) + command = CorrectSlotsCommand( + corrected_slots=[CorrectedSlot(name="bar", value="barbar")] + ) + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + # flow should still be on the stack and a correction flow should have been added + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 3 + + assert dialogue_stack_dump[1]["type"] == "pattern_correction" + assert dialogue_stack_dump[1]["flow_id"] == "pattern_correction" + assert dialogue_stack_dump[1]["step_id"] == "START" + assert dialogue_stack_dump[1]["corrected_slots"] == {"bar": "barbar"} + assert dialogue_stack_dump[1]["reset_flow_id"] == "my_flow" + assert dialogue_stack_dump[1]["reset_step_id"] == "collect_bar" + + assert dialogue_stack_dump[2]["type"] == "pattern_correction" + assert dialogue_stack_dump[2]["corrected_slots"] == {"foo": "not-foofoo"} + + +def test_index_for_correction_frame_handles_empty_stack(): + stack = DialogueStack(frames=[]) + top_flow_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + assert CorrectSlotsCommand.index_for_correction_frame(top_flow_frame, stack) == 0 + + +def test_index_for_correction_handles_non_correction_pattern_at_the_top_of_stack(): + top_flow_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ), + top_flow_frame, + ] + ) + assert CorrectSlotsCommand.index_for_correction_frame(top_flow_frame, stack) == 2 + + +def test_index_for_correction_handles_correction_pattern_at_the_top_of_stack(): + top_flow_frame = CorrectionPatternFlowStackFrame( + corrected_slots={"foo": "not-foofoo"}, + ) + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ), + top_flow_frame, + ] + ) + # new correction pattern should be inserted "under" the existing correction pattern + assert CorrectSlotsCommand.index_for_correction_frame(top_flow_frame, stack) == 1 + + +def test_end_previous_correction(): + top_flow_frame = CorrectionPatternFlowStackFrame( + corrected_slots={"foo": "not-foofoo"}, + ) + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ), + top_flow_frame, + ] + ) + CorrectSlotsCommand.end_previous_correction(top_flow_frame, stack) + # the previous pattern should be about to end + assert stack.frames[1].step_id == "NEXT:END" + # make sure the user flow has not been modified + assert stack.frames[0].step_id == "first_step" + + +def test_end_previous_correction_no_correction_present(): + top_flow_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[top_flow_frame]) + CorrectSlotsCommand.end_previous_correction(top_flow_frame, stack) + # make sure the user flow has not been modified + assert stack.frames[0].step_id == "first_step" + + +@pytest.mark.parametrize( + "updated_slots, expected_step_id", + [ + (["foo", "bar"], "collect_foo"), + (["bar", "foo"], "collect_foo"), + (["bar"], "collect_bar"), + (["foo"], "collect_foo"), + ([], None), + ], +) +def test_find_earliest_updated_collect_info( + updated_slots: List[str], expected_step_id: str +): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + next: collect_baz + - id: collect_baz + collect: baz + """ + ) + + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + step = CorrectSlotsCommand.find_earliest_updated_collect_info( + user_frame, updated_slots, all_flows + ) + if expected_step_id is not None: + assert step.id == expected_step_id + else: + assert step is None + + +@pytest.mark.parametrize( + "proposed_slots, expected", + [ + ({}, True), + ({"foo": "foofoo"}, True), + ({"bar": "barbar"}, False), + ({"foo": "foofoo", "bar": "barbar"}, False), + ], +) +def test_are_all_slots_reset_only(proposed_slots: Dict[str, Any], expected: bool): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + ask_before_filling: true + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + assert ( + CorrectSlotsCommand.are_all_slots_reset_only(proposed_slots, all_flows) + == expected + ) diff --git a/tests/dialogue_understanding/commands/test_error_command.py b/tests/dialogue_understanding/commands/test_error_command.py new file mode 100644 index 000000000000..0b2ab94140b2 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_error_command.py @@ -0,0 +1,35 @@ +from rasa.dialogue_understanding.commands.error_command import ErrorCommand +from rasa.shared.core.events import SlotSet, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert ErrorCommand.command() == "error" + + +def test_from_dict(): + assert ErrorCommand.from_dict({}) == ErrorCommand() + + +def test_run_command_on_tracker(): + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + UserUttered("hi", {"name": "greet"}), + ], + ) + command = ErrorCommand() + + events = command.run_command_on_tracker(tracker, [], tracker) + assert len(events) == 1 + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == "dialogue_stack" + assert len(dialogue_stack_event.value) == 1 + + frame = dialogue_stack_event.value[0] + assert frame["type"] == "pattern_internal_error" + assert frame["step_id"] == "START" + assert frame["flow_id"] == "pattern_internal_error" diff --git a/tests/dialogue_understanding/commands/test_handle_code_change_command.py b/tests/dialogue_understanding/commands/test_handle_code_change_command.py new file mode 100644 index 000000000000..c1868d55f47c --- /dev/null +++ b/tests/dialogue_understanding/commands/test_handle_code_change_command.py @@ -0,0 +1,103 @@ +import pytest + +from rasa.core.channels import CollectingOutputChannel +from rasa.core.nlg import TemplatedNaturalLanguageGenerator +from rasa.dialogue_understanding.commands.handle_code_change_command import ( + HandleCodeChangeCommand, +) +from rasa.core.actions.action_clean_stack import ActionCleanStack + +from rasa.dialogue_understanding.patterns.code_change import FLOW_PATTERN_CODE_CHANGE_ID +from rasa.dialogue_understanding.processor.command_processor import execute_commands +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames import ( + UserFlowStackFrame, + PatternFlowStackFrame, +) +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import SlotSet +from rasa.shared.core.flows.flow import ( + FlowsList, + START_STEP, + ContinueFlowStep, + END_STEP, +) +from rasa.shared.core.trackers import DialogueStateTracker +from tests.dialogue_understanding.commands.test_command_processor import ( + start_bar_user_uttered, + change_cases, +) +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert HandleCodeChangeCommand.command() == "handle code change" + + +def test_from_dict(): + assert HandleCodeChangeCommand.from_dict({}) == HandleCodeChangeCommand() + + +def test_run_command_on_tracker(tracker: DialogueStateTracker, all_flows: FlowsList): + command = HandleCodeChangeCommand() + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == "dialogue_stack" + assert len(dialogue_stack_event.value) == 2 + + frame = dialogue_stack_event.value[1] + assert frame["type"] == FLOW_PATTERN_CODE_CHANGE_ID + + +@pytest.fixture +def about_to_be_cleaned_tracker(tracker: DialogueStateTracker, all_flows: FlowsList): + tracker.update_with_events([start_bar_user_uttered], None) + execute_commands(tracker, all_flows) + changed_flows = flows_from_str(change_cases["step_id_changed"]) + execute_commands(tracker, changed_flows) + dialogue_stack = DialogueStack.from_tracker(tracker) + assert len(dialogue_stack.frames) == 3 + + foo_frame = dialogue_stack.frames[0] + assert isinstance(foo_frame, UserFlowStackFrame) + assert foo_frame.flow_id == "foo" + assert foo_frame.step_id == START_STEP + + bar_frame = dialogue_stack.frames[1] + assert isinstance(bar_frame, UserFlowStackFrame) + assert bar_frame.flow_id == "bar" + assert bar_frame.step_id == START_STEP + + stack_clean_frame = dialogue_stack.frames[2] + assert isinstance(stack_clean_frame, PatternFlowStackFrame) + assert stack_clean_frame.flow_id == FLOW_PATTERN_CODE_CHANGE_ID + assert stack_clean_frame.step_id == START_STEP + + return tracker + + +async def test_stack_cleaning_action(about_to_be_cleaned_tracker: DialogueStateTracker): + events = await ActionCleanStack().run( + CollectingOutputChannel(), + TemplatedNaturalLanguageGenerator({}), + about_to_be_cleaned_tracker, + Domain.empty(), + ) + about_to_be_cleaned_tracker.update_with_events(events, None) + + dialogue_stack = DialogueStack.from_tracker(about_to_be_cleaned_tracker) + assert len(dialogue_stack.frames) == 3 + + foo_frame = dialogue_stack.frames[0] + assert isinstance(foo_frame, UserFlowStackFrame) + assert foo_frame.flow_id == "foo" + assert foo_frame.step_id == ContinueFlowStep.continue_step_for_id(END_STEP) + + bar_frame = dialogue_stack.frames[1] + assert isinstance(bar_frame, UserFlowStackFrame) + assert bar_frame.flow_id == "bar" + assert bar_frame.step_id == ContinueFlowStep.continue_step_for_id(END_STEP) diff --git a/tests/dialogue_understanding/commands/test_human_handoff_command.py b/tests/dialogue_understanding/commands/test_human_handoff_command.py new file mode 100644 index 000000000000..df4e274fbe28 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_human_handoff_command.py @@ -0,0 +1,27 @@ +from rasa.dialogue_understanding.commands.human_handoff_command import ( + HumanHandoffCommand, +) +from rasa.shared.core.events import UserUttered +from rasa.shared.core.trackers import DialogueStateTracker + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert HumanHandoffCommand.command() == "human handoff" + + +def test_from_dict(): + assert HumanHandoffCommand.from_dict({}) == HumanHandoffCommand() + + +def test_run_command_on_tracker(): + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + UserUttered("hi", {"name": "greet"}), + ], + ) + command = HumanHandoffCommand() + + assert command.run_command_on_tracker(tracker, [], tracker) == [] diff --git a/tests/dialogue_understanding/commands/test_konwledge_answer_command.py b/tests/dialogue_understanding/commands/test_konwledge_answer_command.py new file mode 100644 index 000000000000..84126253d05c --- /dev/null +++ b/tests/dialogue_understanding/commands/test_konwledge_answer_command.py @@ -0,0 +1,35 @@ +from rasa.dialogue_understanding.commands.knowledge_answer_command import ( + KnowledgeAnswerCommand, +) +from rasa.shared.core.events import SlotSet, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker + + +def test_name_of_command(): + # names of commands should not change as they are part of persisted + # trackers + assert KnowledgeAnswerCommand.command() == "knowledge" + + +def test_from_dict(): + assert KnowledgeAnswerCommand.from_dict({}) == KnowledgeAnswerCommand() + + +def test_run_command_on_tracker(): + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + UserUttered("hi", {"name": "greet"}), + ], + ) + command = KnowledgeAnswerCommand() + + events = command.run_command_on_tracker(tracker, [], tracker) + assert len(events) == 1 + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == "dialogue_stack" + assert len(dialogue_stack_event.value) == 1 + + frame = dialogue_stack_event.value[0] + assert frame["type"] == "pattern_search" diff --git a/tests/dialogue_understanding/commands/test_set_slot_command.py b/tests/dialogue_understanding/commands/test_set_slot_command.py new file mode 100644 index 000000000000..3b04c5c6dc88 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_set_slot_command.py @@ -0,0 +1,228 @@ +import pytest +from rasa.dialogue_understanding.commands.set_slot_command import SetSlotCommand +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import SlotSet +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_command_name(): + # names of commands should not change as they are part of persisted + # trackers + assert SetSlotCommand.command() == "set slot" + + +def test_from_dict(): + assert SetSlotCommand.from_dict({"name": "foo", "value": "bar"}) == SetSlotCommand( + name="foo", value="bar" + ) + + +def test_from_dict_fails_if_no_parameters(): + with pytest.raises(ValueError): + SetSlotCommand.from_dict({}) + + +def test_from_dict_fails_if_value_is_missing(): + with pytest.raises(ValueError): + SetSlotCommand.from_dict({"name": "bar"}) + + +def test_from_dict_fails_if_name_is_missing(): + with pytest.raises(ValueError): + SetSlotCommand.from_dict({"value": "foo"}) + + +def test_run_command_skips_if_slot_is_set_to_same_value(): + tracker = DialogueStateTracker.from_events("test", evts=[SlotSet("foo", "bar")]) + command = SetSlotCommand(name="foo", value="bar") + + assert command.run_command_on_tracker(tracker, FlowsList(flows=[]), tracker) == [] + + +def test_run_command_sets_slot_if_asked_for(): + all_flows = flows_from_str( + """ + flows: + my_flow: + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "flow_id": "my_flow", + "step_id": "collect_foo", + "frame_id": "some-frame-id", + }, + ], + ), + ], + ) + command = SetSlotCommand(name="foo", value="foofoo") + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert events == [SlotSet("foo", "foofoo")] + + +def test_run_command_skips_set_slot_if_slot_was_not_asked_for(): + all_flows = flows_from_str( + """ + flows: + my_flow: + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + ask_before_filling: true + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "flow_id": "my_flow", + "step_id": "collect_foo", + "frame_id": "some-frame-id", + }, + ], + ), + ], + ) + command = SetSlotCommand(name="bar", value="barbar") + + # can't be set, because the collect information step requires the slot + # to be asked before it can be filled + assert command.run_command_on_tracker(tracker, all_flows, tracker) == [] + + +def test_run_command_can_set_slots_before_asking(): + all_flows = flows_from_str( + """ + flows: + my_flow: + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "flow_id": "my_flow", + "step_id": "collect_foo", + "frame_id": "some-frame-id", + }, + ], + ), + ], + ) + command = SetSlotCommand(name="bar", value="barbar") + + # CAN be set, because the collect information step does not require the slot + # to be asked before it can be filled + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert events == [SlotSet("bar", "barbar")] + + +def test_run_command_can_set_slot_that_was_already_asked_in_the_past(): + all_flows = flows_from_str( + """ + flows: + my_flow: + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "flow_id": "my_flow", + "step_id": "collect_bar", + "frame_id": "some-frame-id", + }, + ], + ), + ], + ) + # set the slot for a collect information that was asked in the past + # this isn't how we'd usually use this command as this should be converted + # to a "correction" to trigger a correction pattern rather than directly + # setting the slot. + command = SetSlotCommand(name="foo", value="foofoo") + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert events == [SlotSet("foo", "foofoo")] + + +def test_run_command_skips_setting_unknown_slot(): + all_flows = flows_from_str( + """ + flows: + my_flow: + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + """ + ) + + tracker = DialogueStateTracker.from_events( + "test", + evts=[ + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "flow_id": "my_flow", + "step_id": "collect_bar", + "frame_id": "some-frame-id", + }, + ], + ), + ], + ) + # set the slot for a collect information that was asked in the past + command = SetSlotCommand(name="unknown", value="unknown") + + assert command.run_command_on_tracker(tracker, all_flows, tracker) == [] diff --git a/tests/dialogue_understanding/commands/test_start_flow_command.py b/tests/dialogue_understanding/commands/test_start_flow_command.py new file mode 100644 index 000000000000..8e411576dfa6 --- /dev/null +++ b/tests/dialogue_understanding/commands/test_start_flow_command.py @@ -0,0 +1,208 @@ +import pytest +from rasa.dialogue_understanding.commands.start_flow_command import StartFlowCommand +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import SlotSet +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_command_name(): + # names of commands should not change as they are part of persisted + # trackers + assert StartFlowCommand.command() == "start flow" + + +def test_from_dict(): + assert StartFlowCommand.from_dict({"flow": "test"}) == StartFlowCommand(flow="test") + + +def test_from_dict_fails_if_parameter_is_missing(): + with pytest.raises(ValueError): + StartFlowCommand.from_dict({}) + + +def test_run_command_on_tracker(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = StartFlowCommand(flow="foo") + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 1 + assert dialogue_stack_dump[0]["frame_type"] == "regular" + assert dialogue_stack_dump[0]["flow_id"] == "foo" + assert dialogue_stack_dump[0]["step_id"] == "START" + assert dialogue_stack_dump[0].get("frame_id") is not None + + +def test_run_start_flow_that_does_not_exist(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = StartFlowCommand(flow="bar") + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 0 + + +def test_run_start_flow_that_is_already_on_the_stack(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + tracker.update( + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "frame_type": "regular", + "flow_id": "foo", + "step_id": "START", + "frame_id": "test", + } + ], + ) + ) + command = StartFlowCommand(flow="foo") + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 0 + + +def test_run_start_flow_which_is_a_pattern(): + all_flows = flows_from_str( + """ + flows: + pattern_foo: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + command = StartFlowCommand(flow="pattern_foo") + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 0 + + +def test_run_start_flow_interrupting_existing_flow(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + bar: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + tracker.update( + SlotSet( + DIALOGUE_STACK_SLOT, + [ + { + "type": "flow", + "frame_type": "regular", + "flow_id": "foo", + "step_id": "START", + "frame_id": "test", + } + ], + ) + ) + command = StartFlowCommand(flow="bar") + + events = command.run_command_on_tracker(tracker, all_flows, tracker) + assert len(events) == 1 + + dialogue_stack_event = events[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 2 + assert dialogue_stack_dump[1]["frame_type"] == "interrupt" + assert dialogue_stack_dump[1]["flow_id"] == "bar" + assert dialogue_stack_dump[1]["step_id"] == "START" + assert dialogue_stack_dump[1].get("frame_id") is not None + + +def test_run_start_flow_with_multiple_flows(): + all_flows = flows_from_str( + """ + flows: + foo: + steps: + - id: first_step + action: action_listen + bar: + steps: + - id: first_step + action: action_listen + """ + ) + + tracker = DialogueStateTracker.from_events("test", evts=[]) + + events_bar = StartFlowCommand(flow="bar").run_command_on_tracker( + tracker, all_flows, tracker + ) + + updated_tracker = tracker.copy() + updated_tracker.update_with_events(events_bar, domain=None) + events_foo = StartFlowCommand(flow="foo").run_command_on_tracker( + updated_tracker, all_flows, tracker + ) + + assert len(events_foo) == 1 + + dialogue_stack_event = events_foo[0] + assert isinstance(dialogue_stack_event, SlotSet) + assert dialogue_stack_event.key == DIALOGUE_STACK_SLOT + + dialogue_stack_dump = dialogue_stack_event.value + assert isinstance(dialogue_stack_dump, list) and len(dialogue_stack_dump) == 2 + + # both frames should be regular if they are started at the same time + assert dialogue_stack_dump[1]["frame_type"] == "regular" + assert dialogue_stack_dump[1]["flow_id"] == "foo" + assert dialogue_stack_dump[0]["frame_type"] == "regular" + assert dialogue_stack_dump[0]["flow_id"] == "bar" diff --git a/tests/dialogue_understanding/generator/__init__.py b/tests/dialogue_understanding/generator/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/dialogue_understanding/generator/rendered_prompt.txt b/tests/dialogue_understanding/generator/rendered_prompt.txt new file mode 100644 index 000000000000..d9e9fd4c14e7 --- /dev/null +++ b/tests/dialogue_understanding/generator/rendered_prompt.txt @@ -0,0 +1,45 @@ +Your task is to analyze the current conversation context and generate a list of actions to start new business processes that we call flows, to extract slots, or respond to small talk and knowledge requests. + +These are the flows that can be started, with their description and slots: + +test_flow: some description + slot: test_slot + + +=== +Here is what happened previously in the conversation: +USER: Hello +AI: Hi +USER: some message + +=== + +You are currently not in any flow and so there are no active slots. +This means you can only set a slot if you first start a flow that requires that slot. + +If you start a flow, first start the flow and then optionally fill that flow's slots with information the user provided in their message. + +The user just said """some message""". + +=== +Based on this information generate a list of actions you want to take. Your job is to start flows and to fill slots where appropriate. Any logic of what happens afterwards is handled by the flow engine. These are your available actions: +* Slot setting, described by "SetSlot(slot_name, slot_value)". An example would be "SetSlot(recipient, Freddy)" +* Starting another flow, described by "StartFlow(flow_name)". An example would be "StartFlow(transfer_money)" +* Cancelling the current flow, described by "CancelFlow()" +* Clarifying which flow should be started. An example would be Clarify(list_contacts, add_contact, remove_contact) if the user just wrote "contacts" and there are multiple potential candidates. It also works with a single flow name to confirm you understood correctly, as in Clarify(transfer_money). +* Responding to knowledge-oriented user messages, described by "SearchAndReply()" +* Responding to a casual, non-task-oriented user message, described by "ChitChat()". +* Handing off to a human, in case the user seems frustrated or explicitly asks to speak to one, described by "HumanHandoff()". + +=== +Write out the actions you want to take, one per line, in the order they should take place. +Do not fill slots with abstract values or placeholders. +Only use information provided by the user. +Only start a flow if it's completely clear what the user wants. Imagine you were a person reading this message. If it's not 100% clear, clarify the next step. +Don't be overly confident. Take a conservative approach and clarify before proceeding. +If the user asks for two things which seem contradictory, clarify before starting a flow. +Strictly adhere to the provided action types listed above. +Focus on the last message and take it one step at a time. +Use the previous conversation steps only to aid understanding. + +Your action list: \ No newline at end of file diff --git a/tests/dialogue_understanding/generator/test_command_generator.py b/tests/dialogue_understanding/generator/test_command_generator.py new file mode 100644 index 000000000000..8a5ea3a5c394 --- /dev/null +++ b/tests/dialogue_understanding/generator/test_command_generator.py @@ -0,0 +1,44 @@ +from typing import Optional, List + +import pytest + +from rasa.dialogue_understanding.commands import Command +from rasa.dialogue_understanding.generator.command_generator import CommandGenerator +from rasa.dialogue_understanding.commands.chit_chat_answer_command import ( + ChitChatAnswerCommand, +) +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.constants import TEXT, COMMANDS +from rasa.shared.nlu.training_data.message import Message + + +class WackyCommandGenerator(CommandGenerator): + def predict_commands( + self, + message: Message, + flows: FlowsList, + tracker: Optional[DialogueStateTracker] = None, + ) -> List[Command]: + if message.get(TEXT) == "Hi": + raise ValueError("Message too banal - I am quitting.") + else: + return [ChitChatAnswerCommand()] + + +def test_command_generator_catches_processing_errors(): + generator = WackyCommandGenerator() + messages = [Message.build("Hi"), Message.build("What is your purpose?")] + generator.process(messages, FlowsList([])) + commands = [m.get(COMMANDS) for m in messages] + + assert len(commands[0]) == 0 + assert len(commands[1]) == 1 + assert commands[1][0]["command"] == ChitChatAnswerCommand.command() + + +def test_command_generator_still_throws_not_implemented_error(): + # This test can be removed if the predict_commands method stops to be abstract + generator = CommandGenerator() + with pytest.raises(NotImplementedError): + generator.process([Message.build("test")], FlowsList([])) diff --git a/tests/dialogue_understanding/generator/test_llm_command_generator.py b/tests/dialogue_understanding/generator/test_llm_command_generator.py new file mode 100644 index 000000000000..56c50fdcf8dd --- /dev/null +++ b/tests/dialogue_understanding/generator/test_llm_command_generator.py @@ -0,0 +1,468 @@ +import uuid + +from typing import Optional, Any +from unittest.mock import Mock, patch + +import pytest +from _pytest.tmpdir import TempPathFactory + +from structlog.testing import capture_logs + +from rasa.dialogue_understanding.generator.llm_command_generator import ( + LLMCommandGenerator, +) +from rasa.dialogue_understanding.commands import ( + Command, + ErrorCommand, + SetSlotCommand, + CancelFlowCommand, + StartFlowCommand, + HumanHandoffCommand, + ChitChatAnswerCommand, + KnowledgeAnswerCommand, + ClarifyCommand, +) +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.events import BotUttered, SlotSet, UserUttered +from rasa.shared.core.flows.flow import ( + CollectInformationFlowStep, + FlowsList, + SlotRejection, +) +from rasa.shared.core.slots import ( + Slot, + BooleanSlot, + CategoricalSlot, + FloatSlot, + TextSlot, +) +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.nlu.training_data.message import Message +from tests.utilities import flows_from_str + + +EXPECTED_PROMPT_PATH = "./tests/dialogue_understanding/generator/rendered_prompt.txt" + + +class TestLLMCommandGenerator: + """Tests for the LLMCommandGenerator.""" + + @pytest.fixture + def command_generator(self): + """Create an LLMCommandGenerator.""" + return LLMCommandGenerator.create( + config={}, resource=Mock(), model_storage=Mock(), execution_context=Mock() + ) + + @pytest.fixture + def flows(self) -> FlowsList: + """Create a FlowsList.""" + return flows_from_str( + """ + flows: + test_flow: + steps: + - id: first_step + action: action_listen + """ + ) + + @pytest.fixture(scope="session") + def resource(self) -> Resource: + return Resource(uuid.uuid4().hex) + + @pytest.fixture(scope="session") + def model_storage(self, tmp_path_factory: TempPathFactory) -> ModelStorage: + return LocalModelStorage(tmp_path_factory.mktemp(uuid.uuid4().hex)) + + async def test_llm_command_generator_prompt_init_custom( + self, model_storage: ModelStorage, resource: Resource + ) -> None: + generator = LLMCommandGenerator( + {"prompt": "data/test_prompt_templates/test_prompt.jinja2"}, + model_storage, + resource, + ) + assert generator.prompt_template.startswith("This is a test prompt.") + + async def test_llm_command_generator_prompt_init_default( + self, model_storage: ModelStorage, resource: Resource + ) -> None: + generator = LLMCommandGenerator({}, model_storage, resource) + assert generator.prompt_template.startswith( + "Your task is to analyze the current conversation" + ) + + def test_predict_commands_with_no_flows( + self, command_generator: LLMCommandGenerator + ): + """Test that predict_commands returns an empty list when flows is None.""" + # Given + empty_flows = FlowsList([]) + # When + predicted_commands = command_generator.predict_commands( + Mock(), flows=empty_flows, tracker=Mock() + ) + # Then + assert not predicted_commands + + def test_predict_commands_with_no_tracker( + self, command_generator: LLMCommandGenerator + ): + """Test that predict_commands returns an empty list when tracker is None.""" + # When + predicted_commands = command_generator.predict_commands( + Mock(), flows=Mock(), tracker=None + ) + # Then + assert not predicted_commands + + def test_generate_action_list_calls_llm_factory_correctly( + self, + command_generator: LLMCommandGenerator, + ): + """Test that _generate_action_list calls llm correctly.""" + # Given + llm_config = { + "_type": "openai", + "request_timeout": 7, + "temperature": 0.0, + "model_name": "gpt-4", + } + # When + with patch( + "rasa.dialogue_understanding.generator.llm_command_generator.llm_factory", + Mock(), + ) as mock_llm_factory: + command_generator._generate_action_list_using_llm("some prompt") + # Then + mock_llm_factory.assert_called_once_with(None, llm_config) + + def test_generate_action_list_calls_llm_correctly( + self, + command_generator: LLMCommandGenerator, + ): + """Test that _generate_action_list calls llm correctly.""" + # Given + with patch( + "rasa.dialogue_understanding.generator.llm_command_generator.llm_factory", + Mock(), + ) as mock_llm_factory: + mock_llm_factory.return_value = Mock() + # When + command_generator._generate_action_list_using_llm("some prompt") + # Then + mock_llm_factory.return_value.assert_called_once_with("some prompt") + + def test_generate_action_list_catches_llm_exception( + self, + command_generator: LLMCommandGenerator, + ): + """Test that _generate_action_list calls llm correctly.""" + # When + mock_llm = Mock(side_effect=Exception("some exception")) + with patch( + "rasa.dialogue_understanding.generator.llm_command_generator.llm_factory", + Mock(return_value=mock_llm), + ): + with capture_logs() as logs: + command_generator._generate_action_list_using_llm("some prompt") + # Then + print(logs) + assert len(logs) == 1 + assert logs[0]["event"] == "llm_command_generator.llm.error" + + def test_render_template( + self, + command_generator: LLMCommandGenerator, + ): + """Test that render_template renders the correct template string.""" + # Given + test_message = Message.build(text="some message") + test_slot = TextSlot( + name="test_slot", + mappings=[{}], + initial_value=None, + influence_conversation=False, + ) + test_tracker = DialogueStateTracker.from_events( + sender_id="test", + evts=[UserUttered("Hello"), BotUttered("Hi")], + slots=[test_slot], + ) + test_flows = flows_from_str( + """ + flows: + test_flow: + description: some description + steps: + - id: first_step + collect: test_slot + """ + ) + with open(EXPECTED_PROMPT_PATH, "r", encoding="unicode_escape") as f: + expected_template = f.readlines() + # When + rendered_template = command_generator.render_template( + message=test_message, tracker=test_tracker, flows=test_flows + ) + # Then + for rendered_line, expected_line in zip( + rendered_template.splitlines(True), expected_template + ): + assert rendered_line == expected_line + + @pytest.mark.parametrize( + "input_action, expected_command", + [ + (None, [ErrorCommand()]), + ( + "SetSlot(transfer_money_amount_of_money, )", + [SetSlotCommand(name="transfer_money_amount_of_money", value=None)], + ), + ("SetSlot(flow_name, some_flow)", [StartFlowCommand(flow="some_flow")]), + ("StartFlow(check_balance)", [StartFlowCommand(flow="check_balance")]), + ("CancelFlow()", [CancelFlowCommand()]), + ("ChitChat()", [ChitChatAnswerCommand()]), + ("SearchAndReply()", [KnowledgeAnswerCommand()]), + ("HumanHandoff()", [HumanHandoffCommand()]), + ("Clarify(transfer_money)", [ClarifyCommand(options=["transfer_money"])]), + ( + "Clarify(list_contacts, add_contact, remove_contact)", + [ + ClarifyCommand( + options=["list_contacts", "add_contact", "remove_contact"] + ) + ], + ), + ( + "Here is a list of commands:\nSetSlot(flow_name, some_flow)\n", + [StartFlowCommand(flow="some_flow")], + ), + ( + """SetSlot(flow_name, some_flow) + SetSlot(transfer_money_amount_of_money,)""", + [ + StartFlowCommand(flow="some_flow"), + SetSlotCommand(name="transfer_money_amount_of_money", value=None), + ], + ), + ], + ) + def test_parse_commands_identifies_correct_command( + self, + input_action: Optional[str], + expected_command: Command, + ): + """Test that parse_commands identifies the correct commands.""" + # When + with patch.object( + LLMCommandGenerator, "coerce_slot_value", Mock(return_value=None) + ): + parsed_commands = LLMCommandGenerator.parse_commands(input_action, Mock()) + # Then + assert parsed_commands == expected_command + + @pytest.mark.parametrize( + "slot_name, slot, slot_value, expected_output", + [ + ("some_other_slot", FloatSlot("some_float", []), None, None), + ("some_float", FloatSlot("some_float", []), 40, 40.0), + ("some_float", FloatSlot("some_float", []), 40.0, 40.0), + ("some_text", TextSlot("some_text", []), "fourty", "fourty"), + ("some_bool", BooleanSlot("some_bool", []), "True", True), + ("some_bool", BooleanSlot("some_bool", []), "false", False), + ], + ) + def test_coerce_slot_value( + self, + slot_name: str, + slot: Slot, + slot_value: Any, + expected_output: Any, + ): + """Test that coerce_slot_value coerces the slot value correctly.""" + # Given + tracker = DialogueStateTracker.from_events("test", evts=[], slots=[slot]) + # When + coerced_value = LLMCommandGenerator.coerce_slot_value( + slot_value, slot_name, tracker + ) + # Then + assert coerced_value == expected_output + + @pytest.mark.parametrize( + "input_value, expected_output", + [ + ("text", "text"), + (" text ", "text"), + ('"text"', "text"), + ("'text'", "text"), + ("' \"text' \" ", "text"), + ("", ""), + ], + ) + def test_clean_extracted_value(self, input_value: str, expected_output: str): + """Test that clean_extracted_value removes + the leading and trailing whitespaces. + """ + # When + cleaned_value = LLMCommandGenerator.clean_extracted_value(input_value) + # Then + assert cleaned_value == expected_output + + @pytest.mark.parametrize( + "input_value, expected_truthiness", + [ + ("", False), + (" ", False), + ("none", False), + ("some text", False), + ("[missing information]", True), + ("[missing]", True), + ("None", True), + ("undefined", True), + ("null", True), + ], + ) + def test_is_none_value(self, input_value: str, expected_truthiness: bool): + """Test that is_none_value returns True when the value is None.""" + assert LLMCommandGenerator.is_none_value(input_value) == expected_truthiness + + @pytest.mark.parametrize( + "slot, slot_name, expected_output", + [ + (TextSlot("test_slot", [], initial_value="hello"), "test_slot", "hello"), + (TextSlot("test_slot", []), "some_other_slot", "undefined"), + ], + ) + def test_slot_value(self, slot: Slot, slot_name: str, expected_output: str): + """Test that slot_value returns the correct string.""" + # Given + tracker = DialogueStateTracker.from_events("test", evts=[], slots=[slot]) + # When + slot_value = LLMCommandGenerator.get_slot_value(tracker, slot_name) + + assert slot_value == expected_output + + @pytest.mark.parametrize( + "input_slot, expected_slot_values", + [ + (FloatSlot("test_slot", []), None), + (TextSlot("test_slot", []), None), + (BooleanSlot("test_slot", []), "[True, False]"), + ( + CategoricalSlot("test_slot", [], values=["Value1", "Value2"]), + "['value1', 'value2']", + ), + ], + ) + def test_allowed_values_for_slot( + self, + command_generator: LLMCommandGenerator, + input_slot: Slot, + expected_slot_values: Optional[str], + ): + """Test that allowed_values_for_slot returns the correct values.""" + # When + allowed_values = command_generator.allowed_values_for_slot(input_slot) + # Then + assert allowed_values == expected_slot_values + + @pytest.fixture + def collect_info_step(self) -> CollectInformationFlowStep: + """Create a CollectInformationFlowStep.""" + return CollectInformationFlowStep( + collect="test_slot", + idx=0, + ask_before_filling=True, + utter="hello", + rejections=[SlotRejection("test_slot", "some rejection")], + custom_id="collect", + description="test_slot", + metadata={}, + next="next_step", + ) + + def test_is_extractable_with_no_slot( + self, + command_generator: LLMCommandGenerator, + collect_info_step: CollectInformationFlowStep, + ): + """Test that is_extractable returns False + when there are no slots to be filled. + """ + # Given + tracker = DialogueStateTracker.from_events(sender_id="test", evts=[], slots=[]) + # When + is_extractable = command_generator.is_extractable(collect_info_step, tracker) + # Then + assert not is_extractable + + def test_is_extractable_when_slot_can_be_filled_without_asking( + self, + command_generator: LLMCommandGenerator, + ): + """Test that is_extractable returns True when + collect_information slot can be filled. + """ + # Given + tracker = DialogueStateTracker.from_events( + sender_id="test", evts=[], slots=[TextSlot(name="test_slot", mappings=[])] + ) + collect_info_step = CollectInformationFlowStep( + collect="test_slot", + ask_before_filling=False, + utter="hello", + rejections=[SlotRejection("test_slot", "some rejection")], + custom_id="collect_information", + idx=0, + description="test_slot", + metadata={}, + next="next_step", + ) + # When + is_extractable = command_generator.is_extractable(collect_info_step, tracker) + # Then + assert is_extractable + + def test_is_extractable_when_slot_has_already_been_set( + self, + command_generator: LLMCommandGenerator, + collect_info_step: CollectInformationFlowStep, + ): + """Test that is_extractable returns True + when collect_information can be filled. + """ + # Given + slot = TextSlot(name="test_slot", mappings=[]) + tracker = DialogueStateTracker.from_events( + sender_id="test", evts=[SlotSet("test_slot", "hello")], slots=[slot] + ) + # When + is_extractable = command_generator.is_extractable(collect_info_step, tracker) + # Then + assert is_extractable + + def test_is_extractable_with_current_step( + self, + command_generator: LLMCommandGenerator, + collect_info_step: CollectInformationFlowStep, + ): + """Test that is_extractable returns True when the current step is a collect + information step and matches the information step. + """ + # Given + tracker = DialogueStateTracker.from_events( + sender_id="test", + evts=[UserUttered("Hello"), BotUttered("Hi")], + slots=[TextSlot(name="test_slot", mappings=[])], + ) + # When + is_extractable = command_generator.is_extractable( + collect_info_step, tracker, current_step=collect_info_step + ) + # Then + assert is_extractable diff --git a/tests/dialogue_understanding/stack/__init__.py b/tests/dialogue_understanding/stack/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/dialogue_understanding/stack/frames/__init__.py b/tests/dialogue_understanding/stack/frames/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/dialogue_understanding/stack/frames/test_chit_chat_frame.py b/tests/dialogue_understanding/stack/frames/test_chit_chat_frame.py new file mode 100644 index 000000000000..7e74ad1c87ad --- /dev/null +++ b/tests/dialogue_understanding/stack/frames/test_chit_chat_frame.py @@ -0,0 +1,13 @@ +from rasa.dialogue_understanding.stack.frames.chit_chat_frame import ChitChatStackFrame + + +def test_chit_chat_frame_type(): + # types should be stable as they are persisted as part of the tracker + frame = ChitChatStackFrame(frame_id="test") + assert frame.type() == "chitchat" + + +def test_chit_chat_frame_from_dict(): + frame = ChitChatStackFrame.from_dict({"frame_id": "test"}) + assert frame.frame_id == "test" + assert frame.type() == "chitchat" diff --git a/tests/dialogue_understanding/stack/frames/test_dialogue_stack_frame.py b/tests/dialogue_understanding/stack/frames/test_dialogue_stack_frame.py new file mode 100644 index 000000000000..155791aca2f9 --- /dev/null +++ b/tests/dialogue_understanding/stack/frames/test_dialogue_stack_frame.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass +from typing import Any, Dict + +import pytest +from rasa.dialogue_understanding.stack.frames.dialogue_stack_frame import ( + DialogueStackFrame, + InvalidStackFrameType, + generate_stack_frame_id, +) + + +@dataclass +class MockStackFrame(DialogueStackFrame): + @classmethod + def type(cls) -> str: + return "mock" + + +@dataclass +class MockStackFrameWithAdditionalProperty(DialogueStackFrame): + + foo: str = "" + + @classmethod + def type(cls) -> str: + return "mock_with_additional_property" + + @staticmethod + def from_dict(data: Dict[str, Any]) -> DialogueStackFrame: + return MockStackFrameWithAdditionalProperty( + frame_id=data["frame_id"], foo=data["foo"] + ) + + +def test_generate_stack_frame_id_generates_different_ids(): + assert generate_stack_frame_id() != generate_stack_frame_id() + + +def test_dialogue_stack_frame_as_dict(): + frame = MockStackFrame(frame_id="test") + + assert frame.as_dict() == {"frame_id": "test", "type": "mock"} + + +def test_dialogue_stack_frame_as_dict_contains_additional_attributes(): + frame = MockStackFrameWithAdditionalProperty(foo="foofoo", frame_id="test") + + assert frame.as_dict() == { + "frame_id": "test", + "type": "mock_with_additional_property", + "foo": "foofoo", + } + + +def test_dialogue_stack_frame_context_as_dict(): + frame = MockStackFrameWithAdditionalProperty(foo="foofoo", frame_id="test") + + assert frame.context_as_dict([]) == { + "frame_id": "test", + "type": "mock_with_additional_property", + "foo": "foofoo", + } + + +def test_create_typed_frame(): + frame = MockStackFrameWithAdditionalProperty(foo="foofoo", frame_id="test") + + assert DialogueStackFrame.create_typed_frame(frame.as_dict()) == frame + + +def test_create_typed_frame_with_unknown_type(): + + with pytest.raises(InvalidStackFrameType): + DialogueStackFrame.create_typed_frame({"type": "unknown"}) diff --git a/tests/dialogue_understanding/stack/frames/test_flow_frame.py b/tests/dialogue_understanding/stack/frames/test_flow_frame.py new file mode 100644 index 000000000000..b07940fa8e77 --- /dev/null +++ b/tests/dialogue_understanding/stack/frames/test_flow_frame.py @@ -0,0 +1,136 @@ +import pytest +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import ( + InvalidFlowIdException, + InvalidFlowStackFrameType, + InvalidFlowStepIdException, + UserFlowStackFrame, + FlowStackFrameType, +) +from rasa.shared.core.flows.flow import ( + ActionFlowStep, + Flow, + FlowLinks, + FlowsList, + StepSequence, +) + + +def test_flow_frame_type(): + # types should be stable as they are persisted as part of the tracker + frame = UserFlowStackFrame(frame_id="test", flow_id="foo", step_id="bar") + assert frame.type() == "flow" + + +def test_flow_frame_from_dict(): + frame = UserFlowStackFrame.from_dict( + {"frame_id": "test", "flow_id": "foo", "step_id": "bar"} + ) + assert frame.frame_id == "test" + assert frame.flow_id == "foo" + assert frame.step_id == "bar" + assert frame.type() == "flow" + assert frame.frame_type == FlowStackFrameType.REGULAR + + +@pytest.mark.parametrize( + "typ,expected_type", + [ + ("regular", FlowStackFrameType.REGULAR), + ("link", FlowStackFrameType.LINK), + ("interrupt", FlowStackFrameType.INTERRUPT), + ], +) +def test_flow_stack_frame_type_from_str(typ: str, expected_type: FlowStackFrameType): + assert FlowStackFrameType.from_str(typ) == expected_type + + +def test_flow_stack_frame_type_from_str_invalid(): + with pytest.raises(InvalidFlowStackFrameType): + FlowStackFrameType.from_str("invalid") + + +def test_flow_stack_frame_type_from_str_none(): + assert FlowStackFrameType.from_str(None) == FlowStackFrameType.REGULAR + + +def test_flow_get_flow(): + frame = UserFlowStackFrame(frame_id="test", flow_id="foo", step_id="bar") + flow = Flow( + id="foo", + step_sequence=StepSequence(child_steps=[]), + name="foo flow", + description="foo flow description", + ) + all_flows = FlowsList(flows=[flow]) + assert frame.flow(all_flows) == flow + + +def test_flow_get_flow_non_existant_id(): + frame = UserFlowStackFrame(frame_id="test", flow_id="unknown", step_id="bar") + all_flows = FlowsList( + flows=[ + Flow( + id="foo", + step_sequence=StepSequence(child_steps=[]), + name="foo flow", + description="foo flow description", + ) + ] + ) + with pytest.raises(InvalidFlowIdException): + frame.flow(all_flows) + + +def test_flow_get_step(): + frame = UserFlowStackFrame(frame_id="test", flow_id="foo", step_id="my_step") + step = ActionFlowStep( + idx=1, + action="action_listen", + custom_id="my_step", + description=None, + metadata={}, + next=FlowLinks(links=[]), + ) + all_flows = FlowsList( + flows=[ + Flow( + id="foo", + step_sequence=StepSequence(child_steps=[step]), + name="foo flow", + description="foo flow description", + ) + ] + ) + assert frame.step(all_flows) == step + + +def test_flow_get_step_non_existant_id(): + frame = UserFlowStackFrame(frame_id="test", flow_id="foo", step_id="unknown") + all_flows = FlowsList( + flows=[ + Flow( + id="foo", + step_sequence=StepSequence(child_steps=[]), + name="foo flow", + description="foo flow description", + ) + ] + ) + with pytest.raises(InvalidFlowStepIdException): + frame.step(all_flows) + + +def test_flow_get_step_non_existant_flow_id(): + frame = UserFlowStackFrame(frame_id="test", flow_id="unknown", step_id="unknown") + all_flows = FlowsList( + flows=[ + Flow( + id="foo", + step_sequence=StepSequence(child_steps=[]), + name="foo flow", + description="foo flow description", + ) + ] + ) + with pytest.raises(InvalidFlowIdException): + frame.step(all_flows) diff --git a/tests/dialogue_understanding/stack/frames/test_search_frame.py b/tests/dialogue_understanding/stack/frames/test_search_frame.py new file mode 100644 index 000000000000..249fc7e7697e --- /dev/null +++ b/tests/dialogue_understanding/stack/frames/test_search_frame.py @@ -0,0 +1,13 @@ +from rasa.dialogue_understanding.stack.frames.search_frame import SearchStackFrame + + +def test_search_frame_type(): + # types should be stable as they are persisted as part of the tracker + frame = SearchStackFrame(frame_id="test") + assert frame.type() == "search" + + +def test_search_frame_from_dict(): + frame = SearchStackFrame.from_dict({"frame_id": "test"}) + assert frame.frame_id == "test" + assert frame.type() == "search" diff --git a/tests/dialogue_understanding/stack/test_dialogue_stack.py b/tests/dialogue_understanding/stack/test_dialogue_stack.py new file mode 100644 index 000000000000..fa0859f3a314 --- /dev/null +++ b/tests/dialogue_understanding/stack/test_dialogue_stack.py @@ -0,0 +1,244 @@ +import dataclasses +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import UserFlowStackFrame +from rasa.shared.core.constants import DIALOGUE_STACK_SLOT +from rasa.shared.core.events import SlotSet + + +def test_dialogue_stack_from_dict(): + stack = DialogueStack.from_dict( + [ + { + "type": "flow", + "flow_id": "foo", + "step_id": "first_step", + "frame_id": "some-frame-id", + }, + { + "type": "pattern_collect_information", + "collect": "foo", + "frame_id": "some-other-id", + "step_id": "START", + "flow_id": "pattern_collect_information", + "utter": "utter_ask_foo", + }, + ] + ) + + assert len(stack.frames) == 2 + + assert stack.frames[0] == UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + assert stack.frames[1] == CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id", utter="utter_ask_foo" + ) + + +def test_dialogue_stack_from_dict_handles_empty(): + stack = DialogueStack.from_dict([]) + assert stack.frames == [] + + +def test_dialogue_stack_as_dict(): + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ), + CollectInformationPatternFlowStackFrame( + collect="foo", + frame_id="some-other-id", + utter="utter_ask_foo", + ), + ] + ) + + assert stack.as_dict() == [ + { + "type": "flow", + "flow_id": "foo", + "frame_type": "regular", + "step_id": "first_step", + "frame_id": "some-frame-id", + }, + { + "type": "pattern_collect_information", + "collect": "foo", + "frame_id": "some-other-id", + "step_id": "START", + "flow_id": "pattern_collect_information", + "rejections": None, + "utter": "utter_ask_foo", + }, + ] + + +def test_dialogue_stack_as_event(): + # check that the stack gets persisted as an event storing the dict + stack = DialogueStack( + frames=[ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ), + CollectInformationPatternFlowStackFrame( + collect="foo", + frame_id="some-other-id", + utter="utter_ask_foo", + ), + ] + ) + + assert stack.persist_as_event() == SlotSet(DIALOGUE_STACK_SLOT, stack.as_dict()) + + +def test_dialogue_stack_as_dict_handles_empty(): + stack = DialogueStack(frames=[]) + assert stack.as_dict() == [] + + +def test_push_to_empty_stack(): + stack = DialogueStack(frames=[]) + stack.push( + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + ) + + assert stack.frames == [ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + ] + + +def test_push_to_non_empty_stack(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + + stack = DialogueStack(frames=[user_frame]) + stack.push(pattern_frame) + assert stack.top() == pattern_frame + assert stack.frames == [user_frame, pattern_frame] + + +def test_push_to_index(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + + stack = DialogueStack(frames=[user_frame]) + stack.push(pattern_frame, index=0) + assert stack.top() == user_frame + assert stack.frames == [pattern_frame, user_frame] + + +def test_dialogue_stack_update(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[user_frame]) + updated_user_frame = dataclasses.replace(user_frame, step_id="second_step") + stack.update(updated_user_frame) + assert stack.top() == updated_user_frame + assert stack.frames == [updated_user_frame] + + +def test_update_empty_stack(): + stack = DialogueStack(frames=[]) + stack.update( + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + ) + + assert stack.frames == [ + UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + ] + + +def test_pop_frame(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + + stack = DialogueStack(frames=[]) + stack.push(user_frame) + stack.push(pattern_frame) + assert stack.pop() == pattern_frame + assert stack.frames == [user_frame] + + +def test_top_empty_stack(): + stack = DialogueStack(frames=[]) + assert stack.top() is None + + +def test_top(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + + stack = DialogueStack(frames=[]) + stack.push(user_frame) + stack.push(pattern_frame) + assert stack.top() == pattern_frame + + +def test_get_current_context_empty_stack(): + stack = DialogueStack(frames=[]) + assert stack.current_context() == {} + + +def test_get_current_context(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id", utter="utter_ask_foo" + ) + + stack = DialogueStack(frames=[]) + stack.push(user_frame) + stack.push(pattern_frame) + assert stack.current_context() == { + "flow_id": "foo", + "frame_id": "some-frame-id", + "frame_type": "regular", + "step_id": "first_step", + "type": "flow", + "collect": "foo", + "utter": "utter_ask_foo", + "rejections": None, + } + + +def test_is_empty_on_empty(): + stack = DialogueStack(frames=[]) + assert stack.is_empty() is True + + +def test_is_empty_on_non_empty(): + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[user_frame]) + assert stack.is_empty() is False diff --git a/tests/dialogue_understanding/stack/test_utils.py b/tests/dialogue_understanding/stack/test_utils.py new file mode 100644 index 000000000000..ecf4c206fa23 --- /dev/null +++ b/tests/dialogue_understanding/stack/test_utils.py @@ -0,0 +1,251 @@ +from rasa.dialogue_understanding.patterns.collect_information import ( + CollectInformationPatternFlowStackFrame, +) +from rasa.dialogue_understanding.stack.dialogue_stack import DialogueStack +from rasa.dialogue_understanding.stack.frames.chit_chat_frame import ChitChatStackFrame +from rasa.dialogue_understanding.stack.frames.flow_stack_frame import UserFlowStackFrame +from rasa.dialogue_understanding.stack.utils import ( + end_top_user_flow, + filled_slots_for_active_flow, + top_flow_frame, + top_user_flow_frame, + user_flows_on_the_stack, +) +from rasa.shared.core.flows.yaml_flows_io import flows_from_str + + +def test_top_flow_frame_ignores_pattern(): + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + stack = DialogueStack( + frames=[ + user_frame, + pattern_frame, + ] + ) + + assert top_flow_frame(stack, ignore_collect_information_pattern=True) == user_frame + + +def test_top_flow_frame_uses_pattern(): + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[user_frame, pattern_frame]) + + assert ( + top_flow_frame(stack, ignore_collect_information_pattern=False) == pattern_frame + ) + + +def test_top_flow_frame_handles_empty(): + stack = DialogueStack(frames=[]) + assert top_flow_frame(stack) is None + + +def test_top_user_flow_frame(): + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[user_frame, pattern_frame]) + + assert top_user_flow_frame(stack) == user_frame + + +def test_top_user_flow_frame_handles_empty(): + stack = DialogueStack(frames=[]) + assert top_user_flow_frame(stack) is None + + +def test_user_flows_on_the_stack(): + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + user_frame = UserFlowStackFrame( + flow_id="foo", step_id="first_step", frame_id="some-frame-id" + ) + another_user_frame = UserFlowStackFrame( + flow_id="bar", step_id="first_step", frame_id="some-other-other-id" + ) + stack = DialogueStack(frames=[user_frame, pattern_frame, another_user_frame]) + + assert user_flows_on_the_stack(stack) == {"foo", "bar"} + + +def test_user_flows_on_the_stack_handles_empty(): + stack = DialogueStack(frames=[]) + assert user_flows_on_the_stack(stack) == set() + + +def test_filled_slots_for_active_flow(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + next: collect_baz + - id: collect_baz + collect: baz + """ + ) + + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + stack = DialogueStack(frames=[user_frame]) + + assert filled_slots_for_active_flow(stack, all_flows) == {"foo", "bar"} + + +def test_filled_slots_for_active_flow_handles_empty(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + next: collect_baz + - id: collect_baz + collect: baz + """ + ) + + stack = DialogueStack(frames=[]) + assert filled_slots_for_active_flow(stack, all_flows) == set() + + +def test_filled_slots_for_active_flow_skips_chitchat(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + next: collect_baz + - id: collect_baz + collect: baz + """ + ) + + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + chitchat_frame = ChitChatStackFrame(frame_id="some-other-id") + stack = DialogueStack(frames=[user_frame, chitchat_frame]) + + assert filled_slots_for_active_flow(stack, all_flows) == {"foo", "bar"} + + +def test_filled_slots_for_active_flow_only_collects_till_top_most_user_flow_frame(): + all_flows = flows_from_str( + """ + flows: + my_flow: + name: foo flow + steps: + - id: collect_foo + collect: foo + next: collect_bar + - id: collect_bar + collect: bar + next: collect_baz + - id: collect_baz + collect: baz + my_other_flow: + name: foo flow + steps: + - id: collect_foo2 + collect: foo2 + next: collect_bar2 + - id: collect_bar2 + collect: bar2 + next: collect_baz2 + - id: collect_baz2 + collect: baz2 + """ + ) + + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + another_user_frame = UserFlowStackFrame( + flow_id="my_other_flow", step_id="collect_bar2", frame_id="some-other-id" + ) + stack = DialogueStack(frames=[another_user_frame, user_frame]) + + assert filled_slots_for_active_flow(stack, all_flows) == {"foo", "bar"} + + +def test_end_top_user_flow(): + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + pattern_frame = CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id" + ) + stack = DialogueStack(frames=[user_frame, pattern_frame]) + + end_top_user_flow(stack) + + assert len(stack.frames) == 2 + + assert stack.frames[0] == UserFlowStackFrame( + flow_id="my_flow", step_id="NEXT:END", frame_id="some-frame-id" + ) + assert stack.frames[1] == CollectInformationPatternFlowStackFrame( + collect="foo", frame_id="some-other-id", step_id="NEXT:END" + ) + + +def test_end_top_user_flow_only_ends_topmost_user_frame(): + user_frame = UserFlowStackFrame( + flow_id="my_flow", step_id="collect_bar", frame_id="some-frame-id" + ) + other_user_frame = UserFlowStackFrame( + flow_id="my_other_flow", step_id="collect_bar2", frame_id="some-other-id" + ) + stack = DialogueStack(frames=[other_user_frame, user_frame]) + + end_top_user_flow(stack) + + assert len(stack.frames) == 2 + + assert stack.frames[0] == UserFlowStackFrame( + flow_id="my_other_flow", step_id="collect_bar2", frame_id="some-other-id" + ) + assert stack.frames[1] == UserFlowStackFrame( + flow_id="my_flow", step_id="NEXT:END", frame_id="some-frame-id" + ) + + +def test_end_top_user_flow_handles_empty(): + stack = DialogueStack(frames=[]) + end_top_user_flow(stack) + + assert len(stack.frames) == 0 diff --git a/tests/dialogues.py b/tests/dialogues.py index 412326cc3baf..d2deefb3559c 100644 --- a/tests/dialogues.py +++ b/tests/dialogues.py @@ -272,6 +272,6 @@ TEST_DOMAINS_FOR_DIALOGUES = [ "data/test_domains/default_with_slots.yml", - "examples/formbot/domain.yml", + "examples/nlu_based/formbot/domain.yml", "data/test_moodbot/domain.yml", ] diff --git a/tests/engine/recipes/test_default_recipe.py b/tests/engine/recipes/test_default_recipe.py index 6a07adf3b9f4..387709d16470 100644 --- a/tests/engine/recipes/test_default_recipe.py +++ b/tests/engine/recipes/test_default_recipe.py @@ -528,7 +528,10 @@ def test_train_core_without_nlu_pipeline(): @pytest.mark.parametrize( "config_path, expected_keys_to_configure", [ - (Path("rasa/cli/initial_project/config.yml"), {"pipeline", "policies"}), + ( + Path("rasa/cli/project_templates/default/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"}), diff --git a/tests/engine/storage/test_local_model_storage.py b/tests/engine/storage/test_local_model_storage.py index 9324c399c948..8ed9c8fbb492 100644 --- a/tests/engine/storage/test_local_model_storage.py +++ b/tests/engine/storage/test_local_model_storage.py @@ -95,8 +95,10 @@ def test_read_long_resource_names_windows( tmp_path_factory: TempPathFactory, domain: Domain, ): + from rasa.constants import MINIMUM_COMPATIBLE_VERSION + model_dir = tmp_path_factory.mktemp("model_dir") - version = "3.5.0" + version = MINIMUM_COMPATIBLE_VERSION # full path length > 260 chars # but each component of the path needs to be below 255 chars diff --git a/tests/engine/test_caching.py b/tests/engine/test_caching.py index 68cfc3c07db9..3f98134f4c43 100644 --- a/tests/engine/test_caching.py +++ b/tests/engine/test_caching.py @@ -13,12 +13,14 @@ import rasa.shared.utils.io import rasa.shared.utils.common -from rasa.engine.caching import ( - LocalTrainingCache, +from rasa.shared.engine.caching import ( CACHE_LOCATION_ENV, DEFAULT_CACHE_NAME, CACHE_SIZE_ENV, CACHE_DB_NAME_ENV, +) +from rasa.engine.caching import ( + LocalTrainingCache, TrainingCache, ) import tests.conftest diff --git a/tests/examples/test_example_bots_training_data.py b/tests/examples/test_example_bots_training_data.py index f5790582afb1..e77b7df48990 100644 --- a/tests/examples/test_example_bots_training_data.py +++ b/tests/examples/test_example_bots_training_data.py @@ -14,23 +14,23 @@ "config_file, domain_file, data_folder, raise_slot_warning, msg", [ ( - "examples/concertbot/config.yml", - "examples/concertbot/domain.yml", - "examples/concertbot/data", + "examples/nlu_based/concertbot/config.yml", + "examples/nlu_based/concertbot/domain.yml", + "examples/nlu_based/concertbot/data", True, None, ), ( - "examples/formbot/config.yml", - "examples/formbot/domain.yml", - "examples/formbot/data", + "examples/nlu_based/formbot/config.yml", + "examples/nlu_based/formbot/domain.yml", + "examples/nlu_based/formbot/data", True, None, ), ( - "examples/knowledgebasebot/config.yml", - "examples/knowledgebasebot/domain.yml", - "examples/knowledgebasebot/data", + "examples/nlu_based/knowledgebasebot/config.yml", + "examples/nlu_based/knowledgebasebot/domain.yml", + "examples/nlu_based/knowledgebasebot/data", True, "You are using an experimental feature: " "Action 'action_query_knowledge_base'!", @@ -43,16 +43,16 @@ None, ), ( - "examples/reminderbot/config.yml", - "examples/reminderbot/domain.yml", - "examples/reminderbot/data", + "examples/nlu_based/reminderbot/config.yml", + "examples/nlu_based/reminderbot/domain.yml", + "examples/nlu_based/reminderbot/data", True, None, ), ( - "examples/rules/config.yml", - "examples/rules/domain.yml", - "examples/rules/data", + "examples/nlu_based/rules/config.yml", + "examples/nlu_based/rules/domain.yml", + "examples/nlu_based/rules/data", True, None, ), 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 index 52e5b7eecb32..0f08ab6b921a 100644 --- a/tests/graph_components/providers/test_domain_for_core_training_provider.py +++ b/tests/graph_components/providers/test_domain_for_core_training_provider.py @@ -98,7 +98,7 @@ def test_train_core_with_original_or_provided_domain_and_compare( default_execution_context: ExecutionContext, ): # Choose an example where the provider will remove a lot of information: - example = Path("examples/formbot/") + example = Path("examples/nlu_based/formbot/") training_files = [example / "data" / "rules.yml"] # Choose a configuration with a policy diff --git a/tests/graph_components/providers/test_rule_only_provider.py b/tests/graph_components/providers/test_rule_only_provider.py index 98100f36a85c..19393fbf3ede 100644 --- a/tests/graph_components/providers/test_rule_only_provider.py +++ b/tests/graph_components/providers/test_rule_only_provider.py @@ -13,8 +13,10 @@ def test_provide( ): resource = Resource("some resource") - domain = Domain.load("examples/rules/domain.yml") - trackers = rasa.core.training.load_data("examples/rules/data/rules.yml", domain) + domain = Domain.load("examples/nlu_based/rules/domain.yml") + trackers = rasa.core.training.load_data( + "examples/nlu_based/rules/data/rules.yml", domain + ) policy = RulePolicy.create( RulePolicy.get_default_config(), diff --git a/tests/graph_components/validators/test_default_recipe_validator.py b/tests/graph_components/validators/test_default_recipe_validator.py index 81216e89b275..165909767d45 100644 --- a/tests/graph_components/validators/test_default_recipe_validator.py +++ b/tests/graph_components/validators/test_default_recipe_validator.py @@ -40,7 +40,7 @@ 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.core.domain import KEY_FORMS, Domain from rasa.shared.exceptions import InvalidConfigException from rasa.shared.data import TrainingType from rasa.shared.nlu.constants import ( @@ -827,8 +827,8 @@ def test_core_raise_if_domain_contains_form_names_but_no_rule_policy_given( lambda *args, **kwargs: None, ) if should_raise: - with pytest.raises( - InvalidDomain, + with pytest.warns( + UserWarning, match="You have defined a form action, but have not added the", ): validator.validate(importer) @@ -1015,7 +1015,9 @@ def test_nlu_training_data_validation(): def test_no_warnings_with_default_project(tmp_path: Path): - rasa.utils.common.copy_directory(Path("rasa/cli/initial_project"), tmp_path) + rasa.utils.common.copy_directory( + Path("rasa/cli/project_templates/default/"), tmp_path + ) importer = TrainingDataImporter.load_from_config( config_path=str(tmp_path / "config.yml"), diff --git a/tests/nlu/test_train.py b/tests/nlu/test_train.py index c0051bbff295..683487f08d77 100644 --- a/tests/nlu/test_train.py +++ b/tests/nlu/test_train.py @@ -90,6 +90,7 @@ def pipelines_for_tests() -> List[Tuple[Text, List[Dict[Text, Any]]]]: ), ), ("fallback", as_pipeline("KeywordIntentClassifier", "FallbackClassifier")), + ("dm2", as_pipeline("LLMCommandGenerator")), ] diff --git a/tests/shared/core/test_domain.py b/tests/shared/core/test_domain.py index 0c5719ab8728..e6c313f07f42 100644 --- a/tests/shared/core/test_domain.py +++ b/tests/shared/core/test_domain.py @@ -1,5 +1,6 @@ import copy import json +import logging import re import textwrap from pathlib import Path @@ -7,6 +8,7 @@ from typing import Dict, List, Text, Any, Union, Set, Optional import pytest +from pytest import LogCaptureFixture from pytest import WarningsRecorder from rasa.shared.exceptions import YamlSyntaxException, YamlException @@ -21,12 +23,14 @@ from rasa.shared.core.slots import InvalidSlotTypeException, TextSlot from rasa.shared.core.constants import ( DEFAULT_INTENTS, + KNOWLEDGE_BASE_SLOT_NAMES, SLOT_LISTED_ITEMS, SLOT_LAST_OBJECT, SLOT_LAST_OBJECT_TYPE, DEFAULT_KNOWLEDGE_BASE_ACTION, ENTITY_LABEL_SEPARATOR, DEFAULT_ACTION_NAMES, + DEFAULT_SLOT_NAMES, ) from rasa.shared.core.domain import ( InvalidDomain, @@ -177,7 +181,7 @@ def test_create_train_data_unfeaturized_entities(): 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) == 19 + assert len(domain.action_names_or_texts) == 5 + len(DEFAULT_ACTION_NAMES) def test_avoid_action_repetition(domain: Domain): @@ -888,7 +892,7 @@ def test_domain_from_multiple_files(): "utter_default": [{"text": "default message"}], "utter_amazement": [{"text": "awesomness!"}], } - expected_slots = [ + expected_slots = list(DEFAULT_SLOT_NAMES) + [ "activate_double_simulation", "activate_simulation", "display_cure_method", @@ -913,8 +917,6 @@ def test_domain_from_multiple_files(): "humbleSelectionManagement", "humbleSelectionStatus", "offers", - "requested_slot", - "session_started_metadata", ] domain_slots = [] @@ -928,7 +930,7 @@ def test_domain_from_multiple_files(): assert expected_responses == domain.responses assert expected_forms == domain.forms assert domain.session_config.session_expiration_time == 360 - assert expected_slots == sorted(domain_slots) + assert sorted(expected_slots) == sorted(domain_slots) def test_domain_warnings(domain: Domain): @@ -2353,3 +2355,29 @@ def test_merge_yaml_domains_loads_actions_which_explicitly_need_domain(): def test_domain_responses_with_ids_are_loaded(domain_yaml, expected) -> None: domain = Domain.from_yaml(domain_yaml) assert domain.responses == expected + + +def test_domain_with_slots_without_mappings(caplog: LogCaptureFixture) -> None: + domain_yaml = """ + slots: + slot_without_mappings: + type: text + """ + with caplog.at_level(logging.WARN): + domain = Domain.from_yaml(domain_yaml) + + assert isinstance(domain.slots[0].mappings, list) + assert len(domain.slots[0].mappings) == 0 + assert ( + "Slot 'slot_without_mappings' has no mappings defined. " + "We will continue with an empty list of mappings." + ) in caplog.text + + +def test_domain_default_slots_are_marked_as_builtin(domain: Domain) -> None: + all_default_slot_names = DEFAULT_SLOT_NAMES.union(KNOWLEDGE_BASE_SLOT_NAMES) + domain_default_slots = [ + slot for slot in domain.slots if slot.name in all_default_slot_names + ] + + assert all(slot.is_builtin for slot in domain_default_slots) diff --git a/tests/shared/core/test_slot_mappings.py b/tests/shared/core/test_slot_mappings.py index 7e67f388b102..9cb2cd861d88 100644 --- a/tests/shared/core/test_slot_mappings.py +++ b/tests/shared/core/test_slot_mappings.py @@ -28,7 +28,7 @@ def test_slot_mapping_entity_is_desired(slot_name: Text, expected: bool): def test_slot_mapping_intent_is_desired(domain: Domain): - domain = Domain.from_file("examples/formbot/domain.yml") + domain = Domain.from_file("examples/nlu_based/formbot/domain.yml") tracker = DialogueStateTracker("sender_id_test", slots=domain.slots) event1 = UserUttered( text="I'd like to book a restaurant for 2 people.", @@ -103,19 +103,6 @@ def test_slot_mappings_ignored_intents_during_active_loop(): ) -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( diff --git a/tests/shared/core/test_slots.py b/tests/shared/core/test_slots.py index 25744b2cc878..efd686e48b17 100644 --- a/tests/shared/core/test_slots.py +++ b/tests/shared/core/test_slots.py @@ -156,6 +156,10 @@ def test_slot_fingerprint_uniqueness( f2 = slot.fingerprint() assert f1 != f2 + def test_slot_is_not_builtin_by_default(self, mappings: List[Dict[Text, Any]]): + slot = self.create_slot(mappings, influence_conversation=False) + assert not slot.is_builtin + class TestTextSlot(SlotTestCollection): def create_slot( 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 index cc18ea0387cb..f875db2aa178 100644 --- 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 @@ -775,12 +775,17 @@ def test_generate_training_data_with_cycles(domain: Domain): # 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 + # if we have 4 trackers, there is going to be one example more for utter_default + num_utter_default = len(training_trackers) - 1 all_label_ids = [id for ids in label_ids for id in ids] - assert Counter(all_label_ids) == {0: 6, 16: 3, 15: num_tens, 1: 2, 17: 1} + assert Counter(all_label_ids) == { + 0: 6, + domain.action_names_or_texts.index("utter_goodbye"): 3, + domain.action_names_or_texts.index("utter_default"): num_utter_default, + 1: 2, + domain.action_names_or_texts.index("utter_greet"): 1, + } def test_generate_training_data_with_unused_checkpoints(domain: Domain): diff --git a/tests/shared/importers/test_importer.py b/tests/shared/importers/test_importer.py index 66336dcfe7d4..9385106888a2 100644 --- a/tests/shared/importers/test_importer.py +++ b/tests/shared/importers/test_importer.py @@ -75,9 +75,11 @@ def test_load_from_dict( ) assert isinstance(actual, E2EImporter) - assert isinstance(actual.importer, ResponsesSyncImporter) + assert isinstance(actual._importer._importer, ResponsesSyncImporter) - actual_importers = [i.__class__ for i in actual.importer._importer._importers] + actual_importers = [ + i.__class__ for i in actual._importer._importer._importer._importers + ] assert actual_importers == expected @@ -90,8 +92,10 @@ def test_load_from_config(tmpdir: 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) + assert isinstance(importer._importer._importer, ResponsesSyncImporter) + assert isinstance( + importer._importer._importer._importer._importers[0], MultiProjectImporter + ) def test_nlu_only(project: Text): @@ -102,7 +106,7 @@ def test_nlu_only(project: Text): ) assert isinstance(actual, NluDataImporter) - assert isinstance(actual._importer, ResponsesSyncImporter) + assert isinstance(actual._importer._importer, ResponsesSyncImporter) stories = actual.get_stories() assert stories.is_empty() @@ -125,7 +129,7 @@ def test_import_nlu_training_data_from_e2e_stories( ): # The `E2EImporter` correctly wraps the underlying `CombinedDataImporter` assert isinstance(default_importer, E2EImporter) - importer_without_e2e = default_importer.importer + importer_without_e2e = default_importer._importer stories = StoryGraph( [ @@ -205,7 +209,7 @@ def mocked_stories(*_: Any, **__: Any) -> StoryGraph: return StoryGraph(stories) # Patch to return our test stories - default_importer.importer.get_stories = mocked_stories + default_importer._importer.get_stories = mocked_stories training_data = default_importer.get_nlu_data() @@ -225,7 +229,7 @@ def test_import_nlu_training_data_with_default_actions( default_importer: TrainingDataImporter, ): assert isinstance(default_importer, E2EImporter) - importer_without_e2e = default_importer.importer + importer_without_e2e = default_importer._importer # Check additional NLU training data from domain was added nlu_data = default_importer.get_nlu_data() @@ -275,7 +279,7 @@ def mocked_stories(*_: Any, **__: Any) -> StoryGraph: return stories # Patch to return our test stories - default_importer.importer.get_stories = mocked_stories + default_importer._importer.get_stories = mocked_stories domain = default_importer.get_domain() diff --git a/tests/shared/importers/test_rasa.py b/tests/shared/importers/test_rasa.py index b1cd859a28b5..a739f1c71c97 100644 --- a/tests/shared/importers/test_rasa.py +++ b/tests/shared/importers/test_rasa.py @@ -11,7 +11,8 @@ from rasa.shared.core.constants import ( DEFAULT_ACTION_NAMES, DEFAULT_INTENTS, - SESSION_START_METADATA_SLOT, + DEFAULT_SLOT_NAMES, + REQUESTED_SLOT, ) from rasa.shared.core.domain import Domain from rasa.shared.core.slots import AnySlot @@ -28,7 +29,15 @@ def test_rasa_file_importer(project: Text): domain = importer.get_domain() assert len(domain.intents) == 7 + len(DEFAULT_INTENTS) - assert domain.slots == [AnySlot(SESSION_START_METADATA_SLOT, mappings=[{}])] + default_slots = [ + AnySlot(slot_name, mappings=[{}]) + for slot_name in DEFAULT_SLOT_NAMES + if slot_name != REQUESTED_SLOT + ] + assert sorted(domain.slots, key=lambda s: s.name) == sorted( + default_slots, key=lambda s: s.name + ) + assert domain.entities == [] assert len(domain.action_names_or_texts) == 6 + len(DEFAULT_ACTION_NAMES) assert len(domain.responses) == 6 diff --git a/tests/shared/utils/test_llm.py b/tests/shared/utils/test_llm.py new file mode 100644 index 000000000000..44e00ca917c1 --- /dev/null +++ b/tests/shared/utils/test_llm.py @@ -0,0 +1,157 @@ +from rasa.shared.core.domain import Domain +from rasa.shared.core.events import BotUttered, UserUttered +from rasa.shared.core.trackers import DialogueStateTracker +from rasa.shared.utils.llm import ( + sanitize_message_for_prompt, + tracker_as_readable_transcript, + embedder_factory, + llm_factory, +) +from langchain import OpenAI +from langchain.embeddings import OpenAIEmbeddings +from pytest import MonkeyPatch + + +def test_tracker_as_readable_transcript_handles_empty_tracker(): + tracker = DialogueStateTracker(sender_id="test", slots=[]) + assert tracker_as_readable_transcript(tracker) == "" + + +def test_tracker_as_readable_transcript_handles_tracker_with_events(domain: Domain): + tracker = DialogueStateTracker(sender_id="test", slots=domain.slots) + tracker.update_with_events( + [ + UserUttered("hello"), + BotUttered("hi"), + ], + domain, + ) + assert tracker_as_readable_transcript(tracker) == ("""USER: hello\nAI: hi""") + + +def test_tracker_as_readable_transcript_handles_tracker_with_events_and_prefixes( + domain: Domain, +): + tracker = DialogueStateTracker(sender_id="test", slots=domain.slots) + tracker.update_with_events( + [ + UserUttered("hello"), + BotUttered("hi"), + ], + domain, + ) + assert tracker_as_readable_transcript( + tracker, human_prefix="FOO", ai_prefix="BAR" + ) == ("""FOO: hello\nBAR: hi""") + + +def test_tracker_as_readable_transcript_handles_tracker_with_events_and_max_turns( + domain: Domain, +): + tracker = DialogueStateTracker(sender_id="test", slots=domain.slots) + tracker.update_with_events( + [ + UserUttered("hello"), + BotUttered("hi"), + ], + domain, + ) + assert tracker_as_readable_transcript(tracker, max_turns=1) == ("""AI: hi""") + + +def test_sanitize_message_for_prompt_handles_none(): + assert sanitize_message_for_prompt(None) == "" + + +def test_sanitize_message_for_prompt_handles_empty_string(): + assert sanitize_message_for_prompt("") == "" + + +def test_sanitize_message_for_prompt_handles_string_with_newlines(): + assert sanitize_message_for_prompt("hello\nworld") == "hello world" + + +def test_llm_factory(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + llm = llm_factory(None, {"_type": "openai"}) + assert isinstance(llm, OpenAI) + + +def test_llm_factory_handles_type_without_underscore(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + llm = llm_factory({"type": "openai"}, {}) + assert isinstance(llm, OpenAI) + + +def test_llm_factory_uses_custom_type(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + llm = llm_factory({"type": "openai"}, {"_type": "foobar"}) + assert isinstance(llm, OpenAI) + + +def test_llm_factory_ignores_irrelevant_default_args(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + # since the types of the custom config and the default are different + # all default arguments should be removed. + llm = llm_factory({"type": "openai"}, {"_type": "foobar", "temperature": -1}) + assert isinstance(llm, OpenAI) + # since the default argument should be removed, this should be the default - + # which is not -1 + assert llm.temperature != -1 + + +def test_llm_factory_fails_on_invalid_args(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + # since the types of the custom config and the default are the same + # all default arguments should be kept. since the "foo" argument + # is not a valid argument for the OpenAI class, this should fail + llm = llm_factory({"type": "openai"}, {"_type": "openai", "temperature": -1}) + assert isinstance(llm, OpenAI) + # since the default argument should NOT be removed, this should be -1 now + assert llm.temperature == -1 + + +def test_llm_factory_uses_additional_args_from_custom(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + llm = llm_factory({"temperature": -1}, {"_type": "openai"}) + assert isinstance(llm, OpenAI) + assert llm.temperature == -1 + + +def test_embedder_factory(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + embedder = embedder_factory(None, {"_type": "openai"}) + assert isinstance(embedder, OpenAIEmbeddings) + + +def test_embedder_factory_handles_type_without_underscore( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + embedder = embedder_factory({"type": "openai"}, {}) + assert isinstance(embedder, OpenAIEmbeddings) + + +def test_embedder_factory_uses_custom_type(monkeypatch: MonkeyPatch) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + embedder = embedder_factory({"type": "openai"}, {"_type": "foobar"}) + assert isinstance(embedder, OpenAIEmbeddings) + + +def test_embedder_factory_ignores_irrelevant_default_args( + monkeypatch: MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "test") + + # embedders don't expect args, they should just be ignored + embedder = embedder_factory({"type": "openai"}, {"_type": "foobar", "foo": "bar"}) + assert isinstance(embedder, OpenAIEmbeddings) diff --git a/tests/shared/utils/test_validation.py b/tests/shared/utils/test_validation.py index c38f8230baea..e9e35b26a793 100644 --- a/tests/shared/utils/test_validation.py +++ b/tests/shared/utils/test_validation.py @@ -4,6 +4,7 @@ import pytest from pep440_version_utils import Version +from rasa.shared.core.flows.yaml_flows_io import FLOWS_SCHEMA_FILE from rasa.shared.exceptions import YamlException, SchemaValidationError import rasa.shared.utils.io @@ -16,7 +17,10 @@ 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 +from rasa.shared.utils.validation import ( + KEY_TRAINING_DATA_FORMAT_VERSION, + validate_yaml_with_jsonschema, +) @pytest.mark.parametrize( @@ -380,3 +384,297 @@ def validate() -> None: thread.join() assert len(successful_results) == len(threads) + + +@pytest.mark.parametrize( + "flow_yaml", + [ + """flows: + replace_eligible_card: + description: Never predict StartFlow for this flow, users are not able to trigger. + name: replace eligible card + steps: + - collect: replacement_reason + next: + - if: replacement_reason == "lost" + then: + - collect: was_card_used_fraudulently + ask_before_filling: true + next: + - if: was_card_used_fraudulently + then: + - action: utter_report_fraud + next: END + - else: start_replacement + - if: "replacement_reason == 'damaged'" + then: start_replacement + - else: + - action: utter_unknown_replacement_reason_handover + next: END + - id: start_replacement + action: utter_will_cancel_and_send_new + - action: utter_new_card_has_been_ordered""", + """flows: + replace_card: + description: The user needs to replace their card. + name: replace_card + steps: + - collect: confirm_correct_card + ask_before_filling: true + next: + - if: "confirm_correct_card" + then: + - link: "replace_eligible_card" + - else: + - action: utter_relevant_card_not_linked + next: END + """, + f""" +version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" +flows: + transfer_money: + description: This flow lets users send money. + name: transfer money + steps: + - id: "ask_recipient" + collect: transfer_recipient + next: "ask_amount" + - id: "ask_amount" + collect: transfer_amount + next: "execute_transfer" + - id: "execute_transfer" + action: action_transfer_money""", + """flows: + setup_recurrent_payment: + name: setup recurrent payment + steps: + - collect: recurrent_payment_type + rejections: + - if: not ({"direct debit" "standing order"} contains recurrent_payment_type) + utter: utter_invalid_recurrent_payment_type + description: the type of payment + - collect: recurrent_payment_recipient + utter: utter_ask_recipient + description: the name of a person + - collect: recurrent_payment_amount_of_money + description: the amount of money without any currency designation + - collect: recurrent_payment_frequency + description: the frequency of the payment + rejections: + - if: not ({"monthly" "yearly"} contains recurrent_payment_frequency) + utter: utter_invalid_recurrent_payment_frequency + - collect: recurrent_payment_start_date + description: the start date of the payment + - collect: recurrent_payment_end_date + description: the end date of the payment + rejections: + - if: recurrent_payment_end_date < recurrent_payment_start_date + utter: utter_invalid_recurrent_payment_end_date + - collect: recurrent_payment_confirmation + description: accepts True or False + ask_before_filling: true + next: + - if: not recurrent_payment_confirmation + then: + - action: utter_payment_cancelled + next: END + - else: "execute_payment" + - id: "execute_payment" + action: action_execute_recurrent_payment + next: + - if: setup_recurrent_payment_successful + then: + - action: utter_payment_complete + next: END + - else: "payment_failed" + - id: "payment_failed" + action: utter_payment_failed + - action: utter_failed_payment_handover + - action: utter_failed_handoff""", + """ + flows: + foo_flow: + steps: + - id: "1" + set_slots: + - foo: bar + next: "2" + - id: "2" + action: action_listen + next: "1" + """, + """ + flows: + test_flow: + description: Test flow + steps: + - id: "1" + action: action_xyz + next: "2" + - id: "2" + action: utter_ask_name""", + ], +) +def test_flow_validation_pass(flow_yaml: str) -> None: + # test fails if exception is raised + validate_yaml_with_jsonschema(flow_yaml, FLOWS_SCHEMA_FILE) + + +@pytest.mark.parametrize( + "flow_yaml, error_msg", + [ + ("""flows:""", "None is not of type 'object'."), + ( + """flows: + test: + name: test + steps:""", + ("None is not of type 'array'."), + ), + ( + """flows: + test: + - id: test""", + "[ordereddict([('id', 'test')])] is not of type 'object'.", + ), + ( + """flows: + test: + name: test + steps: + - collect: recurrent_payment_type + rejections: + - if: not ({"direct debit" "standing order"} contains recurrent_payment_type) + utter: utter_invalid_recurrent_payment_type + desc: the type of payment""", + ( + "('desc', 'the type of payment')]) is not valid" + " under any of the given schemas." + ), + ), + ( # next is a Bool + """flows: + test: + name: test + steps: + - collect: confirm_correct_card + ask_before_filling: true + next: + - if: "confirm_correct_card" + then: + - link: "replace_eligible_card" + - else: + - action: utter_relevant_card_not_linked + next: True""", + "('next', True)])])])])]) is not valid under any of the given schemas.", + ), + ( # just next and ask_before_filling + """flows: + test: + name: test + steps: + - ask_before_filling: true + next: + - if: "confirm_correct_card" + then: + - link: "replace_eligible_card" + - else: + - action: utter_relevant_card_not_linked + next: END""", + ( + "('if', 'confirm_correct_card'), ('then'," + " [ordereddict([('link', 'replace_eligible_card')])])]), " + "ordereddict([('else', [ordereddict([('action', " + "'utter_relevant_card_not_linked'), ('next', 'END')])])])]" + " is not of type 'null'. Failed to validate data," + " make sure your data is valid." + ), + ), + ( # action added to collect + """flows: + test: + steps: + - collect: confirm_correct_card + action: utter_xyz + ask_before_filling: true""", + ( + "([('collect', 'confirm_correct_card'), ('action', 'utter_xyz')," + " ('ask_before_filling', True)])" + " is not valid under any of the given schemas." + ), + ), + ( # random addition to action + """flows: + test: + steps: + - action: utter_xyz + random_xyz: true + next: END""", + "Failed validating 'type' in schema[2]['properties']['next']", + ), + ( # random addition to collect + """flows: + test: + steps: + - collect: confirm_correct_card + random_xyz: utter_xyz + ask_before_filling: true""", + ( + "ordereddict([('collect', 'confirm_correct_card'), " + "('random_xyz', 'utter_xyz'), ('ask_before_filling', True)])" + " is not valid under any of the given schemas." + ), + ), + ( # random addition to flow definition + """flows: + test: + random_xyz: True + steps: + - action: utter_xyz + next: id-21312""", + "Additional properties are not allowed ('random_xyz' was unexpected).", + ), + ( + """flows: + test: + steps: + - action: True + next: id-2132""", + ( + "ordereddict([('action', True), ('next', 'id-2132')])" + " is not valid under any of the given schemas." + ), + ), + ( # next is a step + """flows: + test: + steps: + - action: xyz + next: + - action: utter_xyz""", + ( + "([('action', 'xyz'), ('next'," + " [ordereddict([('action', 'utter_xyz')])])])" + " is not valid under any of the given schemas." + ), + ), + ( # next is without then + """flows: + test: + steps: + - action: xyz + next: + - if: xyz""", + ( + "([('action', 'xyz'), ('next', [ordereddict([('if', 'xyz')])])])" + " is not valid under any of the given schemas." + ), + ), + ], +) +def test_flow_validation_fail(flow_yaml: str, error_msg: str) -> None: + with pytest.raises(SchemaValidationError) as e: + rasa.shared.utils.validation.validate_yaml_with_jsonschema( + flow_yaml, FLOWS_SCHEMA_FILE + ) + assert error_msg in str(e.value) diff --git a/tests/test_model_training.py b/tests/test_model_training.py index 5c7d3ac9027d..1d5e5623d961 100644 --- a/tests/test_model_training.py +++ b/tests/test_model_training.py @@ -43,6 +43,7 @@ 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.core.slots import AnySlot from rasa.shared.exceptions import InvalidConfigException from rasa.utils.tensorflow.constants import EPOCHS @@ -1046,6 +1047,26 @@ def test_check_unresolved_slots(capsys: CaptureFixture): assert rasa.model_training._check_unresolved_slots(domain, stories) is None +def test_check_restricted_slots(monkeypatch: MonkeyPatch): + domain_path = "data/test_domains/default_with_mapping.yml" + domain = Domain.load(domain_path) + mock = Mock() + monkeypatch.setattr(rasa.shared.utils.cli, "print_warning", mock) + rasa.model_training._check_restricted_slots(domain) + assert not mock.called + + domain.slots.append( + AnySlot( + name="context", + mappings=[{}], + initial_value=None, + influence_conversation=False, + ) + ) + rasa.model_training._check_restricted_slots(domain) + assert mock.called + + @pytest.mark.parametrize( "fingerprint_results, expected_code", [ diff --git a/tests/test_server.py b/tests/test_server.py index 8caae2e3ee9b..cb9962a56073 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -55,6 +55,9 @@ ACTION_LISTEN_NAME, REQUESTED_SLOT, SESSION_START_METADATA_SLOT, + DIALOGUE_STACK_SLOT, + RETURN_VALUE_SLOT, + FLOW_HASHES_SLOT, ) from rasa.shared.core.domain import Domain, SessionConfig from rasa.shared.core.events import ( @@ -695,7 +698,6 @@ async def test_evaluate_stories_end_to_end( 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") @@ -1116,7 +1118,10 @@ async def test_requesting_non_existent_tracker(rasa_app: SanicASGITestClient): assert content["slots"] == { "name": None, REQUESTED_SLOT: None, + FLOW_HASHES_SLOT: None, SESSION_START_METADATA_SLOT: None, + DIALOGUE_STACK_SLOT: None, + RETURN_VALUE_SLOT: None, } assert content["sender_id"] == "madeupid" assert content["events"] == [ @@ -1451,6 +1456,7 @@ def test_list_routes(empty_agent: Agent): "load_model", "unload_model", "get_domain", + "get_flows", } @@ -1916,16 +1922,21 @@ async def test_get_story( assert response.content.decode().strip() == expected -async def test_get_story_without_conversation_id( +async def test_get_story_with_new_conversation_id( rasa_app: SanicASGITestClient, monkeypatch: MonkeyPatch ): - conversation_id = "some-conversation-ID" + conversation_id = "some-conversation-ID-42" 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." + expected = """version: "3.1" +stories: +- story: some-conversation-ID-42 + steps: []""" + + assert response.status == HTTPStatus.OK + assert response.content.decode().strip() == expected async def test_get_story_does_not_update_conversation_session( diff --git a/tests/test_validator.py b/tests/test_validator.py index 90084e4fa569..9d5207aa3cf6 100644 --- a/tests/test_validator.py +++ b/tests/test_validator.py @@ -1,6 +1,7 @@ +import logging import textwrap import warnings -from typing import Text +from typing import Any, Dict, List, Text import pytest from _pytest.logging import LogCaptureFixture @@ -100,7 +101,7 @@ def test_verify_valid_responses(): ], ) validator = Validator.from_importer(importer) - assert validator.verify_utterances_in_stories() + assert validator.verify_utterances_in_dialogues() def test_verify_valid_responses_in_rules(nlu_data_path: Text): @@ -113,7 +114,7 @@ def test_verify_valid_responses_in_rules(nlu_data_path: Text): ) validator = Validator.from_importer(importer) # force validator to not ignore warnings (default is True) - assert not validator.verify_utterances_in_stories(ignore_warnings=False) + assert not validator.verify_utterances_in_dialogues(ignore_warnings=False) def test_verify_story_structure(stories_path: Text): @@ -289,9 +290,9 @@ def test_verify_logging_message_for_unused_utterance( 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) + validator_under_test.verify_utterances_in_dialogues(ignore_warnings=False) - assert "The utterance 'utter_chatter' is not used in any story or rule." in ( + assert "The utterance 'utter_chatter' is not used in any story, rule or flow." in ( m.message.args[0] for m in record ) @@ -347,7 +348,7 @@ def test_early_exit_on_invalid_domain(): 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"], + training_data_paths=["examples/nlu_based/knowledgebasebot/data/nlu.yml"], ) validator = Validator.from_importer(importer) # force validator to not ignore warnings (default is True) @@ -451,7 +452,7 @@ def test_response_selector_responses_in_domain_no_errors(): ) validator = Validator.from_importer(importer) # force validator to not ignore warnings (default is True) - assert validator.verify_utterances_in_stories(ignore_warnings=False) + assert validator.verify_utterances_in_dialogues(ignore_warnings=False) def test_invalid_domain_mapping_policy(): @@ -829,9 +830,9 @@ def test_verify_utterances_does_not_error_when_no_utterance_template_provided( validator = Validator.from_importer(importer) # force validator to not ignore warnings (default is True) - assert not validator.verify_utterances_in_stories(ignore_warnings=False) + assert not validator.verify_utterances_in_dialogues(ignore_warnings=False) # test whether ignoring warnings actually works - assert validator.verify_utterances_in_stories(ignore_warnings=True) + assert validator.verify_utterances_in_dialogues(ignore_warnings=True) @pytest.mark.parametrize( @@ -856,3 +857,601 @@ def test_warn_if_config_mandatory_keys_are_not_set_invalid_paths( with pytest.warns(UserWarning, match=message): validator.warn_if_config_mandatory_keys_are_not_set() + + +@pytest.mark.parametrize( + "domain_actions, domain_slots, log_message", + [ + # set_slot slot is not listed in the domain + ( + ["action_transfer_money"], + {"transfer_amount": {"type": "float", "mappings": []}}, + "The slot 'account_type' is used in the step 'set_account_type' " + "of flow id 'transfer_money', but it is not listed in the domain slots.", + ), + # collect slot is not listed in the domain + ( + ["action_transfer_money"], + {"account_type": {"type": "text", "mappings": []}}, + "The slot 'transfer_amount' is used in the step 'ask_amount' " + "of flow id 'transfer_money', but it is not listed in the domain slots.", + ), + # action name is not listed in the domain + ( + [], + { + "account_type": {"type": "text", "mappings": []}, + "transfer_amount": {"type": "float", "mappings": []}, + }, + "The action 'action_transfer_money' is used in the step 'execute_transfer' " + "of flow id 'transfer_money', but it is not listed in the domain file.", + ), + ], +) +def test_verify_flow_steps_against_domain_fail( + tmp_path: Path, + nlu_data_path: Path, + domain_actions: List[Text], + domain_slots: Dict[Text, Any], + log_message: Text, + caplog: LogCaptureFixture, +) -> None: + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: This flow lets users send money. + name: transfer money + steps: + - id: "ask_amount" + collect: transfer_amount + next: "set_account_type" + - id: "set_account_type" + set_slots: + - account_type: "debit" + next: "execute_transfer" + - id: "execute_transfer" + action: action_transfer_money + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + slots: + {domain_slots} + actions: {domain_actions} + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + assert not validator.verify_flows_steps_against_domain() + + assert log_message in caplog.text + + +def test_verify_flow_steps_against_domain_disallowed_list_slot( + tmp_path: Path, + nlu_data_path: Path, + caplog: LogCaptureFixture, +) -> None: + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + order_pizza: + description: This flow lets users order their favourite pizza. + name: order pizza + steps: + - id: "ask_pizza_toppings" + collect: pizza_toppings + next: "ask_address" + - id: "ask_address" + collect: address + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + slots: + pizza_toppings: + type: list + mappings: [] + address: + type: text + mappings: [] + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + assert not validator.verify_flows_steps_against_domain() + + assert ( + "The slot 'pizza_toppings' is used in the step 'ask_pizza_toppings' " + "of flow id 'order_pizza', but it is a list slot. List slots are " + "currently not supported in flows." in caplog.text + ) + + +def test_verify_flow_steps_against_domain_dialogue_stack_slot( + tmp_path: Path, + nlu_data_path: Path, + caplog: LogCaptureFixture, +) -> None: + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + my_flow: + description: Test that dialogue stack is not modified in flows. + name: test flow + steps: + - id: "ask_internal_slot" + collect: dialogue_stack + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + assert not validator.verify_flows_steps_against_domain() + + assert ( + "The slot 'dialogue_stack' is used in the step 'ask_internal_slot' " + "of flow id 'my_flow', but it is a reserved slot." in caplog.text + ) + + +def test_verify_flow_steps_against_domain_interpolated_action_name( + caplog: LogCaptureFixture, + tmp_path: Path, + nlu_data_path: Path, +) -> None: + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + pattern_collect_information: + description: Test that interpolated names log a warning. + name: test flow + steps: + - id: "validate" + action: "validate_{{context.collect}}" + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.WARNING): + assert validator.verify_flows_steps_against_domain() + assert ( + "An interpolated action name 'validate_{context.collect}' was found " + "at step 'validate' of flow id 'pattern_collect_information'. " + "Skipping validation for this step." in caplog.text + ) + + +def test_verify_unique_flows_duplicate_names( + tmp_path: Path, + nlu_data_path: Path, + caplog: LogCaptureFixture, +) -> None: + duplicate_flow_name = "transfer money" + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: This flow lets users send money. + name: {duplicate_flow_name} + steps: + - id: "ask_recipient" + collect: transfer_recipient + next: "ask_amount" + - id: "ask_amount" + collect: transfer_amount + next: "execute_transfer" + - id: "execute_transfer" + action: action_transfer_money + recurrent_payment: + description: This flow sets up a recurrent payment. + name: {duplicate_flow_name} + steps: + - id: "set_up_recurrence" + action: action_set_up_recurrent_payment + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + slots: + transfer_recipient: + type: text + mappings: [] + transfer_amount: + type: float + mappings: [] + actions: + - action_transfer_money + - action_set_up_recurrent_payment + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + assert not validator.verify_unique_flows() + + assert ( + f"Detected duplicate flow name '{duplicate_flow_name}' for " + f"flow id 'recurrent_payment'. Flow names must be unique. " + f"Please make sure that all flows have different names." + ) in caplog.text + + +def test_verify_flow_names_non_empty( + tmp_path: Path, + nlu_data_path: Path, + caplog: LogCaptureFixture, +) -> None: + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: This flow lets users send money. + name: "" + steps: + - collect: transfer_recipient + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + slots: + transfer_recipient: + type: text + mappings: [] + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + assert not validator.verify_unique_flows() + + assert "empty name" in caplog.text + assert "transfer_money" in caplog.text + + +def test_verify_unique_flows_duplicate_descriptions( + tmp_path: Path, + nlu_data_path: Path, + caplog: LogCaptureFixture, +) -> None: + duplicate_flow_description_with_punctuation = "This flow lets users send money." + duplicate_flow_description = "This flow lets users send money" + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: {duplicate_flow_description_with_punctuation} + name: transfer money + steps: + - id: "ask_recipient" + collect: transfer_recipient + next: "ask_amount" + - id: "ask_amount" + collect: transfer_amount + next: "execute_transfer" + - id: "execute_transfer" + action: action_transfer_money + recurrent_payment: + description: {duplicate_flow_description} + name: setup recurrent payment + steps: + - id: "set_up_recurrence" + action: action_set_up_recurrent_payment + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + slots: + transfer_recipient: + type: text + mappings: [] + transfer_amount: + type: float + mappings: [] + actions: + - action_transfer_money + - action_set_up_recurrent_payment + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + validator.verify_unique_flows() + + assert ( + "Detected duplicate flow description for flow id 'recurrent_payment'. " + "Flow descriptions must be unique. " + "Please make sure that all flows have different descriptions." + ) in caplog.text + + +def test_verify_predicates_invalid_rejection_if( + tmp_path: Path, + nlu_data_path: Path, + caplog: LogCaptureFixture, +) -> None: + predicate = 'account_type not in {{"debit", "savings"}}' + error_log = ( + f"Detected invalid rejection '{predicate}' " + f"at `collect` step 'ask_account_type' " + f"for flow id 'transfer_money'. " + f"Please make sure that all conditions are valid." + ) + flows_file = tmp_path / "flows.yml" + with open(flows_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: This flow lets users send money. + name: transfer money + steps: + - id: "ask_account_type" + collect: account_type + rejections: + - if: {predicate} + utter: utter_invalid_account_type + next: "ask_recipient" + - id: "ask_recipient" + collect: transfer_recipient + next: "ask_amount" + - id: "ask_amount" + collect: transfer_amount + next: "execute_transfer" + - id: "execute_transfer" + action: action_transfer_money + recurrent_payment: + description: This flow setups recurrent payments + name: setup recurrent payment + steps: + - id: "set_up_recurrence" + action: action_set_up_recurrent_payment + """ + ) + domain_file = tmp_path / "domain.yml" + with open(domain_file, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + intents: + - greet + slots: + transfer_recipient: + type: text + mappings: [] + transfer_amount: + type: float + mappings: [] + actions: + - action_transfer_money + - action_set_up_recurrent_payment + """ + ) + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file), + training_data_paths=[str(flows_file), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with caplog.at_level(logging.ERROR): + assert not validator.verify_predicates() + + assert error_log in caplog.text + + +@pytest.fixture +def domain_file_name(tmp_path: Path) -> Path: + domain_file_name = tmp_path / "domain.yml" + with open(domain_file_name, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + responses: + utter_ask_recipient: + - text: "Who do you want to send money to?" + utter_ask_amount: + - text: "How much do you want to send?" + utter_amount_too_high: + - text: "Sorry, you can only send up to 1000." + utter_transfer_summary: + - text: You are sending {{amount}} to {{transfer_recipient}}. + """ + ) + return domain_file_name + + +def test_verify_utterances_in_dialogues_finds_all_responses_in_flows( + tmp_path: Path, nlu_data_path: Path, domain_file_name: Path +): + flows_file_name = tmp_path / "flows.yml" + with open(flows_file_name, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: This flow lets users send money. + name: Transfer money + steps: + - id: "ask_recipient" + collect: transfer_recipient + utter: utter_ask_recipient + next: "ask_amount" + - id: "ask_amount" + collect: amount + rejections: + - if: amount > 1000 + utter: utter_amount_too_high + next: "summarize_transfer" + - id: "summarize_transfer" + action: utter_transfer_summary + """ + ) + + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file_name), + training_data_paths=[str(flows_file_name), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + + with warnings.catch_warnings() as record: + warnings.simplefilter("error") + # force validator to not ignore warnings (default is True) + assert validator.verify_utterances_in_dialogues(ignore_warnings=False) + assert record is None + + +def test_verify_utterances_in_dialogues_missing_responses_in_flows( + tmp_path: Path, nlu_data_path: Path, domain_file_name: Path +): + flows_file_name = tmp_path / "flows.yml" + # remove utter_ask_recipient from this flows file, + # but it is listed in the domain file + with open(flows_file_name, "w") as file: + file.write( + f""" + version: "{LATEST_TRAINING_DATA_FORMAT_VERSION}" + flows: + transfer_money: + description: This flow lets users send money. + name: Transfer money + steps: + - id: "ask_recipient" + collect: transfer_money_recipient + next: "ask_amount" + - id: "ask_amount" + collect: transfer_money_amount + rejections: + - if: transfer_money_amount > 1000 + utter: utter_amount_too_high + next: "summarize_transfer" + - id: "summarize_transfer" + action: utter_transfer_summary + """ + ) + + importer = RasaFileImporter( + config_file="data/test_moodbot/config.yml", + domain_path=str(domain_file_name), + training_data_paths=[str(flows_file_name), str(nlu_data_path)], + ) + + validator = Validator.from_importer(importer) + match = ( + "The utterance 'utter_ask_recipient' is not used in any story, rule or flow." + ) + with pytest.warns(UserWarning, match=match): + # force validator to not ignore warnings (default is True) + validator.verify_utterances_in_dialogues(ignore_warnings=False) diff --git a/tests/utilities.py b/tests/utilities.py index 6c334f75905e..0874918d7251 100644 --- a/tests/utilities.py +++ b/tests/utilities.py @@ -1,4 +1,8 @@ from yarl import URL +from rasa.shared.core.domain import Domain +from rasa.shared.core.flows.flow import FlowsList +from rasa.shared.core.flows.yaml_flows_io import flows_from_str +from rasa.shared.importers.importer import FlowSyncImporter def latest_request(mocked, request_type, path): @@ -7,3 +11,13 @@ def latest_request(mocked, request_type, path): def json_of_latest_request(r): return r[-1].kwargs["json"] + + +def flows_from_str_with_defaults(yaml_str: str) -> FlowsList: + """Reads flows from a YAML string and includes buildin flows.""" + return FlowSyncImporter.merge_with_default_flows(flows_from_str(yaml_str)) + + +def flows_default_domain() -> Domain: + """Returns the default domain for the default flows.""" + return FlowSyncImporter.load_default_pattern_flows_domain() diff --git a/tests/utils/test_llm.py b/tests/utils/test_llm.py deleted file mode 100644 index c9203ad8cfe6..000000000000 --- a/tests/utils/test_llm.py +++ /dev/null @@ -1,66 +0,0 @@ -from rasa.shared.core.domain import Domain -from rasa.shared.core.events import BotUttered, UserUttered -from rasa.shared.core.trackers import DialogueStateTracker -from rasa.utils.llm import ( - sanitize_message_for_prompt, - tracker_as_readable_transcript, -) - - -def test_tracker_as_readable_transcript_handles_empty_tracker(): - tracker = DialogueStateTracker(sender_id="test", slots=[]) - assert tracker_as_readable_transcript(tracker) == "" - - -def test_tracker_as_readable_transcript_handles_tracker_with_events(domain: Domain): - tracker = DialogueStateTracker(sender_id="test", slots=domain.slots) - tracker.update_with_events( - [ - UserUttered("hello"), - BotUttered("hi"), - ], - domain, - ) - assert tracker_as_readable_transcript(tracker) == ("""USER: hello\nAI: hi""") - - -def test_tracker_as_readable_transcript_handles_tracker_with_events_and_prefixes( - domain: Domain, -): - tracker = DialogueStateTracker(sender_id="test", slots=domain.slots) - tracker.update_with_events( - [ - UserUttered("hello"), - BotUttered("hi"), - ], - domain, - ) - assert tracker_as_readable_transcript( - tracker, human_prefix="FOO", ai_prefix="BAR" - ) == ("""FOO: hello\nBAR: hi""") - - -def test_tracker_as_readable_transcript_handles_tracker_with_events_and_max_turns( - domain: Domain, -): - tracker = DialogueStateTracker(sender_id="test", slots=domain.slots) - tracker.update_with_events( - [ - UserUttered("hello"), - BotUttered("hi"), - ], - domain, - ) - assert tracker_as_readable_transcript(tracker, max_turns=1) == ("""AI: hi""") - - -def test_sanitize_message_for_prompt_handles_none(): - assert sanitize_message_for_prompt(None) == "" - - -def test_sanitize_message_for_prompt_handles_empty_string(): - assert sanitize_message_for_prompt("") == "" - - -def test_sanitize_message_for_prompt_handles_string_with_newlines(): - assert sanitize_message_for_prompt("hello\nworld") == "hello world"