From d6dbcc1d61a69b5d020bd6a320a184ffb76d299b Mon Sep 17 00:00:00 2001 From: Eric Keilty Date: Fri, 15 May 2026 13:33:38 -0400 Subject: [PATCH 1/2] huge refactor to support additional features --- skill_analytics/README.md | 639 +++++++++--------- skill_analytics/cli/__init__.py | 7 + skill_analytics/cli/__main__.py | 126 ++++ skill_analytics/cli/action_contents.py | 60 -- skill_analytics/cli/action_metadata.py | 55 ++ skill_analytics/cli/action_settings.py | 31 - skill_analytics/cli/assistant_metadata.py | 61 ++ skill_analytics/cli/condition_usage.py | 70 ++ skill_analytics/cli/context_usage.py | 70 ++ .../cli/customer_response_settings.py | 70 ++ skill_analytics/cli/entity_metadata.py | 55 ++ skill_analytics/cli/entity_usage.py | 97 ++- skill_analytics/cli/extension_usage.py | 60 +- skill_analytics/cli/intent_summary.py | 30 - skill_analytics/cli/intent_usage.py | 68 ++ skill_analytics/cli/response_usage.py | 70 ++ skill_analytics/cli/subaction_usage.py | 59 +- .../{src => cli}/utils/clean_cli_list.py | 0 .../{src => cli}/utils/dataframe_helper.py | 0 .../{src => cli}/utils/file_path_helper.py | 0 skill_analytics/cli/validation_settings.py | 70 ++ skill_analytics/cli/variable_metadata.py | 94 +++ skill_analytics/cli/variable_summary.py | 39 -- skill_analytics/cli/variable_usage.py | 99 ++- skill_analytics/pyproject.toml | 48 ++ skill_analytics/setup.py | 38 ++ skill_analytics/src/action.py | 196 ------ skill_analytics/src/analyzers/__init__.py | 17 + skill_analytics/src/analyzers/action.py | 189 ++++++ skill_analytics/src/analyzers/assistant.py | 57 ++ skill_analytics/src/analyzers/entity.py | 115 ++++ skill_analytics/src/analyzers/intent.py | 34 + skill_analytics/src/analyzers/resolver.py | 82 +++ skill_analytics/src/analyzers/subaction.py | 199 ++++++ skill_analytics/src/analyzers/variable.py | 222 ++++++ .../src/assistant_static_analyzer.py | 411 ----------- skill_analytics/src/condition.py | 223 ------ skill_analytics/src/context.py | 116 ---- skill_analytics/src/entity.py | 9 - skill_analytics/src/extension.py | 124 ---- skill_analytics/src/intent.py | 44 -- skill_analytics/src/models/action.py | 87 +++ skill_analytics/src/models/action_settings.py | 33 + skill_analytics/src/models/assistant.py | 154 +++++ skill_analytics/src/models/condition.py | 70 ++ skill_analytics/src/models/context.py | 63 ++ .../src/models/data_types/__init__.py | 18 + .../models/data_types/data_type_registry.py | 95 +++ .../src/models/data_types/data_types.py | 96 +++ skill_analytics/src/models/entity/__init__.py | 32 + skill_analytics/src/models/entity/entity.py | 41 ++ skill_analytics/src/models/entity/factory.py | 17 + skill_analytics/src/models/entity/value.py | 75 ++ skill_analytics/src/models/handler.py | 43 ++ skill_analytics/src/models/intent.py | 41 ++ .../src/models/operands/__init__.py | 32 + skill_analytics/src/models/operands/base.py | 52 ++ .../src/models/operands/collection.py | 25 + skill_analytics/src/models/operands/entity.py | 22 + .../src/models/operands/expression.py | 21 + .../src/models/operands/factory.py | 35 + skill_analytics/src/models/operands/intent.py | 20 + skill_analytics/src/models/operands/scalar.py | 32 + skill_analytics/src/models/operands/time.py | 20 + .../src/models/operands/variable.py | 58 ++ skill_analytics/src/models/question.py | 43 ++ .../src/models/resolvers/__init__.py | 77 +++ skill_analytics/src/models/resolvers/base.py | 58 ++ .../src/models/resolvers/callout_resolver.py | 121 ++++ .../resolvers/connect_to_agent_resolver.py | 26 + .../src/models/resolvers/continue_resolver.py | 26 + .../models/resolvers/end_action_resolver.py | 26 + .../src/models/resolvers/factory.py | 46 ++ .../src/models/resolvers/fallback_resolver.py | 27 + .../invoke_action_and_end_resolver.py | 44 ++ .../resolvers/invoke_action_resolver.py | 44 ++ .../src/models/resolvers/prompt_again.py | 26 + .../src/models/resolvers/replay_resolver.py | 32 + .../src/models/responses/__init__.py | 40 ++ skill_analytics/src/models/responses/base.py | 62 ++ skill_analytics/src/models/responses/date.py | 28 + skill_analytics/src/models/responses/dtmf.py | 28 + .../src/models/responses/dynamic_option.py | 28 + .../src/models/responses/end_session.py | 28 + .../src/models/responses/factory.py | 38 ++ .../src/models/responses/option.py | 67 ++ .../responses/response_from_data_type.py | 28 + .../src/models/responses/speech_to_text.py | 28 + skill_analytics/src/models/responses/text.py | 56 ++ .../src/models/responses/text_to_speech.py | 28 + skill_analytics/src/models/responses/time.py | 28 + .../src/models/responses/user_defined.py | 31 + .../src/models/statements/__init__.py | 31 + .../src/models/statements/assign.py | 33 + skill_analytics/src/models/statements/base.py | 36 + .../src/models/statements/binary.py | 35 + .../src/models/statements/contains.py | 51 ++ .../src/models/statements/datetime.py | 34 + .../src/models/statements/exists.py | 45 ++ .../src/models/statements/expression.py | 24 + .../src/models/statements/factory.py | 82 +++ .../src/models/statements/matches.py | 51 ++ .../src/models/statements/operation.py | 104 +++ skill_analytics/src/models/step.py | 102 +++ skill_analytics/src/models/system_settings.py | 44 ++ .../src/models/variables/__init__.py | 15 + skill_analytics/src/models/variables/base.py | 40 ++ .../src/models/variables/result_variable.py | 48 ++ .../src/models/variables/skill_variable.py | 74 ++ .../src/models/variables/step_variable.py | 48 ++ .../src/models/variables/system_variable.py | 43 ++ .../src/output_handlers/__init__.py | 16 + skill_analytics/src/output_handlers/base.py | 30 + .../src/output_handlers/csv_handler.py | 38 ++ .../src/output_handlers/dataframe_handler.py | 19 + .../src/output_handlers/factory.py | 49 ++ .../src/output_handlers/json_handler.py | 39 ++ .../src/output_handlers/python_handler.py | 21 + .../src/response_types/__init__.py | 4 - .../response_types/dynamic_option_response.py | 6 - .../src/response_types/option_response.py | 111 --- .../src/response_types/response.py | 63 -- .../src/response_types/text_response.py | 53 -- .../src/response_types/validation_response.py | 73 -- skill_analytics/src/statement.py | 161 ----- skill_analytics/src/step.py | 160 ----- skill_analytics/src/subaction.py | 107 --- .../parse_variables_from_spel_expression.py | 41 +- skill_analytics/src/utils/parse_wxa_ids.py | 60 ++ skill_analytics/src/variable.py | 62 -- 130 files changed, 5897 insertions(+), 2506 deletions(-) create mode 100644 skill_analytics/cli/__init__.py create mode 100644 skill_analytics/cli/__main__.py delete mode 100644 skill_analytics/cli/action_contents.py create mode 100644 skill_analytics/cli/action_metadata.py delete mode 100644 skill_analytics/cli/action_settings.py create mode 100644 skill_analytics/cli/assistant_metadata.py create mode 100644 skill_analytics/cli/condition_usage.py create mode 100644 skill_analytics/cli/context_usage.py create mode 100644 skill_analytics/cli/customer_response_settings.py create mode 100644 skill_analytics/cli/entity_metadata.py delete mode 100644 skill_analytics/cli/intent_summary.py create mode 100644 skill_analytics/cli/intent_usage.py create mode 100644 skill_analytics/cli/response_usage.py rename skill_analytics/{src => cli}/utils/clean_cli_list.py (100%) rename skill_analytics/{src => cli}/utils/dataframe_helper.py (100%) rename skill_analytics/{src => cli}/utils/file_path_helper.py (100%) create mode 100644 skill_analytics/cli/validation_settings.py create mode 100644 skill_analytics/cli/variable_metadata.py delete mode 100644 skill_analytics/cli/variable_summary.py create mode 100644 skill_analytics/pyproject.toml create mode 100644 skill_analytics/setup.py delete mode 100644 skill_analytics/src/action.py create mode 100644 skill_analytics/src/analyzers/__init__.py create mode 100644 skill_analytics/src/analyzers/action.py create mode 100644 skill_analytics/src/analyzers/assistant.py create mode 100644 skill_analytics/src/analyzers/entity.py create mode 100644 skill_analytics/src/analyzers/intent.py create mode 100644 skill_analytics/src/analyzers/resolver.py create mode 100644 skill_analytics/src/analyzers/subaction.py create mode 100644 skill_analytics/src/analyzers/variable.py delete mode 100644 skill_analytics/src/assistant_static_analyzer.py delete mode 100644 skill_analytics/src/condition.py delete mode 100644 skill_analytics/src/context.py delete mode 100644 skill_analytics/src/entity.py delete mode 100644 skill_analytics/src/extension.py delete mode 100644 skill_analytics/src/intent.py create mode 100644 skill_analytics/src/models/action.py create mode 100644 skill_analytics/src/models/action_settings.py create mode 100644 skill_analytics/src/models/assistant.py create mode 100644 skill_analytics/src/models/condition.py create mode 100644 skill_analytics/src/models/context.py create mode 100644 skill_analytics/src/models/data_types/__init__.py create mode 100644 skill_analytics/src/models/data_types/data_type_registry.py create mode 100644 skill_analytics/src/models/data_types/data_types.py create mode 100644 skill_analytics/src/models/entity/__init__.py create mode 100644 skill_analytics/src/models/entity/entity.py create mode 100644 skill_analytics/src/models/entity/factory.py create mode 100644 skill_analytics/src/models/entity/value.py create mode 100644 skill_analytics/src/models/handler.py create mode 100644 skill_analytics/src/models/intent.py create mode 100644 skill_analytics/src/models/operands/__init__.py create mode 100644 skill_analytics/src/models/operands/base.py create mode 100644 skill_analytics/src/models/operands/collection.py create mode 100644 skill_analytics/src/models/operands/entity.py create mode 100644 skill_analytics/src/models/operands/expression.py create mode 100644 skill_analytics/src/models/operands/factory.py create mode 100644 skill_analytics/src/models/operands/intent.py create mode 100644 skill_analytics/src/models/operands/scalar.py create mode 100644 skill_analytics/src/models/operands/time.py create mode 100644 skill_analytics/src/models/operands/variable.py create mode 100644 skill_analytics/src/models/question.py create mode 100644 skill_analytics/src/models/resolvers/__init__.py create mode 100644 skill_analytics/src/models/resolvers/base.py create mode 100644 skill_analytics/src/models/resolvers/callout_resolver.py create mode 100644 skill_analytics/src/models/resolvers/connect_to_agent_resolver.py create mode 100644 skill_analytics/src/models/resolvers/continue_resolver.py create mode 100644 skill_analytics/src/models/resolvers/end_action_resolver.py create mode 100644 skill_analytics/src/models/resolvers/factory.py create mode 100644 skill_analytics/src/models/resolvers/fallback_resolver.py create mode 100644 skill_analytics/src/models/resolvers/invoke_action_and_end_resolver.py create mode 100644 skill_analytics/src/models/resolvers/invoke_action_resolver.py create mode 100644 skill_analytics/src/models/resolvers/prompt_again.py create mode 100644 skill_analytics/src/models/resolvers/replay_resolver.py create mode 100644 skill_analytics/src/models/responses/__init__.py create mode 100644 skill_analytics/src/models/responses/base.py create mode 100644 skill_analytics/src/models/responses/date.py create mode 100644 skill_analytics/src/models/responses/dtmf.py create mode 100644 skill_analytics/src/models/responses/dynamic_option.py create mode 100644 skill_analytics/src/models/responses/end_session.py create mode 100644 skill_analytics/src/models/responses/factory.py create mode 100644 skill_analytics/src/models/responses/option.py create mode 100644 skill_analytics/src/models/responses/response_from_data_type.py create mode 100644 skill_analytics/src/models/responses/speech_to_text.py create mode 100644 skill_analytics/src/models/responses/text.py create mode 100644 skill_analytics/src/models/responses/text_to_speech.py create mode 100644 skill_analytics/src/models/responses/time.py create mode 100644 skill_analytics/src/models/responses/user_defined.py create mode 100644 skill_analytics/src/models/statements/__init__.py create mode 100644 skill_analytics/src/models/statements/assign.py create mode 100644 skill_analytics/src/models/statements/base.py create mode 100644 skill_analytics/src/models/statements/binary.py create mode 100644 skill_analytics/src/models/statements/contains.py create mode 100644 skill_analytics/src/models/statements/datetime.py create mode 100644 skill_analytics/src/models/statements/exists.py create mode 100644 skill_analytics/src/models/statements/expression.py create mode 100644 skill_analytics/src/models/statements/factory.py create mode 100644 skill_analytics/src/models/statements/matches.py create mode 100644 skill_analytics/src/models/statements/operation.py create mode 100644 skill_analytics/src/models/step.py create mode 100644 skill_analytics/src/models/system_settings.py create mode 100644 skill_analytics/src/models/variables/__init__.py create mode 100644 skill_analytics/src/models/variables/base.py create mode 100644 skill_analytics/src/models/variables/result_variable.py create mode 100644 skill_analytics/src/models/variables/skill_variable.py create mode 100644 skill_analytics/src/models/variables/step_variable.py create mode 100644 skill_analytics/src/models/variables/system_variable.py create mode 100644 skill_analytics/src/output_handlers/__init__.py create mode 100644 skill_analytics/src/output_handlers/base.py create mode 100644 skill_analytics/src/output_handlers/csv_handler.py create mode 100644 skill_analytics/src/output_handlers/dataframe_handler.py create mode 100644 skill_analytics/src/output_handlers/factory.py create mode 100644 skill_analytics/src/output_handlers/json_handler.py create mode 100644 skill_analytics/src/output_handlers/python_handler.py delete mode 100644 skill_analytics/src/response_types/__init__.py delete mode 100644 skill_analytics/src/response_types/dynamic_option_response.py delete mode 100644 skill_analytics/src/response_types/option_response.py delete mode 100644 skill_analytics/src/response_types/response.py delete mode 100644 skill_analytics/src/response_types/text_response.py delete mode 100644 skill_analytics/src/response_types/validation_response.py delete mode 100644 skill_analytics/src/statement.py delete mode 100644 skill_analytics/src/step.py delete mode 100644 skill_analytics/src/subaction.py create mode 100644 skill_analytics/src/utils/parse_wxa_ids.py delete mode 100644 skill_analytics/src/variable.py diff --git a/skill_analytics/README.md b/skill_analytics/README.md index ffc7ef5..c2bd358 100644 --- a/skill_analytics/README.md +++ b/skill_analytics/README.md @@ -6,453 +6,428 @@ This becomes very useful in the final stages of a project. For example, you want ## Table of Contents -- [Set Up](#set-up) +- [Installation](#installation) + * [Option 1: Install as a Package (Recommended)](#option-1-install-as-a-package-recommended) + * [Option 2: Development Setup](#option-2-development-setup) * [Configuration](#configuration) - [How to Use This Repository](#how-to-use-this-repository) - [Command-Line Interface](#command-line-interface) - * [Action Contents](#action-contents) - * [Action Settings](#action-settings) - * [Variable Usage](#variable-usage) - * [Variable Summary](#variable-summary) - * [Intent Summary](#intent-summary) - * [Entity Usage](#entity-usage) - * [Subaction Usage](#subaction-usage) - * [Extension Usage](#extension-usage) + * [Quick Start](#quick-start) + * [Common Options](#common-options) + * [Available Commands](#available-commands) + - [Assistant Metadata](#assistant-metadata) + - [Action Analysis](#action-analysis) + - [Variable Analysis](#variable-analysis) + - [Entity Analysis](#entity-analysis) + - [Intent Analysis](#intent-analysis) + - [Extension & Subaction Analysis](#extension--subaction-analysis) + * [Alternative CLI Usage](#alternative-cli-usage) - [Software Development Kit](#software-development-kit) - * [Class Methods of AssistantStaticAnalyzer](#class-methods-of-assistantstaticanalyzer) - * [Looping Over Actions and Steps](#looping-over-actions-and-steps) + * [Basic Usage](#basic-usage) + * [Output Formats](#output-formats) + * [Available Analyzers and Methods](#available-analyzers-and-methods) + * [Working with the Assistant Object](#working-with-the-assistant-object) + * [Example: Custom Analysis Script](#example-custom-analysis-script) + * [Advanced: Looping Over Actions and Steps](#advanced-looping-over-actions-and-steps) - [FAQ](#faq) * [My Assistant JSON Failed to Parse](#my-assistant-json-failed-to-parse) * [How Do I Report an Issue/Bug](#how-do-i-report-an-issuebug) * [I Have a Feature Request](#i-have-a-feature-request) + * [Is There More SDK Documentation?](#is-there-more-sdk-documentation) --- -## Set Up +## Installation -Optional but highly recommended to create a virtual environment. +### Option 1: Install as a Package (Recommended) -``` -conda create -n wxa-skill-analysis python=3.11 -c conda-forge -conda activate wxa-skill-analysis -``` - -Pip install the requirements -``` -pip install -r requirements.txt -``` - -### Configuration - -For the assistant that you wish to analyze, locate the assistant json file. This can be found in the `Actions` tab, `Global Settings` which is the gear icon in the top right, `Upload/Download` tab on the far right. Click `Download` and place the file in the folder `jsons`. - -You may also review the file `config/config.py` with contais some default paths used by the CLI. - -## How to Use This Repository - -There are two ways to use this repository. The first is through the [Command-Line Interface](#command-line-interface) (CLI), which gives pre-defined functionality such as searching over actions, subactions, variables, extensions, and entities. The CLI returns all results as a simple CSV file, which can be further filtered and refined in Excel. I recommend first playing around with the CLI and its results to gain an understanding of the capabilities of this repository. - -The second way to use this repository is as a [Software Development Kit](#software-development-kit) (SDK) if you want to integrate this code-base into your own custom scripts. - ---- +Install the package directly using pip: -## Command-Line Interface - -The Command-Line Interface (CLI) provides gives pre-defined functionality such as searching over actions, subactions, variables, extensions, and entities. All results are returned as a CSV file. - -### Action Contents - -Returns a CSV reporting the "contents" of the provided action(s), e.g. conditions, variables assignments, subactions, extensions, assistant responses, etc. - -``` -usage: action_contents.py [-h] [-v] [-s] [-e] [-r] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [actions ...] - -Search for action contents inside an assistant - -positional arguments: - actions List of actions names to search contents of. - -options: - -h, --help show this help message and exit - -v, --variables If included, the output will include all variable usage inside the listed actions. - -s, --subactions If included, the output will include all subaction usage inside the listed actions. - -e, --extensions If included, the output will include all extension usage inside the listed actions. - -r, --responses If included, the output will include all text response inside the listed actions. - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. +```bash +pip install wxa-analyze ``` -The columns of the CSV depend on what "contents" are included in the results (i.e. which flags you choose to include). -- Columns `action_id`, `action_title`, `step_id`, `step_title`, and `step_number` will always be present in the CSV. -- If `--variables` is included in the CLI, columns `source`, `variable`, `LHS`, `RHS`, `operation`, `SpEL expression`, `text`, `is_protected` will be present. `entity_id`, `subaction_id`, `response_type` will be included in the CSV -- If `--subactions` is included in the CLI, columns `subaction_id` and `subaction_title` will be included in the CSV -- If `--extensions` is included in the CLI, columns `extension_method` and `extension_path` will be included in the CSV -- If `--responses` is included in the CLI, columns `response_type`, `response_text`, `handler_type`, and `resolver_type` will be included in the CSV +Or install from source: -#### Example 1 -``` -python -m cli.action_contents "Example Action" +```bash +git clone https://github.com/cognitive-catalyst/WA-Testing-Tool.git +cd WA-Testing-Tool/skill_analytics +pip install -e . ``` -This will give all contents of the provided action. +After installation, you can use the `wxa-analyze` command from anywhere: -#### Example 2 -``` -python -m cli.action_contents "Example Action" --variables --responses +```bash +wxa-analyze --help ``` -By adding the content flags, the output will only contain content of those types. +### Option 2: Development Setup -#### Example 3 -``` -python -m cli.action_contents "Example Action 1" "Example Action 2" --variables -``` +If you want to contribute or modify the code, set up a development environment: -We can also include a list of actions that we want to search on. +```bash +# Optional but highly recommended: create a virtual environment +conda create -n wxa-skill-analysis python=3.11 -c conda-forge +conda activate wxa-skill-analysis -#### Example 4 +# Clone the repository +git clone https://github.com/cognitive-catalyst/WA-Testing-Tool.git +cd WA-Testing-Tool/skill_analytics +# Install in editable mode with dependencies +pip install -e . ``` -python -m cli.action_contents --variables -``` - -If we include no actions, then it will search over all actions. -#### Example 5 +### Configuration -``` -python -m cli.action_contents -``` +For the assistant that you wish to analyze, locate the assistant json file. This can be found in the `Actions` tab, `Global Settings` which is the gear icon in the top right, `Upload/Download` tab on the far right. Click `Download` and place the file in the folder `jsons`. -This gives the mega spreadsheet of all content in all actions. +You may also review the file `config/config.py` with contais some default paths used by the CLI. -### Action Settings +## How to Use This Repository -Returns a CSV reporting the settings of the provided action(s), e.g. step validation, disambiguation, etc. +There are two ways to use this repository. The first is through the [Command-Line Interface](#command-line-interface) (CLI), which gives pre-defined functionality such as searching over actions, subactions, variables, extensions, and entities. The CLI returns all results as a simple CSV file, which can be further filtered and refined in Excel. I recommend first playing around with the CLI and its results to gain an understanding of the capabilities of this repository. -``` -usage: action_settings.py [-h] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [actions ...] +The second way to use this repository is as a [Software Development Kit](#software-development-kit) (SDK) if you want to integrate this code-base into your own custom scripts. -Search for action contents inside an assistant +--- -positional arguments: - actions List of actions names to search contents of. +## Command-Line Interface -options: - -h, --help show this help message and exit - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. -``` +After installation, you can use the `wxa-analyze` command followed by a subcommand. The CLI provides pre-defined functionality for analyzing actions, variables, entities, extensions, and more. All results are returned as CSV or JSON files. -The CSV will contain columns related to action settings and configuration. +### Quick Start -#### Example 1 -``` -python -m cli.action_settings "Example Action" +View all available commands: +```bash +wxa-analyze --help ``` -This will return the settings for the specified action. - -#### Example 2 +Get help for a specific command: +```bash +wxa-analyze action-metadata --help ``` -python -m cli.action_settings -``` - -If no actions are provided, then it will return the settings for all actions in the assistant. -### Variable Usage +### Common Options -Returns a CSV reporting the usage of the provided variable(s), e.g. conditions, context, extensions, etc. - -``` -usage: variable_usage.py [-h] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [variables ...] +All commands support these options: +- `-i, --assistant_json_path PATH` - Path to the watsonx Assistant JSON file (default: configured in `config/config.py`) +- `-o, --output_path PATH` - Directory where output files will be saved (default: configured in `config/config.py`) -Search for variable usage inside an assistant +### Available Commands -positional arguments: - variables List of variable names to search for. +#### Assistant Metadata -options: - -h, --help show this help message and exit - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. +**assistant-metadata** - Extract comprehensive metadata about the assistant including IDs, settings, and aggregated statistics: +```bash +wxa-analyze assistant-metadata +wxa-analyze assistant-metadata -i assistant.json -o ./reports ``` +Output: JSON file with assistant-level information -The CSV will contain the following columns: `variable`, `action_id`, `action_title`, `step_id`, `step_title`, `step_number`, `source`, `LHS`, `RHS`, `operation`, `SpEL expression`, `entity_id`, `is_protected`. +#### Action Analysis -#### Example 1 +**action-metadata** - Extract metadata for all actions in the assistant: +```bash +wxa-analyze action-metadata +wxa-analyze action-metadata -i assistant.json -o ./reports ``` -python -m cli.variable_usage variable1 variable2 -``` - -`variable1` and `variable2` are names of variables in the assistant. +Output: CSV with action IDs, titles, and configuration details -#### Example 2 -``` -python -m cli.variable_usage +**condition-usage** - Report all conditions used in action steps: +```bash +wxa-analyze condition-usage # All actions +wxa-analyze condition-usage action_1 # Single action +wxa-analyze condition-usage action_1 action_2 # Multiple actions ``` +Output: CSV with condition expressions and their locations -If no variable names are provided, then it will return the usage of all variables in the assistant (this could be a lot). - -### Variable Summary - -Returns a CSV providing a summary of all variables defined in the assistant. - -``` -usage: variable_summary.py [-h] [-s] [-a] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] - -Summary of all variables defined in the assistant - -options: - -h, --help show this help message and exit - -s, --system_variables - If included, the output will include system variables. - -a, --action_variables - If included, the output will include action variables. - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. +**context-usage** - Report all context statements used in action steps: +```bash +wxa-analyze context-usage # All actions +wxa-analyze context-usage action_1 # Single action +wxa-analyze context-usage action_1 action_2 # Multiple actions ``` +Output: CSV with context variable assignments -The CSV will contain columns with variable definitions and metadata. - -#### Example 1 -``` -python -m cli.variable_summary +**customer-response-settings** - Extract customer response settings (prompts, retries, disambiguation) for action steps: +```bash +wxa-analyze customer-response-settings # All actions +wxa-analyze customer-response-settings action_1 # Single action +wxa-analyze customer-response-settings action_1 action_2 # Multiple actions ``` +Output: CSV with response configuration details -This will return a summary of all session variables in the assistant. - -#### Example 2 +**response-usage** - Report all customer response types used in action steps (text, options, dates, etc.): +```bash +wxa-analyze response-usage # All actions +wxa-analyze response-usage action_1 # Single action +wxa-analyze response-usage action_1 action_2 # Multiple actions ``` -python -m cli.variable_summary --system_variables -``` - -This will return a summary including system variables. +Output: CSV with response types and content -#### Example 3 -``` -python -m cli.variable_summary --action_variables +**validation-settings** - Extract validation settings (input validation rules) for action steps: +```bash +wxa-analyze validation-settings # All actions +wxa-analyze validation-settings action_1 # Single action +wxa-analyze validation-settings action_1 action_2 # Multiple actions ``` +Output: CSV with validation rules and error messages -This will return a summary including action variables. +#### Variable Analysis -#### Example 4 -``` -python -m cli.variable_summary --system_variables --action_variables +**variable-metadata** - Extract metadata for all variables defined in the assistant: +```bash +wxa-analyze variable-metadata # All variable types +wxa-analyze variable-metadata --skill_variables # Only skill variables +wxa-analyze variable-metadata --step_variables # Only step variables +wxa-analyze variable-metadata --result_variables # Only result variables +wxa-analyze variable-metadata --system_variables # Only system variables +wxa-analyze variable-metadata --skill_variables --step_variables # Multiple types ``` +Output: CSV with variable definitions, types, and scopes -This will return a summary including both system and action variables. - -### Intent Summary - -Returns a CSV providing a summary of all intents (customer starts) in the assistant. - +**variable-usage** - Find where variables are used throughout the assistant: +```bash +wxa-analyze variable-usage # All variables +wxa-analyze variable-usage var1 # Single variable +wxa-analyze variable-usage var1 var2 # Multiple variables +wxa-analyze variable-usage --metadata # Include variable metadata ``` -usage: intent_summary.py [-h] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [actions ...] +Output: CSV with variable usage locations and context -Search for action contents inside an assistant +#### Entity Analysis -positional arguments: - actions List of actions names to search contents of. - -options: - -h, --help show this help message and exit - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. +**entity-metadata** - Extract metadata for all entities defined in the assistant: +```bash +wxa-analyze entity-metadata +wxa-analyze entity-metadata -i assistant.json -o ./reports ``` +Output: CSV with entity IDs, types, values, and fuzzy matching settings -The CSV will contain columns with intent information and examples. - -#### Example +**entity-usage** - Find where entities are used throughout the assistant: +```bash +wxa-analyze entity-usage # All entities +wxa-analyze entity-usage sys-date # Single entity +wxa-analyze entity-usage sys-date sys-time # Multiple entities +wxa-analyze entity-usage --metadata # Include entity metadata ``` -python -m cli.intent_summary -``` - -This will return a summary of all intents in the assistant. +Output: CSV with entity usage locations -### Entity Usage - -Returns a CSV reporting the usage of the provided entity(s). +#### Intent Analysis +**intent-usage** - Find where intents are used in action conditions and step handlers: +```bash +wxa-analyze intent-usage # All actions +wxa-analyze intent-usage action_1 # Single action +wxa-analyze intent-usage action_1 action_2 # Multiple actions ``` -usage: entity_usage.py [-h] [-d] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [entities ...] - -Search for entity usage inside an assistant +Output: CSV with intent references and their locations -positional arguments: - entities List of entity names to search for. +#### Extension & Subaction Analysis -options: - -h, --help show this help message and exit - -d, --definition_only - If included, the output will include the places where the entity was defined, not where it was used. - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. +**extension-usage** - Find all custom extension calls in the assistant: +```bash +wxa-analyze extension-usage +wxa-analyze extension-usage -i assistant.json -o ./reports ``` +Output: CSV with extension methods, paths, and invocation locations -The CSV will contain the following columns: `entity`, `action_id`, `action_title`, `step_id`, `step_title`, `step_number`, `source`, `LHS`, `RHS`, `operation`, `SpEL expression`. - -#### Example 1 -``` -python -m cli.entity_usage entity_id_1 entity_id_2 +**subaction-usage** - Find all subaction invocations in the assistant: +```bash +wxa-analyze subaction-usage +wxa-analyze subaction-usage -i assistant.json -o ./reports ``` +Output: CSV with subaction IDs, titles, and where they are called -`entity_id_1` and `entity_id_2` are IDs of entities in the assistant. +### Alternative CLI Usage -#### Example 2 -``` -python -m cli.entity_usage +You can also run commands directly using Python modules: +```bash +python -m cli.action_metadata -i assistant.json -o ./reports +python -m cli.variable_usage var1 var2 --metadata +python -m cli.entity_usage sys-date --metadata ``` -If no entities are provided, then it will return the usage of all entities in the assistant. - - -### Subaction Usage - -Returns a CSV reporting the usage of the provided subaction(s). - -``` -usage: subaction_usage.py [-h] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [subactions ...] +This approach provides the same functionality as the `wxa-analyze` command. -Search for subaction usage inside an assistant +--- -positional arguments: - subactions List of subactions names to search for. +## Software Development Kit -options: - -h, --help show this help message and exit - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. -``` +This repository can be used as a software development kit (SDK), where you can import the models and analyzers into your own custom scripts for programmatic access to assistant analysis. -The CSV will contain the following columns: `action_id`, `action_title`, `step_id`, `step_title`, `step_number`, `subaction_id`, `subaction_title`. +### Basic Usage -#### Example 1 -``` -python -m cli.subaction_usage subaction_id_1 "Subaction 2" -``` - -`subaction1` and `Subaction 2` are names or IDs of actions in the assistant. Notice that if a space is required then you must put it in quotes. +```python +import json +from src.models.assistant import Assistant +from src.analyzers import ( + ActionAnalyzer, + AssistantAnalyzer, + EntityAnalyzer, + IntentAnalyzer, + ResolverAnalyzer, + VariableAnalyzer +) + +# Load assistant JSON +with open("path/to/assistant.json", 'r') as f: + assistant_dict = json.load(f) + +# Create Assistant object +assistant = Assistant.from_dict(assistant_dict) + +# Create analyzers +action_analyzer = ActionAnalyzer(assistant) +variable_analyzer = VariableAnalyzer(assistant) +entity_analyzer = EntityAnalyzer(assistant) +intent_analyzer = IntentAnalyzer(assistant) +resolver_analyzer = ResolverAnalyzer(assistant) +assistant_analyzer = AssistantAnalyzer(assistant) +``` + +### Output Formats + +All analyzer methods support a `return_as` parameter that controls the output format: +- `return_as="python"` (default) - Returns a Python list of dictionaries +- `return_as="dataframe"` - Returns a pandas DataFrame + +Example: +```python +# Get as Python list +results = variable_analyzer.get_variable_usage(return_as="python") -#### Example 2 -``` -python -m cli.subaction_usage +# Get as pandas DataFrame +df = variable_analyzer.get_variable_usage(return_as="dataframe") ``` -If no subactions are provided, then it will return the usage of all subaction in the assistant. +### Available Analyzers and Methods -### Extension Usage +#### AssistantAnalyzer +- `assistant_metadata(return_as="python")` - Get comprehensive assistant metadata -Returns a CSV reporting the usage of the provided extensions(s). +#### ActionAnalyzer +- `action_metadata(return_as="python")` - Get metadata for all actions +- `condition_usage(*action_ids, return_as="python")` - Get all conditions used in steps +- `context_usage(*action_ids, return_as="python")` - Get all context statements +- `customer_response_settings(*action_ids, return_as="python")` - Get customer response settings +- `response_usage(*action_ids, return_as="python")` - Get all response types used +- `validation_settings(*action_ids, return_as="python")` - Get validation settings -``` -usage: extension_usage.py [-h] [-i ASSISTANT_JSON_PATH] [-o OUTPUT_PATH] [extensions ...] +#### VariableAnalyzer +- `get_variable_metadata(include_skill_variables=True, include_step_variables=True, include_result_variables=True, include_system_variables=True, return_as="python")` - Get metadata for all variables +- `get_variable_usage(*variable_ids, return_as="python")` - Find where variables are used +- `get_variables_by_type(variable_type, return_as="python")` - Get variables of a specific type -Search for subaction usage inside an assistant +#### EntityAnalyzer +- `entity_metadata(return_as="python")` - Get metadata for all entities +- `entity_usage(*entity_ids, return_as="python")` - Find where entities are used -positional arguments: - extensions List of extensions names to search for. +#### IntentAnalyzer +- `intent_usage(*action_ids, return_as="python")` - Find where intents are used -options: - -h, --help show this help message and exit - -i ASSISTANT_JSON_PATH, --assistant_json_path ASSISTANT_JSON_PATH - Path to assistant json. If not included, the code will search for one in `jsons/`. - -o OUTPUT_PATH, --output_path OUTPUT_PATH - Path to output directory where the results will be saved. If not included, the code default to `reports/`. -``` +#### ResolverAnalyzer +- `subaction_usage(return_as="python")` - Find all subaction invocations +- `extension_usage(return_as="python")` - Find all extension calls -The CSV will contain the following columns: `action_id`, `action_title`, `step_id`, `step_title`, `step_number`, `extension_method`, `extension_path`. +### Working with the Assistant Object -#### Example +The `Assistant` object provides direct access to all assistant components: -``` -python -m cli.extension_usage -``` +```python +# Access actions +for action_id, action in assistant.actions.items(): + print(f"Action: {action.title} ({action.id})") + + # Access steps within an action + for i, step in enumerate(action.steps): + print(f" Step {i+1}: {step.title} ({step.id})") -This will return the usage of all extensions in the assistant. +# Access variables +for var_id, variable in assistant.skill_variables.items(): + print(f"Skill Variable: {variable.id}") -Currently there is no good way to uniquely identify extensions in the assistant json becuase they don't provide the extension name. Thus, for now we can only query for all extension use. Typically this should not be an overwhelming amount. If you have any ideas, please open a GitHub issue. +for var_id, variable in assistant.step_variables.items(): + print(f"Step Variable: {variable.id}") ---- +for var_id, variable in assistant.result_variables.items(): + print(f"Result Variable: {variable.id}") -## Software Development Kit + for var_id, variable in assistant.system_variables.items(): + print(f"System Variable: {variable.id}") -This repository can be used as a software development kit (SDK), where the main class is imported and integrated into your own custom scripts. An example of this can be found in the file [`main.py`](/main.py). This gives an example of how to correctly import and instantiate the called `AssistantStaticAnalyzer`. After making the appropriate updates to the file, you can run it with the following command. +# Access entities +for entity_id, entity in assistant.entities.items(): + print(f"Entity: {entity.id}") -``` -python -m main +# Access intents +for intent_id, intent in assistant.intents.items(): + print(f"Intent: {intent.id}") ``` -### Class Methods of `AssistantStaticAnalyzer` +### Example: Custom Analysis Script -All class methods have an optional parameter `return_as` which defaults to returning the results as a python list of dictionaries. If `return_as="csv"`, then the class method will return the results as a `pandas` DataFrame. If `return_as="json"`, then the class method will return the results as a JSON serialized string. [`main.py`](/main.py) shows an example of this usage. - -#### Variables -- `get_all_variable_usage(return_as=None)` -- `search_for_variables(*variable_ids, return_as=None)` -- `get_all_variables_used_in_action(*action_title_or_id_list, return_as=None)` -- `variable_summary(return_as=None)` +```python +import json +from src.models.assistant import Assistant +from src.analyzers import VariableAnalyzer -#### Entities -- `get_all_entity_usage(return_as=None)` -- `search_for_entities(*entity_ids, return_as=None)` -- `get_all_entities_used_in_action(*action_title_or_id_list, return_as=None)` +# Load assistant +with open("assistant.json", 'r') as f: + assistant_dict = json.load(f) +assistant = Assistant.from_dict(assistant_dict) -#### Subactions -- `get_all_subaction_usage(return_as=None)` -- `search_for_subactions(*subaction_title_or_id_list, return_as=None)` -- `get_all_subactions_used_in_action(*action_title_or_id_list, return_as=None)` +# Analyze variable usage +variable_analyzer = VariableAnalyzer(assistant) -#### Extensions -- `get_all_extension_usage(return_as=None)` -- `get_all_extensions_used_in_action(*action_title_or_id_list, return_as=None)` +# Get usage of specific variables +usage_df = variable_analyzer.get_variable_usage( + "customer_name", + "order_id", + return_as="dataframe" +) -#### Responses -- `get_all_responses(return_as=None)` -- `get_all_responses_in_action(*action_title_or_id_list, return_as=None)` +# Save to CSV +usage_df.to_csv("variable_usage_report.csv", index=False) -#### Action Settings -- `get_all_action_settings(return_as=None)` -- `search_for_action_settings(*action_title_or_id_list, return_as=None)` +# Get all skill variables +skill_vars = variable_analyzer.get_variable_metadata( + include_skill_variables=True, + include_step_variables=False, + include_result_variables=False, + include_system_variables=False, + return_as="dataframe" +) -#### Intents -- `intent_summary(action_title_or_id_list=None, return_as=None)` +print(f"Found {len(skill_vars)} skill variables") +``` -### Looping Over Actions and Steps +### Advanced: Looping Over Actions and Steps -A common activity when writing custom scripts is to loop over actions and steps. The following code snippet shows how to do this ```python -import json -from src.assistant_static_analyzer import AssistantStaticAnalyzer - -path_to_assistant_json = "./jsons/.json" # <-- TODO: Add your variable name here -assistant_obj = json.load(open(path_to_assistant_json, 'r')) -analyzer = AssistantStaticAnalyzer(assistant_obj) - -for action in analyzer.actions: - action_id = action.ID - action_title = action.title - - for step in action.steps: - step_id = step.ID - step_title = step.title - step_number = step.step_number - - print(action_title, step_number, step_title) +from src.models.assistant import Assistant + +# Load and parse assistant +assistant = Assistant.from_dict(assistant_dict) + +# Loop through all actions and steps +for action in assistant.actions.values(): + print(f"Action: {action.title} (ID: {action.id})") + + # Access action-level properties + print(f" Intent: {action.intent.id}") + print(f" Number of steps: {len(action.steps)}") + + # Loop through steps + for i, step in enumerate(action.steps): + print(f" Step {i+1}: {step.title} (ID: {step.id})") + + # Access step properties + if step.question: + print(f" Has question: {step.question.response_type}") + if step.resolver: + print(f" Resolver: {step.resolver.__class__.__name__}") ``` If you find this SDK useful and would like to see more documentation, please open a GitHub issue. @@ -463,7 +438,7 @@ If you find this SDK useful and would like to see more documentation, please ope ### My Assistant JSON Failed to Parse -I have only tested this codebase on the select projects that I have been a part of. Watsonx Assistant is a complex tool and as such I have not found an exhaustive list of all possible things that I could encounter in the JSON. Please open a GitHub issue. +I have only tested this codebase on the select projects that I have been a part of. Watsonx Assistant is a complex tool and as such I have not found an exhaustive list of all possible things that I could encounter in the JSON. If you run into any errors, please open a GitHub issue. ### How Do I Report an Issue/Bug @@ -475,4 +450,4 @@ Please open a GitHub issue. ### Is There More SDK Documentation? -For now you can consult the actual source code found in [`/src`](/src), which is reasonably structured. If you find the SDK useful and want more detailed documentation, please open a GitHub issue. +For now you can consult the actual source code found in [`/src`](/src), which is reasonably structured and well-typed. If you find the SDK useful and want more detailed documentation, please open a GitHub issue. diff --git a/skill_analytics/cli/__init__.py b/skill_analytics/cli/__init__.py new file mode 100644 index 0000000..6469244 --- /dev/null +++ b/skill_analytics/cli/__init__.py @@ -0,0 +1,7 @@ +""" +WxA Analyzer CLI Module + +This module provides command-line interface tools for analyzing watsonx Assistant instances. +""" + +__version__ = "0.1.0" diff --git a/skill_analytics/cli/__main__.py b/skill_analytics/cli/__main__.py new file mode 100644 index 0000000..ceaecaa --- /dev/null +++ b/skill_analytics/cli/__main__.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Main CLI entry point for wxa-analyze. + +This module provides a unified command-line interface for all analysis commands. +""" + +import sys +from argparse import ArgumentParser + + +def main(): + """Main entry point for the wxa-analyze CLI.""" + parser = ArgumentParser( + prog="wxa-analyze", + description="Analyze and search various aspects of a watsonx Assistant instance", + epilog="Use 'wxa-analyze --help' for more information on a specific command." + ) + + subparsers = parser.add_subparsers( + title="Available commands", + dest="command", + help="Command to run", + required=True, + metavar="" + ) + + # Define all available commands + commands = { + "action-metadata": { + "module": "cli.action_metadata", + "help": "Extract metadata for all actions in the assistant" + }, + "assistant-metadata": { + "module": "cli.assistant_metadata", + "help": "Extract comprehensive metadata about the assistant" + }, + "condition-usage": { + "module": "cli.condition_usage", + "help": "Report all conditions used in action steps" + }, + "context-usage": { + "module": "cli.context_usage", + "help": "Report all context statements used in action steps" + }, + "customer-response-settings": { + "module": "cli.customer_response_settings", + "help": "Extract customer response settings for action steps" + }, + "entity-metadata": { + "module": "cli.entity_metadata", + "help": "Extract metadata for all entities defined in the assistant" + }, + "entity-usage": { + "module": "cli.entity_usage", + "help": "Find where entities are used throughout the assistant" + }, + "extension-usage": { + "module": "cli.extension_usage", + "help": "Find all custom extension calls in the assistant" + }, + "intent-usage": { + "module": "cli.intent_usage", + "help": "Find where intents are used in action conditions and step handlers" + }, + "response-usage": { + "module": "cli.response_usage", + "help": "Report all customer response types used in action steps" + }, + "subaction-usage": { + "module": "cli.subaction_usage", + "help": "Find all subaction invocations in the assistant" + }, + "validation-settings": { + "module": "cli.validation_settings", + "help": "Extract validation settings for action steps" + }, + "variable-metadata": { + "module": "cli.variable_metadata", + "help": "Extract metadata for all variables defined in the assistant" + }, + "variable-usage": { + "module": "cli.variable_usage", + "help": "Find where variables are used throughout the assistant" + }, + } + + # Add subparsers for each command + for cmd_name, cmd_info in commands.items(): + subparsers.add_parser( + cmd_name, + help=cmd_info["help"], + add_help=False # Let the actual module handle help + ) + + # Parse only the command name first + args, remaining = parser.parse_known_args() + + # Get the module for the selected command + if args.command in commands: + module_name = commands[args.command]["module"] + + # Import and run the command's main function + try: + # Restore sys.argv to include the remaining arguments + sys.argv = [f"wxa-analyze {args.command}"] + remaining + + # Import the module dynamically + import importlib + module = importlib.import_module(module_name) + + # Run the main function + module.main() + except ImportError as e: + print(f"Error: Could not import module '{module_name}': {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error running command '{args.command}': {e}", file=sys.stderr) + sys.exit(1) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skill_analytics/cli/action_contents.py b/skill_analytics/cli/action_contents.py deleted file mode 100644 index 206f1e2..0000000 --- a/skill_analytics/cli/action_contents.py +++ /dev/null @@ -1,60 +0,0 @@ -from argparse import ArgumentParser -from dotenv import load_dotenv -import pandas as pd - -from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.assistant_static_analyzer import AssistantStaticAnalyzer - -def main(): - cfg = get_config() - - parser = ArgumentParser(description="Search for action contents inside an assistant") - parser.add_argument('actions', nargs='*', help='List of actions names to search contents of.') - parser.add_argument('-v', '--variables', action='store_true', default=False, help="If included, the output will include all variable usage inside the listed actions.") - parser.add_argument('-s', '--subactions', action='store_true', default=False, help="If included, the output will include all subaction usage inside the listed actions.") - parser.add_argument('-e', '--extensions', action='store_true', default=False, help="If included, the output will include all extension usage inside the listed actions.") - parser.add_argument('-r', '--responses', action='store_true', default=False, help="If included, the output will include all text response inside the listed actions.") - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') - args = parser.parse_args() - - content_types = { - "variables": args.variables, - "subactions": args.subactions, - "extensions": args.extensions, - "responses": args.responses, - } - - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) - - actions = clean_cli_list(args.actions) - action_contents_df = pd.DataFrame() - all_false = not any(content_types.values()) - - if args.variables or all_false: - variable_usage_df = analyzer.get_all_variables_used_in_action(*actions, return_as="csv") - action_contents_df = pd.concat([action_contents_df, variable_usage_df], ignore_index=True) - - if args.subactions or all_false: - subaction_usage_df = analyzer.get_all_subactions_used_in_action(*actions, return_as="csv") - action_contents_df = pd.concat([action_contents_df, subaction_usage_df], ignore_index=True) - - if args.extensions or all_false: - extension_usage_df = analyzer.get_all_extensions_used_in_action(*actions, return_as="csv") - action_contents_df = pd.concat([action_contents_df, extension_usage_df], ignore_index=True) - - if args.responses or all_false: - responses_df = analyzer.get_all_responses_in_action(*actions, return_as="csv") - action_contents_df = pd.concat([action_contents_df, responses_df], ignore_index=True) - - contents_string = "all contents" if all_false else ', '.join([content_name for content_name, content_included in content_types.items() if content_included]) - default_file_name = f"{', '.join(actions)} - {contents_string}.csv" if actions else f"all actions - {contents_string}.csv" - create_directory(args.output_path) - output_path = get_output_save_path(args.output_path, default_file_name) - action_contents_df.to_csv(output_path, index=False) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/skill_analytics/cli/action_metadata.py b/skill_analytics/cli/action_metadata.py new file mode 100644 index 0000000..aefb395 --- /dev/null +++ b/skill_analytics/cli/action_metadata.py @@ -0,0 +1,55 @@ +from argparse import ArgumentParser + +from config.config import get_config +from src.analyzers import ActionAnalyzer +from src.models.assistant import Assistant + +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Extract metadata for all actions in the assistant", + epilog="Example: python -m cli.action_metadata -i assistant.json -o ./reports" + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + action_analyzer = ActionAnalyzer(assistant) + + action_metadata_df = action_analyzer.action_metadata(return_as="dataframe") + + action_metadata_df = action_metadata_df.sort_values("action_id") + + default_file_name = "action_metadata.csv" + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + action_metadata_df.to_csv(output_path, index=False) + + print(f"Action metadata saved to: {output_path}") + +if __name__ == "__main__": + main() diff --git a/skill_analytics/cli/action_settings.py b/skill_analytics/cli/action_settings.py deleted file mode 100644 index 39adf28..0000000 --- a/skill_analytics/cli/action_settings.py +++ /dev/null @@ -1,31 +0,0 @@ -from argparse import ArgumentParser -from dotenv import load_dotenv -import pandas as pd - -from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.assistant_static_analyzer import AssistantStaticAnalyzer - -def main(): - cfg = get_config() - - parser = ArgumentParser(description="Search for action contents inside an assistant") - parser.add_argument('actions', nargs='*', help='List of actions names to search contents of.') - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') - args = parser.parse_args() - - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) - - actions = clean_cli_list(args.actions) - action_settings_df = analyzer.search_for_action_settings(*actions, return_as="csv").sort_values("action_title") - - default_file_name = f"{', '.join(actions)} - settings.csv" if actions else f"all actions - settings.csv" - create_directory(args.output_path) - output_path = get_output_save_path(args.output_path, default_file_name) - action_settings_df.to_csv(output_path, index=False) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/skill_analytics/cli/assistant_metadata.py b/skill_analytics/cli/assistant_metadata.py new file mode 100644 index 0000000..cf808ce --- /dev/null +++ b/skill_analytics/cli/assistant_metadata.py @@ -0,0 +1,61 @@ +import json +from argparse import ArgumentParser + +from config.config import get_config +from src.analyzers import AssistantAnalyzer +from src.models.assistant import Assistant + +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Extract comprehensive metadata about the assistant including IDs, settings, and aggregated statistics", + epilog="Example: python -m cli.assistant_metadata -i assistant.json -o ./reports" + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the JSON output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + assistant_analyzer = AssistantAnalyzer(assistant) + + # Get metadata as Python dict (list with single dict) + assistant_metadata = assistant_analyzer.assistant_metadata(return_as="python") + + # Since there's only one assistant, extract the single dict from the list + metadata_dict = assistant_metadata[0] if assistant_metadata else {} + + default_file_name = "assistant_metadata.json" + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + + # Write as formatted JSON + with open(output_path, 'w') as f: + json.dump(metadata_dict, f, indent=2) + + print(f"Assistant metadata saved to: {output_path}") + +if __name__ == "__main__": + main() diff --git a/skill_analytics/cli/condition_usage.py b/skill_analytics/cli/condition_usage.py new file mode 100644 index 0000000..ea7de18 --- /dev/null +++ b/skill_analytics/cli/condition_usage.py @@ -0,0 +1,70 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import ActionAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Report all conditions used in action steps", + epilog="Examples:\n" + " python -m cli.condition_usage # All actions\n" + " python -m cli.condition_usage action_1 # Single action\n" + " python -m cli.condition_usage action_1 action_2 # Multiple actions", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'action_ids', + nargs='*', + metavar='ACTION_ID', + help='One or more action IDs to analyze. If omitted, analyzes all actions in the assistant.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + action_analyzer = ActionAnalyzer(assistant) + + action_ids = clean_cli_list(args.action_ids) + condition_usage_df = action_analyzer.condition_usage(*action_ids, return_as="dataframe") + + condition_usage_df = condition_usage_df.sort_values(["action_id", "step_number"]) + + default_file_name = "all_condition_usage.csv" + if len(action_ids): + default_file_name = f"condition_usage ({', '.join(action_ids)}).csv" + + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + condition_usage_df.to_csv(output_path, index=False) + + print(f"Condition usage saved to: {output_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skill_analytics/cli/context_usage.py b/skill_analytics/cli/context_usage.py new file mode 100644 index 0000000..0062961 --- /dev/null +++ b/skill_analytics/cli/context_usage.py @@ -0,0 +1,70 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import ActionAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Report all context statements used in action steps", + epilog="Examples:\n" + " python -m cli.context_usage # All actions\n" + " python -m cli.context_usage action_1 # Single action\n" + " python -m cli.context_usage action_1 action_2 # Multiple actions", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'action_ids', + nargs='*', + metavar='ACTION_ID', + help='One or more action IDs to analyze. If omitted, analyzes all actions in the assistant.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + action_analyzer = ActionAnalyzer(assistant) + + action_ids = clean_cli_list(args.action_ids) + context_usage_df = action_analyzer.context_usage(*action_ids, return_as="dataframe") + + context_usage_df = context_usage_df.sort_values(["action_id", "step_number"]) + + default_file_name = "all_context_usage.csv" + if len(action_ids): + default_file_name = f"context_usage ({', '.join(action_ids)}).csv" + + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + context_usage_df.to_csv(output_path, index=False) + + print(f"Context usage saved to: {output_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skill_analytics/cli/customer_response_settings.py b/skill_analytics/cli/customer_response_settings.py new file mode 100644 index 0000000..c69aa0d --- /dev/null +++ b/skill_analytics/cli/customer_response_settings.py @@ -0,0 +1,70 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import ActionAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Extract customer response settings (prompts, retries, disambiguation) for action steps", + epilog="Examples:\n" + " python -m cli.customer_response_settings # All actions\n" + " python -m cli.customer_response_settings action_1 # Single action\n" + " python -m cli.customer_response_settings action_1 action_2 # Multiple actions", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'action_ids', + nargs='*', + metavar='ACTION_ID', + help='One or more action IDs to analyze. If omitted, analyzes all actions in the assistant.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + action_analyzer = ActionAnalyzer(assistant) + + action_ids = clean_cli_list(args.action_ids) + customer_response_settings_df = action_analyzer.customer_response_settings(*action_ids, return_as="dataframe") + + customer_response_settings_df = customer_response_settings_df.sort_values(["action_id", "step_number"]) + + default_file_name = "all_customer_response_settings.csv" + if len(action_ids): + default_file_name = f"customer_response_settings ({', '.join(action_ids)}).csv" + + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + customer_response_settings_df.to_csv(output_path, index=False) + + print(f"Customer response settings saved to: {output_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skill_analytics/cli/entity_metadata.py b/skill_analytics/cli/entity_metadata.py new file mode 100644 index 0000000..04ab32f --- /dev/null +++ b/skill_analytics/cli/entity_metadata.py @@ -0,0 +1,55 @@ +from argparse import ArgumentParser + +from config.config import get_config +from src.analyzers import EntityAnalyzer +from src.models.assistant import Assistant + +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Extract metadata for all entities defined in the assistant", + epilog="Example: python -m cli.entity_metadata -i assistant.json -o ./reports" + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + entity_analyzer = EntityAnalyzer(assistant) + + entity_metadata_df = entity_analyzer.entity_metadata(return_as="dataframe") + + entity_metadata_df = entity_metadata_df.sort_values("entity_id") + + default_file_name = "entity_metadata.csv" + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + entity_metadata_df.to_csv(output_path, index=False) + + print(f"Entity metadata saved to: {output_path}") + +if __name__ == "__main__": + main() diff --git a/skill_analytics/cli/entity_usage.py b/skill_analytics/cli/entity_usage.py index 8caf4ae..db220e6 100644 --- a/skill_analytics/cli/entity_usage.py +++ b/skill_analytics/cli/entity_usage.py @@ -1,42 +1,87 @@ -from argparse import ArgumentParser -from dotenv import load_dotenv +from argparse import ArgumentParser, RawDescriptionHelpFormatter from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.utils.dataframe_helper import move_col_to_front -from src.assistant_static_analyzer import AssistantStaticAnalyzer +from src.analyzers import EntityAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + def main(): cfg = get_config() - parser = ArgumentParser(description="Search for entity usage inside an assistant") - parser.add_argument('entities', nargs='*', help='List of entity names to search for.') - parser.add_argument('-d', '--definition_only', action='store_true', default=False, help="If included, the output will include the places where the entity was defined, not where it was used.") - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') + parser = ArgumentParser( + description="Find where entities are used throughout the assistant", + epilog="Examples:\n" + " python -m cli.entity_usage # All entities\n" + " python -m cli.entity_usage sys-date # Single entity\n" + " python -m cli.entity_usage sys-date sys-time # Multiple entities\n" + " python -m cli.entity_usage --metadata # Include entity metadata", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'entities', + nargs='*', + metavar='ENTITY_ID', + help='One or more entity IDs to search for. If omitted, reports usage of all entities.' + ) + parser.add_argument( + '--metadata', + action='store_true', + default=False, + help='Include entity metadata (type, values, fuzzy matching settings) in the output.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) args = parser.parse_args() - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + entity_analyzer = EntityAnalyzer(assistant) - entities = clean_cli_list(args.entities) - if len(entities): - entity_usage_df = analyzer.search_for_entities(*entities, return_as="csv") - default_file_name = f"{', '.join(entities)} usage.csv" - else: - entity_usage_df = analyzer.get_all_entity_usage(return_as="csv") - default_file_name = "all entity usage.csv" - - if args.definition_only: - entity_usage_df = entity_usage_df[entity_usage_df["source"] == "customer_response"] + entity_ids = clean_cli_list(args.entities) + entity_usage_df = entity_analyzer.entity_usage(*entity_ids, return_as="dataframe") - entity_usage_df = move_col_to_front(entity_usage_df, "entity") - entity_usage_df = entity_usage_df.dropna(axis=1, how='all') + if args.metadata: + entity_metadata_df = entity_analyzer.entity_metadata(return_as="dataframe") + + # Prepend 'entity_' to all columns in entity_metadata_df to avoid conflicts + entity_metadata_df = entity_metadata_df.add_prefix('entity_metadata_') + + # Join on entity_id = entity_metadata_entity_id + entity_usage_df = entity_usage_df.merge( + entity_metadata_df, + left_on='entity_id', + right_on='entity_metadata_entity_id', + how='left' + ) + default_file_name = "all_entity_usage.csv" + create_directory(args.output_path) output_path = get_output_save_path(args.output_path, default_file_name) entity_usage_df.to_csv(output_path, index=False) + + print(f"Entity usage saved to: {output_path}") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/skill_analytics/cli/extension_usage.py b/skill_analytics/cli/extension_usage.py index 798d5ff..dc4abd9 100644 --- a/skill_analytics/cli/extension_usage.py +++ b/skill_analytics/cli/extension_usage.py @@ -1,35 +1,55 @@ from argparse import ArgumentParser -from dotenv import load_dotenv from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.assistant_static_analyzer import AssistantStaticAnalyzer +from src.analyzers import ResolverAnalyzer +from src.models.assistant import Assistant + +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + def main(): cfg = get_config() - parser = ArgumentParser(description="Search for subaction usage inside an assistant") - # TODO: There is currently no good way to search for extensions because the assistant JSON doesn't include their name - parser.add_argument('extensions', nargs='*', help='List of extensions names to search for.') - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') + parser = ArgumentParser( + description="Find all custom extension calls in the assistant", + epilog="Example: python -m cli.extension_usage -i assistant.json -o ./reports" + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) args = parser.parse_args() - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) - - extensions = clean_cli_list(args.extensions) - if len(extensions): - extension_usage_df = analyzer.search_for_extensions(*extensions, return_as="csv") - default_file_name = f"{', '.join(extensions)} usage.csv" - else: - extension_usage_df = analyzer.get_all_extension_usage(return_as="csv") - default_file_name = f"all extension usage.csv" + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + resolver_analyzer = ResolverAnalyzer(assistant) + extension_usage_df = resolver_analyzer.extension_usage(return_as="dataframe") + + extension_usage_df = extension_usage_df.sort_values(["action_id", "step_number"]) + + default_file_name = "extension_usage.csv" create_directory(args.output_path) output_path = get_output_save_path(args.output_path, default_file_name) extension_usage_df.to_csv(output_path, index=False) + + print(f"Extension usage saved to: {output_path}") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/skill_analytics/cli/intent_summary.py b/skill_analytics/cli/intent_summary.py deleted file mode 100644 index e77b456..0000000 --- a/skill_analytics/cli/intent_summary.py +++ /dev/null @@ -1,30 +0,0 @@ -from argparse import ArgumentParser -from dotenv import load_dotenv -import pandas as pd - -from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.assistant_static_analyzer import AssistantStaticAnalyzer - -def main(): - cfg = get_config() - - parser = ArgumentParser(description="Search for action contents inside an assistant") - parser.add_argument('actions', nargs='*', help='List of actions names to search contents of.') - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') - args = parser.parse_args() - - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) - - intent_summary_df = analyzer.intent_summary(return_as="csv") - - default_file_name = "intent_summary.csv" - create_directory(args.output_path) - output_path = get_output_save_path(args.output_path, default_file_name) - intent_summary_df.to_csv(output_path, index=False) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/skill_analytics/cli/intent_usage.py b/skill_analytics/cli/intent_usage.py new file mode 100644 index 0000000..ce32e91 --- /dev/null +++ b/skill_analytics/cli/intent_usage.py @@ -0,0 +1,68 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import IntentAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Find where intents are used in action conditions and step handlers", + epilog="Examples:\n" + " python -m cli.intent_usage # All actions\n" + " python -m cli.intent_usage action_1 # Single action\n" + " python -m cli.intent_usage action_1 action_2 # Multiple actions", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'action_ids', + nargs='*', + metavar='ACTION_ID', + help='One or more action IDs to analyze. If omitted, analyzes all actions in the assistant.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + intent_analyzer = IntentAnalyzer(assistant) + + action_ids = clean_cli_list(args.action_ids) + intent_usage_df = intent_analyzer.intent_usage(*action_ids, return_as="dataframe") + + default_file_name = "all_intent_usage.csv" + if len(action_ids): + default_file_name = f"intent_usage ({', '.join(action_ids)}).csv" + + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + intent_usage_df.to_csv(output_path, index=False) + + print(f"Intent usage saved to: {output_path}") + +if __name__ == "__main__": + main() diff --git a/skill_analytics/cli/response_usage.py b/skill_analytics/cli/response_usage.py new file mode 100644 index 0000000..22751f0 --- /dev/null +++ b/skill_analytics/cli/response_usage.py @@ -0,0 +1,70 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import ActionAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Report all customer response types used in action steps (text, options, dates, etc.)", + epilog="Examples:\n" + " python -m cli.response_usage # All actions\n" + " python -m cli.response_usage action_1 # Single action\n" + " python -m cli.response_usage action_1 action_2 # Multiple actions", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'action_ids', + nargs='*', + metavar='ACTION_ID', + help='One or more action IDs to analyze. If omitted, analyzes all actions in the assistant.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + action_analyzer = ActionAnalyzer(assistant) + + action_ids = clean_cli_list(args.action_ids) + response_usage_df = action_analyzer.response_usage(*action_ids, return_as="dataframe") + + response_usage_df = response_usage_df.sort_values(["action_id", "step_number"]) + + default_file_name = "all_response_usage.csv" + if len(action_ids): + default_file_name = f"response_usage ({', '.join(action_ids)}).csv" + + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + response_usage_df.to_csv(output_path, index=False) + + print(f"Response usage saved to: {output_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skill_analytics/cli/subaction_usage.py b/skill_analytics/cli/subaction_usage.py index ca6a82b..7ae1d02 100644 --- a/skill_analytics/cli/subaction_usage.py +++ b/skill_analytics/cli/subaction_usage.py @@ -1,34 +1,55 @@ from argparse import ArgumentParser -from dotenv import load_dotenv from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.assistant_static_analyzer import AssistantStaticAnalyzer +from src.analyzers import ResolverAnalyzer +from src.models.assistant import Assistant + +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + def main(): cfg = get_config() - parser = ArgumentParser(description="Search for subaction usage inside an assistant") - parser.add_argument('subactions', nargs='*', help='List of subactions names to search for.') - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') + parser = ArgumentParser( + description="Find all subaction invocations in the assistant", + epilog="Example: python -m cli.subaction_usage -i assistant.json -o ./reports" + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) args = parser.parse_args() - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) - - subactions = clean_cli_list(args.subactions) - if len(subactions): - subaction_usage_df = analyzer.search_for_subactions(*subactions, return_as="csv") - default_file_name = f"{', '.join(subactions)} usage.csv" - else: - subaction_usage_df = analyzer.get_all_subaction_usage(return_as="csv") - default_file_name = "all subaction usage.csv" + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + resolver_analyzer = ResolverAnalyzer(assistant) + subaction_usage_df = resolver_analyzer.subaction_usage(return_as="dataframe") + + subaction_usage_df = subaction_usage_df.sort_values(["action_id", "step_number"]) + + default_file_name = "subaction_usage.csv" create_directory(args.output_path) output_path = get_output_save_path(args.output_path, default_file_name) subaction_usage_df.to_csv(output_path, index=False) + + print(f"Subaction usage saved to: {output_path}") if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/skill_analytics/src/utils/clean_cli_list.py b/skill_analytics/cli/utils/clean_cli_list.py similarity index 100% rename from skill_analytics/src/utils/clean_cli_list.py rename to skill_analytics/cli/utils/clean_cli_list.py diff --git a/skill_analytics/src/utils/dataframe_helper.py b/skill_analytics/cli/utils/dataframe_helper.py similarity index 100% rename from skill_analytics/src/utils/dataframe_helper.py rename to skill_analytics/cli/utils/dataframe_helper.py diff --git a/skill_analytics/src/utils/file_path_helper.py b/skill_analytics/cli/utils/file_path_helper.py similarity index 100% rename from skill_analytics/src/utils/file_path_helper.py rename to skill_analytics/cli/utils/file_path_helper.py diff --git a/skill_analytics/cli/validation_settings.py b/skill_analytics/cli/validation_settings.py new file mode 100644 index 0000000..e1f98f4 --- /dev/null +++ b/skill_analytics/cli/validation_settings.py @@ -0,0 +1,70 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import ActionAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Extract validation settings (input validation rules) for action steps", + epilog="Examples:\n" + " python -m cli.validation_settings # All actions\n" + " python -m cli.validation_settings action_1 # Single action\n" + " python -m cli.validation_settings action_1 action_2 # Multiple actions", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'action_ids', + nargs='*', + metavar='ACTION_ID', + help='One or more action IDs to analyze. If omitted, analyzes all actions in the assistant.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + action_analyzer = ActionAnalyzer(assistant) + + action_ids = clean_cli_list(args.action_ids) + validation_settings_df = action_analyzer.validation_settings(*action_ids, return_as="dataframe") + + validation_settings_df = validation_settings_df.sort_values(["action_id", "step_number"]) + + default_file_name = "all_validation_settings.csv" + if len(action_ids): + default_file_name = f"validation_settings ({', '.join(action_ids)}).csv" + + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + validation_settings_df.to_csv(output_path, index=False) + + print(f"Validation settings saved to: {output_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skill_analytics/cli/variable_metadata.py b/skill_analytics/cli/variable_metadata.py new file mode 100644 index 0000000..35a2a76 --- /dev/null +++ b/skill_analytics/cli/variable_metadata.py @@ -0,0 +1,94 @@ +from argparse import ArgumentParser, RawDescriptionHelpFormatter + +from config.config import get_config +from src.analyzers import VariableAnalyzer +from src.models.assistant import Assistant + +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + + +def main(): + cfg = get_config() + + parser = ArgumentParser( + description="Extract metadata for all variables defined in the assistant", + epilog="Examples:\n" + " python -m cli.variable_metadata # All variable types\n" + " python -m cli.variable_metadata --skill_variables # Only skill variables\n" + " python -m cli.variable_metadata --step_variables --result_variables # Multiple types", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + '--skill_variables', + action='store_true', + default=False, + help='Include only skill (action-level) variables. If no flags are set, all types are included.' + ) + parser.add_argument( + '--step_variables', + action='store_true', + default=False, + help='Include only step (local) variables. If no flags are set, all types are included.' + ) + parser.add_argument( + '--result_variables', + action='store_true', + default=False, + help='Include only result variables from extensions/subactions. If no flags are set, all types are included.' + ) + parser.add_argument( + '--system_variables', + action='store_true', + default=False, + help='Include only system variables (built-in Watson variables). If no flags are set, all types are included.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) + args = parser.parse_args() + + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + variable_analyzer = VariableAnalyzer(assistant) + + all_false = not any([args.skill_variables, args.step_variables, args.result_variables, args.system_variables]) + if all_false: + variable_metadata_df = variable_analyzer.get_variable_metadata(return_as="dataframe") + else: + variable_metadata_df = variable_analyzer.get_variable_metadata( + include_skill_variables=args.skill_variables, + include_step_variables=args.step_variables, + include_result_variables=args.result_variables, + include_system_variables=args.system_variables, + ) + + variable_metadata_df = variable_metadata_df.sort_values("id") + # if 'step_number' in variable_metadata_df.columns: + # variable_metadata_df['step_number'] = variable_metadata_df['step_number'].astype('Int64') + + default_file_name = "variable_metadata.csv" + create_directory(args.output_path) + output_path = get_output_save_path(args.output_path, default_file_name) + variable_metadata_df.to_csv(output_path, index=False) + + print(f"Variable metadata saved to: {output_path}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/skill_analytics/cli/variable_summary.py b/skill_analytics/cli/variable_summary.py deleted file mode 100644 index 369d5ce..0000000 --- a/skill_analytics/cli/variable_summary.py +++ /dev/null @@ -1,39 +0,0 @@ -from argparse import ArgumentParser -from dotenv import load_dotenv - -from config.config import get_config -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.assistant_static_analyzer import AssistantStaticAnalyzer - -def main(): - cfg = get_config() - - parser = ArgumentParser(description="Summary of all variables defined in the assistant") - parser.add_argument('-s', '--system_variables', action='store_true', default=False, help="If included, the output will include system variables.") - parser.add_argument('-a', '--action_variables', action='store_true', default=False, help="If included, the output will include action variables.") - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') - args = parser.parse_args() - - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) - - variable_summary_df = analyzer.variable_summary(return_as="csv") - - all_false = not any([args.system_variables, args.action_variables]) - if not args.system_variables and not all_false: - variable_summary_df = variable_summary_df[variable_summary_df["source"] != "system_variable"] - if not args.action_variables and not all_false: - variable_summary_df = variable_summary_df[variable_summary_df["source"] != "action_variable"] - - variable_summary_df = variable_summary_df.sort_values("variable_id") - if 'step_number' in variable_summary_df.columns: - variable_summary_df['step_number'] = variable_summary_df['step_number'].astype('Int64') - - default_file_name = "variable_summary.csv" - create_directory(args.output_path) - output_path = get_output_save_path(args.output_path, default_file_name) - variable_summary_df.to_csv(output_path, index=False) - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/skill_analytics/cli/variable_usage.py b/skill_analytics/cli/variable_usage.py index 7271328..c3ac09b 100644 --- a/skill_analytics/cli/variable_usage.py +++ b/skill_analytics/cli/variable_usage.py @@ -1,37 +1,94 @@ -from argparse import ArgumentParser -from dotenv import load_dotenv +from argparse import ArgumentParser, RawDescriptionHelpFormatter from config.config import get_config -from src.utils.clean_cli_list import clean_cli_list -from src.utils.file_path_helper import get_assistant_json, create_directory, get_output_save_path -from src.utils.dataframe_helper import move_col_to_front -from src.assistant_static_analyzer import AssistantStaticAnalyzer +from src.analyzers import VariableAnalyzer +from src.models.assistant import Assistant + +from .utils.clean_cli_list import clean_cli_list +from .utils.file_path_helper import ( + create_directory, + get_assistant_json, + get_output_save_path, +) + def main(): cfg = get_config() - parser = ArgumentParser(description="Search for variable usage inside an assistant") - parser.add_argument('variables', nargs='*', help='List of variable names to search for.') - parser.add_argument('-i', '--assistant_json_path', required=False, default=cfg["assistant_json_directory"], type=str, help=f'Path to assistant json. If not included, the code will search for one in `{cfg["assistant_json_directory"]}`.') - parser.add_argument('-o', '--output_path', required=False, default=cfg["output_directory"], type=str, help=f'Path to output directory where the results will be saved. If not included, the code default to `{cfg["output_directory"]}`.') + parser = ArgumentParser( + description="Find where variables are used throughout the assistant", + epilog="Examples:\n" + " python -m cli.variable_usage # All variables\n" + " python -m cli.variable_usage var1 # Single variable\n" + " python -m cli.variable_usage var1 var2 # Multiple variables\n" + " python -m cli.variable_usage --metadata # Include variable metadata", + formatter_class=RawDescriptionHelpFormatter + ) + parser.add_argument( + 'variables', + nargs='*', + metavar='VARIABLE_NAME', + help='One or more variable names to search for. If omitted, reports usage of all variables.' + ) + parser.add_argument( + '--metadata', + action='store_true', + default=False, + help='Include variable metadata (type, scope, initial values) in the output.' + ) + parser.add_argument( + '-i', '--assistant_json_path', + required=False, + default=cfg["assistant_json_directory"], + type=str, + metavar='PATH', + help=f'Path to the watsonx Assistant JSON file. Default: {cfg["assistant_json_directory"]}' + ) + parser.add_argument( + '-o', '--output_path', + required=False, + default=cfg["output_directory"], + type=str, + metavar='PATH', + help=f'Directory where the CSV output will be saved. Default: {cfg["output_directory"]}' + ) args = parser.parse_args() - assistant_obj = get_assistant_json(args.assistant_json_path) - analyzer = AssistantStaticAnalyzer(assistant_obj) + assistant_dict = get_assistant_json(args.assistant_json_path) + assistant = Assistant.from_dict(assistant_dict) + variable_analyzer = VariableAnalyzer(assistant) - variables = clean_cli_list(args.variables) - if len(variables): - variable_usage_df = analyzer.search_for_variables(*variables, return_as="csv") - default_file_name = f"{', '.join(variables)} usage.csv" - else: - variable_usage_df = analyzer.get_all_variable_usage(return_as="csv") - default_file_name = "all variable usage.csv" - - variable_usage_df = move_col_to_front(variable_usage_df, "variable") + variable_ids = clean_cli_list(args.variables) + variable_usage_df = variable_analyzer.get_variable_usage(*variable_ids, return_as="dataframe") + + if args.metadata: + variable_metadata_df = variable_analyzer.get_variable_metadata(return_as="dataframe") + + # Drop columns that would conflict or are redundant + variable_metadata_df = variable_metadata_df.drop(columns=['id', 'action_id', 'step_id', 'type']) + # Prepend 'variable_' to all columns in variable_summary_df + variable_metadata_df = variable_metadata_df.add_prefix('variable_') + + # Join on uid = variable_uid + variable_usage_df = variable_usage_df.merge( + variable_metadata_df, + left_on='variable_uid', + right_on='variable_uid', + how='left' + ) + + default_file_name = "all_variable_usage.csv" + if len(variable_ids): + default_file_name = f"variable_usage ({', '.join(variable_ids)}).csv" + + # variable_usage_df = move_col_to_front(variable_usage_df, "variable") + create_directory(args.output_path) output_path = get_output_save_path(args.output_path, default_file_name) variable_usage_df.to_csv(output_path, index=False) + + print(f"Variable usage saved to: {output_path}") if __name__ == "__main__": main() \ No newline at end of file diff --git a/skill_analytics/pyproject.toml b/skill_analytics/pyproject.toml new file mode 100644 index 0000000..4e310b5 --- /dev/null +++ b/skill_analytics/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "wxa-analyze" +version = "0.1.0" +description = "A tool to analyze and search various aspects of a watsonx Assistant instance" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "Eric Keilty", email = "eric.keilty@ibm.com"} +] +keywords = ["watsonx", "assistant", "watson", "analysis", "cli"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", +] +dependencies = [ + "networkx", + "matplotlib", + "pydot", + "graphviz", + "pandas", + "python-dotenv==1.0.1", +] + +[project.urls] +Homepage = "https://github.com/cognitive-catalyst/WA-Testing-Tool/tree/main/skill_analytics" +Repository = "https://github.com/cognitive-catalyst/WA-Testing-Tool" +Issues = "https://github.com/cognitive-catalyst/WA-Testing-Tool/issues" + +[project.scripts] +wxa-analyze = "cli.__main__:main" + +[tool.setuptools] +packages = ["cli", "config", "src"] + +[tool.setuptools.package-data] +"*" = ["*.json", "*.txt"] \ No newline at end of file diff --git a/skill_analytics/setup.py b/skill_analytics/setup.py new file mode 100644 index 0000000..0a3f888 --- /dev/null +++ b/skill_analytics/setup.py @@ -0,0 +1,38 @@ +from setuptools import setup, find_packages + +with open("README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +with open("requirements.txt", "r", encoding="utf-8") as fh: + requirements = [line.strip() for line in fh if line.strip() and not line.startswith("#")] + +setup( + name="wxa-analyze", + version="0.1.0", + author="Eric Keilty", + author_email="eric.keilty@ibm.com", + description="A tool to analyze and search various aspects of a watsonx Assistant instance", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/cognitive-catalyst/WA-Testing-Tool/tree/main/skill_analytics", + packages=find_packages(), + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + python_requires=">=3.8", + install_requires=requirements, + entry_points={ + "console_scripts": [ + "wxa-analyze=cli.__main__:main", + ], + }, + include_package_data=True, +) diff --git a/skill_analytics/src/action.py b/skill_analytics/src/action.py deleted file mode 100644 index b0449b2..0000000 --- a/skill_analytics/src/action.py +++ /dev/null @@ -1,196 +0,0 @@ -from src.step import Step -from src.condition import Condition -from src.variable import Variable - -class Action: - - def __init__(self, action_obj, index): - self.raw_obj = action_obj - self.index = index - - self.ID = action_obj["action"] - self.title = action_obj.get("title") - self.description = action_obj.get("description") - - self.conditions = Condition(action_obj.get("condition", {})) - - self.action_variables = [Variable(variable_obj) for variable_obj in action_obj.get("variables", {})] - - self.steps = [Step(step_obj, j) for j, step_obj in enumerate(action_obj["steps"])] - - self.ask_clarifying_question = (not action_obj.get("disambiguation_opt_out", True)) - self.allowed_from = action_obj.get("topic_switch", {}).get("allowed_from", True) - self.allowed_into = action_obj.get("topic_switch", {}).get("allowed_into", True) - if self.allowed_from != self.allowed_into: - raise ValueError(f"In action {self.title} the values `allowed_from` and `allowed_into` are different, and they shouldn't be.") - self.change_coversation_topic = self.allowed_into - self.never_return = action_obj.get("topic_switch", {}).get("never_return", False) - - # for step in self.steps: - # for action_variable in self.action_variables: - # if action_variable.ID == step.ID: - # step.attach_action_variable(action_variable) - - # self.extensions = [step.extension for step in self.steps if step.extension.extension_exists] - # self.subactions = [step.subaction for step in self.steps if step.subaction.subaction_exists] - - self.variable_ids = list(sorted(set([variable for step in self.steps for variable in step.variable_ids] + self.conditions.variable_ids))) - self.utterances = [] # to be added later - - - def __repr__(self): - return self.title - - def to_json(self): - return { - "ID": self.ID, - "title": self.title, - "description": self.description, - "index": self.index, - "steps": self.steps.to_json(), - "utterances": self.utterances, - "variables": self.variable_ids - } - - def add_utterances(self, intents): - for intent in intents: - if intent.action_id == self.ID: - self.utterances.extend(intent.examples) - return self.utterances - - # ================================================================================ - # Parsing - # ================================================================================ - - @staticmethod - def _parse_action_variables(action_variables_obj, action_id): - action_variables = [] - - for variable_obj in action_variables_obj: - action_variables.append( Variable(variable_obj, action_id=action_id) ) - - return action_variables - - # ================================================================================ - # Searching - # ================================================================================ - - def get_all_variable_usage(self): - results = [] - - condition_results = self.conditions.get_all_variable_usage() - for condition_result in condition_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - "step_id": None, - "step_title": None, - "step_number": 0, - **condition_result - }) - - for step in self.steps: - step_results = step.get_all_variable_usage() - for step_result in step_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - **step_result - }) - - return results - - def get_all_entity_usage(self): - results = [] - - condition_results = self.conditions.get_all_entity_usage() - for condition_result in condition_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - "step_id": None, - "step_title": None, - "step_number": 0, - **condition_result - }) - - for step in self.steps: - step_results = step.get_all_entity_usage() - for step_result in step_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - **step_result - }) - - return results - - def get_all_subaction_usage(self): - results = [] - - for step in self.steps: - step_results = step.get_all_subaction_usage() - for step_result in step_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - **step_result - }) - - return results - - def get_all_extension_usage(self): - results = [] - - for step in self.steps: - step_results = step.get_all_extension_usage() - for step_result in step_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - **step_result - }) - - return results - - def action_variable_summary(self): - results = [] - - for action_variable in self.action_variables: - step = None - for step in self.steps: - if action_variable.ID == step.ID: - break - - results.append({ - "action_id": self.ID, - "action_title": self.title, - "step_id": step.ID if step else None, - "step_title": step.title if step else None, - "step_number": step.step_number if step else None, - **action_variable.summary(), - }) - - return results - - def get_all_responses(self): - results = [] - for step in self.steps: - step_results = step.get_all_responses() - for step_result in step_results: - results.append({ - "action_id": self.ID, - "action_title": self.title, - **step_result - }) - - return results - - def get_settings(self): - return { - "action_id": self.ID, - "action_title": self.title, - "ask_clarifying_question": self.ask_clarifying_question, - "change_coversation_topic": self.change_coversation_topic, - "never_return" : self.never_return, - } \ No newline at end of file diff --git a/skill_analytics/src/analyzers/__init__.py b/skill_analytics/src/analyzers/__init__.py new file mode 100644 index 0000000..dfcf5ea --- /dev/null +++ b/skill_analytics/src/analyzers/__init__.py @@ -0,0 +1,17 @@ +from src.analyzers.action import ActionAnalyzer +from src.analyzers.assistant import AssistantAnalyzer +from src.analyzers.entity import EntityAnalyzer +from src.analyzers.intent import IntentAnalyzer +from src.analyzers.resolver import ResolverAnalyzer +from src.analyzers.subaction import SubactionAnalyzer +from src.analyzers.variable import VariableAnalyzer + +__all__ = [ + "ActionAnalyzer", + "AssistantAnalyzer", + "EntityAnalyzer", + "IntentAnalyzer", + "ResolverAnalyzer", + "SubactionAnalyzer", + "VariableAnalyzer", +] diff --git a/skill_analytics/src/analyzers/action.py b/skill_analytics/src/analyzers/action.py new file mode 100644 index 0000000..4512bef --- /dev/null +++ b/skill_analytics/src/analyzers/action.py @@ -0,0 +1,189 @@ +from typing import Any + +from src.models.assistant import Assistant +from src.models.handler import HandlerType +from src.output_handlers import OutputFormat, create_output_handler +from src.utils.parse_wxa_ids import build_long_id + +class ActionAnalyzer: + """Analyzer for extracting and analyzing action metadata from an Assistant.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def action_metadata( + self, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + + results = [] + for action in self.assistant.actions.values(): + results.append({ + "action_id": action.id, + "action_title": action.title, + "intent_id": action.intent.id, + "num_steps": len(action.steps), + "condition_spel_expression": action.condition.spel_expression, + **action.settings.to_dict() + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + def customer_response_settings( + self, + *action_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON + ) -> Any: + + results = [] + for action in self.assistant.actions.values(): + if len(action_ids) and action.id not in action_ids: + continue + for i, step in enumerate(action.steps): + if step.question: + variable = self.assistant.get_variable(step.id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {step.id} from action {action.title} and step number {i+1} is missing from assistant variables.") + + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "customer_response_collection_behavior": step.question.response_collection_behavior.value, + "display_options_toggle": step.get_display_options_toggle(), + "is_protected": variable.is_protected if variable else False + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + def validation_settings( + self, + *action_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON + ) -> Any: + + results = [] + for action in self.assistant.actions.values(): + if len(action_ids) and action.id not in action_ids: + continue + for i, step in enumerate(action.steps): + for handler in step.handlers: + if handler.handler_type == HandlerType.NOT_FOUND: + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "validation_responses": str([str(response) for response in handler.responses]), + "max_tries": step.question.max_tries if step.question else None, + "repeat_on_reprompt": step.get_repeat_on_reprompt_toggle() + }) + + handler = create_output_handler(return_as) + + def condition_usage( + self, + *action_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON + ) -> Any: + """Report all conditions used in the assistant.""" + results = [] + + for action in self.assistant.actions.values(): + if len(action_ids) and action.id not in action_ids: + continue + + # Action-level conditions + for statement in action.condition.get_all_statements(): + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": None, + "step_title": None, + "step_number": 0, + "action_step_id": None, + "source": "action condition", + **statement.to_dict() + }) + + # Step-level conditions + for i, step in enumerate(action.steps): + for statement in step.condition.get_all_statements(): + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "source": "step condition", + **statement.to_dict() + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + def context_usage( + self, + *action_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON + ) -> Any: + """Report all context statements used in the assistant.""" + results = [] + + for action in self.assistant.actions.values(): + if len(action_ids) and action.id not in action_ids: + continue + + # Step-level context (context is only at step level, not action level) + for i, step in enumerate(action.steps): + for statement in step.context.get_all_statements(): + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "source": "step context", + **statement.to_dict() + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + def response_usage( + self, + *action_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON + ) -> Any: + """Report all responses used in the assistant steps.""" + results = [] + + for action in self.assistant.actions.values(): + if len(action_ids) and action.id not in action_ids: + continue + + # Step-level responses + for i, step in enumerate(action.steps): + # TODO: Maybe I should iterate over responses? Idk + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "response_types": [response.response_type.value for response in step.responses], + "assistant_response": "\n".join([str(response) for response in step.responses]) + }) + + handler = create_output_handler(return_as) + return handler.handle(results) \ No newline at end of file diff --git a/skill_analytics/src/analyzers/assistant.py b/skill_analytics/src/analyzers/assistant.py new file mode 100644 index 0000000..2611aa9 --- /dev/null +++ b/skill_analytics/src/analyzers/assistant.py @@ -0,0 +1,57 @@ +from typing import Any + +from src.models.assistant import Assistant +from src.output_handlers import OutputFormat, create_output_handler + + +class AssistantAnalyzer: + """Analyzer for extracting and analyzing assistant metadata.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def assistant_metadata( + self, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + """Extract comprehensive metadata about the assistant including IDs, settings, and aggregated stats.""" + + # Count various elements + num_actions = len(self.assistant.actions) + num_intents = len(self.assistant.intents) + num_entities = len(self.assistant.entities) + num_skill_variables = len(self.assistant.skill_variables) + num_step_variables = len(self.assistant.step_variables) + num_result_variables = len(self.assistant.result_variables) + num_system_variables = len(self.assistant.system_variables) + num_custom_data_types = len(self.assistant.data_type_registry.get_all_custom_types()) + + # Count total steps across all actions + total_steps = sum(len(action.steps) for action in self.assistant.actions.values()) + + # Build the metadata dictionary + result = { + # ID Information + "name": self.assistant.name, + "description": self.assistant.description, + "skill_id": self.assistant.skill_id, + "assistant_id": self.assistant.assistant_id, + "workspace_id": self.assistant.workspace_id, + + # System Settings + **self.assistant.settings.to_dict(), + + # Aggregated Statistics + "num_actions": num_actions, + "num_intents": num_intents, + "num_entities": num_entities, + "num_skill_variables": num_skill_variables, + "num_step_variables": num_step_variables, + "num_result_variables": num_result_variables, + "num_system_variables": num_system_variables, + "num_custom_data_types": num_custom_data_types, + "total_steps": total_steps, + } + + handler = create_output_handler(return_as) + return handler.handle([result]) diff --git a/skill_analytics/src/analyzers/entity.py b/skill_analytics/src/analyzers/entity.py new file mode 100644 index 0000000..17f3cac --- /dev/null +++ b/skill_analytics/src/analyzers/entity.py @@ -0,0 +1,115 @@ +from typing import Any + +from src.models.assistant import Assistant +from src.models.operands import EntityOperand +from src.output_handlers import OutputFormat, create_output_handler +from src.utils.parse_wxa_ids import build_long_id + + +class EntityAnalyzer: + """Analyzer for extracting and analyzing entities metadata from an Assistant.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def entity_metadata( + self, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + + results = [] + for entity in self.assistant.entities.values(): + for value in entity.values: + results.append({ + "entity_id": entity.id, + "fuzzy_match": entity.fuzzy_match, + **value.to_dict() + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + def entity_usage( + self, + *entity_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + """Get usage of entities across all actions and steps.""" + results = [] + + for action in self.assistant.actions.values(): + # Check action condition + for statement in action.condition.get_all_statements(): + for operand in statement.get_operands(): + if isinstance(operand, EntityOperand): + if len(entity_ids) and operand.entity_id not in entity_ids: + continue + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": None, + "step_title": None, + "step_number": 0, + "action_step_id": None, + "entity_id": operand.entity_id, + "entity_value": operand.value, + "source": "action condition", + **statement.to_dict() + }) + + # Check each step + for i, step in enumerate(action.steps): + # Check step condition + for statement in step.condition.get_all_statements(): + for operand in statement.get_operands(): + if isinstance(operand, EntityOperand): + if len(entity_ids) and operand.entity_id not in entity_ids: + continue + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "entity_id": operand.entity_id, + "entity_value": operand.value, + "source": "condition", + **statement.to_dict() + }) + + # Check step context + for statement in step.context.get_all_statements(): + for operand in statement.get_operands(): + if isinstance(operand, EntityOperand): + if len(entity_ids) and operand.entity_id not in entity_ids: + continue + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "entity_id": operand.entity_id, + "entity_value": operand.value, + "source": "context", + **statement.to_dict() + }) + + if step.entity: + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + "entity_id": step.entity.id, + "source": "response", + }) + + # TODO: When I finish implementing buttons, I'll need to add that here as well + + handler = create_output_handler(return_as) + return handler.handle(results) \ No newline at end of file diff --git a/skill_analytics/src/analyzers/intent.py b/skill_analytics/src/analyzers/intent.py new file mode 100644 index 0000000..8fbf3f7 --- /dev/null +++ b/skill_analytics/src/analyzers/intent.py @@ -0,0 +1,34 @@ +from typing import Any + +from src.models.assistant import Assistant +from src.output_handlers import OutputFormat, create_output_handler + + +class IntentAnalyzer: + """Analyzer for extracting and analyzing intent metadata from an Assistant.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def intent_usage( + self, + *action_ids, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + + results = [] + for action in self.assistant.actions.values(): + if len(action_ids) and action.id not in action_ids: + continue + + intent = action.intent + for example in intent.examples: + results.append({ + "action_id": action.id, + "action_title": action.title, + "intent_id": intent.id, + "utterance": example + }) + + handler = create_output_handler(return_as) + return handler.handle(results) \ No newline at end of file diff --git a/skill_analytics/src/analyzers/resolver.py b/skill_analytics/src/analyzers/resolver.py new file mode 100644 index 0000000..ba86c4e --- /dev/null +++ b/skill_analytics/src/analyzers/resolver.py @@ -0,0 +1,82 @@ +from typing import Any + +from src.models.assistant import Assistant +from src.models.resolvers import CalloutResolver, InvokeActionAndEndResolver, InvokeActionResolver +from src.output_handlers import OutputFormat, create_output_handler +from src.utils.parse_wxa_ids import build_long_id + + +class ResolverAnalyzer: + """Analyzer for extracting and analyzing resolver usage from an Assistant.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def subaction_usage( + self, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + """Find all subaction invocations (invoke_action_resolver and invoke_action_and_end_resolver).""" + results = [] + + for action in self.assistant.actions.values(): + for i, step in enumerate(action.steps): + resolver = step.resolver + + # Check if resolver is an invoke action type + if isinstance(resolver, (InvokeActionResolver, InvokeActionAndEndResolver)): + # Get subaction details + subaction = self.assistant.actions.get(resolver.subaction_id) + subaction_title = subaction.title if subaction else None + + # Determine if action ends + ends_action = isinstance(resolver, InvokeActionAndEndResolver) + + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i + 1, + "action_step_id": build_long_id(action.id, step.id), + "subaction_id": resolver.subaction_id, + "subaction_title": subaction_title, + "ends_action": ends_action, + "policy": resolver.policy, + "result_variable_id": resolver.result_variable_id, + "ignore_end_action_steps": resolver.ignore_end_action_steps, + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + def extension_usage( + self, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + """Find all extension usages (callout_resolver).""" + results = [] + + for action in self.assistant.actions.values(): + for i, step in enumerate(action.steps): + resolver = step.resolver + + # Check if resolver is a callout type + if isinstance(resolver, CalloutResolver): + variable = self.assistant.get_variable(resolver.result_variable_id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {resolver.result_variable_id} from action {action.title} and step number {i+1} is missing from assistant variables.") + + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + **resolver.to_dict(), + "is_protected": variable.is_protected if variable else False + }) + + handler = create_output_handler(return_as) + return handler.handle(results) diff --git a/skill_analytics/src/analyzers/subaction.py b/skill_analytics/src/analyzers/subaction.py new file mode 100644 index 0000000..cd00c01 --- /dev/null +++ b/skill_analytics/src/analyzers/subaction.py @@ -0,0 +1,199 @@ +from typing import Any, Optional +from io import BytesIO + +from src.models.assistant import Assistant +from src.models.resolvers import InvokeActionAndEndResolver, InvokeActionResolver +from src.utils.graph import Graph + +# TODO: Needs testing + +class SubactionAnalyzer: + """Analyzer for generating subaction flow graphs.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def generate_subaction_flow_graph( + self, + start_action_id: Optional[str] = None, + include_action_titles: bool = True + ) -> BytesIO: + """ + Generate a flow graph of subactions and return as PNG bytes. + + Args: + start_action_id: Optional action ID to start from. If None, generates full graph. + include_action_titles: Whether to include action titles in node labels. + + Returns: + BytesIO object containing the PNG image data that can be saved by the caller. + """ + try: + import graphviz + except ImportError: + raise ImportError( + "graphviz package is required for generating flow graphs. " + "Install it with: pip install graphviz" + ) + + # Build the graph + graph = self._build_subaction_graph() + + # Create graphviz Digraph + dot = graphviz.Digraph(comment='Subaction Flow Graph') + dot.attr(rankdir='TB') # Top to bottom layout + dot.attr('node', shape='box', style='rounded,filled', fillcolor='lightblue') + + # Determine which nodes to include + if start_action_id: + if start_action_id not in graph.nodes: + raise ValueError(f"Action ID '{start_action_id}' not found in assistant") + + # Get connected subgraph starting from the specified action + terminal_actions = set() + ignore_actions = set() + subgraph = graph.get_connected_subgraph(start_action_id, terminal_actions, ignore_actions) + nodes_to_include = subgraph.get_node_ID_list() + edges_to_include = subgraph.get_edge_list_node_IDs() + else: + # Include all nodes and edges + nodes_to_include = graph.get_node_ID_list() + edges_to_include = graph.get_edge_list_node_IDs() + + # Add nodes + for node_id in nodes_to_include: + node_data = graph.get_node_data(node_id) + action = self.assistant.actions.get(node_id) + + if action: + if include_action_titles: + label = f"{action.title}\n({node_id})" + else: + label = node_id + + # Color code based on whether action has subactions + has_subactions = node_data.get('has_subactions', False) + fillcolor = 'lightgreen' if has_subactions else 'lightblue' + + dot.node(node_id, label=label, fillcolor=fillcolor) + else: + # Action not found (shouldn't happen, but handle gracefully) + dot.node(node_id, label=f"{node_id}\n(not found)", fillcolor='lightcoral') + + # Add edges + for u, v in edges_to_include: + edge_data = graph.get_edge_data(u, v) + + # Customize edge appearance based on resolver type + if edge_data.get('ends_action', False): + # InvokeActionAndEndResolver - dashed line + dot.edge(u, v, label='invoke & end', style='dashed', color='red') + else: + # InvokeActionResolver - solid line + dot.edge(u, v, label='invoke', color='blue') + + # Render to PNG bytes + png_bytes = dot.pipe(format='png') + png_buffer = BytesIO(png_bytes) + + # Reset buffer position to beginning + png_buffer.seek(0) + + return png_buffer + + def _build_subaction_graph(self) -> Graph: + """ + Build a directed graph of subaction relationships. + + Returns: + Graph object with actions as nodes and subaction invocations as edges. + """ + graph = Graph() + + # First pass: Add all actions as nodes + for action_id, action in self.assistant.actions.items(): + has_subactions = False + + # Check if this action invokes any subactions + for step in action.steps: + resolver = step.resolver + if isinstance(resolver, (InvokeActionResolver, InvokeActionAndEndResolver)): + has_subactions = True + break + + graph.add_node(action_id, node_data={ + 'title': action.title, + 'has_subactions': has_subactions + }) + + # Second pass: Add edges for subaction invocations + for action_id, action in self.assistant.actions.items(): + for i, step in enumerate(action.steps): + resolver = step.resolver + + # Check if resolver is an invoke action type + if isinstance(resolver, (InvokeActionResolver, InvokeActionAndEndResolver)): + subaction_id = resolver.subaction_id + + # Only add edge if subaction exists + if subaction_id in self.assistant.actions: + ends_action = isinstance(resolver, InvokeActionAndEndResolver) + + edge_data = { + 'step_id': step.id, + 'step_title': step.title, + 'step_number': i + 1, + 'ends_action': ends_action, + 'policy': resolver.policy, + 'result_variable_id': resolver.result_variable_id + } + + graph.add_edge(action_id, subaction_id, edge_data=edge_data) + + return graph + + def get_subaction_statistics(self) -> dict[str, Any]: + """ + Get statistics about subaction usage in the assistant. + + Returns: + Dictionary containing various statistics about subactions. + """ + graph = self._build_subaction_graph() + + total_actions = len(self.assistant.actions) + actions_with_subactions = sum( + 1 for node_id in graph.get_node_ID_list() + if graph.get_node_data(node_id).get('has_subactions', False) + ) + + total_subaction_calls = len(graph.get_edge_list_node_IDs()) + + # Find actions that are never invoked as subactions + invoked_actions = set(v for u, v in graph.get_edge_list_node_IDs()) + root_actions = set(graph.get_node_ID_list()) - invoked_actions + + # Find actions that invoke themselves (recursive) + recursive_actions = [ + action_id for action_id in graph.get_node_ID_list() + if graph.is_edge_exists(action_id, action_id) + ] + + return { + 'total_actions': total_actions, + 'actions_with_subactions': actions_with_subactions, + 'total_subaction_calls': total_subaction_calls, + 'root_actions_count': len(root_actions), + 'root_actions': list(root_actions), + 'recursive_actions_count': len(recursive_actions), + 'recursive_actions': recursive_actions + } + +""" +analyzer = SubactionAnalyzer(assistant) +png_buffer = analyzer.generate_subaction_flow_graph() + +# Caller can save the PNG if needed +with open("subaction_flow.png", "wb") as f: + f.write(png_buffer.getvalue()) +""" \ No newline at end of file diff --git a/skill_analytics/src/analyzers/variable.py b/skill_analytics/src/analyzers/variable.py new file mode 100644 index 0000000..3250c14 --- /dev/null +++ b/skill_analytics/src/analyzers/variable.py @@ -0,0 +1,222 @@ +from typing import Any + +from src.models.assistant import Assistant +from src.models.resolvers import CalloutResolver +from src.models.statements.operation import OperationType +from src.models.variables import VariableType +from src.output_handlers import OutputFormat, create_output_handler +from src.utils.parse_wxa_ids import build_long_id + + +class VariableAnalyzer: + """Analyzer for extracting and analyzing variable metadata from an Assistant.""" + + def __init__(self, assistant: Assistant): + self.assistant = assistant + + def get_variable_metadata( + self, + include_skill_variables: bool = True, + include_step_variables: bool = True, + include_result_variables: bool = True, + include_system_variables: bool = True, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + """Get a comprehensive summary of all variables in the assistant.""" + variables_data = [] + + # Collect skill variables + if include_skill_variables: + for var in self.assistant.skill_variables.values(): + variables_data.append(var.to_dict()) + + # Collect step variables + if include_step_variables: + for var in self.assistant.step_variables.values(): + variables_data.append(var.to_dict()) + + # Collect result variables + if include_result_variables: + for var in self.assistant.result_variables.values(): + variables_data.append(var.to_dict()) + + # Collect system variables + if include_system_variables: + for var in self.assistant.system_variables.values(): + variables_data.append(var.to_dict()) + + handler = create_output_handler(return_as) + return handler.handle(variables_data) + + def get_variables_by_type( + self, + variable_type: VariableType, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + """Get all variables of a specific type.""" + if variable_type == VariableType.SKILL: + variables = list(self.assistant.skill_variables.values()) + elif variable_type == VariableType.STEP: + variables = list(self.assistant.step_variables.values()) + elif variable_type == VariableType.RESULT: + variables = list(self.assistant.result_variables.values()) + elif variable_type == VariableType.SYSTEM: + variables = list(self.assistant.system_variables.values()) + else: + raise ValueError(f"Unknown variable type: {variable_type}") + + results = [var.to_dict() for var in variables] + handler = create_output_handler(return_as) + return handler.handle(results) + def get_variable_usage( + self, + *variable_ids: str, + return_as: OutputFormat | str = OutputFormat.PYTHON, + ) -> Any: + results = [] + + for action in self.assistant.actions.values(): + + for statement in action.condition.get_all_statements(): + for variable_id in statement.get_all_variable_ids(): + variable = self.assistant.get_variable(variable_id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {variable_id} in action condition of action {action.title} - {action.id}, but is missing from assistant variables.") + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": None, + "step_title": None, + "step_number": 0, + "action_step_id": None, + "variable_id": variable_id, + "source": "action condition", + **statement.to_dict() + }) + continue + if len(variable_ids) and variable.id not in variable_ids: + continue + results.append({ + "action_id": action.id, + "action_title": action.title, + "step_id": None, + "step_title": None, + "step_number": 0, + "action_step_id": None, + "variable_id": variable.id, + "variable_uid": variable.uid, + "variable_type": variable.variable_type.value, + "source": "action condition", + **statement.to_dict() + }) + + + for i, step in enumerate(action.steps): + + action_and_step_metadata = { + "action_id": action.id, + "action_title": action.title, + "step_id": step.id, + "step_title": step.title, + "step_number": i+1, + "action_step_id": build_long_id(action.id, step.id), + } + + for statement in step.condition.get_all_statements(): + for variable_id in statement.get_all_variable_ids(): + variable = self.assistant.get_variable(variable_id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {variable_id} in condition of action {action.title} - {action.id}, step {i+1}, but is missing from assistant variables.") + results.append({ + **action_and_step_metadata, + "variable_id": variable_id, + "source": "condition", + **statement.to_dict() + }) + continue + if len(variable_ids) and variable.id not in variable_ids: + continue + results.append({ + **action_and_step_metadata, + "variable_id": variable.id, + "variable_uid": variable.uid, + "variable_type": variable.variable_type.value, + "source": "condition", + **statement.to_dict() + }) + for statement in step.context.get_all_statements(): + for variable_id in statement.get_all_variable_ids(): + variable = self.assistant.get_variable(variable_id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {variable_id} in context of action {action.title} - {action.id}, step {i+1}, but is missing from assistant variables.") + results.append({ + **action_and_step_metadata, + "variable_id": variable_id, + "source": "context", + **statement.to_dict() + }) + continue + if len(variable_ids) and variable.id not in variable_ids: + continue + results.append({ + **action_and_step_metadata, + "variable_id": variable.id, + "variable_uid": variable.uid, + "variable_type": variable.variable_type.value, + "source": "context", + **statement.to_dict() + }) + + if isinstance(step.resolver, CalloutResolver): + params = step.resolver.request_mapping.header + step.resolver.request_mapping.query + for param in params: + for variable_id in param.get_all_variable_ids(): + variable = self.assistant.get_variable(variable_id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {variable_id} in extension of action {action.title} - {action.id}, step {i+1}, but is missing from assistant variables.") + results.append({ + **action_and_step_metadata, + "variable_id": variable_id, + "source": "extension", + }) + continue + if len(variable_ids) and variable.id not in variable_ids: + continue + results.append({ + **action_and_step_metadata, + "variable_id": variable.id, + "variable_uid": variable.uid, + "variable_type": variable.variable_type.value, + "source": "extension", + "operation": OperationType.ASSIGN.value, + "LHS": param.parameter, + "RHS": param.value.value, + "spel_expression": param.spel_expression + }) + + for response in step.responses: + for variable_id in response.get_all_variable_ids(): + variable = self.assistant.get_variable(variable_id, action_id=action.id) + if variable is None: + print(f"Warning: Variable {variable_id} in response of action {action.title} - {action.id}, step {i+1}, but is missing from assistant variables.") + results.append({ + **action_and_step_metadata, + "variable_id": variable_id, + "source": f"response - {response.response_type.value}", + }) + continue + if len(variable_ids) and variable.id not in variable_ids: + continue + results.append({ + **action_and_step_metadata, + "variable_id": variable.id, + "variable_uid": variable.uid, + "variable_type": variable.variable_type.value, + "source": f"response - {response.response_type.value}", + "response": str(response) + }) + + handler = create_output_handler(return_as) + return handler.handle(results) + + \ No newline at end of file diff --git a/skill_analytics/src/assistant_static_analyzer.py b/skill_analytics/src/assistant_static_analyzer.py deleted file mode 100644 index f17fa8c..0000000 --- a/skill_analytics/src/assistant_static_analyzer.py +++ /dev/null @@ -1,411 +0,0 @@ -import json -import pandas as pd -import networkx as nx -import matplotlib.pyplot as plt -from networkx.drawing.nx_pydot import graphviz_layout - -from src.action import Action -from src.entity import Entity -from src.intent import Intent -from src.variable import Variable - -from src.utils.graph import Graph - -class AssistantStaticAnalyzer: - - def __init__(self, assistant_obj): - self.raw_obj = assistant_obj - - self.name = assistant_obj["name"] - self.description = assistant_obj["description"] - self.skill_id = assistant_obj["skill_id"] - self.assistant_id = assistant_obj["assistant_id"] - self.workspace_id = assistant_obj["workspace_id"] - - self.entities = AssistantStaticAnalyzer._parse_assistant_obj_for_entities(assistant_obj) - - self.actions = AssistantStaticAnalyzer._parse_assistant_obj_for_actions(assistant_obj) - self.intents = AssistantStaticAnalyzer._parse_assistant_obj_for_intents(assistant_obj) - for action in self.actions: - action.add_utterances(self.intents) - - self.system_variables = AssistantStaticAnalyzer._parse_assistant_obj_for_system_variables(assistant_obj) - self.action_variables = [action_variable for action in self.actions for action_variable in action.action_variables] - - self.variable_ids = list(sorted(set([variable for action in self.actions for variable in action.variable_ids]))) - - def __repr__(self): - return f"{self.name} - Static Analyzer ({self.assistant_id})" - - # ================================================================================ - # Getters - # ================================================================================ - - @staticmethod - def _get_by_id_or_title(obj, title_or_id): - for subobj in obj: - if subobj.ID == title_or_id or subobj.title.lower() == title_or_id.lower(): - return subobj - return None - - def get_action(self, action_title_or_id, warning=True): - action = AssistantStaticAnalyzer._get_by_id_or_title(self.actions, action_title_or_id) - if warning and action is None: - print(f"Warning: could not find action with ID or title '{action_title_or_id}'") - return action - - def get_variable(self, variable_title_or_id, warning=True): - variable = AssistantStaticAnalyzer._get_by_id_or_title(self.variables, variable_title_or_id) - if warning and variable is None: - print(f"Warning: could not find variable with ID or title '{variable_title_or_id}'") - return variable - - def get_entity(self, entity_id, warning=True): - for entity in self.entities: - if entity.ID == entity_id: - return entity - if warning: - print(f"Warning: could not find variable with ID or title '{entity_id}'") - return entity - - # ================================================================================ - # Parsing - # ================================================================================ - - @staticmethod - def _parse_assistant_obj_for_entities(assistant_obj): - entities = [] - for entity_obj in assistant_obj["workspace"]["entities"]: - pass - # print(entity_obj) - return entities - - @staticmethod - def _parse_assistant_obj_for_actions(assistant_obj): - actions = [] - for i, action_obj in enumerate(assistant_obj["workspace"]["actions"]): - actions.append( Action(action_obj, i) ) - - return actions - - @staticmethod - def _parse_assistant_obj_for_intents(assistant_obj): - intents = [] - for i, intent_obj in enumerate(assistant_obj["workspace"]["intents"]): - intents.append( Intent(intent_obj, i) ) - return intents - - @staticmethod - def _parse_assistant_obj_for_system_variables(assistant_obj): - system_variables = [] - - for variable_obj in assistant_obj["workspace"]["variables"]: - system_variables.append( Variable(variable_obj) ) - - # These are extra system variables that the assistant creates by default - default_system_variables = [ - "current_date", - "current_time", - "digressed_from", - "fallback_reason", - "no_action_matches_count", - "session_history", - "system_current_date", - "system_current_time", - "system_integrations", - "system_session_history", - "system_timezone", - ] - for system_variable_id in default_system_variables: - system_variables.append(Variable({"variable": system_variable_id, "title": system_variable_id, "privacy": {"enabled": True}})) - - return system_variables - - # ================================================================================ - # Searching - Helpers - # ================================================================================ - - @staticmethod - def _return_as(obj, return_as): - - if return_as is None: - return obj - - if not isinstance(return_as, str): - raise TypeError("`return_as` should be type `str`, instead got", type(return_as)) - - if return_as.lower() in ["dict", "dictionary"]: - return obj - - if return_as.lower() in ["csv", "dataframe", "df"]: - return pd.DataFrame(obj) - - if return_as.lower() in ["json"]: - return json.dumps(obj) - - raise ValueError(f"Unknown value for `return_as`, got '{return_as}'") - - def _get_action_id_list(self, action_title_or_id_list=None): - if action_title_or_id_list is None: - return [action.ID for action in self.actions] - - action_ids = [] - for action_id_or_title in action_title_or_id_list: - action = self.get_action(action_id_or_title) - if action is not None: - action_ids.append(action.ID) - - return action_ids - - def is_variable_protected(self, variable_id, source): - - for variable in self.system_variables: - if variable_id == variable.ID: - return variable.is_protected - - for variable in self.action_variables: - if variable_id == variable.ID: - return variable.is_protected - - if source in ["extension"]: - return None - - print(f"Warning: Could not find `{variable_id}` in either the system or action variables") - return False - - # ================================================================================ - # Searching - Variables - # ================================================================================ - - def get_all_variable_usage(self, return_as=None): - results = [] - for action in self.actions: - action_results = action.get_all_variable_usage() - for action_result in action_results: - variable_id = action_result["variable"] - results.append({ - **action_result, - "is_protected": self.is_variable_protected(variable_id, action_result["source"]) - }) - - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def search_for_variables(self, *variable_ids, return_as=None): - results = self.get_all_variable_usage(return_as="dict") - results = [result for result in results if result["variable"] in list(variable_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def get_all_variables_used_in_action(self, *action_title_or_id_list, return_as=None): - action_ids = self._get_action_id_list(action_title_or_id_list) - results = self.get_all_variable_usage(return_as="dict") - if action_ids: - results = [result for result in results if result["action_id"] in list(action_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def variable_summary(self, return_as=None): - results = [] - - for action in self.actions: - action_variable_results = action.action_variable_summary() - for action_variable_result in action_variable_results: - results.append({ - **action_variable_result, - "source": "action_variables" - }) - - for variable in self.system_variables: - results.append({ - **variable.summary(), - "source": "system_variable" - }) - - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def intent_summary(self, action_title_or_id_list=None, return_as=None): - results = [] - action_ids = self._get_action_id_list(action_title_or_id_list) - for action in self.actions: - if action.ID not in action_ids: - continue - for utterance in action.utterances: - results.append({ - "action_id": action.ID, - "action_title": action.title, - "utterance": utterance - }) - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # ================================================================================ - # Searching - Entities - # ================================================================================ - - def get_all_entity_usage(self, return_as=None): - results = [] - for action in self.actions: - action_results = action.get_all_entity_usage() - for action_result in action_results: - entity_id = action_result["entity"] - # TODO - # entity = self.get_entity(entity_id) - results.append({ - **action_result, - # TODO - }) - - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def search_for_entities(self, *entity_ids, return_as=None): - results = self.get_all_entity_usage(return_as="dict") - if entity_ids: - results = [result for result in results if result["entity"] in list(entity_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def get_all_entities_used_in_action(self, *action_title_or_id_list, return_as=None): - action_ids = self._get_action_id_list(action_title_or_id_list) - results = self.get_all_entity_usage(return_as="dict") - if action_ids: - results = [result for result in results if result["action_id"] in list(action_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # ================================================================================ - # Searching - Subactions - # ================================================================================ - - def get_all_subaction_usage(self, return_as=None): - results = [] - for action in self.actions: - action_results = action.get_all_subaction_usage() - for action_result in action_results: - results.append({ - **action_result, - "subaction_title": self.get_action(action_result["subaction_id"]).title - }) - - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def search_for_subactions(self, *subaction_title_or_id_list, return_as=None): - subaction_ids = self._get_action_id_list(subaction_title_or_id_list) - results = self.get_all_subaction_usage(return_as="dict") - if subaction_ids: - results = [result for result in results if result["subaction_id"] in list(subaction_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def get_all_subactions_used_in_action(self, *action_title_or_id_list, return_as=None): - action_ids = self._get_action_id_list(action_title_or_id_list) - results = self.get_all_subaction_usage(return_as="dict") - if action_ids: - results = [result for result in results if result["action_id"] in list(action_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # ================================================================================ - # Searching - Extensions - # ================================================================================ - - def get_all_extension_usage(self, return_as=None): - results = [] - for action in self.actions: - action_results = action.get_all_extension_usage() - for action_result in action_results: - results.append(action_result) - - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # TODO: There's not a good way to identify extensions - def search_for_extensions(self, *action_title_or_id_list, return_as=None): - raise NotImplementedError("There is currently no good way to uniquely identify extensions. This functionality is still under development.") - - def get_all_extensions_used_in_action(self, *action_title_or_id_list, return_as=None): - action_ids = self._get_action_id_list(action_title_or_id_list) - results = self.get_all_extension_usage(return_as="dict") - if action_ids: - results = [result for result in results if result["action_id"] in list(action_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # ================================================================================ - # Searching - Responses - # ================================================================================ - - def get_all_responses(self, return_as=None): - results = [] - for action in self.actions: - action_results = action.get_all_responses() - for action_result in action_results: - results.append(action_result) - - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def get_all_responses_in_action(self, *action_title_or_id_list, return_as=None): - action_ids = self._get_action_id_list(action_title_or_id_list) - results = self.get_all_responses(return_as="dict") - if action_ids: - results = [result for result in results if result["action_id"] in list(action_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # ================================================================================ - # Searching - Get Settings - # ================================================================================ - def get_all_action_settings(self, return_as=None): - results = [action.get_settings() for action in self.actions] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - def search_for_action_settings(self, *action_title_or_id_list, return_as=None): - action_ids = self._get_action_id_list(action_title_or_id_list) - results = self.get_all_action_settings(return_as="dict") - if action_ids: - results = [result for result in results if result["action_id"] in list(action_ids)] - return AssistantStaticAnalyzer._return_as(results, return_as=return_as) - - # ================================================================================ - # Graph - # ================================================================================ - def _create_subaction_graph(self): - G = Graph() - for action in self.actions: - G.add_node(action.ID) - - for action in self.actions: - for step in action.steps: - if step.subaction.subaction_exists: - G.add_edge(action.ID, step.subaction.ID) - - return G - - @staticmethod - def _draw_network(node_list, edge_list): - nxG = nx.DiGraph() - nxG.add_nodes_from( node_list ) - nxG.add_edges_from( edge_list ) - pos = graphviz_layout(nxG, prog='dot') # 'dot' gives top-down layout - nx.draw(nxG, pos, with_labels=True, arrows=True, node_size=1000, node_color='lightblue', font_size=5) - plt.show() - - def visualize_action_flow(self, action_title_or_id, terminal_actions=[], ignore_actions=[]): - action = self.get_action(action_title_or_id) - if action is None: - return - - terminal_actions_by_ID = [] - for terminal_action_title_or_id in terminal_actions: - terminal_action = self.get_action(terminal_action_title_or_id) - if terminal_action is None: - print(f"Warning: Did not find action with ID or title '{terminal_action_title_or_id}'") - else: - terminal_actions_by_ID.append(terminal_action.ID) - - ignore_actions_by_ID = [] - for ignore_action_title_or_id in ignore_actions: - ignore_action = self.get_action(ignore_action_title_or_id) - if ignore_action is None: - print(f"Warning: Did not find action with ID or title '{ignore_action_title_or_id}'") - else: - ignore_actions_by_ID.append(ignore_action.ID) - - G = self._create_subaction_graph() - subG = G.get_connected_subgraph(action.ID, terminal_actions_by_ID, ignore_actions_by_ID) - - node_list = [self.get_action(action_id).title for action_id in subG.nodes] - edge_list = [(self.get_action(action_id), self.get_action(subaction_id)) for action_id, neighbors in subG.Adj.items() for subaction_id in neighbors] - - # print( json.dumps(node_list, indent=4) ) - # print( json.dumps(edge_list, indent=4) ) - AssistantStaticAnalyzer._draw_network(node_list, edge_list) \ No newline at end of file diff --git a/skill_analytics/src/condition.py b/skill_analytics/src/condition.py deleted file mode 100644 index 9844d81..0000000 --- a/skill_analytics/src/condition.py +++ /dev/null @@ -1,223 +0,0 @@ -from src.statement import Statement - -class Condition: - - name = "condition" - - def __init__(self, condition_obj): - self.raw_obj = condition_obj - - self.condition_statements, self.SpEL_expression = Condition._parse_condition(condition_obj) - self.SpEL_expression = "null" if self.SpEL_expression is None else str(self.SpEL_expression) - - self.variable_ids = list(sorted(set([variable for statement in self.condition_statements for variable in statement.variable_ids]))) - - def __repr__(self): - return self.SpEL_expression - - def __iter__(self): - return self.condition_statements.__iter__() - - def to_json(self): - return { - "statements": [statement.to_json() for statement in self.condition_statements], - "SpEL expression": self.SpEL_expression, - "variables": self.variable_ids, - } - - # ================================================================================ - # Parsing - # ================================================================================ - - @staticmethod - def _parse_condition(condition_obj): - if condition_obj == {}: - return [], None - - if ("and" not in condition_obj) and ("or" not in condition_obj): - statement = Condition._parse_condition_statement(condition_obj) - if statement is None: - return [], None - return [statement], statement.SpEL_expression - - condition_statements = [] - SpEL_expression = "" - for boolean_operator, sub_condition_list in condition_obj.items(): - sub_SpEL_expressions = [] - for sub_condition_obj in sub_condition_list: - sub_condition_statements, sub_SpEL_expression = Condition._parse_condition(sub_condition_obj) - condition_statements.extend(sub_condition_statements) - if sub_SpEL_expression is not None: - sub_SpEL_expressions.append(sub_SpEL_expression) - - SpEL_expression = f") {Statement.operation_to_SpEL_map[boolean_operator]} (".join(sub_SpEL_expressions) - if len(sub_condition_list) > 1: - SpEL_expression = f"({SpEL_expression})" - - return condition_statements, SpEL_expression - - @staticmethod - def _parse_condition_statement(condition_statement_obj): - statement = None - - boolean_operations = list(Statement.operation_to_SpEL_map.keys()) + ["matches", "not_matches", "contains", "not_contains", "in", "not_in"] - - for field in condition_statement_obj: - - if field == "not": - LHS, LHS_SpEL_expression, _ = Condition._parse_condition_operand(condition_statement_obj["not"]["exists"]) - statement = Statement( - LHS=LHS, - LHS_SpEL_expression=LHS_SpEL_expression, - operation="not exists" - ) - - elif field == "exists": - LHS, LHS_SpEL_expression, _ = Condition._parse_condition_operand(condition_statement_obj["exists"]) - statement = Statement( - LHS=LHS, - LHS_SpEL_expression=LHS_SpEL_expression, - operation="exists" - ) - - elif field in boolean_operations: - # I don't think an entity would ever be on the LHS of a condition - LHS, LHS_SpEL_expression, _ = Condition._parse_condition_operand(condition_statement_obj[field][0]) - RHS, RHS_SpEL_expression, RHS_entity_id = Condition._parse_condition_operand(condition_statement_obj[field][1]) - statement = Statement( - LHS=LHS, - LHS_SpEL_expression=LHS_SpEL_expression, - operation=field, - RHS=RHS, - RHS_SpEL_expression=RHS_SpEL_expression, - entity_id=RHS_entity_id - ) - - elif field == "expression": - statement = Statement( - RHS=condition_statement_obj["expression"], - RHS_SpEL_expression=condition_statement_obj["expression"] - ) - - elif field in ["entity", "intent"]: - pass - - else: - raise ValueError("Unknown condition field:", field) - - return statement - - @staticmethod - def _parse_condition_operand(condition_operand_obj): - - if "add" in condition_operand_obj: - return Condition._parse_add_or_subtract_date_obj(condition_operand_obj["add"], "add") - - if "subtract" in condition_operand_obj: - return Condition._parse_add_or_subtract_date_obj(condition_operand_obj["subtract"], "subtract") - - if "time" in condition_operand_obj: - return Condition._parse_condition_operand(condition_operand_obj["time"]) - - if "value" in condition_operand_obj: - value = condition_operand_obj["value"] - SpEL_expression = f'"{value}"' - entity_id = condition_operand_obj.get("from_entity") - return value, SpEL_expression, entity_id - - if "scalar" in condition_operand_obj: - value = condition_operand_obj["scalar"] - SpEL_expression = str(value) - if isinstance(value, bool): - SpEL_expression = str(value).lower() - if isinstance(value, str): - SpEL_expression = f'"{value}"' - - ignore_case = condition_operand_obj.get("options", {}).get("ignore_case", False) - if ignore_case: - SpEL_expression = f"(?i){SpEL_expression}" - return value, SpEL_expression, None - - if "expression" in condition_operand_obj: - value = condition_operand_obj["expression"] - SpEL_expression = value - return value, SpEL_expression, None - - if "collection" in condition_operand_obj: - value = [obj["value"] for obj in condition_operand_obj["collection"]] - SpEL_expression = str(value) - return value, SpEL_expression, None - - variable_types = ["variable", "skill_variable", "system_variable"] - for field in variable_types: - if field in condition_operand_obj: - value = condition_operand_obj[field] - SpEL_expression = f"${{{value}}}" - if "variable_path" in condition_operand_obj: - value = f"{SpEL_expression}.{condition_operand_obj['variable_path']}" - SpEL_expression = value - - return value, SpEL_expression, None - - raise ValueError("Unknown condition operand:", condition_operand_obj) - - @staticmethod - def _parse_add_or_subtract_date_obj(add_or_subtract_date_obj, add_or_subtract_field): - if add_or_subtract_field != "add" and add_or_subtract_field != "subtract": - raise ValueError(f"The argument `add_or_subtract_date_obj` must be one of 'add' or 'subtract', instead got '{add_or_subtract_field}'") - - plus_or_minus = "plus" if add_or_subtract_field == "addition" else "minus" - - LHS = add_or_subtract_date_obj[0]["system_variable"] # This should always be ${current_date} or ${current_time} I believe - LHS_SpEL_expression = f"${{{LHS}}}" - - duration_obj = add_or_subtract_date_obj[1]["duration"] - RHS_SpEL_expression = "" - - if "seconds" in duration_obj: - RHS_SpEL_expression = f".{plus_or_minus}Seconds({duration_obj['seconds']})" - elif "minutes" in duration_obj: - RHS_SpEL_expression = f".{plus_or_minus}Minutes({duration_obj['minutes']})" - elif "hours" in duration_obj: - RHS_SpEL_expression = f".{plus_or_minus}Hours({duration_obj['hours']})" - elif "days" in duration_obj: - RHS_SpEL_expression = f".{plus_or_minus}Days({duration_obj['days']})" - elif "months" in duration_obj: - RHS_SpEL_expression = f".{plus_or_minus}Months({duration_obj['months']})" - elif "years" in duration_obj: - RHS_SpEL_expression = f".{plus_or_minus}Years({duration_obj['years']})" - else: - raise ValueError("Unknown duration object:", duration_obj) - - return LHS, f"{LHS_SpEL_expression}{RHS_SpEL_expression}", None - - - # ================================================================================ - # Searching - # ================================================================================ - - def get_all_variable_usage(self): - results = [] - - for statement in self.condition_statements: - statement_results = statement.get_all_variable_usage() - for statement_result in statement_results: - results.append({ - "source": "condition", - **statement_result - }) - - return results - - def get_all_entity_usage(self): - results = [] - - for statement in self.condition_statements: - statement_results = statement.get_all_entity_usage() - for statement_result in statement_results: - results.append({ - "source": "condition", - **statement_result - }) - - return results \ No newline at end of file diff --git a/skill_analytics/src/context.py b/skill_analytics/src/context.py deleted file mode 100644 index 8b958e1..0000000 --- a/skill_analytics/src/context.py +++ /dev/null @@ -1,116 +0,0 @@ -from src.statement import Statement - -class Context: - - name = "context" - - def __init__(self, context_obj): - self.raw_obj = context_obj - - self.context_statements = Context._parse_context(context_obj) - self.variable_ids = list(sorted(set([variable for statement in self.context_statements for variable in statement.variable_ids]))) - - def __repr__(self): - return "\n".join([context_statement["SpEL expression"] for context_statement in self.context_statements]) - - def __iter__(self): - return self.context_statements.__iter__() - - def to_json(self): - return { - "statements": [statement.to_json() for statement in self.context_statements], - "variables": self.variable_ids, - } - - # ================================================================================ - # Parsing - # ================================================================================ - - @staticmethod - def _parse_context(context_obj): - context = [] - for context_variable_obj in context_obj.get("variables", []): - context.append( Context._parse_context_variable(context_variable_obj) ) - - return context - - @staticmethod - def _parse_context_variable(variable_obj): - - LHS = LHS_SpEL_expression = None - RHS = RHS_SpEL_expression = None - - if "skill_variable" in variable_obj: - LHS = variable_obj["skill_variable"] - LHS_SpEL_expression = f"${{{LHS}}}" - - RHS, RHS_SpEL_expression = Context._parse_value_obj(variable_obj["value"]) - - return Statement( - LHS=LHS, - LHS_SpEL_expression=LHS_SpEL_expression, - RHS=RHS, - RHS_SpEL_expression=RHS_SpEL_expression, - operation="assign" if LHS else None - ) - - @staticmethod - def _parse_value_obj(value_obj): - - if "time" in value_obj: - value = value_obj["time"] - return value, f'"{value}"' - - if "scalar" in value_obj: - value = value_obj["scalar"] - if isinstance(value, str): - return value, f'"{value}"' - if isinstance(value, bool): - return value, str(value).lower() - return value, str(value) - - if "expression" in value_obj: - value = value_obj["expression"] - return value, value - - if "skill_variable" in value_obj: - value = f"${{{value_obj['skill_variable']}}}" - return value, value - - if "variable" in value_obj: - value = f"${{{value_obj['variable']}}}" - if "variable_path" in value_obj: - value = f"{value}.{value_obj['variable_path']}" - return value, value - - raise Exception("Unknown", value_obj) - - # ================================================================================ - # Searching - # ================================================================================ - - def get_all_variable_usage(self): - results = [] - - for statement in self.context_statements: - statement_results = statement.get_all_variable_usage() - for statement_result in statement_results: - results.append({ - "source": "context", - **statement_result - }) - - return results - - def get_all_entity_usage(self): - results = [] - - for statement in self.context_statements: - statement_results = statement.get_all_entity_usage() - for statement_result in statement_results: - results.append({ - "source": "context", - **statement_result - }) - - return results \ No newline at end of file diff --git a/skill_analytics/src/entity.py b/skill_analytics/src/entity.py deleted file mode 100644 index 41aa858..0000000 --- a/skill_analytics/src/entity.py +++ /dev/null @@ -1,9 +0,0 @@ - -class Entity: - - def __init__(self, entity_obj): - self.raw_obj = entity_obj - - self.ID = entity_obj.get("entity") - self.is_fuzzy_match = entity_obj.get("fuzzy_match", False) - self.values = entity_obj.get("values") \ No newline at end of file diff --git a/skill_analytics/src/extension.py b/skill_analytics/src/extension.py deleted file mode 100644 index 96990b1..0000000 --- a/skill_analytics/src/extension.py +++ /dev/null @@ -1,124 +0,0 @@ -from src.statement import Statement - -class Extension: - - name = "extension" - - def __init__(self, resolver_obj): - self.raw_obj = resolver_obj - - callout_obj = resolver_obj.get("callout", {}) - self.extension_exists = bool(callout_obj) - - self.spec_hash_id = callout_obj.get("internal", {}).get("spec_hash_id") - self.catalog_item_id = callout_obj.get("internal", {}).get("catalog_item_id") - - self.path = callout_obj.get("path") - self.method = callout_obj.get("method") - - self.result_variable = callout_obj.get("result_variable") - self.is_protected = False - - self.parameter_statements = [] - for field in ["path", "query", "header", "body"]: - self.parameter_statements.extend( - Extension._parse_parameter_obj(callout_obj.get("request_mapping", {}).get(field, [])) - ) - - self.variable_ids = list(sorted(set([variable for statement in self.parameter_statements for variable in statement.variable_ids]))) - if self.result_variable: - self.variable_ids.append(self.result_variable) - - def __repr__(self): - if self.extension_exists: - return f"{self.method} {self.path}" - return "No Extension" - - def __bool__(self): - return self.extension_exists - - def to_json(self): - return { - "method": self.method, - "path": self.path, - "result variable": self.result_variable, - "statements": self.parameter_statements, - "variables": self.variable_ids - } - - # ================================================================================ - # Parsing - # ================================================================================ - - @staticmethod - def _parse_parameter_obj(parameter_obj_list): - if parameter_obj_list is None: - parameter_obj_list = [] - - parameter_statements = [] - for parameter_obj in parameter_obj_list: - LHS = parameter_obj["parameter"] - LHS_SpEL_expression = f"${{{LHS}}}" - RHS, RHS_SpEL_expression = Extension._parse_value_obj(parameter_obj["value"]) - parameter_statements.append(Statement( - LHS=LHS, - LHS_SpEL_expression=LHS_SpEL_expression, - RHS=RHS, - RHS_SpEL_expression=RHS_SpEL_expression, - operation="assign" - )) - - return parameter_statements - - @staticmethod - def _parse_value_obj(value_obj): - - if "time" in value_obj: - value = value_obj["time"] - return value, f'"{value}"' - - if "scalar" in value_obj: - value = value_obj["scalar"] - if isinstance(value, str): - return value, f'"{value}"' - if isinstance(value, bool): - return value, str(value).lower() - return value, str(value) - - if "expression" in value_obj: - value = value_obj["expression"] - return value, value - - if "skill_variable" in value_obj: - value = f"${{{value_obj['skill_variable']}}}" - return value, value - - if "variable" in value_obj: - value = f"${{{value_obj['variable']}}}" - if "variable_path" in value_obj: - value = f"{value}.{value_obj['variable_path']}" - return value, value - - if "system_variable" in value_obj: - value = f"${{{value_obj['system_variable']}}}" - return value, value - - raise Exception("Unknown", value_obj) - - - # ================================================================================ - # Searching - # ================================================================================ - - def get_all_variable_usage(self): - results = [] - - for statement in self.parameter_statements: - statement_results = statement.get_all_variable_usage() - for statement_result in statement_results: - results.append({ - "source": "extension", - **statement_result - }) - - return results \ No newline at end of file diff --git a/skill_analytics/src/intent.py b/skill_analytics/src/intent.py deleted file mode 100644 index 295613d..0000000 --- a/skill_analytics/src/intent.py +++ /dev/null @@ -1,44 +0,0 @@ -import re - -class Intent: - - def __init__(self, intent_obj, index): - self.raw_obj = intent_obj - self.index = index - - self.ID = intent_obj.get("intent") - self.action_id = Intent._parse_intent_id(self.ID) - self.examples = Intent._parse_examples(intent_obj.get("examples", [])) - - def __repr__(self): - return self.ID - - def to_json(self): - return { - "ID": self.ID, - "action_id": self.action_id, - "examples": self.examples - } - - # ================================================================================ - # Parsing - # ================================================================================ - - @staticmethod - def _parse_examples(examples): - return [text_obj.get("text") for text_obj in examples if text_obj.get("text")] - - @staticmethod - def _parse_intent_id(full_intent_id): - if full_intent_id is None: - return None - if full_intent_id == "fallback_connect_to_agent": - return "fallback" - - pattern = r"(action\_\d+)_(intent\_\d+)(-\d+)?" - match = re.search(pattern, full_intent_id) - if match: - end = "" if match.group(3) is None else match.group(3) - return match.group(1) + end - - print(f"Warning: Did not parse intent id {full_intent_id}") \ No newline at end of file diff --git a/skill_analytics/src/models/action.py b/skill_analytics/src/models/action.py new file mode 100644 index 0000000..d055c24 --- /dev/null +++ b/skill_analytics/src/models/action.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from .action_settings import ActionSettings +from .condition import Condition +from .entity import Entity +from .intent import Intent +from .step import Step +from .variables import ResultVariable, StepVariable + + +@dataclass +class Action: + id: str + title: str + description: Optional[str] + + intent: Intent + condition: Condition + settings: Any + + steps: List[Step] + + step_variables: Dict[str, StepVariable] + result_variables: Dict[str, ResultVariable] + + def __hash__(self) -> int: + return hash(self.id) + + @classmethod + def from_dict( + cls, + action_dict: dict, + intents: Dict[str, Intent], + entities: Dict[str, Entity] + ) -> 'Action': + """Create an Action instance from a dictionary.""" + id = action_dict["action"] + + step_variables: Dict[str, StepVariable] = {} + result_variables: Dict[str, ResultVariable] = {} + for variable_dict in action_dict.get("variables", []): + variable_id = variable_dict["variable"] + if "_result" in variable_id: + result_variable = ResultVariable.from_dict(variable_dict, id) + result_variables[result_variable.id] = result_variable + else: + step_variable = StepVariable.from_dict(variable_dict, id) + step_variables[step_variable.id] = step_variable + + condition_dict = action_dict.get("condition", {}) + + intent_id = condition_dict.get("intent") + intent = intents.get(intent_id, Intent()) + + steps: List[Step] = [] + for step_dict in action_dict["steps"]: + step = Step.from_dict(step_dict, entities) + steps.append(step) + + return cls( + id=id, + title=action_dict["title"], + description=action_dict.get("description"), + + intent=intent, + condition=Condition.from_dict(condition_dict), + settings=ActionSettings.from_dict(action_dict), + + steps=steps, + step_variables=step_variables, + result_variables=result_variables, + ) + + def get_subaction_ids(self) -> List[str]: + """Get all subaction IDs referenced in this action's steps.""" + return [step.subaction_id for step in self.steps if step.subaction_id] + + def get_extensions(self) -> List[Any]: + """Get all extensions used in this action.""" + # TODO + return [] + + def get_skill_variable_ids(self) -> List[str]: + """Get all skill variable IDs referenced in this action.""" + skill_variable_ids = [] + return skill_variable_ids \ No newline at end of file diff --git a/skill_analytics/src/models/action_settings.py b/skill_analytics/src/models/action_settings.py new file mode 100644 index 0000000..20eed48 --- /dev/null +++ b/skill_analytics/src/models/action_settings.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass + + +@dataclass +class ActionSettings: + ask_clarifying_question: bool + change_coversation_topic: bool + never_return: bool + + @classmethod + def from_dict(cls, data: dict) -> 'ActionSettings': + """Create an ActionSettings instance from a dictionary.""" + disambiguation_opt_out = data.get("disambiguation_opt_out", True) + allowed_from = data.get("topic_switch", {}).get("allowed_from", True) + allowed_into = data.get("topic_switch", {}).get("allowed_into", True) + never_return = data.get("topic_switch", {}).get("never_return", False) + + if allowed_from != allowed_into: + raise ValueError(f"The values `allowed_from` and `allowed_into` are different, and they shouldn't be.") + + return cls( + ask_clarifying_question=(not disambiguation_opt_out), + change_coversation_topic=allowed_into, + never_return=never_return + ) + + def to_dict(self) -> dict: + """Convert the action settings to a dictionary representation.""" + return { + "ask_clarifying_question": self.ask_clarifying_question, + "change_coversation_topic": self.change_coversation_topic, + "never_return": self.never_return, + } diff --git a/skill_analytics/src/models/assistant.py b/skill_analytics/src/models/assistant.py new file mode 100644 index 0000000..4e8b218 --- /dev/null +++ b/skill_analytics/src/models/assistant.py @@ -0,0 +1,154 @@ +from dataclasses import dataclass +from typing import Dict, Optional + +from src.utils.parse_wxa_ids import build_long_id + +from .action import Action +from .intent import Intent +from .data_types import DataTypeRegistry +from .entity import Entity +from .system_settings import SystemSettings +from .variables import ( + ResultVariable, + SkillVariable, + StepVariable, + SystemVariable, + Variable, + SYSTEM_VARIABLE_IDS, +) + + +@dataclass +class Assistant: + name: str + description: str + skill_id: str + assistant_id: str + workspace_id: Optional[str] + + settings: SystemSettings + + intents: Dict[str, Intent] + entities: Dict[str, Entity] + actions: Dict[str, Action] + + data_type_registry: DataTypeRegistry + skill_variables: Dict[str, SkillVariable] + + def __post_init__(self): + # cached variables + self._system_variables = None + self._step_variables = None + self._result_variables = None + + @classmethod + def from_dict(cls, data: dict) -> 'Assistant': + """Create an Assistant instance from a dictionary.""" + workspace = data["workspace"] + + intents: Dict[str, Intent] = {} + for intent_dict in workspace["intents"]: + intent = Intent.from_dict(intent_dict) + assert intent.id is not None + intents[intent.id] = intent + + entities: Dict[str, Entity] = {} + for entity_dict in workspace["entities"]: + entity = Entity.from_dict(entity_dict) + entities[entity.id] = entity + + actions: Dict[str, Action] = {} + for action_dict in workspace["actions"]: + action = Action.from_dict(action_dict, intents, entities) + actions[action.id] = action + + data_type_registry = DataTypeRegistry() + data_type_registry.register_many(workspace.get("data_types", [])) + + skill_variables: Dict[str, SkillVariable] = {} + for variable_dict in workspace["variables"]: + skill_variable = SkillVariable.from_dict(variable_dict, data_type_registry) + skill_variables[skill_variable.id] = skill_variable + + return cls( + name=data["name"], + description=data["description"], + skill_id=data["skill_id"], + assistant_id=data["assistant_id"], + workspace_id=data.get("workspace_id"), + + settings=SystemSettings.from_dict(workspace["system_settings"]), + + entities=entities, + intents=intents, + actions=actions, + + data_type_registry=data_type_registry, + skill_variables=skill_variables, + ) + + def get_action(self, id_or_title: str) -> Optional[Action]: + """Retrieve an action by its ID or title.""" + for action in self.actions.values(): + if id_or_title == action.id or id_or_title == action.title: + return action + + def get_variable(self, variable_id: str, action_id: Optional[str] = None) -> Optional[Variable]: + """Retrieve a variable by ID, searching across skill, step, result, and system variables.""" + long_id = variable_id + if action_id is not None: + long_id = build_long_id(action_id, variable_id) + + if variable_id in self.skill_variables: + return self.skill_variables[variable_id] + + if long_id in self.step_variables: + return self.step_variables[long_id] + + if long_id in self.result_variables: + return self.result_variables[long_id] + + if variable_id in self.system_variables: + return self.system_variables[variable_id] + + @property + def system_variables(self) -> Dict[str, SystemVariable]: + """Get all system variables, cached after first access.""" + if self._system_variables: + return self._system_variables + + system_variables: Dict[str, SystemVariable] = {} + for system_variable_id in SYSTEM_VARIABLE_IDS: + system_variable = SystemVariable.from_dict({"variable": system_variable_id}) + system_variables[system_variable.uid] = system_variable + + self._system_variables = system_variables + return self._system_variables + + @property + def step_variables(self) -> Dict[str, StepVariable]: + """Get all step variables from all actions, cached after first access.""" + if self._step_variables: + return self._step_variables + + step_variables: Dict[str, StepVariable] = {} + for action in self.actions.values(): + for step_variable in action.step_variables.values(): + step_variables[step_variable.uid] = step_variable + + self._step_variables = step_variables + return self._step_variables + + @property + def result_variables(self) -> Dict[str, ResultVariable]: + """Get all result variables from all actions, cached after first access.""" + if self._result_variables: + return self._result_variables + + result_variables: Dict[str, ResultVariable] = {} + for action in self.actions.values(): + for result_variable in action.result_variables.values(): + result_variables[result_variable.uid] = result_variable + + self._result_variables = result_variables + return self._result_variables \ No newline at end of file diff --git a/skill_analytics/src/models/condition.py b/skill_analytics/src/models/condition.py new file mode 100644 index 0000000..3a2f982 --- /dev/null +++ b/skill_analytics/src/models/condition.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass, field +from typing import List, Set + +from src.utils.parse_variables_from_spel_expression import parse_variables_from_spel_expression + +from .statements import OperationType, Statement, create_statement_from_dict + +@dataclass +class Condition: + operation_type: OperationType = OperationType.AND + condition_group: List['Condition'] = field(default_factory=list) + statements: List[Statement] = field(default_factory=list) + + def __bool__(self) -> bool: + return bool(len(self.condition_group + self.statements)) + + @classmethod + def from_dict(cls, data: dict) -> 'Condition': + """Create a Condition instance from a dictionary.""" + if ("and" not in data) and ("or" not in data): + statements = [create_statement_from_dict(data)] + return cls(statements=[statement for statement in statements if statement]) + + operation_str, condition_group_list = list(data.items())[0] + operation_type = OperationType(operation_str) + + # Recursively go down condition group + return cls( + operation_type=operation_type, + condition_group=[Condition.from_dict(condition_dict) for condition_dict in condition_group_list] + ) + + def to_dict(self) -> dict: + """Convert the condition to a dictionary representation.""" + return { + "operation_type": self.operation_type.value, + "condition_group": [condition.to_dict() for condition in self.condition_group], + "statements": [statement.to_dict() for statement in self.statements], + "spel_expression": self.spel_expression + } + + def get_all_statements(self) -> List[Statement]: + """Recursively get all statements from this condition and nested condition groups.""" + statements = list(self.statements) + for condition in self.condition_group: + statements.extend(condition.get_all_statements()) + return statements + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this condition.""" + obj_list = self.statements + self.condition_group + + spel_clauses = [statement.spel_expression for statement in obj_list] + spel_clauses = [exp for exp in spel_clauses if exp != ""] + + if len(spel_clauses) == 0: + return "" + if len(spel_clauses) == 1: + return spel_clauses[0] + + op_symbol = self.operation_type.get_spel_symbol() + return f" {op_symbol} ".join([ + f"({clause})" for clause in spel_clauses + ]) + + def get_all_variable_ids(self) -> Set[str]: + """Extract all variable IDs referenced in this condition's SpEL expression.""" + spel_expression = self.spel_expression + return parse_variables_from_spel_expression(spel_expression) diff --git a/skill_analytics/src/models/context.py b/skill_analytics/src/models/context.py new file mode 100644 index 0000000..cf00fd0 --- /dev/null +++ b/skill_analytics/src/models/context.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass +from typing import List, Set + +from src.utils.parse_variables_from_spel_expression import parse_variables_from_spel_expression + +from .operands import ExpressionOperand, SkillVariableOperand, create_operand_from_dict +from .statements import AssignStatement, ExpressionStatement, Statement + +@dataclass +class Context: + statements: List[Statement] + + def __bool__(self) -> bool: + return bool(len(self.statements)) + + @classmethod + def from_dict(cls, data: dict) -> 'Context': + """Create a Context instance from a dictionary.""" + + statements: List[Statement] = [] + for statement_dict in data.get("variables", []): + + if "skill_variable" in statement_dict: + statement = AssignStatement( + SkillVariableOperand.from_dict(statement_dict), + create_operand_from_dict(statement_dict["value"]) + ) + else: + statement = ExpressionStatement( + ExpressionOperand.from_dict(statement_dict["value"]) + ) + + statements.append(statement) + + return cls(statements=statements) + + def to_dict(self) -> dict: + """Convert the context to a dictionary representation.""" + return { + "statements": [statement.to_dict() for statement in self.statements], + "spel_expression": self.spel_expression + } + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this context.""" + if len(self.statements) == 0: + return "" + if len(self.statements) == 1: + statement = self.statements[0] + return statement.spel_expression + + return " && ".join([f"({statement.spel_expression})" for statement in self.statements]) + + def get_all_statements(self) -> List[Statement]: + """Get all statements in this context.""" + return self.statements + + def get_all_variable_ids(self) -> Set[str]: + """Extract all variable IDs referenced in this context's SpEL expression.""" + spel_expression = self.spel_expression + return parse_variables_from_spel_expression(spel_expression) + \ No newline at end of file diff --git a/skill_analytics/src/models/data_types/__init__.py b/skill_analytics/src/models/data_types/__init__.py new file mode 100644 index 0000000..8f51e95 --- /dev/null +++ b/skill_analytics/src/models/data_types/__init__.py @@ -0,0 +1,18 @@ +from .data_types import ( + BuiltInDataType, + CustomDataType, + DataType, +) + +from .data_type_registry import ( + DataTypeRegistry, +) + +__all__ = [ + # Data types + "BuiltInDataType", + "CustomDataType", + "DataType", + # Registry + "DataTypeRegistry", +] \ No newline at end of file diff --git a/skill_analytics/src/models/data_types/data_type_registry.py b/skill_analytics/src/models/data_types/data_type_registry.py new file mode 100644 index 0000000..03b6071 --- /dev/null +++ b/skill_analytics/src/models/data_types/data_type_registry.py @@ -0,0 +1,95 @@ +from typing import Optional, Dict, List + +from .data_types import BuiltInDataType, CustomDataType, DataType + + +class DataTypeRegistry: + """Simple registry for managing custom data types.""" + + def __init__(self): + """Initialize an empty registry.""" + self.custom_types: Dict[str, CustomDataType] = {} + + def register(self, data: dict) -> CustomDataType: + """Register a custom data type from a dictionary.""" + custom = CustomDataType.from_dict(data) + self.custom_types[custom.id] = custom + return custom + + def register_many(self, data_list: List[dict]) -> List[CustomDataType]: + """Register multiple custom data types.""" + return [self.register(data) for data in data_list] + + def get_custom(self, type_id: str) -> Optional[CustomDataType]: + """Get a custom data type by ID.""" + return self.custom_types.get(type_id) + + def resolve(self, type_id: str) -> DataType: + """Resolve a data type ID to either a BuiltInDataType or CustomDataType.""" + # Try built-in types first + try: + return BuiltInDataType.from_id(type_id) + except ValueError: + pass + + # Try custom types + custom = self.get_custom(type_id) + if custom is not None: + return custom + + # Not found + raise ValueError( + f"Unknown data type '{type_id}'. " + f"Not a built-in type and not found in custom types registry." + ) + + def is_built_in(self, type_id: str) -> bool: + """Check if a type ID is a built-in data type.""" + try: + BuiltInDataType.from_id(type_id) + return True + except ValueError: + return False + + def is_custom(self, type_id: str) -> bool: + """Check if a type ID is a registered custom data type.""" + return type_id in self.custom_types + + def get_all_custom_types(self) -> List[CustomDataType]: + """Get all registered custom data types.""" + return list(self.custom_types.values()) + + def get_title(self, type_id: str) -> str: + """Get a human-readable title for a data type.""" + try: + data_type = self.resolve(type_id) + return data_type.title + except ValueError: + # Unknown type + return type_id + + def clear(self) -> None: + """Clear all registered custom types.""" + self.custom_types.clear() + + def __len__(self) -> int: + """Get the number of registered custom types.""" + return len(self.custom_types) + + def __contains__(self, type_id: str) -> bool: + """Check if a custom type ID is registered.""" + return type_id in self.custom_types + + def __repr__(self) -> str: + """Return a string representation of the registry.""" + return f"DataTypeRegistry(custom_types={len(self.custom_types)})" + + def __str__(self) -> str: + """Return a human-readable string representation.""" + if not self.custom_types: + return "DataTypeRegistry (no custom types registered)" + + lines = [f"DataTypeRegistry with {len(self.custom_types)} custom type(s):"] + for custom in self.custom_types.values(): + lines.append(f" - {custom}") + return "\n".join(lines) \ No newline at end of file diff --git a/skill_analytics/src/models/data_types/data_types.py b/skill_analytics/src/models/data_types/data_types.py new file mode 100644 index 0000000..159f1c4 --- /dev/null +++ b/skill_analytics/src/models/data_types/data_types.py @@ -0,0 +1,96 @@ +from enum import Enum +from dataclasses import dataclass +from typing import Union + +class BuiltInDataType(Enum): + """Built-in data types supported by Watson Assistant. + + Each enum value is a tuple of (id, display_name). + """ + ANY = ("any", "Any") + STRING = ("string", "String") + NUMBER = ("number", "Number") + BOOLEAN = ("boolean", "Boolean") + CURRENCY = ("currency", "Currency") + DATE = ("date", "Date") + TIME = ("time", "Time") + PERCENTAGE = ("percentage", "Percentage") + FREE_TEXT = ("free_text", "Free Text") + CONFIRMATION = ("yes_no", "Confirmation") + + def __init__(self, type_id: str, title: str): + self._id = type_id + self._title = title + + @property + def id(self) -> str: + """Get the unique identifier for this data type.""" + return self._id + + @property + def title(self) -> str: + """Get a human-readable display name for this data type.""" + return self._title + + def is_custom(self) -> bool: + """Check if this is a custom data type.""" + return False + + @classmethod + def from_id(cls, type_id: str) -> 'BuiltInDataType': + """Get a BuiltInDataType by its ID string.""" + for member in cls: + if member.id == type_id: + return member + raise ValueError(f"'{type_id}' is not a valid BuiltInDataType") + + def to_dict(self) -> dict: + """Convert to dictionary representation.""" + return { + "id": self.value, + "title": self.title, + } + + +@dataclass +class CustomDataType: + """Represents a custom data type defined in the assistant configuration.""" + _id: str + title: str + entity_id: str + + @property + def id(self) -> str: + """Get the unique identifier for this data type.""" + return self._id + + def is_custom(self) -> bool: + """Check if this is a custom data type.""" + return True + + @classmethod + def from_dict(cls, data: dict) -> 'CustomDataType': + """Create a CustomDataType instance from a dictionary.""" + return cls( + _id=data["data_type"], + title=data["title"], + entity_id=data["entity"] + ) + + def to_dict(self) -> dict: + """Convert to dictionary representation.""" + return { + "id": self._id, + "title": self.title, + "entity_id": self.entity_id + } + + def __str__(self) -> str: + return self.title + + def __repr__(self) -> str: + return f"CustomDataType(id='{self._id}', title='{self.title}', entity_id='{self.entity_id}')" + + +# Type alias for any data type +DataType = Union[BuiltInDataType, CustomDataType] diff --git a/skill_analytics/src/models/entity/__init__.py b/skill_analytics/src/models/entity/__init__.py new file mode 100644 index 0000000..988836c --- /dev/null +++ b/skill_analytics/src/models/entity/__init__.py @@ -0,0 +1,32 @@ +"""Entity module for parsing and managing Watson Assistant entities. + +This module provides classes for working with entities and their values, +including synonyms and pattern-based entity values. +""" + +from .entity import Entity +from .value import ( + EntityValue, + EntityValueType, + SynonymsEntityValue, + PatternsEntityValue, +) +from .factory import ( + create_entity_value_from_dict, + ENTITY_VALUE_REGISTRY, +) + +__all__ = [ + # Main entity class + "Entity", + + # Entity value classes + "EntityValue", + "EntityValueType", + "SynonymsEntityValue", + "PatternsEntityValue", + + # Factory functions and registry + "create_entity_value_from_dict", + "ENTITY_VALUE_REGISTRY", +] diff --git a/skill_analytics/src/models/entity/entity.py b/skill_analytics/src/models/entity/entity.py new file mode 100644 index 0000000..6fb185f --- /dev/null +++ b/skill_analytics/src/models/entity/entity.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import List + +from .value import EntityValue +from .factory import create_entity_value_from_dict + +@dataclass +class Entity: + id: str + values: List[EntityValue] + fuzzy_match: bool = False + + def __hash__(self) -> int: + return hash(self.id) + + @classmethod + def from_dict(cls, data: dict) -> 'Entity': + """Create an Entity instance from a dictionary.""" + entity_id = data.get('entity', '') + fuzzy_match = data.get('fuzzy_match', False) + + # Parse all entity values + values = [] + for value_data in data.get('values', []): + values.append( + create_entity_value_from_dict(value_data) + ) + + return cls( + id=entity_id, + values=values, + fuzzy_match=fuzzy_match + ) + + def to_dict(self) -> dict: + """Convert the entity to a dictionary representation.""" + return { + "id": self.id, + "fuzzy_match": self.fuzzy_match, + "values": [value.to_dict() for value in self.values] + } \ No newline at end of file diff --git a/skill_analytics/src/models/entity/factory.py b/skill_analytics/src/models/entity/factory.py new file mode 100644 index 0000000..7c55a69 --- /dev/null +++ b/skill_analytics/src/models/entity/factory.py @@ -0,0 +1,17 @@ +from typing import Type +from .value import PatternsEntityValue, SynonymsEntityValue, EntityValueType, EntityValue + +ENTITY_VALUE_REGISTRY: dict[EntityValueType, Type[EntityValue]] = { + EntityValueType.SYNONYMS: SynonymsEntityValue, + EntityValueType.PATTERNS: PatternsEntityValue, +} + +def create_entity_value_from_dict(data: dict) -> EntityValue: + """Create an EntityValue instance from a dictionary based on the value type.""" + for entity_value_type, entity_value_class in ENTITY_VALUE_REGISTRY.items(): + key = entity_value_type.value + if key in data: + return entity_value_class.from_dict(data) + + # If no matching key found, raise an error + raise ValueError(f"Unknown entity value type. Data keys: {list(data.keys())}") \ No newline at end of file diff --git a/skill_analytics/src/models/entity/value.py b/skill_analytics/src/models/entity/value.py new file mode 100644 index 0000000..4aba67a --- /dev/null +++ b/skill_analytics/src/models/entity/value.py @@ -0,0 +1,75 @@ + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum + + +class EntityValueType(Enum): + SYNONYMS = "synonyms" + PATTERNS = "patterns" + +@dataclass +class EntityValue(ABC): + value_type: EntityValueType = field(init=False) + value: str + + @classmethod + @abstractmethod + def from_dict(cls, data: dict) -> 'EntityValue': + """Create an EntityValue instance from a dictionary.""" + pass + + def to_dict(self) -> dict: + """Convert the entity value to a dictionary representation.""" + return { + "value_type": self.value_type.value, + "value": self.value + } + + +@dataclass +class SynonymsEntityValue(EntityValue): + synonyms: list[str] = field(default_factory=list) + + def __post_init__(self): + self.value_type = EntityValueType.SYNONYMS + + @classmethod + def from_dict(cls, data: dict) -> 'SynonymsEntityValue': + """Create a SynonymsEntityValue instance from a dictionary.""" + return cls( + value=data["value"], + synonyms=data["synonyms"] + ) + + def to_dict(self) -> dict: + """Convert the synonyms entity value to a dictionary representation.""" + return { + "value_type": self.value_type.value, + "value": self.value, + "synonyms": self.synonyms, + } + + +@dataclass +class PatternsEntityValue(EntityValue): + patterns: list[str] = field(default_factory=list) + + def __post_init__(self): + self.value_type = EntityValueType.PATTERNS + + @classmethod + def from_dict(cls, data: dict) -> 'PatternsEntityValue': + """Create a PatternsEntityValue instance from a dictionary.""" + return cls( + value=data["value"], + patterns=data["patterns"] + ) + + def to_dict(self) -> dict: + """Convert the patterns entity value to a dictionary representation.""" + return { + "value_type": self.value_type.value, + "value": self.value, + "patterns": self.patterns, + } \ No newline at end of file diff --git a/skill_analytics/src/models/handler.py b/skill_analytics/src/models/handler.py new file mode 100644 index 0000000..29fd5d4 --- /dev/null +++ b/skill_analytics/src/models/handler.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List, Optional + +from .resolvers import Resolver, create_resolver_from_dict +from .responses import Response, create_response_from_dict + +class HandlerType(Enum): + NOT_FOUND = "not_found" + NOT_FOUND_MAX_RETRIES = "not_found_max_tries" + MAX_HITS = "max_hits" + +@dataclass +class Handler: + id: str + title: Optional[str] + handler_type: HandlerType + responses: List[Response] + resolver: Resolver + next_handler_id: Optional[str] = None + + @classmethod + def from_dict(cls, data: dict) -> 'Handler': + """Create a Handler instance from a dictionary.""" + return cls( + id=data["handler"], + title=data.get("title"), + handler_type=HandlerType(data["type"]), + responses=[create_response_from_dict(response_dict) for response_dict in data.get("output", {}).get("generic", [])], + resolver=create_resolver_from_dict(data["resolver"]), + next_handler_id=data.get("next_handler") + ) + + def to_dict(self) -> dict: + """Convert the handler to a dictionary representation.""" + return { + "id": self.id, + "title": self.title, + "handler_type": self.handler_type.value, + "responses": [response.to_dict() for response in self.responses], + "resolver": self.resolver.to_dict(), + "next_handler_id": self.next_handler_id, + } \ No newline at end of file diff --git a/skill_analytics/src/models/intent.py b/skill_analytics/src/models/intent.py new file mode 100644 index 0000000..7d6d514 --- /dev/null +++ b/skill_analytics/src/models/intent.py @@ -0,0 +1,41 @@ + +from dataclasses import dataclass, field +from typing import List, Optional + +from src.utils.parse_wxa_ids import parse_long_id + +@dataclass +class Intent: + id: Optional[str] = None + examples: List[str] = field(default_factory=list) + + def __post_init__(self): + if self.id is None: + self.action_id = None + self.short_id = None + elif self.id == "fallback_connect_to_agent": + self.action_id = "fallback" + self.short_id = "fallback_connect_to_agent" + else: + self.action_id, self.short_id = parse_long_id(self.id) + + def __hash__(self) -> int: + return hash(self.id) + + def __str__(self) -> str: + return str(self.examples) + + @classmethod + def from_dict(cls, data: dict) -> 'Intent': + """Create an Intent instance from a dictionary.""" + return cls( + id=data["intent"], + examples=[example_dict["text"] for example_dict in data["examples"]] + ) + + def to_dict(self) -> dict: + """Convert the intent to a dictionary representation.""" + return { + "id": self.id, + "examples": self.examples + } \ No newline at end of file diff --git a/skill_analytics/src/models/operands/__init__.py b/skill_analytics/src/models/operands/__init__.py new file mode 100644 index 0000000..24ab51b --- /dev/null +++ b/skill_analytics/src/models/operands/__init__.py @@ -0,0 +1,32 @@ +"""Operand module for handling different types of operands in statements.""" + +from .base import OperandType, Operand +from .scalar import ScalarOperand +from .time import TimeOperand +from .expression import ExpressionOperand +from .variable import SkillVariableOperand, SystemVariableOperand, VariableOperand +from .intent import IntentOperand +from .entity import EntityOperand +from .collection import CollectionOperand +from .factory import Operand, create_operand_from_dict + +__all__ = [ + # Base types + "OperandType", + "Operand", + + # Operand types + "ScalarOperand", + "TimeOperand", + "ExpressionOperand", + "SkillVariableOperand", + "SystemVariableOperand", + "VariableOperand", + "IntentOperand", + "EntityOperand", + "CollectionOperand", + + # Union type and factory + "Operand", + "create_operand_from_dict", +] diff --git a/skill_analytics/src/models/operands/base.py b/skill_analytics/src/models/operands/base.py new file mode 100644 index 0000000..84a76e6 --- /dev/null +++ b/skill_analytics/src/models/operands/base.py @@ -0,0 +1,52 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Set + +from src.utils.parse_variables_from_spel_expression import parse_variables_from_spel_expression + +class OperandType(Enum): + SCALAR = "scalar" + TIME = "time" + EXPRESSION = "expression" + SKILL_VARIABLE = "skill_variable" + SYSTEM_VARIABLE = "system_variable" + VARIABLE = "variable" + INTENT = "intent" + COLLECTION = "collection" + ENTITY = "from_entity" + + +@dataclass +class Operand(ABC): + operand_type: OperandType = field(init=False) + value: Any + + @classmethod + @abstractmethod + def from_dict(cls, data: dict) -> 'Operand': + """Create an operand instance from a dictionary.""" + pass + + def to_dict(self) -> dict: + """Convert the operand to a dictionary representation.""" + return { + "operand_type": self.operand_type.value, + "value": self.value, + "SpEL_expression": self.spel_expression + } + + @property + @abstractmethod + def spel_expression(self) -> str: + """Get the SpEL expression representation of this operand.""" + pass + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(type={self.operand_type.value})" + + def get_all_variable_ids(self) -> Set[str]: + """Extract all variable IDs referenced in this operand's SpEL expression.""" + spel_expression = self.spel_expression + return parse_variables_from_spel_expression(spel_expression) + diff --git a/skill_analytics/src/models/operands/collection.py b/skill_analytics/src/models/operands/collection.py new file mode 100644 index 0000000..951a673 --- /dev/null +++ b/skill_analytics/src/models/operands/collection.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +from .base import Operand, OperandType +from .entity import EntityOperand + + +@dataclass +class CollectionOperand(Operand): + + def __post_init__(self): + self.operand_type = OperandType.COLLECTION + + @classmethod + def from_dict(cls, data: dict) -> 'CollectionOperand': + """Create a CollectionOperand instance from a dictionary.""" + return cls(value=[ + EntityOperand.from_dict(entity_dict) for entity_dict in data['collection'] + ]) + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this collection.""" + list_str = ', '.join(operand.spel_expression for operand in self.value) + return f"[{list_str}]" + diff --git a/skill_analytics/src/models/operands/entity.py b/skill_analytics/src/models/operands/entity.py new file mode 100644 index 0000000..b597f74 --- /dev/null +++ b/skill_analytics/src/models/operands/entity.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field + +from .base import Operand, OperandType + + +@dataclass +class EntityOperand(Operand): + entity_id: str = field(kw_only=True) + + def __post_init__(self): + self.operand_type = OperandType.ENTITY + + @classmethod + def from_dict(cls, data: dict) -> 'EntityOperand': + """Create an EntityOperand instance from a dictionary.""" + return cls(value=data['value'], entity_id=data["from_entity"]) + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this entity operand.""" + return f"{self.entity_id}(\"{self.value}\")" + diff --git a/skill_analytics/src/models/operands/expression.py b/skill_analytics/src/models/operands/expression.py new file mode 100644 index 0000000..f4fe7c2 --- /dev/null +++ b/skill_analytics/src/models/operands/expression.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + +from .base import Operand, OperandType + + +@dataclass +class ExpressionOperand(Operand): + + def __post_init__(self): + self.operand_type = OperandType.EXPRESSION + + @classmethod + def from_dict(cls, data: dict) -> 'ExpressionOperand': + """Create an ExpressionOperand instance from a dictionary.""" + return cls(value=data["expression"]) + + @property + def spel_expression(self) -> str: + """Get the SpEL expression value.""" + return self.value + diff --git a/skill_analytics/src/models/operands/factory.py b/skill_analytics/src/models/operands/factory.py new file mode 100644 index 0000000..c20afea --- /dev/null +++ b/skill_analytics/src/models/operands/factory.py @@ -0,0 +1,35 @@ +from typing import Type + +from .base import Operand, OperandType +from .scalar import ScalarOperand +from .time import TimeOperand +from .expression import ExpressionOperand +from .variable import SkillVariableOperand, SystemVariableOperand, VariableOperand +from .intent import IntentOperand +from .entity import EntityOperand +from .collection import CollectionOperand + + +# Map OperandType enum values to their corresponding operand classes +OPERAND_REGISTRY: dict[OperandType, Type[Operand]] = { + OperandType.SCALAR: ScalarOperand, + OperandType.TIME: TimeOperand, + OperandType.EXPRESSION: ExpressionOperand, + OperandType.SKILL_VARIABLE: SkillVariableOperand, + OperandType.SYSTEM_VARIABLE: SystemVariableOperand, + OperandType.VARIABLE: VariableOperand, + OperandType.INTENT: IntentOperand, + OperandType.ENTITY: EntityOperand, + OperandType.COLLECTION: CollectionOperand, +} + + +def create_operand_from_dict(data: dict) -> Operand: + """Create the appropriate operand instance from a dictionary based on its type.""" + for operand_type, operand_class in OPERAND_REGISTRY.items(): + key = operand_type.value + if key in data: + return operand_class.from_dict(data) + + # If no matching key found, raise an error + raise ValueError(f"Unknown operand type. Data keys: {list(data.keys())}") diff --git a/skill_analytics/src/models/operands/intent.py b/skill_analytics/src/models/operands/intent.py new file mode 100644 index 0000000..e46fd11 --- /dev/null +++ b/skill_analytics/src/models/operands/intent.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from .base import Operand, OperandType + + +@dataclass +class IntentOperand(Operand): + + def __post_init__(self): + self.operand_type = OperandType.INTENT + + @classmethod + def from_dict(cls, data: dict) -> 'IntentOperand': + """Create an IntentOperand instance from a dictionary.""" + return cls(value=data['intent']) + + @property + def spel_expression(self) -> str: + """Get the SpEL expression value.""" + return self.value diff --git a/skill_analytics/src/models/operands/scalar.py b/skill_analytics/src/models/operands/scalar.py new file mode 100644 index 0000000..60be803 --- /dev/null +++ b/skill_analytics/src/models/operands/scalar.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass + +from .base import Operand, OperandType + + +@dataclass +class ScalarOperand(Operand): + _ignore_case: bool = False + + def __post_init__(self): + self.operand_type = OperandType.SCALAR + + @classmethod + def from_dict(cls, data: dict) -> 'ScalarOperand': + """Create a ScalarOperand instance from a dictionary.""" + return cls( + value=data["scalar"], + _ignore_case=data.get("options", {}).get("ignore_case", False) + ) + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this scalar value.""" + spel_expression = str(self.value) + if isinstance(self.value, str): + spel_expression = f'"{self.value}"' + if isinstance(self.value, bool): + spel_expression = str(self.value).lower() + + # if self._ignore_case: + # spel_expression = f"(?i){spel_expression}" + return spel_expression diff --git a/skill_analytics/src/models/operands/time.py b/skill_analytics/src/models/operands/time.py new file mode 100644 index 0000000..93cfbcf --- /dev/null +++ b/skill_analytics/src/models/operands/time.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + +from .base import Operand, OperandType + + +@dataclass +class TimeOperand(Operand): + + def __post_init__(self): + self.operand_type = OperandType.TIME + + @classmethod + def from_dict(cls, data: dict) -> 'TimeOperand': + """Create a TimeOperand instance from a dictionary.""" + return cls(value=data["time"]["value"]) + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this time value.""" + return f'"{self.value}"' diff --git a/skill_analytics/src/models/operands/variable.py b/skill_analytics/src/models/operands/variable.py new file mode 100644 index 0000000..9d86ad5 --- /dev/null +++ b/skill_analytics/src/models/operands/variable.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass + +from .base import Operand, OperandType + + +@dataclass +class SkillVariableOperand(Operand): + skill_variable_id: str + + def __post_init__(self): + self.operand_type = OperandType.SKILL_VARIABLE + + @classmethod + def from_dict(cls, data: dict) -> 'SkillVariableOperand': + """Create a SkillVariableOperand instance from a dictionary.""" + skill_variable_id = data['skill_variable'] + value = f"${{{skill_variable_id}}}" + return cls(value=value, skill_variable_id=skill_variable_id) + + @property + def spel_expression(self) -> str: + """Get the SpEL expression value.""" + return self.value + +@dataclass +class SystemVariableOperand(Operand): + + def __post_init__(self): + self.operand_type = OperandType.SYSTEM_VARIABLE + + @classmethod + def from_dict(cls, data: dict) -> 'SystemVariableOperand': + """Create a SystemVariableOperand instance from a dictionary.""" + value = f"${{{data['system_variable']}}}" + return cls(value=value) + + @property + def spel_expression(self) -> str: + """Get the SpEL expression value.""" + return self.value + + +@dataclass +class VariableOperand(Operand): + + def __post_init__(self): + self.operand_type = OperandType.VARIABLE + + @classmethod + def from_dict(cls, data: dict) -> 'VariableOperand': + value = f"${{{data['variable']}}}" + if "variable_path" in data: + value = f"{value}.{data['variable_path']}" + return cls(value=value) + + @property + def spel_expression(self) -> str: + return self.value diff --git a/skill_analytics/src/models/question.py b/skill_analytics/src/models/question.py new file mode 100644 index 0000000..5afa9ad --- /dev/null +++ b/skill_analytics/src/models/question.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +class ResponseCollectionBehaviorType(Enum): + ALWAYS_ASK = "always_ask" + NEVER_ASK = "never_ask" + OPTIONALLY_ASK = "optionally_ask" + +@dataclass +class Question: + """ + This part of the assistant JSON is a mix of a bunch of things. It's not 1-to-1 with the UI + """ + + entity_id: Optional[str] + # data_type: Optional[DataType] + max_tries: int + response_collection_behavior: ResponseCollectionBehaviorType + allow_topic_switch: bool + collect_verbatim_response: bool + + @classmethod + def from_dict(cls, data: dict) -> 'Question': + """Create a Question instance from a dictionary.""" + return cls( + entity_id=data.get("entity"), + # data_type=DataType data.get("data_type"), # This would take a lot of work to validate properly, and I'm not sure if anyone even cares + max_tries=data.get("max_tries", 3), + response_collection_behavior=ResponseCollectionBehaviorType(data.get("response_collection_behavior", "optionally_ask")), + allow_topic_switch=data.get("allow_topic_switch", False), + collect_verbatim_response=data.get("collect_verbatim_response", False) + ) + + def to_dict(self) -> dict: + """Convert the question to a dictionary representation.""" + return { + "entity_id": self.entity_id, + "max_tries": self.max_tries, + "response_collection_behavior": self.response_collection_behavior.value, + "allow_topic_switch": self.allow_topic_switch, + "collect_verbatim_response": self.collect_verbatim_response, + } \ No newline at end of file diff --git a/skill_analytics/src/models/resolvers/__init__.py b/skill_analytics/src/models/resolvers/__init__.py new file mode 100644 index 0000000..d61c045 --- /dev/null +++ b/skill_analytics/src/models/resolvers/__init__.py @@ -0,0 +1,77 @@ +""" +Resolvers package for Watson Assistant skill analytics. + +This package provides classes for parsing and managing different resolver types +in Watson Assistant actions, including continue, replay, invoke action, callout, and end action. +""" + +from .base import ( + Resolver, + ResolverType, +) + +from .continue_resolver import ( + ContinueResolver, +) + +from .replay_resolver import ( + ReplayResolver, +) + +from .invoke_action_resolver import ( + InvokeActionResolver, +) + +from .invoke_action_and_end_resolver import ( + InvokeActionAndEndResolver, +) + +from .callout_resolver import ( + CalloutResolver, + RequestMapping, + ParameterMapping, +) + +from .end_action_resolver import ( + EndActionResolver, +) + +from .connect_to_agent_resolver import ( + ConnectToAgentResolver, +) + +from .fallback_resolver import ( + FallbackResolver, +) + +from .factory import ( + create_resolver_from_dict, + RESOLVER_REGISTRY, +) + +__all__ = [ + # Base + "Resolver", + "ResolverType", + # Continue + "ContinueResolver", + # Replay (Re-ask) + "ReplayResolver", + # Invoke Action (Subaction) + "InvokeActionResolver", + # Invoke Action and End + "InvokeActionAndEndResolver", + # Callout (Extension) + "CalloutResolver", + "RequestMapping", + "ParameterMapping", + # End Action + "EndActionResolver", + # Connect to Agent + "ConnectToAgentResolver", + # Fallback + "FallbackResolver", + # Factory + "create_resolver_from_dict", + "RESOLVER_REGISTRY", +] diff --git a/skill_analytics/src/models/resolvers/base.py b/skill_analytics/src/models/resolvers/base.py new file mode 100644 index 0000000..6a0be3b --- /dev/null +++ b/skill_analytics/src/models/resolvers/base.py @@ -0,0 +1,58 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum + + +class ResolverType(Enum): + """Enum representing different resolver types in Watson Assistant.""" + CONTINUE = ("continue", "Continue to next step") + REPLAY = ("replay", "Re-ask") + INVOKE_ANOTHER_ACTION = ("invoke_another_action", "Go to a subaction") + INVOKE_ANOTHER_ACTION_AND_END = ("invoke_another_action_and_end", "Go to a subaction and end the current action") + CALLOUT = ("callout", "Use an extension") + END_ACTION = ("end_action", "End the action") + CONNECT_TO_AGENT = ("connect_to_agent", "Connect to agent") + FALLBACK = ("fallback", "Fallback") + PROMPT_AGAIN = ("prompt_again", "Prompt Again") + + def __init__(self, type_id: str, title: str): + self._id = type_id + self._title = title + + @property + def id(self) -> str: + """Get the unique identifier for this resolver type.""" + return self._id + + @property + def title(self) -> str: + """Get a human-readable display name for this resolver type.""" + return self._title + + @classmethod + def from_id(cls, type_id: str) -> 'ResolverType': + """Get a ResolverType by its ID string.""" + for member in cls: + if member.id == type_id: + return member + raise ValueError(f"'{type_id}' is not a valid ResolverType") + + +@dataclass +class Resolver(ABC): + """Abstract base class for all resolver types.""" + resolver_type: ResolverType = field(init=False) + + @classmethod + @abstractmethod + def from_dict(cls, data: dict) -> 'Resolver': + """Create a resolver instance from a dictionary.""" + pass + + @abstractmethod + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + pass + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(type={self.resolver_type.id})" \ No newline at end of file diff --git a/skill_analytics/src/models/resolvers/callout_resolver.py b/skill_analytics/src/models/resolvers/callout_resolver.py new file mode 100644 index 0000000..dd39893 --- /dev/null +++ b/skill_analytics/src/models/resolvers/callout_resolver.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass +from typing import List, Set + +from src.utils.parse_variables_from_spel_expression import parse_variables_from_spel_expression + +from ..operands import create_operand_from_dict, Operand +from .base import Resolver, ResolverType + + +@dataclass +class ParameterMapping: + """Represents a parameter mapping for callout requests.""" + parameter: str + value: Operand + + @classmethod + def from_dict(cls, data: dict) -> 'ParameterMapping': + """Create a ParameterMapping instance from a dictionary.""" + return cls( + parameter=data["parameter"], + value=create_operand_from_dict(data["value"]) + ) + + def to_dict(self) -> dict: + """Convert to dictionary representation.""" + return { + "parameter": self.parameter, + "value": self.value.value + } + + @property + def spel_expression(self) -> str: + """Generate the SpEL expression for this parameter mapping.""" + return f"{self.parameter} = {self.value.spel_expression}" + + def get_all_variable_ids(self) -> Set[str]: + return self.value.get_all_variable_ids() + + +@dataclass +class RequestMapping: + """Request mapping configuration for callout.""" + query: List[ParameterMapping] + header: List[ParameterMapping] + + @classmethod + def from_dict(cls, data: dict) -> 'RequestMapping': + """Create a RequestMapping instance from a dictionary.""" + query = [ParameterMapping.from_dict(item) for item in data.get("query", [])] + header = [ParameterMapping.from_dict(item) for item in data.get("header", [])] + return cls( + query=query, + header=header, + ) + + def to_dict(self) -> dict: + """Convert to dictionary representation.""" + return { + "query": [param.spel_expression for param in self.query], + "header": [param.spel_expression for param in self.header], + + } + + def get_all_variable_ids(self) -> Set[str]: + """Extract all variable IDs from query and header parameters.""" + """Extract all variable IDs from this parameter mapping.""" + variable_ids = set([]) + for param in self.query: + variable_ids.update(param.get_all_variable_ids()) + for param in self.header: + variable_ids.update(param.get_all_variable_ids()) + return variable_ids + +@dataclass +class CalloutResolver(Resolver): + """Resolver that calls an external extension.""" + path: str + type: str + method: str + + spec_hash_id: str + catalog_item_id: str + + request_mapping: RequestMapping + result_variable_id: str + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.CALLOUT + + @classmethod + def from_dict(cls, data: dict) -> 'CalloutResolver': + """Create a CalloutResolver instance from a dictionary.""" + callout_data = data["callout"] + return cls( + path=callout_data["path"], + type=callout_data["type"], + method=callout_data["method"], + + spec_hash_id=callout_data["internal"]["spec_hash_id"], + catalog_item_id=callout_data["internal"]["catalog_item_id"], + + request_mapping=RequestMapping.from_dict(callout_data["request_mapping"]), + result_variable_id=callout_data["result_variable"], + ) + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id, + "path": self.path, + "type": self.type, + "method": self.method, + "spec_hash_id": self.spec_hash_id, + "catalog_item_id": self.catalog_item_id, + **self.request_mapping.to_dict(), + "result_variable_id": self.result_variable_id, + } + + def __repr__(self) -> str: + return f"CalloutResolver(path={self.path}, method={self.method}, result_variable_id={self.result_variable_id})" diff --git a/skill_analytics/src/models/resolvers/connect_to_agent_resolver.py b/skill_analytics/src/models/resolvers/connect_to_agent_resolver.py new file mode 100644 index 0000000..47cfd20 --- /dev/null +++ b/skill_analytics/src/models/resolvers/connect_to_agent_resolver.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from .base import Resolver, ResolverType + + +@dataclass +class ConnectToAgentResolver(Resolver): + """Resolver that connects to a live agent.""" + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.CONNECT_TO_AGENT + + @classmethod + def from_dict(cls, data: dict) -> 'ConnectToAgentResolver': + """Create a ConnectToAgentResolver instance from a dictionary.""" + return cls() + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id + } + + def __repr__(self) -> str: + return "ConnectToAgentResolver()" diff --git a/skill_analytics/src/models/resolvers/continue_resolver.py b/skill_analytics/src/models/resolvers/continue_resolver.py new file mode 100644 index 0000000..4b0988b --- /dev/null +++ b/skill_analytics/src/models/resolvers/continue_resolver.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from .base import Resolver, ResolverType + + +@dataclass +class ContinueResolver(Resolver): + """Resolver that continues to the next step.""" + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.CONTINUE + + @classmethod + def from_dict(cls, data: dict) -> 'ContinueResolver': + """Create a ContinueResolver instance from a dictionary.""" + return cls() + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id + } + + def __repr__(self) -> str: + return "ContinueResolver()" diff --git a/skill_analytics/src/models/resolvers/end_action_resolver.py b/skill_analytics/src/models/resolvers/end_action_resolver.py new file mode 100644 index 0000000..d1db312 --- /dev/null +++ b/skill_analytics/src/models/resolvers/end_action_resolver.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from .base import Resolver, ResolverType + + +@dataclass +class EndActionResolver(Resolver): + """Resolver that ends the current action.""" + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.END_ACTION + + @classmethod + def from_dict(cls, data: dict) -> 'EndActionResolver': + """Create an EndActionResolver instance from a dictionary.""" + return cls() + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id + } + + def __repr__(self) -> str: + return "EndActionResolver()" diff --git a/skill_analytics/src/models/resolvers/factory.py b/skill_analytics/src/models/resolvers/factory.py new file mode 100644 index 0000000..69509e6 --- /dev/null +++ b/skill_analytics/src/models/resolvers/factory.py @@ -0,0 +1,46 @@ +from typing import Dict, Type +from .base import Resolver, ResolverType +from .continue_resolver import ContinueResolver +from .replay_resolver import ReplayResolver +from .invoke_action_resolver import InvokeActionResolver +from .invoke_action_and_end_resolver import InvokeActionAndEndResolver +from .callout_resolver import CalloutResolver +from .end_action_resolver import EndActionResolver +from .connect_to_agent_resolver import ConnectToAgentResolver +from .fallback_resolver import FallbackResolver +from .prompt_again import PromptAgainResolver + + +# Mapping of resolver type IDs to their corresponding classes +RESOLVER_REGISTRY: Dict[ResolverType, Type[Resolver]] = { + ResolverType.CONTINUE: ContinueResolver, + ResolverType.REPLAY: ReplayResolver, + ResolverType.INVOKE_ANOTHER_ACTION: InvokeActionResolver, + ResolverType.INVOKE_ANOTHER_ACTION_AND_END: InvokeActionAndEndResolver, + ResolverType.CALLOUT: CalloutResolver, + ResolverType.END_ACTION: EndActionResolver, + ResolverType.CONNECT_TO_AGENT: ConnectToAgentResolver, + ResolverType.FALLBACK: FallbackResolver, + ResolverType.PROMPT_AGAIN: PromptAgainResolver, +} + + +def create_resolver_from_dict(data: dict) -> Resolver: + """Create the appropriate resolver instance from a dictionary based on its type.""" + resolver_type_id = data.get("type", "").lower() + try: + resolver_type = ResolverType.from_id(resolver_type_id) + except: + raise ValueError(f"Unknown resolver type: {resolver_type_id}") + + resolver_class = RESOLVER_REGISTRY.get(resolver_type) + if resolver_class is None: + raise ValueError(f"Unknown resolver type: {resolver_type}") + + return resolver_class.from_dict(data) + + +__all__ = [ + "create_resolver_from_dict", + "RESOLVER_REGISTRY", +] diff --git a/skill_analytics/src/models/resolvers/fallback_resolver.py b/skill_analytics/src/models/resolvers/fallback_resolver.py new file mode 100644 index 0000000..377ee63 --- /dev/null +++ b/skill_analytics/src/models/resolvers/fallback_resolver.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from .base import Resolver, ResolverType + + +@dataclass +class FallbackResolver(Resolver): + """Resolver that triggers fallback behavior.""" + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.FALLBACK + + @classmethod + def from_dict(cls, data: dict) -> 'FallbackResolver': + """Create a FallbackResolver instance from a dictionary.""" + return cls() + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id + } + + def __repr__(self) -> str: + return "FallbackResolver()" + diff --git a/skill_analytics/src/models/resolvers/invoke_action_and_end_resolver.py b/skill_analytics/src/models/resolvers/invoke_action_and_end_resolver.py new file mode 100644 index 0000000..53eeb80 --- /dev/null +++ b/skill_analytics/src/models/resolvers/invoke_action_and_end_resolver.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import Any, Optional + +from .base import Resolver, ResolverType + + +@dataclass +class InvokeActionAndEndResolver(Resolver): + """Resolver that invokes another action (subaction) and ends the current action.""" + subaction_id: str + policy: str + parameters: Optional[Any] + result_variable_id: str + ignore_end_action_steps: bool + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.INVOKE_ANOTHER_ACTION_AND_END + + @classmethod + def from_dict(cls, data: dict) -> 'InvokeActionAndEndResolver': + """Create an InvokeActionAndEndResolver instance from a dictionary.""" + invoke_action_data = data["invoke_action"] + return cls( + subaction_id=invoke_action_data["action"], + policy=invoke_action_data["policy"], + parameters=invoke_action_data.get("parameters"), + result_variable_id=invoke_action_data["result_variable"], + ignore_end_action_steps=invoke_action_data.get("ignore_end_action_steps", False) + ) + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id, + "subaction_id": self.subaction_id, + "policy": self.policy, + "parameters": self.parameters, + "result_variable_id": self.result_variable_id, + "ignore_end_action_steps": self.ignore_end_action_steps + } + + def __repr__(self) -> str: + return f"InvokeActionAndEndResolver(subaction_id={self.subaction_id})" diff --git a/skill_analytics/src/models/resolvers/invoke_action_resolver.py b/skill_analytics/src/models/resolvers/invoke_action_resolver.py new file mode 100644 index 0000000..5eda9de --- /dev/null +++ b/skill_analytics/src/models/resolvers/invoke_action_resolver.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from typing import Any, Optional + +from .base import Resolver, ResolverType + + +@dataclass +class InvokeActionResolver(Resolver): + """Resolver that invokes another action (subaction) and continues.""" + subaction_id: str + policy: str + parameters: Optional[Any] + result_variable_id: str + ignore_end_action_steps: bool + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.INVOKE_ANOTHER_ACTION + + @classmethod + def from_dict(cls, data: dict) -> 'InvokeActionResolver': + """Create an InvokeActionResolver instance from a dictionary.""" + invoke_action_data = data["invoke_action"] + return cls( + subaction_id=invoke_action_data["action"], + policy=invoke_action_data["policy"], + parameters=invoke_action_data.get("parameters"), + result_variable_id=invoke_action_data["result_variable"], + ignore_end_action_steps=invoke_action_data.get("ignore_end_action_steps", False) + ) + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id, + "subaction_id": self.subaction_id, + "policy": self.policy, + "parameters": self.parameters, + "result_variable_id": self.result_variable_id, + "ignore_end_action_steps": self.ignore_end_action_steps + } + + def __repr__(self) -> str: + return f"InvokeActionResolver(subaction_id={self.subaction_id})" diff --git a/skill_analytics/src/models/resolvers/prompt_again.py b/skill_analytics/src/models/resolvers/prompt_again.py new file mode 100644 index 0000000..92f17a1 --- /dev/null +++ b/skill_analytics/src/models/resolvers/prompt_again.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from .base import Resolver, ResolverType + + +@dataclass +class PromptAgainResolver(Resolver): + """Resolver that prompts the user again.""" + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.PROMPT_AGAIN + + @classmethod + def from_dict(cls, data: dict) -> 'PromptAgainResolver': + """Create a PromptAgainResolver instance from a dictionary.""" + return cls() + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id + } + + def __repr__(self) -> str: + return "PromptAgainResolver()" diff --git a/skill_analytics/src/models/resolvers/replay_resolver.py b/skill_analytics/src/models/resolvers/replay_resolver.py new file mode 100644 index 0000000..1d59e48 --- /dev/null +++ b/skill_analytics/src/models/resolvers/replay_resolver.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field +from typing import List + +from .base import Resolver, ResolverType + + +@dataclass +class ReplayResolver(Resolver): + """Resolver that replays previous steps.""" + steps_to_replay: List[str] = field(default_factory=list) + + def __post_init__(self): + """Set the resolver type after dataclass initialization.""" + self.resolver_type = ResolverType.REPLAY + + @classmethod + def from_dict(cls, data: dict) -> 'ReplayResolver': + """Create a ReplayResolver instance from a dictionary.""" + clear_data = data["clear"] + return cls( + steps_to_replay=[var_dict["variable"] for var_dict in clear_data] + ) + + def to_dict(self) -> dict: + """Convert the resolver to a dictionary representation.""" + return { + "type": self.resolver_type.id, + "steps_to_replay": self.steps_to_replay, + } + + def __repr__(self) -> str: + return f"ReplayResolver(steps_to_replay={self.steps_to_replay})" diff --git a/skill_analytics/src/models/responses/__init__.py b/skill_analytics/src/models/responses/__init__.py new file mode 100644 index 0000000..d437087 --- /dev/null +++ b/skill_analytics/src/models/responses/__init__.py @@ -0,0 +1,40 @@ +from .base import Response, ResponseType, SelectionPolicyType +from .text import TextResponse +from .option import OptionResponse, Option, PreferenceType +from .dynamic_option import DynamicOptionResponse +from .date import DateResponse +from .time import TimeResponse +from .user_defined import UserDefinedResponse +from .dtmf import DtmfResponse +from .speech_to_text import SpeechToTextResponse +from .text_to_speech import TextToSpeechResponse +from .response_from_data_type import ResponseFromDataTypeResponse +from .end_session import EndSessionResponse +from .factory import create_response_from_dict + +__all__ = [ + # Base classes and enums + "Response", + "ResponseType", + "SelectionPolicyType", + + # Response types + "TextResponse", + "OptionResponse", + "DynamicOptionResponse", + "DateResponse", + "TimeResponse", + "UserDefinedResponse", + "DtmfResponse", + "SpeechToTextResponse", + "TextToSpeechResponse", + "ResponseFromDataTypeResponse", + "EndSessionResponse", + + # Option-related + "Option", + "PreferenceType", + + # Factory + "create_response_from_dict", +] \ No newline at end of file diff --git a/skill_analytics/src/models/responses/base.py b/skill_analytics/src/models/responses/base.py new file mode 100644 index 0000000..b210f78 --- /dev/null +++ b/skill_analytics/src/models/responses/base.py @@ -0,0 +1,62 @@ + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Set + +class ResponseType(Enum): + TEXT = "text" + USER_DEFINED = "user_defined" + OPTION = "option" + DYNAMIC_OPTION = "response_from_variable" + DATE = "date" + TIME = "time" + SPEECH_TO_TEXT = "speech_to_text" + TEXT_TO_SPEECH = "text_to_speech" + DTMF = "dtmf" + RESPONSE_FROM_DATA_TYPE = "response_from_data_type" + END_SESSION = "end_session" + +class SelectionPolicyType(Enum): + SEQUENTIAL = "sequential" + INCREMENTAL = "incremental" + +class Response(ABC): + response_type: ResponseType + selection_policy: SelectionPolicyType + repeat_on_reprompt: bool + + _DEFAULT_SELECTION_POLICY: SelectionPolicyType = SelectionPolicyType.SEQUENTIAL + _DEFAULT_REPEAT_ON_REPROMPT: bool = False + + @classmethod + def _get_selection_policy(cls, data: dict) -> SelectionPolicyType: + """Get selection_policy from data dict with default fallback.""" + policy_str = data.get("selection_policy") + if policy_str is None: + return cls._DEFAULT_SELECTION_POLICY + return SelectionPolicyType(policy_str) + + @classmethod + def _get_repeat_on_reprompt(cls, data: dict) -> bool: + """Get repeat_on_reprompt from data dict with default fallback.""" + return data.get("repeat_on_reprompt", cls._DEFAULT_REPEAT_ON_REPROMPT) + + @classmethod + @abstractmethod + def from_dict(cls, data: dict) -> 'Response': + """Create a Response instance from a dictionary.""" + pass + + @abstractmethod + def to_dict(self) -> dict: + """Convert the response to a dictionary representation.""" + pass + + @abstractmethod + def __str__(self) -> str: + """Get string representation of the response.""" + pass + + def get_all_variable_ids(self) -> Set[str]: + """Extract all variable IDs referenced in this response.""" + return set([]) \ No newline at end of file diff --git a/skill_analytics/src/models/responses/date.py b/skill_analytics/src/models/responses/date.py new file mode 100644 index 0000000..52d62d5 --- /dev/null +++ b/skill_analytics/src/models/responses/date.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class DateResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.DATE + + def __str__(self) -> str: + return f"" + + @classmethod + def from_dict(cls, data: dict) -> 'DateResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/dtmf.py b/skill_analytics/src/models/responses/dtmf.py new file mode 100644 index 0000000..672eda3 --- /dev/null +++ b/skill_analytics/src/models/responses/dtmf.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class DtmfResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.DTMF + + def __str__(self) -> str: + return "" + + @classmethod + def from_dict(cls, data: dict) -> 'DtmfResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/dynamic_option.py b/skill_analytics/src/models/responses/dynamic_option.py new file mode 100644 index 0000000..4d27685 --- /dev/null +++ b/skill_analytics/src/models/responses/dynamic_option.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class DynamicOptionResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.DYNAMIC_OPTION + + def __str__(self) -> str: + return f"" + + @classmethod + def from_dict(cls, data: dict) -> 'DynamicOptionResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/end_session.py b/skill_analytics/src/models/responses/end_session.py new file mode 100644 index 0000000..8a94df0 --- /dev/null +++ b/skill_analytics/src/models/responses/end_session.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class EndSessionResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.END_SESSION + + def __str__(self) -> str: + return "" + + @classmethod + def from_dict(cls, data: dict) -> 'EndSessionResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/factory.py b/skill_analytics/src/models/responses/factory.py new file mode 100644 index 0000000..0b92a4f --- /dev/null +++ b/skill_analytics/src/models/responses/factory.py @@ -0,0 +1,38 @@ +from typing import Type + +from .base import Response, ResponseType +from .text import TextResponse +from .user_defined import UserDefinedResponse +from .option import OptionResponse +from .dynamic_option import DynamicOptionResponse +from .date import DateResponse +from .time import TimeResponse +from .speech_to_text import SpeechToTextResponse +from .text_to_speech import TextToSpeechResponse +from .dtmf import DtmfResponse +from .response_from_data_type import ResponseFromDataTypeResponse +from .end_session import EndSessionResponse + +RESPONSE_REGISTRY: dict[ResponseType, Type[Response]] = { + ResponseType.TEXT: TextResponse, + ResponseType.USER_DEFINED: UserDefinedResponse, + ResponseType.OPTION: OptionResponse, + ResponseType.DYNAMIC_OPTION: DynamicOptionResponse, + ResponseType.DATE: DateResponse, + ResponseType.TIME: TimeResponse, + ResponseType.SPEECH_TO_TEXT: SpeechToTextResponse, + ResponseType.TEXT_TO_SPEECH: TextToSpeechResponse, + ResponseType.DTMF: DtmfResponse, + ResponseType.RESPONSE_FROM_DATA_TYPE: ResponseFromDataTypeResponse, + ResponseType.END_SESSION: EndSessionResponse, +} + +def create_response_from_dict(data: dict) -> Response: + """Create the appropriate response instance from a dictionary based on its type.""" + try: + response_type = ResponseType(data["response_type"]) + except: + raise ValueError(f"Unknown response type: {data['response_type']}") + + response_class = RESPONSE_REGISTRY[response_type] + return response_class.from_dict(data) diff --git a/skill_analytics/src/models/responses/option.py b/skill_analytics/src/models/responses/option.py new file mode 100644 index 0000000..4005e48 --- /dev/null +++ b/skill_analytics/src/models/responses/option.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import List + +from .base import Response, ResponseType, SelectionPolicyType + +class PreferenceType(Enum): + BUTTON = "button" + +@dataclass +class Option: + label: str + value: str + + def __repr__(self) -> str: + return f"Option(label='{self.label}', value='{self.value}')" + + def __str__(self) -> str: + return str(self.label) + + @classmethod + def from_dict(cls, data: dict) -> 'Option': + """Create an Option instance from a dictionary.""" + return cls( + label=data["label"], + value=data["value"]["input"]["text"] + ) + + def to_dict(self) -> dict: + """Convert the option to a dictionary representation.""" + return { + "label": self.label, + "value": self.value + } + +@dataclass +class OptionResponse(Response): + options: List[Option] + preference: PreferenceType + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.OPTION + + def __str__(self) -> str: + button_str = '", "'.join([str(option) for option in self.options]) + return f": [\"{button_str}\"]" + + @classmethod + def from_dict(cls, data: dict) -> 'OptionResponse': + """Create an OptionResponse instance from a dictionary.""" + return cls( + options=[Option.from_dict(option_dict) for option_dict in data["options"]], + preference=PreferenceType(data.get("preference", "button")), + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "preference": self.preference.value, + "options": [option.to_dict() for option in self.options], + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/response_from_data_type.py b/skill_analytics/src/models/responses/response_from_data_type.py new file mode 100644 index 0000000..d848bf2 --- /dev/null +++ b/skill_analytics/src/models/responses/response_from_data_type.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class ResponseFromDataTypeResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.RESPONSE_FROM_DATA_TYPE + + def __str__(self) -> str: + return "" + + @classmethod + def from_dict(cls, data: dict) -> 'ResponseFromDataTypeResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/speech_to_text.py b/skill_analytics/src/models/responses/speech_to_text.py new file mode 100644 index 0000000..7f06baa --- /dev/null +++ b/skill_analytics/src/models/responses/speech_to_text.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class SpeechToTextResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.SPEECH_TO_TEXT + + def __str__(self) -> str: + return "" + + @classmethod + def from_dict(cls, data: dict) -> 'SpeechToTextResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/text.py b/skill_analytics/src/models/responses/text.py new file mode 100644 index 0000000..25ba5ed --- /dev/null +++ b/skill_analytics/src/models/responses/text.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, field +from typing import Set + +from src.utils.parse_variables_from_spel_expression import parse_variables_from_spel_expression + +from ..operands import create_operand_from_dict +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class TextResponse(Response): + text: str + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.TEXT + + def __str__(self) -> str: + return self.text + + @classmethod + def from_dict(cls, data: dict) -> 'TextResponse': + """Create a TextResponse instance from a dictionary.""" + text = "\n".join([TextResponse._parse_value_dict(value_dict) for value_dict in data["values"]]) + return cls( + text=text, + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + """Convert the text response to a dictionary representation.""" + return { + "response_type": self.response_type.value, + "text": self.text, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } + + def get_all_variable_ids(self) -> Set[str]: + """Extract all variable IDs referenced in the response text.""" + variable_ids = parse_variables_from_spel_expression(self.text) + return variable_ids + + @staticmethod + def _parse_value_dict(data: dict) -> str: + """Parse a value dictionary to extract text content.""" + if "text" in data: + return data["text"] + + if "text_expression" in data: + concat_list = data["text_expression"]["concat"] + concat_operands = [create_operand_from_dict(operand_dict) for operand_dict in concat_list] + return "".join([operand.value for operand in concat_operands]) + + raise ValueError(f"Unable to parse: {data}") \ No newline at end of file diff --git a/skill_analytics/src/models/responses/text_to_speech.py b/skill_analytics/src/models/responses/text_to_speech.py new file mode 100644 index 0000000..c7b9f3d --- /dev/null +++ b/skill_analytics/src/models/responses/text_to_speech.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class TextToSpeechResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.TEXT_TO_SPEECH + + def __str__(self) -> str: + return "" + + @classmethod + def from_dict(cls, data: dict) -> 'TextToSpeechResponse': + return cls( + selection_policy=cls._get_selection_policy(data), + repeat_on_reprompt=cls._get_repeat_on_reprompt(data) + ) + + def to_dict(self) -> dict: + return { + "response_type": self.response_type.value, + "selection_policy": self.selection_policy.value, + "repeat_on_reprompt": self.repeat_on_reprompt, + } \ No newline at end of file diff --git a/skill_analytics/src/models/responses/time.py b/skill_analytics/src/models/responses/time.py new file mode 100644 index 0000000..419d5ac --- /dev/null +++ b/skill_analytics/src/models/responses/time.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field + +from .base import Response, ResponseType, SelectionPolicyType + +@dataclass +class TimeResponse(Response): + selection_policy: SelectionPolicyType = field(default=Response._DEFAULT_SELECTION_POLICY) + repeat_on_reprompt: bool = field(default=Response._DEFAULT_REPEAT_ON_REPROMPT) + + def __post_init__(self): + self.response_type = ResponseType.TIME + + def __str__(self) -> str: + return "