diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22984ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,279 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +venv*/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. For PyCharm +# Community Edition, use 'PyCharm CE' instead of 'PyCharm'. +.idea/ + +# AWS specific +.aws/ +*.pem +*.key +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Lambda deployment packages +*.zip +layer/ + +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl + +# CloudFormation +packaged-template.yaml +packaged-template.yml + +# SAM +.aws-sam/ +samconfig.toml + +# CDK +cdk.out/ +*.d.ts +node_modules/ + +# VS Code +.vscode/ +*.code-workspace + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db +*.tmp +*.temp +*.swp +*.swo +*~ + +# Windows shortcuts +*.lnk + +# Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# Project specific +# Ignore any local configuration files +config.local.py +settings.local.py + +# Ignore any local test data +test_data/ +sample_data/ + +# Ignore any generated documentation +docs/build/ +docs/_build/ + +# Ignore any local scripts for development +dev_scripts/ +local_scripts/ + +# Ignore any backup files +*.bak +*.backup +*.orig + +# Ignore any log files +*.log +logs/ + +# Ignore any temporary directories +tmp/ +temp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7aa6562 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,261 @@ +# Changelog + +All notable changes to the amazon-lex-helper library will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2025-01-29 + +### Major Release - Enhanced Lex V2 Features + +This major release adds comprehensive support for advanced Amazon Lex V2 features while maintaining full backward compatibility. + +### Added + +#### Rich Message Support +- **MessageBuilder**: Fluent interface for creating multiple message types in a single response +- **SSMLBuilder**: Create complex SSML content with prosody, emphasis, pauses, and more +- **CardBuilder**: Build image response cards with titles, subtitles, images, and buttons +- **Enhanced LexResponse functions**: + - `elicit_slot_with_rich_messages()` - Elicit slots with multiple message types + - `elicit_intent_with_rich_messages()` - Elicit intents with rich content + - `confirm_intent_with_rich_messages()` - Confirm intents with enhanced messages + - `close_with_rich_messages()` - Close conversations with rich responses + - `delegate_with_rich_messages()` - Delegate with enhanced message support +- **Message creation functions**: + - `create_plain_text_message()` - Create plain text messages + - `create_ssml_message()` - Create SSML messages + - `create_custom_payload_message()` - Create custom payload messages + - `create_image_response_card()` - Create image response cards + +#### Runtime Hints for Speech Recognition +- **RuntimeHintsBuilder**: Improve speech recognition accuracy with slot and phrase hints +- **Slot hints**: Provide expected values for specific slots +- **Phrase hints**: Boost recognition of common phrases +- **Integration**: Runtime hints support in all enhanced response functions + +#### Multi-valued Slot Support +- **LexEvent enhancements**: + - `get_slot_values_list()` - Get all values from List-shaped slots + - `is_multi_valued_slot()` - Check if a slot is multi-valued + - `get_slot_original_value()` - Get original user input + - `get_slot_resolved_values()` - Get all resolved values +- **SlotHandler utility**: + - `create_scalar_slot()` - Create scalar slot structures + - `create_list_slot()` - Create multi-valued slot structures + - `add_value_to_list_slot()` - Add values to existing list slots + - `merge_slot_values()` - Merge slot values intelligently + +#### Sentiment Analysis +- **LexEvent enhancements**: + - `get_sentiment_analysis()` - Access user sentiment data + - `get_transcription_confidence()` - Get speech recognition confidence + - `get_nlu_confidence_score()` - Get NLU confidence scores + - `is_low_confidence_intent()` - Detect low confidence requests +- **IntentHandler enhancements**: + - `is_negative_sentiment()` - Detect negative user sentiment + - `handle_negative_sentiment()` - Custom negative sentiment handling + - `handle_low_confidence()` - Custom low confidence handling + +#### Enhanced Slot Validation +- **SlotValidator utility** with built-in validators: + - `validate_email()` - Email format validation + - `validate_phone_number()` - Phone number validation + - `validate_date_format()` - Date format validation + - `validate_numeric_range()` - Numeric range validation + - `validate_string_length()` - String length validation + - `validate_choice()` - Choice validation with case sensitivity options +- **SlotHandler validation**: + - `validate_slot_value()` - Validate against allowed values + - `validate_slot_pattern()` - Regex pattern validation + - Error tracking and retry logic + +#### Context Management +- **ContextManager utility**: + - `create_context()` - Create context structures + - `get_context_by_name()` - Find contexts by name + - `update_context_attributes()` - Update context attributes + - `add_context()` - Add new contexts + - `remove_context()` - Remove contexts + - `extend_context_ttl()` - Extend context time-to-live +- **ConversationState utility**: + - `get_state()` / `set_state()` - Manage conversation state + - `update_state()` - Update specific state values + - `clear_state()` - Clear conversation state + - `is_state_active()` - Check if state is active + +#### Enhanced Intent Handlers +- **BaseIntentHandler**: Simplified base class for common intent patterns + - Automatic slot validation + - Required slots management + - Built-in fulfillment flow +- **Enhanced IntentHandler**: + - `pre_process_request()` - Pre-processing hooks + - `post_process_response()` - Post-processing hooks + - `validate_slots()` - Custom slot validation + - `handle_slot_validation_error()` - Validation error handling + - `get_multi_valued_slot()` - Multi-valued slot access + - `log_request_info()` - Enhanced logging + +#### LexEvent Enhancements +- **Context access**: + - `get_active_contexts()` - Get all active contexts + - `get_context_attributes()` - Get specific context attributes +- **Bot information**: + - `get_bot_info()` - Get bot name, version, locale + - `get_session_id()` - Get session identifier + - `get_user_id()` - Get user identifier +- **Alternative intents**: + - `has_alternative_intents()` - Check for alternative interpretations + - `get_alternative_intents()` - Get alternative intent options +- **Input mode detection**: + - `is_voice_input()` - Check if input was voice + - `is_text_input()` - Check if input was text + - `is_dtmf_input()` - Check if input was DTMF + +#### Quick Helper Functions +- `quick_text_message()` - Quick plain text message creation +- `quick_ssml_message()` - Quick SSML message creation +- `quick_card_message()` - Quick image response card creation +- `quick_mixed_message()` - Quick mixed message creation + +#### Developer Experience +- **Comprehensive type hints** throughout the codebase +- **Enhanced error handling** and validation +- **Detailed documentation** and inline comments +- **Extensive examples** demonstrating all features +- **Unit tests** for new functionality + +### Enhanced + +#### LexEventDispatcher +- Updated to use enhanced IntentHandler processing hooks +- Backward compatibility with existing handlers +- Enhanced logging and error handling + +#### LexResponse +- Added type hints for better IDE support +- Enhanced existing functions with optional rich message support +- Improved error handling and validation + +### Documentation + +#### New Files +- `examples/enhanced_features_examples.py` - Comprehensive usage examples +- `test/test_enhanced_lex_event.py` - Tests for enhanced LexEvent features +- `test/test_message_builder.py` - Tests for message builders +- `CHANGELOG.md` - This changelog + +#### Updated Files +- `README.md` - Complete rewrite with enhanced examples and feature documentation +- Inline documentation throughout all modules + +### Migration Guide + +#### From v1.x to v2.0 + +The library maintains full backward compatibility. Existing code will continue to work without changes. + +**Optional Enhancements:** + +1. **Replace IntentHandler with BaseIntentHandler** for simpler intent handling: + ```python + # Old way (still works) + class MyIntent(IntentHandler): + def process_request(self, lex): + # Manual slot checking and delegation + return LexResponse.delegate(lex) + + # New way (recommended) + class MyIntent(BaseIntentHandler): + def __init__(self): + super().__init__("MyIntent", required_slots=["Slot1", "Slot2"]) + + def fulfill_intent(self, lex): + # Called automatically when all slots are filled + return LexResponse.close(...) + ``` + +2. **Add rich messages** using MessageBuilder: + ```python + # Old way (still works) + return LexResponse.close(session_attrs, intent, {}, "Simple message") + + # New way (enhanced) + messages = MessageBuilder() \ + .add_plain_text("Thank you!") \ + .add_ssml(SSMLBuilder().speak("Have a great day!").build()) \ + .build() + return LexResponse.close_with_rich_messages(session_attrs, intent, {}, messages) + ``` + +3. **Use runtime hints** for better speech recognition: + ```python + hints = RuntimeHintsBuilder() \ + .add_slot_hint("City", ["London", "Paris", "New York"]) \ + .build() + return LexResponse.elicit_slot_with_rich_messages(lex, "City", messages, hints) + ``` + +4. **Access sentiment analysis**: + ```python + sentiment = lex.get_sentiment_analysis() + if sentiment and sentiment['sentiment'] == 'NEGATIVE': + # Handle negative sentiment + ``` + +5. **Handle multi-valued slots**: + ```python + if lex.is_multi_valued_slot("Toppings"): + toppings = lex.get_slot_values_list("Toppings") + # Process list of toppings + ``` + +### Fixed + +- Fixed `valid_intent()` method in IntentHandler to properly access slot data +- Improved error handling in LexEventDispatcher +- Enhanced logging configuration and level management + +### Security + +- No security-related changes in this release +- All new features follow AWS security best practices + +### Dependencies + +- No new external dependencies added +- Maintains compatibility with existing Python environments +- Enhanced type hints require Python 3.6+ for full benefit + +--- + +## [1.x] - Previous Versions + +### Legacy Features (Maintained) +- Basic LexEvent functionality +- Standard LexResponse methods +- LexEventDispatcher with intent routing +- Basic IntentHandler abstract class +- Disambiguation support + +All legacy features remain fully functional and supported in v2.0.0. + +--- + +## Contributing + +When contributing to this project, please: +1. Update this changelog with your changes +2. Follow semantic versioning principles +3. Maintain backward compatibility when possible +4. Add tests for new features +5. Update documentation and examples + +## Support + +For questions about this changelog or the library: +- Review the updated README.md for usage examples +- Check the examples/ directory for comprehensive code samples +- Refer to the AWS Lex V2 documentation for service-specific details diff --git a/README.md b/README.md index 9714e20..1177673 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,470 @@ -## amazon-lex-helper +# Amazon Lex V2 Helper Library -This repository contains a list of helper classes to handle Amazon Lex V2 responses and create custom requests. -More specifically, it provides the following functionality: -* LexEvent: Amazon Lex event class -* LexResponse: builder to create Amazon Lex responses -* LexEventDispatcher: utility class to define your intent handlers -* IntentHandler: base class providing basic intent functionality +A comprehensive Python library for building Amazon Lex V2 Lambda functions with enhanced features including rich messages, runtime hints, multi-valued slots, sentiment analysis, and advanced slot validation. -## Quick Install -```python +## Features + +### Core Functionality +* **LexEvent**: Enhanced Amazon Lex event class with 20+ new methods for advanced features +* **LexResponse**: Response builder with support for rich message types (SSML, Image Response Cards, Custom Payload) +* **LexEventDispatcher**: Event routing with enhanced processing hooks and ambiguity handling +* **IntentHandler**: Abstract base class with pre/post processing hooks and validation +* **BaseIntentHandler**: Simplified base class for common intent patterns with automatic slot management + +### Enhanced Features (v2.0.0) +* **Rich Message Support**: SSML, Image Response Cards, Custom Payload messages with fluent builders +* **Runtime Hints**: Improve speech recognition accuracy with slot and phrase hints +* **Multi-valued Slots**: Complete support for List-shaped slots with validation utilities +* **Sentiment Analysis**: Access user sentiment data with confidence scores and custom handling +* **Enhanced Slot Validation**: Built-in validators for email, phone, date, numeric ranges, and more +* **Context Management**: Utilities for managing active contexts and conversation state +* **Message Builders**: Fluent interfaces for creating complex messages (MessageBuilder, SSMLBuilder, CardBuilder) +* **Slot Handlers**: Advanced slot manipulation, validation, and multi-value support +* **Type Safety**: Comprehensive type hints throughout the codebase + +## Installation + +```bash pip install amazon-lex-helper ``` -## Example -*LexEventDispatcher* class provides an [observer](https://refactoring.guru/design-patterns/observer/python/example#:~:text=Observer%20is%20a%20behavioral%20design,that%20implements%20a%20subscriber%20interface.) approach to register intent handlers which scales better and is more modular than just a simple loop, as in the [examples](https://docs.aws.amazon.com/lex/latest/dg/ex-book-trip-create-lambda-function.html): -For example, let's add extra validation to the [Book Hotel example](https://docs.aws.amazon.com/lex/latest/dg/ex-book-trip-create-bot.html) . -First step is to create our own BookHotel class to handle the intent. Notice the intent name (_BookHotel_) must match the intent name defined in Amazon Lex. +## Quick Start + +### Basic Intent Handler + ```python -from amazon_lex_helper import LexEventDispatcher +from amazon_lex_helper import LexEventDispatcher, BaseIntentHandler -from BookHotelIntent import BookHotelIntent +class BookHotelIntent(BaseIntentHandler): + def __init__(self): + super().__init__("BookHotel", required_slots=["Location", "CheckInDate"]) + + def fulfill_intent(self, lex): + location = lex.get_slot_interpreted_value("Location") + check_in = lex.get_slot_interpreted_value("CheckInDate") + + return self.close_with_message( + lex, f"Hotel booked in {location} for {check_in}!" + ) def lambda_handler(event, context): - lexEventDispatcher = LexEventDispatcher() - lexEventDispatcher.subscribe( - BookHotelIntent("BookHotel") - ) - return lexEventDispatcher.dispatch(event) + dispatcher = LexEventDispatcher() + dispatcher.subscribe(BookHotelIntent()) + return dispatcher.dispatch(event) +``` + +## Enhanced Examples + +### Rich Messages with SSML and Cards + +```python +from amazon_lex_helper import BaseIntentHandler, MessageBuilder, SSMLBuilder +from amazon_lex_helper import LexResponse + +class BookHotelIntent(BaseIntentHandler): + def __init__(self): + super().__init__("BookHotel", required_slots=["Location", "CheckInDate"]) + + def fulfill_intent(self, lex): + location = lex.get_slot_interpreted_value("Location") + + # Create rich response with SSML and card + messages = MessageBuilder() \ + .add_plain_text(f"Great! I found hotels in {location}.") \ + .add_ssml( + SSMLBuilder() + .speak("Perfect choice!") + .pause("500ms") + .emphasis(location, level="strong") + .speak("has amazing hotels.") + .build() + ) \ + .add_image_response_card( + title=f"Hotels in {location}", + subtitle="Best available rates", + image_url="https://example.com/hotel.jpg", + buttons=[ + {"text": "Book Now", "value": "book"}, + {"text": "See More", "value": "more"} + ] + ).build() + + return LexResponse.close_with_rich_messages( + lex.get_session_attrs(), + lex.get_intent(), + {}, + messages + ) +``` + +### Runtime Hints for Better Speech Recognition + +```python +from amazon_lex_helper import RuntimeHintsBuilder, LexResponse + +class FlightBookingIntent(BaseIntentHandler): + def process_request(self, lex): + if lex.is_requesting_slot("Origin"): + # Add runtime hints for airport codes + hints = RuntimeHintsBuilder() \ + .add_slot_hint("Origin", ["JFK", "LAX", "ORD", "SFO"]) \ + .add_phrase_hints(["John F Kennedy", "Los Angeles International"]) \ + .build() + + messages = [{"contentType": "PlainText", "content": "Which airport?"}] + + return LexResponse.elicit_slot_with_rich_messages( + lex, "Origin", messages, runtime_hints=hints + ) + + return super().process_request(lex) +``` + +### Multi-valued Slots + +```python +from amazon_lex_helper import SlotHandler, SlotValidator + +class OrderPizzaIntent(BaseIntentHandler): + def validate_slots(self, lex): + errors = {} + + # Handle multi-valued toppings slot + if lex.is_multi_valued_slot("Toppings"): + toppings = lex.get_slot_values_list("Toppings") + + # Validate count + if len(toppings) > 5: + errors["Toppings"] = "Please choose no more than 5 toppings." + + # Validate each topping + valid_toppings = ["pepperoni", "mushrooms", "cheese"] + for topping in toppings: + if not SlotValidator.validate_choice(topping, valid_toppings): + errors["Toppings"] = f"{topping} is not available." + break + + return errors +``` + +### Sentiment Analysis and Enhanced Validation + +```python +class CustomerServiceIntent(BaseIntentHandler): + def handle_negative_sentiment(self, lex): + """Handle frustrated customers with empathy.""" + messages = MessageBuilder() \ + .add_plain_text("I understand your frustration. Let me help you right away.") \ + .add_ssml( + SSMLBuilder() + .prosody("I'm here to help", rate="slow", volume="soft") + .build() + ).build() + + return LexResponse.elicit_intent_with_rich_messages( + lex, "CustomerService", "InProgress", messages + ) + + def validate_slots(self, lex): + errors = {} + + # Validate email with built-in validator + email = lex.get_slot_interpreted_value("Email") + if email and not SlotValidator.validate_email(email): + errors["Email"] = "Please provide a valid email address." + + return errors +``` + +### Context Management + +```python +from amazon_lex_helper import ContextManager, ConversationState + +class ShoppingIntent(BaseIntentHandler): + def __init__(self): + super().__init__("Shopping") + self.conversation_state = ConversationState("shoppingCart") + + def process_request(self, lex): + contexts = lex.get_active_contexts() + + # Get current cart items + cart_items = self.conversation_state.get_state_value(contexts, "items") + + if cart_items: + message = f"You have {cart_items} in your cart. What else would you like?" + else: + message = "Welcome! What would you like to shop for today?" + + # Update conversation state + contexts = self.conversation_state.update_state(contexts, { + "last_interaction": "shopping_start" + }) + + response = LexResponse.elicit_intent_with_rich_messages( + lex, "Shopping", "InProgress", [{"contentType": "PlainText", "content": message}] + ) + response['sessionState']['activeContexts'] = contexts + + return response +``` + +## Available Utilities + +### Message Builders +- **MessageBuilder**: Fluent interface for creating multiple message types +- **SSMLBuilder**: Create SSML content with prosody, emphasis, pauses +- **CardBuilder**: Create image response cards with buttons +- **RuntimeHintsBuilder**: Create runtime hints for speech recognition + +### Slot Utilities +- **SlotHandler**: Create and manipulate scalar and list slots +- **SlotValidator**: Built-in validators (email, phone, date, numeric range, etc.) + +### Context Utilities +- **ContextManager**: Manage active contexts and attributes +- **ConversationState**: Maintain conversation state across turns + +### Enhanced LexEvent Methods + +#### Sentiment and Confidence +```python +sentiment = lex.get_sentiment_analysis() +confidence = lex.get_nlu_confidence_score() +is_low_confidence = lex.is_low_confidence_intent(threshold=0.5) +``` + +#### Multi-valued Slots +```python +if lex.is_multi_valued_slot("Toppings"): + toppings = lex.get_slot_values_list("Toppings") + original_values = [lex.get_slot_original_value(slot) for slot in toppings] +``` + +#### Context Access +```python +contexts = lex.get_active_contexts() +bot_info = lex.get_bot_info() +session_id = lex.get_session_id() +``` + +#### Input Mode Detection +```python +if lex.is_voice_input(): + # Handle voice-specific logic +elif lex.is_text_input(): + # Handle text-specific logic +``` + +### Quick Functions +```python +from amazon_lex_helper import quick_text_message, quick_ssml_message, quick_card_message + +# Quick message creation +text_msg = quick_text_message("Hello!") +ssml_msg = quick_ssml_message("Hello world!") +card_msg = quick_card_message("Title", "Subtitle", buttons=[{"text": "OK", "value": "ok"}]) +``` + +## Advanced Features + +### Custom Slot Validation + +```python +class BookingIntent(BaseIntentHandler): + def validate_slots(self, lex): + errors = {} + + # Email validation + email = lex.get_slot_interpreted_value("Email") + if email and not SlotValidator.validate_email(email): + errors["Email"] = "Please provide a valid email address." + + # Phone validation + phone = lex.get_slot_interpreted_value("Phone") + if phone and not SlotValidator.validate_phone_number(phone): + errors["Phone"] = "Please provide a valid phone number." + + # Custom validation + booking_date = lex.get_slot_interpreted_value("BookingDate") + if booking_date: + from datetime import datetime, timedelta + try: + date_obj = datetime.strptime(booking_date, "%Y-%m-%d") + if date_obj < datetime.now() + timedelta(days=1): + errors["BookingDate"] = "Booking must be at least 1 day in advance." + except ValueError: + errors["BookingDate"] = "Please provide a valid date in YYYY-MM-DD format." + + return errors ``` -*BookHotelIntent* class adds some extra behaviour to the intent handling. -In this case we will: -1. allow only reservations for London and Bristol cities -2. if the city is Bristol, we will set number of nights (_Nights_)to 5. +### Processing Hooks + +```python +class EnhancedIntent(BaseIntentHandler): + def pre_process_request(self, lex): + # Log request details + self.log_request_info(lex) + + # Check for negative sentiment + if self.is_negative_sentiment(lex): + return self.handle_negative_sentiment(lex) + + # Continue with normal processing + return None + + def post_process_response(self, lex, response): + # Add custom headers or modify response + response['customData'] = { + 'processed_at': datetime.now().isoformat(), + 'confidence': lex.get_nlu_confidence_score() + } + return response +``` + +## Migration from v1.x + +The library maintains full backward compatibility. Existing code will continue to work without changes. + +### Optional Enhancements: + +1. **Replace IntentHandler with BaseIntentHandler** for simpler intent handling +2. **Add rich messages** using MessageBuilder and enhanced response functions +3. **Use runtime hints** for better speech recognition +4. **Access sentiment analysis** with `lex.get_sentiment_analysis()` +5. **Handle multi-valued slots** with `lex.get_slot_values_list()` + +### Migration Example: + ```python -from amazon_lex_helper import IntentHandler, LexEvent, LexResponse +# Old way (still works) +class MyIntent(IntentHandler): + def process_request(self, lex): + if not self.valid_intent(lex): + return LexResponse.delegate(lex) + return LexResponse.close(session_attrs, intent, {}, "Done!") -class BookHotelIntent (IntentHandler): +# New way (recommended) +class MyIntent(BaseIntentHandler): + def __init__(self): + super().__init__("MyIntent", required_slots=["Slot1", "Slot2"]) - def process_request(self, req: LexEvent): + def fulfill_intent(self, lex): + messages = MessageBuilder().add_plain_text("Done!").build() + return LexResponse.close_with_rich_messages( + lex.get_session_attrs(), lex.get_intent(), {}, messages + ) +``` + +## Testing + +Run the test suite: + +```bash +# Install test dependencies +pip install pytest + +# Run all tests +python -m pytest test/ -v + +# Run specific test files +python -m pytest test/test_enhanced_lex_event.py -v +python -m pytest test/test_message_builder.py -v +``` + +## AWS Lambda Usage + +### Creating a Lambda Layer + +You can create a Lambda layer for easy deployment: + +```bash +# Clone the repository +git clone https://github.com/aws-samples/amazon-lex-v2-helper.git +cd amazon-lex-v2-helper + +# Create the layer +./create_layer.sh +``` - if req.slot_exists("Location"): - location = req.get_slot_interpreted_value("Location") - if location not in ["London", "Bristol"]: - return LexResponse.elicit_slot(req, "Location", message="Sorry, location can only be London or Bristol") - - if location == "Bristol": - return LexResponse.delegate(req, "Nights", "5") +This creates a .zip file in the `/layer` folder that can be used as an [AWS Lambda layer](https://docs.aws.amazon.com/lambda/latest/dg/adding-layers.html). + +### Lambda Function Example + +```python +import json +from amazon_lex_helper import LexEventDispatcher, BaseIntentHandler, MessageBuilder + +class WelcomeIntent(BaseIntentHandler): + def __init__(self): + super().__init__("Welcome") + + def fulfill_intent(self, lex): + messages = MessageBuilder() \ + .add_plain_text("Welcome to our service!") \ + .add_image_response_card( + title="Welcome", + subtitle="How can I help you today?", + buttons=[ + {"text": "Book Hotel", "value": "book_hotel"}, + {"text": "Check Booking", "value": "check_booking"} + ] + ).build() - return LexResponse.delegate(req) + return self.close_with_rich_messages(lex, messages) + +def lambda_handler(event, context): + dispatcher = LexEventDispatcher() + dispatcher.subscribe(WelcomeIntent()) + + try: + response = dispatcher.dispatch(event) + return response + except Exception as e: + print(f"Error processing request: {str(e)}") + return { + "sessionState": { + "dialogAction": {"type": "Close"}, + "intent": {"name": "FallbackIntent", "state": "Failed"} + }, + "messages": [{ + "contentType": "PlainText", + "content": "I'm sorry, I encountered an error. Please try again." + }] + } ``` -## AWS Lambda usage +## Requirements -You can clone this repo and execute ./create_layer.sh script, which will create a .zip file inside /layer folder. -That zip can be then used to create a layer for your [AWS Lambda function](https://docs.aws.amazon.com/lambda/latest/dg/adding-layers.html). +- Python 3.6+ +- No external dependencies (uses only Python standard library) +- Compatible with AWS Lambda Python runtime +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## Security -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. +See [CONTRIBUTING.md](CONTRIBUTING.md#security-issue-notifications) for security issue reporting. ## License -This library is licensed under the MIT-0 License. See the LICENSE file. +This library is licensed under the MIT-0 License. See the [LICENSE](LICENSE) file for details. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for detailed information about changes in each version. + +## Support + +- **Documentation**: Check the examples in the `/examples` directory +- **Issues**: Report bugs or request features via GitHub Issues +- **AWS Lex V2 Documentation**: [Official AWS Documentation](https://docs.aws.amazon.com/lexv2/) + +## Version + +Current version: **2.0.0** - Major enhancement release with comprehensive Lex V2 feature support. diff --git a/amazon_lex_helper/ContextManager.py b/amazon_lex_helper/ContextManager.py new file mode 100644 index 0000000..eaea748 --- /dev/null +++ b/amazon_lex_helper/ContextManager.py @@ -0,0 +1,374 @@ +""" + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: MIT-0 + + Permission is hereby granted, free of charge, to any person obtaining a copy of this + software and associated documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" + +""" +Context management utilities for Amazon Lex V2. +Provides utilities for managing active contexts and their attributes. +""" + +from typing import Dict, List, Optional, Any +from amazon_lex_helper.LexEvent import LexEvent + + +class ContextManager: + """Utility class for managing Lex V2 contexts.""" + + @staticmethod + def create_context(name: str, attributes: Dict[str, str] = None, + time_to_live_seconds: int = 600, turns_to_live: int = 1) -> Dict: + """ + Create a context structure. + + Args: + name: Context name + attributes: Context attributes dictionary + time_to_live_seconds: Time to live in seconds (default 600) + turns_to_live: Number of turns to live (default 1) + """ + return { + 'name': name, + 'contextAttributes': attributes or {}, + 'timeToLive': { + 'timeToLiveInSeconds': time_to_live_seconds, + 'turnsToLive': turns_to_live + } + } + + @staticmethod + def get_context_by_name(contexts: List[Dict], context_name: str) -> Optional[Dict]: + """ + Find a context by name in the contexts list. + + Args: + contexts: List of context dictionaries + context_name: Name of context to find + + Returns: + Context dictionary or None if not found + """ + for context in contexts: + if context.get('name') == context_name: + return context + return None + + @staticmethod + def update_context_attributes(contexts: List[Dict], context_name: str, + attributes: Dict[str, str]) -> List[Dict]: + """ + Update attributes for a specific context. + + Args: + contexts: List of context dictionaries + context_name: Name of context to update + attributes: New attributes to set + + Returns: + Updated contexts list + """ + for context in contexts: + if context.get('name') == context_name: + context['contextAttributes'].update(attributes) + break + return contexts + + @staticmethod + def add_context(contexts: List[Dict], name: str, attributes: Dict[str, str] = None, + time_to_live_seconds: int = 600, turns_to_live: int = 1) -> List[Dict]: + """ + Add a new context to the contexts list. + + Args: + contexts: Existing contexts list + name: Context name + attributes: Context attributes + time_to_live_seconds: Time to live in seconds + turns_to_live: Number of turns to live + + Returns: + Updated contexts list + """ + new_context = ContextManager.create_context( + name, attributes, time_to_live_seconds, turns_to_live + ) + + # Remove existing context with same name if it exists + contexts = [ctx for ctx in contexts if ctx.get('name') != name] + contexts.append(new_context) + + return contexts + + @staticmethod + def remove_context(contexts: List[Dict], context_name: str) -> List[Dict]: + """ + Remove a context from the contexts list. + + Args: + contexts: List of context dictionaries + context_name: Name of context to remove + + Returns: + Updated contexts list + """ + return [ctx for ctx in contexts if ctx.get('name') != context_name] + + @staticmethod + def extend_context_ttl(contexts: List[Dict], context_name: str, + additional_seconds: int = 300, additional_turns: int = 1) -> List[Dict]: + """ + Extend the time-to-live for a specific context. + + Args: + contexts: List of context dictionaries + context_name: Name of context to extend + additional_seconds: Additional seconds to add + additional_turns: Additional turns to add + + Returns: + Updated contexts list + """ + for context in contexts: + if context.get('name') == context_name: + ttl = context.get('timeToLive', {}) + current_seconds = ttl.get('timeToLiveInSeconds', 0) + current_turns = ttl.get('turnsToLive', 0) + + context['timeToLive'] = { + 'timeToLiveInSeconds': current_seconds + additional_seconds, + 'turnsToLive': current_turns + additional_turns + } + break + + return contexts + + @staticmethod + def get_context_attribute(contexts: List[Dict], context_name: str, + attribute_name: str) -> Optional[str]: + """ + Get a specific attribute from a context. + + Args: + contexts: List of context dictionaries + context_name: Name of context + attribute_name: Name of attribute + + Returns: + Attribute value or None if not found + """ + context = ContextManager.get_context_by_name(contexts, context_name) + if context: + return context.get('contextAttributes', {}).get(attribute_name) + return None + + @staticmethod + def set_context_attribute(contexts: List[Dict], context_name: str, + attribute_name: str, attribute_value: str) -> List[Dict]: + """ + Set a specific attribute in a context. + + Args: + contexts: List of context dictionaries + context_name: Name of context + attribute_name: Name of attribute + attribute_value: Value to set + + Returns: + Updated contexts list + """ + for context in contexts: + if context.get('name') == context_name: + if 'contextAttributes' not in context: + context['contextAttributes'] = {} + context['contextAttributes'][attribute_name] = attribute_value + break + + return contexts + + @staticmethod + def clear_context_attributes(contexts: List[Dict], context_name: str) -> List[Dict]: + """ + Clear all attributes from a specific context. + + Args: + contexts: List of context dictionaries + context_name: Name of context + + Returns: + Updated contexts list + """ + for context in contexts: + if context.get('name') == context_name: + context['contextAttributes'] = {} + break + + return contexts + + @staticmethod + def is_context_active(contexts: List[Dict], context_name: str) -> bool: + """ + Check if a context is active (exists in the contexts list). + + Args: + contexts: List of context dictionaries + context_name: Name of context to check + + Returns: + True if context is active + """ + return ContextManager.get_context_by_name(contexts, context_name) is not None + + @staticmethod + def get_all_context_names(contexts: List[Dict]) -> List[str]: + """ + Get names of all active contexts. + + Args: + contexts: List of context dictionaries + + Returns: + List of context names + """ + return [ctx.get('name') for ctx in contexts if ctx.get('name')] + + @staticmethod + def merge_contexts(contexts1: List[Dict], contexts2: List[Dict]) -> List[Dict]: + """ + Merge two context lists, with contexts2 taking precedence for duplicates. + + Args: + contexts1: First contexts list + contexts2: Second contexts list (takes precedence) + + Returns: + Merged contexts list + """ + merged = contexts1.copy() + + for ctx2 in contexts2: + ctx2_name = ctx2.get('name') + if ctx2_name: + # Remove any existing context with the same name + merged = [ctx for ctx in merged if ctx.get('name') != ctx2_name] + # Add the new context + merged.append(ctx2) + + return merged + + +class ConversationState: + """Utility class for managing conversation state using contexts.""" + + def __init__(self, context_name: str = 'conversationState'): + self.context_name = context_name + + def get_state(self, contexts: List[Dict]) -> Dict[str, str]: + """ + Get the current conversation state. + + Args: + contexts: List of context dictionaries + + Returns: + State attributes dictionary + """ + context = ContextManager.get_context_by_name(contexts, self.context_name) + if context: + return context.get('contextAttributes', {}) + return {} + + def set_state(self, contexts: List[Dict], state: Dict[str, str], + time_to_live_seconds: int = 3600, turns_to_live: int = 10) -> List[Dict]: + """ + Set the conversation state. + + Args: + contexts: List of context dictionaries + state: State attributes to set + time_to_live_seconds: Time to live in seconds + turns_to_live: Number of turns to live + + Returns: + Updated contexts list + """ + return ContextManager.add_context( + contexts, self.context_name, state, time_to_live_seconds, turns_to_live + ) + + def update_state(self, contexts: List[Dict], updates: Dict[str, str]) -> List[Dict]: + """ + Update specific state attributes. + + Args: + contexts: List of context dictionaries + updates: State updates to apply + + Returns: + Updated contexts list + """ + return ContextManager.update_context_attributes(contexts, self.context_name, updates) + + def get_state_value(self, contexts: List[Dict], key: str) -> Optional[str]: + """ + Get a specific state value. + + Args: + contexts: List of context dictionaries + key: State key + + Returns: + State value or None + """ + return ContextManager.get_context_attribute(contexts, self.context_name, key) + + def set_state_value(self, contexts: List[Dict], key: str, value: str) -> List[Dict]: + """ + Set a specific state value. + + Args: + contexts: List of context dictionaries + key: State key + value: State value + + Returns: + Updated contexts list + """ + return ContextManager.set_context_attribute(contexts, self.context_name, key, value) + + def clear_state(self, contexts: List[Dict]) -> List[Dict]: + """ + Clear all conversation state. + + Args: + contexts: List of context dictionaries + + Returns: + Updated contexts list + """ + return ContextManager.remove_context(contexts, self.context_name) + + def is_state_active(self, contexts: List[Dict]) -> bool: + """ + Check if conversation state context is active. + + Args: + contexts: List of context dictionaries + + Returns: + True if state is active + """ + return ContextManager.is_context_active(contexts, self.context_name) diff --git a/amazon_lex_helper/IntentHandler.py b/amazon_lex_helper/IntentHandler.py index ffec698..fdef290 100644 --- a/amazon_lex_helper/IntentHandler.py +++ b/amazon_lex_helper/IntentHandler.py @@ -18,6 +18,7 @@ import logging from abc import abstractmethod +from typing import Dict, List, Optional, Any from amazon_lex_helper import LexEvent @@ -27,28 +28,311 @@ class IntentHandler: """ - Handles a Lex event received by the dispatcher. + Enhanced intent handler for Lex V2 events. Each intent handler is subscribed to the dispatcher by intent name, so only one handler is allowed for each of the intents defined in the Lex bot. + + New features: + - Enhanced slot validation + - Multi-valued slot support + - Sentiment analysis access + - Confidence score handling + - Rich message support """ - def __init__(self, intent_name): + def __init__(self, intent_name: str): self.intent_name = intent_name - def get_intent_name(self): + def get_intent_name(self) -> str: return self.intent_name @abstractmethod - def process_request(self, request: LexEvent): + def process_request(self, request: LexEvent) -> Dict: + """Process the Lex request and return a response.""" pass def log(self): return logger - def valid_intent(self, lex: LexEvent): + def valid_intent(self, lex: LexEvent) -> bool: + """Check if all required slots are filled.""" valid = False - slots = lex.get_session_slots() - if slots: + intent = lex.get_intent() + if intent and 'slots' in intent: + slots = intent['slots'] none_slots = [slot_name for slot_name in slots if not slots[slot_name]] - logger.debug("intent list = {}".format(none_slots)) + logger.debug("empty slots = {}".format(none_slots)) valid = len(none_slots) == 0 - return valid \ No newline at end of file + return valid + + def get_required_slots(self) -> List[str]: + """ + Override this method to define required slots for the intent. + Returns list of slot names that must be filled. + """ + return [] + + def validate_slots(self, lex: LexEvent) -> Dict[str, str]: + """ + Validate all slots and return validation errors. + Override this method to implement custom slot validation. + + Returns: + Dictionary mapping slot names to error messages + """ + return {} + + def handle_slot_validation_error(self, lex: LexEvent, slot_name: str, + error_message: str) -> Dict: + """ + Handle slot validation error. Override to customize error handling. + + Args: + lex: LexEvent object + slot_name: Name of the slot with validation error + error_message: Error message + + Returns: + Lex response dictionary + """ + from amazon_lex_helper import LexResponse + return LexResponse.elicit_slot(lex, slot_name, error_message) + + def is_low_confidence_request(self, lex: LexEvent, threshold: float = 0.4) -> bool: + """ + Check if the request has low confidence score. + + Args: + lex: LexEvent object + threshold: Confidence threshold + + Returns: + True if confidence is below threshold + """ + return lex.is_low_confidence_intent(threshold) + + def handle_low_confidence(self, lex: LexEvent) -> Dict: + """ + Handle low confidence requests. Override to customize behavior. + + Args: + lex: LexEvent object + + Returns: + Lex response dictionary + """ + from amazon_lex_helper import LexResponse + return LexResponse.elicit_intent( + lex, + self.intent_name, + "InProgress", + "I'm not sure I understood that correctly. Could you please rephrase?" + ) + + def get_sentiment_analysis(self, lex: LexEvent) -> Optional[Dict]: + """ + Get sentiment analysis for the current request. + + Args: + lex: LexEvent object + + Returns: + Sentiment analysis data or None + """ + return lex.get_sentiment_analysis() + + def is_negative_sentiment(self, lex: LexEvent, threshold: float = 0.5) -> bool: + """ + Check if the user sentiment is negative. + + Args: + lex: LexEvent object + threshold: Sentiment threshold + + Returns: + True if sentiment is negative above threshold + """ + sentiment = self.get_sentiment_analysis(lex) + if sentiment and 'sentiment' in sentiment: + sentiment_label = sentiment['sentiment'] + if sentiment_label == 'NEGATIVE': + sentiment_score = sentiment.get('sentimentScore', {}) + negative_score = sentiment_score.get('negative', 0) + return negative_score >= threshold + return False + + def handle_negative_sentiment(self, lex: LexEvent) -> Optional[Dict]: + """ + Handle negative sentiment. Override to customize behavior. + Return None to continue with normal processing. + + Args: + lex: LexEvent object + + Returns: + Lex response dictionary or None to continue normal processing + """ + # Default: continue with normal processing + return None + + def get_multi_valued_slot(self, lex: LexEvent, slot_name: str) -> List[str]: + """ + Get values from a multi-valued slot. + + Args: + lex: LexEvent object + slot_name: Name of the slot + + Returns: + List of slot values + """ + return lex.get_slot_values_list(slot_name) or [] + + def validate_multi_valued_slot(self, values: List[str], min_count: int = None, + max_count: int = None) -> Optional[str]: + """ + Validate multi-valued slot constraints. + + Args: + values: List of slot values + min_count: Minimum number of values required + max_count: Maximum number of values allowed + + Returns: + Error message if validation fails, None otherwise + """ + if min_count is not None and len(values) < min_count: + return f"Please provide at least {min_count} values." + + if max_count is not None and len(values) > max_count: + return f"Please provide no more than {max_count} values." + + return None + + def pre_process_request(self, lex: LexEvent) -> Optional[Dict]: + """ + Pre-process the request before main processing. + Override to implement pre-processing logic. + Return a response to short-circuit normal processing. + + Args: + lex: LexEvent object + + Returns: + Lex response dictionary or None to continue normal processing + """ + # Check for low confidence + if self.is_low_confidence_request(lex): + return self.handle_low_confidence(lex) + + # Check for negative sentiment + if self.is_negative_sentiment(lex): + response = self.handle_negative_sentiment(lex) + if response: + return response + + # Validate slots + validation_errors = self.validate_slots(lex) + if validation_errors: + # Return error for first validation failure + slot_name, error_message = next(iter(validation_errors.items())) + return self.handle_slot_validation_error(lex, slot_name, error_message) + + return None + + def post_process_response(self, lex: LexEvent, response: Dict) -> Dict: + """ + Post-process the response before returning. + Override to implement post-processing logic. + + Args: + lex: LexEvent object + response: Generated response + + Returns: + Modified response + """ + return response + + def process_request_with_hooks(self, lex: LexEvent) -> Dict: + """ + Process request with pre and post processing hooks. + This method is called by the dispatcher. + """ + # Pre-processing + pre_response = self.pre_process_request(lex) + if pre_response: + return self.post_process_response(lex, pre_response) + + # Main processing + response = self.process_request(lex) + + # Post-processing + return self.post_process_response(lex, response) + + def create_context_attributes(self, **kwargs) -> Dict: + """ + Create context attributes dictionary. + + Args: + **kwargs: Key-value pairs for context attributes + + Returns: + Context attributes dictionary + """ + return kwargs + + def log_request_info(self, lex: LexEvent): + """Log useful request information for debugging.""" + logger.info(f"Processing intent: {lex.get_intent_name()}") + logger.info(f"Input mode: {lex.get_input_mode()}") + logger.info(f"Input transcript: {lex.get_input_transcript()}") + + confidence = lex.get_nlu_confidence_score() + if confidence: + logger.info(f"NLU confidence: {confidence}") + + sentiment = lex.get_sentiment_analysis() + if sentiment: + logger.info(f"Sentiment: {sentiment.get('sentiment')}") + + +class BaseIntentHandler(IntentHandler): + """ + Base intent handler with common functionality implemented. + Extend this class for simpler intent handlers. + """ + + def __init__(self, intent_name: str, required_slots: List[str] = None): + super().__init__(intent_name) + self._required_slots = required_slots or [] + + def get_required_slots(self) -> List[str]: + return self._required_slots + + def process_request(self, lex: LexEvent) -> Dict: + """ + Default implementation that delegates to Lex if all required slots are filled, + otherwise continues slot elicitation. + """ + from amazon_lex_helper import LexResponse + + # Check if all required slots are filled + missing_slots = [] + for slot_name in self.get_required_slots(): + if not lex.slot_exists(slot_name): + missing_slots.append(slot_name) + + if missing_slots: + # Continue slot elicitation + return LexResponse.delegate(lex) + + # All slots filled, fulfill the intent + return self.fulfill_intent(lex) + + @abstractmethod + def fulfill_intent(self, lex: LexEvent) -> Dict: + """ + Fulfill the intent when all required slots are filled. + Override this method to implement intent fulfillment logic. + """ + pass \ No newline at end of file diff --git a/amazon_lex_helper/LexEvent.py b/amazon_lex_helper/LexEvent.py index 0ebc1cc..1ac7899 100644 --- a/amazon_lex_helper/LexEvent.py +++ b/amazon_lex_helper/LexEvent.py @@ -23,6 +23,8 @@ and to modify attributes by means of setters. """ +from typing import Dict, List, Optional, Any + class LexEvent: @@ -99,3 +101,190 @@ def is_requesting_slot (self, slot_name: str): slot_to_elicit = dialog_action.get("slotToElicit") return type == "ElicitSlot" and slot_to_elicit.lower() == slot_name.lower() return False + + # Enhanced methods for new Lex V2 features + + def get_sentiment_analysis(self) -> Optional[Dict]: + """ + Get sentiment analysis data from interpretations. + Returns sentiment score and label if available. + """ + interpretations = self.get_interpretations() + if interpretations and len(interpretations) > 0: + return interpretations[0].get('sentimentResponse') + return None + + def get_transcription_confidence(self) -> Optional[float]: + """ + Get transcription confidence score. + Returns confidence score between 0.0 and 1.0 if available. + """ + interpretations = self.get_interpretations() + if interpretations and len(interpretations) > 0: + nlu_confidence = interpretations[0].get('nluConfidence') + if nlu_confidence: + return nlu_confidence.get('score') + return None + + def get_slot_values_list(self, slot_name: str) -> Optional[List[str]]: + """ + Get all values for a multi-valued slot (shape: "List"). + Returns list of interpreted values or None if slot doesn't exist. + """ + slot = self.get_slot(slot_name) + if slot and slot.get('shape') == 'List': + values = slot.get('values', []) + return [value['value']['interpretedValue'] for value in values if 'value' in value] + return None + + def get_slot_original_value(self, slot_name: str) -> Optional[str]: + """ + Get the original value (as spoken/typed by user) for a slot. + """ + slot = self.get_slot(slot_name) + if slot and 'value' in slot: + return slot['value'].get('originalValue') + return None + + def get_slot_resolved_values(self, slot_name: str) -> Optional[List[str]]: + """ + Get all resolved values for a slot. + """ + slot = self.get_slot(slot_name) + if slot and 'value' in slot: + return slot['value'].get('resolvedValues', []) + return None + + def is_multi_valued_slot(self, slot_name: str) -> bool: + """ + Check if a slot is multi-valued (shape: "List"). + """ + slot = self.get_slot(slot_name) + return slot is not None and slot.get('shape') == 'List' + + def get_active_contexts(self) -> List[Dict]: + """ + Get all active contexts from the session state. + """ + return self.req.get('sessionState', {}).get('activeContexts', []) + + def get_context_attributes(self, context_name: str) -> Optional[Dict]: + """ + Get attributes for a specific active context. + """ + contexts = self.get_active_contexts() + for context in contexts: + if context.get('name') == context_name: + return context.get('contextAttributes', {}) + return None + + def get_bot_info(self) -> Dict: + """ + Get bot information including name, version, and locale. + """ + bot = self.req.get('bot', {}) + return { + 'name': bot.get('name'), + 'version': bot.get('version'), + 'locale_id': bot.get('localeId'), + 'alias_id': bot.get('aliasId') + } + + def get_request_attributes(self) -> Dict: + """ + Get request attributes if available. + """ + return self.req.get('requestAttributes', {}) + + def get_session_id(self) -> Optional[str]: + """ + Get the session ID. + """ + return self.req.get('sessionId') + + def get_user_id(self) -> Optional[str]: + """ + Get the user ID if available. + """ + return self.req.get('userId') + + def has_alternative_intents(self) -> bool: + """ + Check if there are alternative intent interpretations available. + """ + interpretations = self.get_interpretations() + return interpretations is not None and len(interpretations) > 1 + + def get_alternative_intents(self) -> List[Dict]: + """ + Get alternative intent interpretations. + Returns list of alternative intents with confidence scores. + """ + interpretations = self.get_interpretations() + if interpretations and len(interpretations) > 1: + return interpretations[1:] # Skip the first one (current intent) + return [] + + def get_nlu_confidence_score(self) -> Optional[float]: + """ + Get NLU confidence score for the current intent. + """ + interpretations = self.get_interpretations() + if interpretations and len(interpretations) > 0: + nlu_confidence = interpretations[0].get('nluConfidence') + if nlu_confidence: + return nlu_confidence.get('score') + return None + + def is_low_confidence_intent(self, threshold: float = 0.4) -> bool: + """ + Check if the current intent has low confidence score. + + Args: + threshold: Confidence threshold (default 0.4) + """ + confidence = self.get_nlu_confidence_score() + return confidence is not None and confidence < threshold + + def get_slot_elicitation_style(self, slot_name: str) -> Optional[str]: + """ + Get the elicitation style for a slot if specified in proposed next state. + """ + proposed_state = self.req.get('proposedNextState', {}) + dialog_action = proposed_state.get('dialogAction', {}) + slot_elicitation_style = dialog_action.get('slotElicitationStyle') + + if (dialog_action.get('type') == 'ElicitSlot' and + dialog_action.get('slotToElicit') == slot_name): + return slot_elicitation_style + return None + + def get_input_source(self) -> Optional[str]: + """ + Get the input source (e.g., 'Voice', 'Text'). + """ + return self.req.get('inputSource') + + def get_message_version(self) -> Optional[str]: + """ + Get the message version. + """ + return self.req.get('messageVersion') + + def is_voice_input(self) -> bool: + """ + Check if the input was voice-based. + """ + return self.get_input_mode() == 'Speech' + + def is_text_input(self) -> bool: + """ + Check if the input was text-based. + """ + return self.get_input_mode() == 'Text' + + def is_dtmf_input(self) -> bool: + """ + Check if the input was DTMF (phone keypad). + """ + return self.get_input_mode() == 'DTMF' diff --git a/amazon_lex_helper/LexEventDispatcher.py b/amazon_lex_helper/LexEventDispatcher.py index c20650a..d8fb398 100644 --- a/amazon_lex_helper/LexEventDispatcher.py +++ b/amazon_lex_helper/LexEventDispatcher.py @@ -64,15 +64,24 @@ def set_ambiguity_handler (self, ambiguity_handler: Disambiguation): def dispatch(self, lex_request: dict) -> LexResponse: logger.debug("Input request = {}".format(lex_request)) event = LexEvent(lex_request) + + # Check for ambiguity if handler is set if self.ambiguity_handler: ambiguity = self.ambiguity_handler.check_ambiguity_limit(event) if ambiguity: return self.ambiguity_handler.handle_ambiguity (ambiguity["i1"], ambiguity["i2"], ambiguity["amb"]) + intent_name = event.get_intent_name().lower() if intent_name not in self.subscribers: logger.debug("Warning: no observer defined for intent '{}', using default behaviour".format(intent_name)) response = LexResponse.delegate(event) else: - response = self.subscribers[intent_name].process_request(event) + handler = self.subscribers[intent_name] + # Use enhanced processing if available, otherwise fall back to original method + if hasattr(handler, 'process_request_with_hooks'): + response = handler.process_request_with_hooks(event) + else: + response = handler.process_request(event) + logger.debug("Output response = {}".format(response)) return response diff --git a/amazon_lex_helper/LexResponse.py b/amazon_lex_helper/LexResponse.py index e62e5ab..7979c72 100644 --- a/amazon_lex_helper/LexResponse.py +++ b/amazon_lex_helper/LexResponse.py @@ -25,6 +25,7 @@ visit the Lex Getting Started documentation http://docs.aws.amazon.com/lex/latest/dg/getting-started.html. """ from amazon_lex_helper import LexEvent +from typing import Dict, List, Optional, Union def ask_due_to_ambiguity (req: LexEvent, intent_name, message=None): @@ -168,3 +169,261 @@ def initial_message(intent_name, welcome_message): } ] } + + +# Enhanced Message Creation Functions + +def create_plain_text_message(content: str) -> Dict: + """Create a plain text message.""" + return { + "contentType": "PlainText", + "content": content + } + + +def create_ssml_message(ssml_content: str) -> Dict: + """Create an SSML message for enhanced speech output.""" + return { + "contentType": "SSML", + "content": ssml_content + } + + +def create_custom_payload_message(payload: Dict) -> Dict: + """Create a custom payload message for platform-specific responses.""" + return { + "contentType": "CustomPayload", + "content": payload + } + + +def create_image_response_card(title: str, subtitle: str = None, image_url: str = None, + buttons: List[Dict] = None) -> Dict: + """ + Create an image response card message. + + Args: + title: Card title + subtitle: Optional card subtitle + image_url: Optional image URL + buttons: Optional list of buttons with 'text' and 'value' keys + """ + card = { + "contentType": "ImageResponseCard", + "imageResponseCard": { + "title": title + } + } + + if subtitle: + card["imageResponseCard"]["subtitle"] = subtitle + + if image_url: + card["imageResponseCard"]["imageUrl"] = image_url + + if buttons: + card["imageResponseCard"]["buttons"] = buttons + + return card + + +# Enhanced Response Functions with Rich Message Support + +def elicit_slot_with_rich_messages(req: LexEvent, slot_to_elicit: str, + messages: List[Dict] = None, + runtime_hints: Dict = None) -> Dict: + """ + Enhanced elicit_slot with support for multiple message types and runtime hints. + + Args: + req: LexEvent object + slot_to_elicit: Name of slot to elicit + messages: List of message objects (PlainText, SSML, ImageResponseCard, etc.) + runtime_hints: Runtime hints to improve speech recognition + """ + resp = { + 'sessionState': { + 'activeContexts': [{ + 'name': 'intentContext', + 'contextAttributes': {}, + 'timeToLive': {'timeToLiveInSeconds': 600, 'turnsToLive': 1} + }], + 'sessionAttributes': req.get_session_attrs() or {}, + 'dialogAction': { + 'type': 'ElicitSlot', + 'slotToElicit': slot_to_elicit + }, + 'intent': req.get_intent() + } + } + + # Clear any existing slot value + resp['sessionState']['intent']['slots'][slot_to_elicit] = None + + if messages: + resp['messages'] = messages + + if runtime_hints: + resp['sessionState']['runtimeHints'] = runtime_hints + + return resp + + +def elicit_intent_with_rich_messages(req: LexEvent, intent_name: str, state: str, + messages: List[Dict] = None) -> Dict: + """Enhanced elicit_intent with support for multiple message types.""" + resp = { + "sessionState": { + 'activeContexts': [{ + 'name': 'intentContext', + 'contextAttributes': {}, + 'timeToLive': {'timeToLiveInSeconds': 600, 'turnsToLive': 1} + }], + 'sessionAttributes': req.get_session_attrs(), + 'dialogAction': {'type': 'ElicitIntent'}, + 'intent': {'name': intent_name, 'state': state}, + } + } + + if messages: + resp['messages'] = messages + + return resp + + +def confirm_intent_with_rich_messages(session_attributes: Dict, active_contexts: Dict, + intent: Dict, messages: List[Dict] = None) -> Dict: + """Enhanced confirm_intent with support for multiple message types.""" + resp = { + 'sessionState': { + 'activeContexts': [{ + 'name': 'intentContext', + 'contextAttributes': active_contexts, + 'timeToLive': { + 'timeToLiveInSeconds': 600, + 'turnsToLive': 1 + } + }], + 'sessionAttributes': session_attributes, + 'dialogAction': { + 'type': 'ConfirmIntent' + }, + 'intent': intent + } + } + + if messages: + resp['messages'] = messages + + return resp + + +def close_with_rich_messages(session_attributes: Dict, intent: Dict, context_attrs: Dict, + messages: List[Dict] = None) -> Dict: + """Enhanced close with support for multiple message types.""" + resp = { + 'sessionState': { + 'activeContexts': [{ + 'name': 'intentContext', + 'contextAttributes': context_attrs, + 'timeToLive': { + 'timeToLiveInSeconds': 600, + 'turnsToLive': 1 + } + }], + 'sessionAttributes': session_attributes, + 'dialogAction': {'type': 'Close'}, + 'intent': intent + } + } + + if messages: + resp['messages'] = messages + + return resp + + +def delegate_with_rich_messages(req: LexEvent, slot_to_override: str = None, + slot_value_to_override: str = None, + messages: List[Dict] = None, + runtime_hints: Dict = None) -> Dict: + """ + Enhanced delegate with support for multiple message types and runtime hints. + """ + session_attributes = req.get_session_attrs() or {} + context_attrs = {} + intent = req.get_intent() + + resp = { + 'sessionState': { + 'activeContexts': [{ + 'name': 'intentContext', + 'contextAttributes': context_attrs, + 'timeToLive': { + 'timeToLiveInSeconds': 600, + 'turnsToLive': 1 + } + }], + 'sessionAttributes': session_attributes, + 'dialogAction': {'type': 'Delegate'}, + 'intent': intent + } + } + + if slot_to_override: + resp['sessionState']['intent']['slots'][slot_to_override] = { + 'shape': 'Scalar', + 'value': { + 'originalValue': slot_value_to_override, + 'resolvedValues': [slot_value_to_override], + 'interpretedValue': slot_value_to_override + } + } + + if messages: + resp['messages'] = messages + + if runtime_hints: + resp['sessionState']['runtimeHints'] = runtime_hints + + return resp + + +# Runtime Hints Helper Functions + +def create_runtime_hints(slot_hints: Dict[str, Dict] = None, + phrase_hints: List[str] = None) -> Dict: + """ + Create runtime hints to improve speech recognition. + + Args: + slot_hints: Dictionary mapping slot names to hint configurations + phrase_hints: List of phrases to boost recognition + """ + hints = {} + + if slot_hints: + hints['slotHints'] = slot_hints + + if phrase_hints: + hints['phraseHints'] = [{'value': phrase} for phrase in phrase_hints] + + return hints + + +def create_slot_hint(values: List[str], subslot_hints: Dict = None) -> Dict: + """ + Create a slot hint configuration. + + Args: + values: List of expected values for the slot + subslot_hints: Optional hints for subslots + """ + hint = { + 'runtimeHintValues': [{'phrase': value} for value in values] + } + + if subslot_hints: + hint['subSlotHints'] = subslot_hints + + return hint diff --git a/amazon_lex_helper/MessageBuilder.py b/amazon_lex_helper/MessageBuilder.py new file mode 100644 index 0000000..e68edb2 --- /dev/null +++ b/amazon_lex_helper/MessageBuilder.py @@ -0,0 +1,325 @@ +""" + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: MIT-0 + + Permission is hereby granted, free of charge, to any person obtaining a copy of this + software and associated documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" + +""" +Rich message builder utilities for Amazon Lex V2. +Provides fluent interface for creating complex messages with multiple content types. +""" + +from typing import Dict, List, Optional, Any + + +class MessageBuilder: + """Fluent builder for creating rich Lex V2 messages.""" + + def __init__(self): + self.messages = [] + + def add_plain_text(self, content: str) -> 'MessageBuilder': + """Add a plain text message.""" + self.messages.append({ + "contentType": "PlainText", + "content": content + }) + return self + + def add_ssml(self, ssml_content: str) -> 'MessageBuilder': + """Add an SSML message for enhanced speech output.""" + self.messages.append({ + "contentType": "SSML", + "content": ssml_content + }) + return self + + def add_custom_payload(self, payload: Dict) -> 'MessageBuilder': + """Add a custom payload message for platform-specific responses.""" + self.messages.append({ + "contentType": "CustomPayload", + "content": payload + }) + return self + + def add_image_response_card(self, title: str, subtitle: str = None, + image_url: str = None, buttons: List[Dict] = None) -> 'MessageBuilder': + """ + Add an image response card message. + + Args: + title: Card title + subtitle: Optional card subtitle + image_url: Optional image URL + buttons: Optional list of buttons with 'text' and 'value' keys + """ + card = { + "contentType": "ImageResponseCard", + "imageResponseCard": { + "title": title + } + } + + if subtitle: + card["imageResponseCard"]["subtitle"] = subtitle + + if image_url: + card["imageResponseCard"]["imageUrl"] = image_url + + if buttons: + card["imageResponseCard"]["buttons"] = buttons + + self.messages.append(card) + return self + + def build(self) -> List[Dict]: + """Build and return the list of messages.""" + return self.messages.copy() + + def clear(self) -> 'MessageBuilder': + """Clear all messages and start fresh.""" + self.messages.clear() + return self + + +class SSMLBuilder: + """Builder for creating SSML content.""" + + def __init__(self): + self.content = [] + + def speak(self, text: str) -> 'SSMLBuilder': + """Add plain text to speak.""" + self.content.append(text) + return self + + def pause(self, duration: str = "1s") -> 'SSMLBuilder': + """Add a pause with specified duration.""" + self.content.append(f'') + return self + + def emphasis(self, text: str, level: str = "moderate") -> 'SSMLBuilder': + """ + Add emphasized text. + + Args: + text: Text to emphasize + level: Emphasis level ("strong", "moderate", "reduced") + """ + self.content.append(f'{text}') + return self + + def prosody(self, text: str, rate: str = None, pitch: str = None, + volume: str = None) -> 'SSMLBuilder': + """ + Add text with prosody modifications. + + Args: + text: Text to modify + rate: Speech rate ("x-slow", "slow", "medium", "fast", "x-fast") + pitch: Speech pitch ("x-low", "low", "medium", "high", "x-high") + volume: Speech volume ("silent", "x-soft", "soft", "medium", "loud", "x-loud") + """ + attributes = [] + if rate: + attributes.append(f'rate="{rate}"') + if pitch: + attributes.append(f'pitch="{pitch}"') + if volume: + attributes.append(f'volume="{volume}"') + + attr_str = " ".join(attributes) + self.content.append(f'{text}') + return self + + def say_as(self, text: str, interpret_as: str, format_attr: str = None) -> 'SSMLBuilder': + """ + Add text with specific interpretation. + + Args: + text: Text to interpret + interpret_as: How to interpret ("spell-out", "digits", "date", "time", etc.) + format_attr: Format attribute for dates/times + """ + if format_attr: + self.content.append(f'{text}') + else: + self.content.append(f'{text}') + return self + + def phoneme(self, text: str, alphabet: str, ph: str) -> 'SSMLBuilder': + """ + Add phonetic pronunciation. + + Args: + text: Text to pronounce + alphabet: Phonetic alphabet ("ipa" or "x-sampa") + ph: Phonetic pronunciation + """ + self.content.append(f'{text}') + return self + + def substitute(self, text: str, alias: str) -> 'SSMLBuilder': + """ + Substitute text with alias for pronunciation. + + Args: + text: Original text + alias: Text to speak instead + """ + self.content.append(f'{text}') + return self + + def build(self) -> str: + """Build and return the SSML content wrapped in speak tags.""" + content_str = "".join(self.content) + return f'{content_str}' + + def clear(self) -> 'SSMLBuilder': + """Clear all content and start fresh.""" + self.content.clear() + return self + + +class CardBuilder: + """Builder for creating image response cards.""" + + def __init__(self, title: str): + self.card = { + "contentType": "ImageResponseCard", + "imageResponseCard": { + "title": title, + "buttons": [] + } + } + + def subtitle(self, subtitle: str) -> 'CardBuilder': + """Set card subtitle.""" + self.card["imageResponseCard"]["subtitle"] = subtitle + return self + + def image_url(self, url: str) -> 'CardBuilder': + """Set card image URL.""" + self.card["imageResponseCard"]["imageUrl"] = url + return self + + def add_button(self, text: str, value: str) -> 'CardBuilder': + """Add a button to the card.""" + self.card["imageResponseCard"]["buttons"].append({ + "text": text, + "value": value + }) + return self + + def add_url_button(self, text: str, url: str) -> 'CardBuilder': + """Add a URL button to the card.""" + self.card["imageResponseCard"]["buttons"].append({ + "text": text, + "value": url + }) + return self + + def build(self) -> Dict: + """Build and return the card.""" + # Remove empty buttons array if no buttons were added + if not self.card["imageResponseCard"]["buttons"]: + del self.card["imageResponseCard"]["buttons"] + return self.card.copy() + + +class RuntimeHintsBuilder: + """Builder for creating runtime hints to improve speech recognition.""" + + def __init__(self): + self.hints = {} + + def add_slot_hint(self, slot_name: str, values: List[str], + subslot_hints: Dict = None) -> 'RuntimeHintsBuilder': + """ + Add hints for a specific slot. + + Args: + slot_name: Name of the slot + values: List of expected values + subslot_hints: Optional hints for subslots + """ + if 'slotHints' not in self.hints: + self.hints['slotHints'] = {} + + hint = { + 'runtimeHintValues': [{'phrase': value} for value in values] + } + + if subslot_hints: + hint['subSlotHints'] = subslot_hints + + self.hints['slotHints'][slot_name] = hint + return self + + def add_phrase_hints(self, phrases: List[str]) -> 'RuntimeHintsBuilder': + """ + Add general phrase hints to boost recognition. + + Args: + phrases: List of phrases to boost + """ + if 'phraseHints' not in self.hints: + self.hints['phraseHints'] = [] + + for phrase in phrases: + self.hints['phraseHints'].append({'value': phrase}) + + return self + + def build(self) -> Dict: + """Build and return the runtime hints.""" + return self.hints.copy() + + def clear(self) -> 'RuntimeHintsBuilder': + """Clear all hints and start fresh.""" + self.hints.clear() + return self + + +# Convenience functions for quick message creation + +def quick_text_message(content: str) -> List[Dict]: + """Quick function to create a single plain text message.""" + return MessageBuilder().add_plain_text(content).build() + + +def quick_ssml_message(ssml_content: str) -> List[Dict]: + """Quick function to create a single SSML message.""" + return MessageBuilder().add_ssml(ssml_content).build() + + +def quick_card_message(title: str, subtitle: str = None, image_url: str = None, + buttons: List[Dict] = None) -> List[Dict]: + """Quick function to create a single image response card message.""" + return MessageBuilder().add_image_response_card(title, subtitle, image_url, buttons).build() + + +def quick_mixed_message(text: str, ssml: str = None, card_title: str = None) -> List[Dict]: + """Quick function to create a mixed message with text, optional SSML, and optional card.""" + builder = MessageBuilder().add_plain_text(text) + + if ssml: + builder.add_ssml(ssml) + + if card_title: + builder.add_image_response_card(card_title) + + return builder.build() diff --git a/amazon_lex_helper/SlotHandler.py b/amazon_lex_helper/SlotHandler.py new file mode 100644 index 0000000..a57b596 --- /dev/null +++ b/amazon_lex_helper/SlotHandler.py @@ -0,0 +1,318 @@ +""" + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: MIT-0 + + Permission is hereby granted, free of charge, to any person obtaining a copy of this + software and associated documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights to use, copy, modify, + merge, publish, distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" + +""" +Enhanced slot handling utilities for Amazon Lex V2. +Provides support for multi-valued slots, slot validation, and elicitation styles. +""" + +from typing import Dict, List, Optional, Any, Union +from amazon_lex_helper.LexEvent import LexEvent + + +class SlotHandler: + """Utility class for enhanced slot handling in Lex V2.""" + + @staticmethod + def create_scalar_slot(original_value: str, interpreted_value: str = None, + resolved_values: List[str] = None) -> Dict: + """ + Create a scalar slot value structure. + + Args: + original_value: The original value as provided by user + interpreted_value: The interpreted value (defaults to original_value) + resolved_values: List of resolved values (defaults to [interpreted_value]) + """ + if interpreted_value is None: + interpreted_value = original_value + if resolved_values is None: + resolved_values = [interpreted_value] + + return { + 'shape': 'Scalar', + 'value': { + 'originalValue': original_value, + 'interpretedValue': interpreted_value, + 'resolvedValues': resolved_values + } + } + + @staticmethod + def create_list_slot(values: List[Dict]) -> Dict: + """ + Create a list slot value structure for multi-valued slots. + + Args: + values: List of value dictionaries with originalValue, interpretedValue, resolvedValues + """ + slot_values = [] + for value in values: + slot_values.append({ + 'value': { + 'originalValue': value.get('originalValue'), + 'interpretedValue': value.get('interpretedValue'), + 'resolvedValues': value.get('resolvedValues', [value.get('interpretedValue')]) + } + }) + + return { + 'shape': 'List', + 'values': slot_values + } + + @staticmethod + def add_value_to_list_slot(existing_slot: Dict, new_value: Dict) -> Dict: + """ + Add a new value to an existing list slot. + + Args: + existing_slot: Existing list slot structure + new_value: New value to add with originalValue, interpretedValue, resolvedValues + """ + if existing_slot.get('shape') != 'List': + raise ValueError("Slot must be of shape 'List'") + + new_slot_value = { + 'value': { + 'originalValue': new_value.get('originalValue'), + 'interpretedValue': new_value.get('interpretedValue'), + 'resolvedValues': new_value.get('resolvedValues', [new_value.get('interpretedValue')]) + } + } + + existing_slot['values'].append(new_slot_value) + return existing_slot + + @staticmethod + def validate_slot_value(slot_value: str, valid_values: List[str], + case_sensitive: bool = False) -> bool: + """ + Validate a slot value against a list of valid values. + + Args: + slot_value: The slot value to validate + valid_values: List of valid values + case_sensitive: Whether validation should be case sensitive + """ + if not case_sensitive: + slot_value = slot_value.lower() + valid_values = [v.lower() for v in valid_values] + + return slot_value in valid_values + + @staticmethod + def validate_slot_pattern(slot_value: str, pattern: str) -> bool: + """ + Validate a slot value against a regex pattern. + + Args: + slot_value: The slot value to validate + pattern: Regex pattern to match against + """ + import re + return bool(re.match(pattern, slot_value)) + + @staticmethod + def get_slot_validation_error_count(req: LexEvent, slot_name: str) -> int: + """ + Get the number of validation errors for a specific slot. + + Args: + req: LexEvent object + slot_name: Name of the slot + """ + attrs = req.get_session_attrs() + error_key = f'validation_errors_{slot_name.lower()}' + return int(attrs.get(error_key, 0)) + + @staticmethod + def increment_slot_validation_error(req: LexEvent, slot_name: str) -> int: + """ + Increment the validation error count for a specific slot. + + Args: + req: LexEvent object + slot_name: Name of the slot + + Returns: + New error count + """ + attrs = req.get_session_attrs() + error_key = f'validation_errors_{slot_name.lower()}' + current_count = int(attrs.get(error_key, 0)) + new_count = current_count + 1 + attrs[error_key] = str(new_count) + return new_count + + @staticmethod + def reset_slot_validation_errors(req: LexEvent, slot_name: str): + """ + Reset validation error count for a specific slot. + + Args: + req: LexEvent object + slot_name: Name of the slot + """ + attrs = req.get_session_attrs() + error_key = f'validation_errors_{slot_name.lower()}' + if error_key in attrs: + del attrs[error_key] + + @staticmethod + def create_elicitation_style_config(style: str = "Default", + spell_by_letter: bool = False, + spell_by_word: bool = False) -> Dict: + """ + Create slot elicitation style configuration. + + Args: + style: Elicitation style ("Default", "SpellByLetter", "SpellByWord") + spell_by_letter: Enable spell by letter mode + spell_by_word: Enable spell by word mode + """ + if spell_by_letter: + style = "SpellByLetter" + elif spell_by_word: + style = "SpellByWord" + + return { + "slotElicitationStyle": style + } + + @staticmethod + def extract_slot_values_from_list(list_slot: Dict) -> List[str]: + """ + Extract interpreted values from a list slot. + + Args: + list_slot: List slot structure + + Returns: + List of interpreted values + """ + if list_slot.get('shape') != 'List': + return [] + + values = [] + for slot_value in list_slot.get('values', []): + if 'value' in slot_value: + interpreted_value = slot_value['value'].get('interpretedValue') + if interpreted_value: + values.append(interpreted_value) + + return values + + @staticmethod + def merge_slot_values(slot1: Dict, slot2: Dict) -> Dict: + """ + Merge two slot values. If both are lists, combine them. + If one is scalar and other is list, convert scalar to list and combine. + + Args: + slot1: First slot + slot2: Second slot + + Returns: + Merged slot + """ + if not slot1: + return slot2 + if not slot2: + return slot1 + + # If both are lists, combine them + if (slot1.get('shape') == 'List' and slot2.get('shape') == 'List'): + combined_values = slot1.get('values', []) + slot2.get('values', []) + return { + 'shape': 'List', + 'values': combined_values + } + + # If one is list and other is scalar, convert scalar to list and combine + elif slot1.get('shape') == 'List' and slot2.get('shape') == 'Scalar': + new_value = {'value': slot2['value']} + slot1['values'].append(new_value) + return slot1 + + elif slot1.get('shape') == 'Scalar' and slot2.get('shape') == 'List': + new_value = {'value': slot1['value']} + slot2['values'].insert(0, new_value) + return slot2 + + # If both are scalar, return the second one (override) + else: + return slot2 + + +class SlotValidator: + """Utility class for slot validation with common validation patterns.""" + + @staticmethod + def validate_email(email: str) -> bool: + """Validate email format.""" + import re + pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + return bool(re.match(pattern, email)) + + @staticmethod + def validate_phone_number(phone: str) -> bool: + """Validate phone number format (US format).""" + import re + # Remove all non-digit characters + digits_only = re.sub(r'\D', '', phone) + # Check if it's 10 or 11 digits (with or without country code) + return len(digits_only) in [10, 11] + + @staticmethod + def validate_date_format(date_str: str, format_pattern: str = r'^\d{4}-\d{2}-\d{2}$') -> bool: + """Validate date format (default: YYYY-MM-DD).""" + import re + return bool(re.match(format_pattern, date_str)) + + @staticmethod + def validate_numeric_range(value: str, min_val: float = None, max_val: float = None) -> bool: + """Validate numeric value within a range.""" + try: + num_value = float(value) + if min_val is not None and num_value < min_val: + return False + if max_val is not None and num_value > max_val: + return False + return True + except ValueError: + return False + + @staticmethod + def validate_string_length(value: str, min_length: int = None, max_length: int = None) -> bool: + """Validate string length.""" + length = len(value) + if min_length is not None and length < min_length: + return False + if max_length is not None and length > max_length: + return False + return True + + @staticmethod + def validate_choice(value: str, choices: List[str], case_sensitive: bool = False) -> bool: + """Validate value is one of the allowed choices.""" + if not case_sensitive: + value = value.lower() + choices = [choice.lower() for choice in choices] + return value in choices diff --git a/amazon_lex_helper/__init__.py b/amazon_lex_helper/__init__.py index 0f84687..2f01c81 100644 --- a/amazon_lex_helper/__init__.py +++ b/amazon_lex_helper/__init__.py @@ -2,5 +2,16 @@ from amazon_lex_helper.LexEvent import LexEvent from amazon_lex_helper import LexResponse from amazon_lex_helper.LexEventDispatcher import LexEventDispatcher -from amazon_lex_helper.IntentHandler import IntentHandler +from amazon_lex_helper.IntentHandler import IntentHandler, BaseIntentHandler +from amazon_lex_helper.SlotHandler import SlotHandler, SlotValidator +from amazon_lex_helper.MessageBuilder import ( + MessageBuilder, SSMLBuilder, CardBuilder, RuntimeHintsBuilder, + quick_text_message, quick_ssml_message, quick_card_message, quick_mixed_message +) +from amazon_lex_helper.ContextManager import ContextManager, ConversationState + +# Version info +__version__ = "2.0.0" +__author__ = "Amazon Web Services" +__description__ = "Enhanced Amazon Lex V2 Helper Library with rich message support, runtime hints, and advanced slot handling" diff --git a/examples/enhanced_features_examples.py b/examples/enhanced_features_examples.py new file mode 100644 index 0000000..2920498 --- /dev/null +++ b/examples/enhanced_features_examples.py @@ -0,0 +1,328 @@ +""" +Enhanced Amazon Lex V2 Helper Examples + +This file demonstrates the new features added to the amazon-lex-helper library: +- Rich message types (SSML, Image Response Cards, Custom Payload) +- Runtime hints for improved speech recognition +- Multi-valued slot handling +- Sentiment analysis +- Enhanced slot validation +- Context management +""" + +from amazon_lex_helper import ( + LexEvent, LexEventDispatcher, BaseIntentHandler, + MessageBuilder, SSMLBuilder, CardBuilder, RuntimeHintsBuilder, + SlotHandler, SlotValidator, ContextManager, ConversationState, + quick_text_message, quick_ssml_message, quick_card_message +) +from amazon_lex_helper import LexResponse + + +# Example 1: Enhanced Intent Handler with Rich Messages +class BookHotelIntentEnhanced(BaseIntentHandler): + """Enhanced BookHotel intent with rich messages and validation.""" + + def __init__(self): + super().__init__("BookHotel", required_slots=["Location", "CheckInDate", "Nights"]) + + def validate_slots(self, lex: LexEvent): + """Custom slot validation.""" + errors = {} + + # Validate location + location = lex.get_slot_interpreted_value("Location") + if location and not SlotValidator.validate_choice( + location, ["London", "Bristol", "Manchester"], case_sensitive=False + ): + errors["Location"] = "Sorry, we only have hotels in London, Bristol, and Manchester." + + # Validate nights + nights = lex.get_slot_interpreted_value("Nights") + if nights and not SlotValidator.validate_numeric_range(nights, min_val=1, max_val=30): + errors["Nights"] = "Please choose between 1 and 30 nights." + + return errors + + def handle_negative_sentiment(self, lex: LexEvent): + """Handle negative sentiment with empathetic response.""" + messages = MessageBuilder() \ + .add_plain_text("I understand you might be frustrated. Let me help you find the perfect hotel.") \ + .add_ssml( + SSMLBuilder() + .prosody("I'm here to help", rate="slow", volume="soft") + .build() + ).build() + + return LexResponse.elicit_slot_with_rich_messages( + lex, "Location", messages + ) + + def fulfill_intent(self, lex: LexEvent): + """Fulfill the hotel booking with rich response.""" + location = lex.get_slot_interpreted_value("Location") + nights = lex.get_slot_interpreted_value("Nights") + check_in = lex.get_slot_interpreted_value("CheckInDate") + + # Create rich response with card + messages = MessageBuilder() \ + .add_plain_text(f"Great! I've found hotels in {location} for {nights} nights starting {check_in}.") \ + .add_image_response_card( + title=f"Hotels in {location}", + subtitle=f"{nights} nights from {check_in}", + image_url="https://example.com/hotel-image.jpg", + buttons=[ + {"text": "Book Now", "value": "book_hotel"}, + {"text": "See More Options", "value": "more_options"} + ] + ).build() + + return LexResponse.close_with_rich_messages( + lex.get_session_attrs(), + lex.get_intent(), + {}, + messages + ) + + +# Example 2: Multi-valued Slot Handler +class OrderPizzaIntent(BaseIntentHandler): + """Pizza ordering intent with multi-valued toppings slot.""" + + def __init__(self): + super().__init__("OrderPizza", required_slots=["Size", "Toppings"]) + + def validate_slots(self, lex: LexEvent): + """Validate pizza order slots.""" + errors = {} + + # Validate toppings (multi-valued slot) + if lex.is_multi_valued_slot("Toppings"): + toppings = lex.get_slot_values_list("Toppings") + if len(toppings) > 5: + errors["Toppings"] = "Please choose no more than 5 toppings." + + # Validate each topping + valid_toppings = ["pepperoni", "mushrooms", "cheese", "sausage", "peppers", "onions"] + for topping in toppings: + if not SlotValidator.validate_choice(topping, valid_toppings, case_sensitive=False): + errors["Toppings"] = f"Sorry, {topping} is not available. Choose from: {', '.join(valid_toppings)}" + break + + return errors + + def fulfill_intent(self, lex: LexEvent): + """Fulfill pizza order.""" + size = lex.get_slot_interpreted_value("Size") + toppings = lex.get_slot_values_list("Toppings") or [lex.get_slot_interpreted_value("Toppings")] + + toppings_text = ", ".join(toppings) + + # Create SSML response + ssml_content = SSMLBuilder() \ + .speak("Perfect! I've ordered a") \ + .emphasis(size, level="strong") \ + .speak("pizza with") \ + .emphasis(toppings_text, level="moderate") \ + .pause("500ms") \ + .speak("Your order will be ready in 20 minutes.") \ + .build() + + messages = MessageBuilder() \ + .add_plain_text(f"Perfect! I've ordered a {size} pizza with {toppings_text}. Your order will be ready in 20 minutes.") \ + .add_ssml(ssml_content) \ + .build() + + return LexResponse.close_with_rich_messages( + lex.get_session_attrs(), + lex.get_intent(), + {}, + messages + ) + + +# Example 3: Intent with Runtime Hints +class FlightBookingIntent(BaseIntentHandler): + """Flight booking with runtime hints for better speech recognition.""" + + def __init__(self): + super().__init__("BookFlight", required_slots=["Origin", "Destination", "DepartureDate"]) + + def process_request(self, lex: LexEvent): + """Process with runtime hints for airport codes.""" + + # If we're eliciting Origin or Destination, add runtime hints + if lex.is_requesting_slot("Origin") or lex.is_requesting_slot("Destination"): + # Create runtime hints for common airports + hints = RuntimeHintsBuilder() \ + .add_slot_hint("Origin", [ + "JFK", "LAX", "ORD", "DFW", "ATL", "SFO", "SEA", "MIA" + ]) \ + .add_slot_hint("Destination", [ + "JFK", "LAX", "ORD", "DFW", "ATL", "SFO", "SEA", "MIA" + ]) \ + .add_phrase_hints([ + "John F Kennedy Airport", + "Los Angeles International", + "O'Hare International", + "Dallas Fort Worth" + ]) \ + .build() + + messages = quick_text_message("Which airport would you like to fly from?") + + return LexResponse.elicit_slot_with_rich_messages( + lex, "Origin", messages, runtime_hints=hints + ) + + return super().process_request(lex) + + def fulfill_intent(self, lex: LexEvent): + """Fulfill flight booking.""" + origin = lex.get_slot_interpreted_value("Origin") + destination = lex.get_slot_interpreted_value("Destination") + departure_date = lex.get_slot_interpreted_value("DepartureDate") + + messages = quick_card_message( + title="Flight Search Results", + subtitle=f"{origin} to {destination} on {departure_date}", + image_url="https://example.com/flight-image.jpg", + buttons=[ + {"text": "Book Flight", "value": "book_flight"}, + {"text": "Change Dates", "value": "change_dates"} + ] + ) + + return LexResponse.close_with_rich_messages( + lex.get_session_attrs(), + lex.get_intent(), + {}, + messages + ) + + +# Example 4: Context-Aware Intent Handler +class CustomerServiceIntent(BaseIntentHandler): + """Customer service intent using conversation state.""" + + def __init__(self): + super().__init__("CustomerService") + self.conversation_state = ConversationState("customerServiceState") + + def process_request(self, lex: LexEvent): + """Process customer service request with context awareness.""" + contexts = lex.get_active_contexts() + + # Check if this is a returning customer + customer_id = self.conversation_state.get_state_value(contexts, "customer_id") + issue_type = self.conversation_state.get_state_value(contexts, "issue_type") + + if customer_id and issue_type: + # Returning customer with known issue + messages = MessageBuilder() \ + .add_plain_text(f"Welcome back! I see you're still working on your {issue_type} issue.") \ + .add_plain_text("How can I help you today?") \ + .build() + else: + # New customer + messages = MessageBuilder() \ + .add_plain_text("Welcome to customer service! I'm here to help.") \ + .add_plain_text("What can I assist you with today?") \ + .build() + + # Set up conversation state + contexts = self.conversation_state.set_state(contexts, { + "session_start": "true", + "interaction_count": "1" + }) + + # Update contexts in response + response = LexResponse.elicit_intent_with_rich_messages( + lex, "CustomerService", "InProgress", messages + ) + response['sessionState']['activeContexts'] = contexts + + return response + + def fulfill_intent(self, lex: LexEvent): + """This intent doesn't fulfill, it routes to other intents.""" + return LexResponse.delegate(lex) + + +# Example 5: Lambda Handler with Enhanced Dispatcher +def lambda_handler(event, context): + """Enhanced lambda handler demonstrating new features.""" + + # Create dispatcher + dispatcher = LexEventDispatcher() + + # Subscribe enhanced intent handlers + dispatcher.subscribe( + BookHotelIntentEnhanced(), + OrderPizzaIntent(), + FlightBookingIntent(), + CustomerServiceIntent() + ) + + # Process the request + return dispatcher.dispatch(event) + + +# Example 6: Utility Functions Usage +def demonstrate_utilities(): + """Demonstrate utility functions.""" + + # Message Builder + messages = MessageBuilder() \ + .add_plain_text("Welcome to our service!") \ + .add_ssml( + SSMLBuilder() + .speak("Thank you for calling") + .pause("500ms") + .emphasis("premium support", level="strong") + .build() + ) \ + .add_image_response_card( + title="Service Options", + buttons=[ + {"text": "Technical Support", "value": "tech_support"}, + {"text": "Billing", "value": "billing"} + ] + ) \ + .build() + + # Slot Handler + scalar_slot = SlotHandler.create_scalar_slot("London", "London", ["London", "Greater London"]) + list_slot = SlotHandler.create_list_slot([ + {"originalValue": "pepperoni", "interpretedValue": "pepperoni"}, + {"originalValue": "cheese", "interpretedValue": "cheese"} + ]) + + # Context Manager + contexts = [] + contexts = ContextManager.add_context( + contexts, "userPreferences", + {"preferred_location": "London", "loyalty_tier": "Gold"} + ) + + # Runtime Hints + hints = RuntimeHintsBuilder() \ + .add_slot_hint("City", ["London", "Paris", "New York"]) \ + .add_phrase_hints(["book a flight", "cancel reservation"]) \ + .build() + + return { + "messages": messages, + "scalar_slot": scalar_slot, + "list_slot": list_slot, + "contexts": contexts, + "hints": hints + } + + +if __name__ == "__main__": + # Demonstrate utilities + demo_results = demonstrate_utilities() + print("Enhanced features demonstration completed!") + print(f"Created {len(demo_results['messages'])} messages") + print(f"Created contexts: {[ctx['name'] for ctx in demo_results['contexts']]}") diff --git a/test/test_enhanced_lex_event.py b/test/test_enhanced_lex_event.py new file mode 100644 index 0000000..5a5285a --- /dev/null +++ b/test/test_enhanced_lex_event.py @@ -0,0 +1,242 @@ +""" +Test cases for enhanced LexEvent features. +""" + +import unittest +from amazon_lex_helper import LexEvent + + +class TestEnhancedLexEvent(unittest.TestCase): + + def setUp(self): + """Set up test data.""" + self.sample_event = { + "messageVersion": "1.0", + "invocationSource": "DialogCodeHook", + "userId": "test-user-123", + "sessionId": "test-session-456", + "inputMode": "Speech", + "inputTranscript": "I want to book a hotel in London", + "bot": { + "name": "TestBot", + "version": "1.0", + "localeId": "en_US", + "aliasId": "TestAlias" + }, + "interpretations": [ + { + "intent": { + "name": "BookHotel", + "confirmationState": "None", + "state": "InProgress", + "slots": { + "Location": { + "shape": "Scalar", + "value": { + "originalValue": "London", + "interpretedValue": "London", + "resolvedValues": ["London", "Greater London"] + } + }, + "Toppings": { + "shape": "List", + "values": [ + { + "value": { + "originalValue": "pepperoni", + "interpretedValue": "pepperoni", + "resolvedValues": ["pepperoni"] + } + }, + { + "value": { + "originalValue": "cheese", + "interpretedValue": "cheese", + "resolvedValues": ["cheese"] + } + } + ] + } + } + }, + "nluConfidence": { + "score": 0.85 + }, + "sentimentResponse": { + "sentiment": "POSITIVE", + "sentimentScore": { + "positive": 0.8, + "negative": 0.1, + "neutral": 0.1 + } + } + } + ], + "sessionState": { + "activeContexts": [ + { + "name": "testContext", + "contextAttributes": { + "key1": "value1" + }, + "timeToLive": { + "timeToLiveInSeconds": 600, + "turnsToLive": 1 + } + } + ], + "sessionAttributes": { + "sessionKey": "sessionValue" + }, + "intent": { + "name": "BookHotel", + "confirmationState": "None", + "state": "InProgress", + "slots": { + "Location": { + "shape": "Scalar", + "value": { + "originalValue": "London", + "interpretedValue": "London", + "resolvedValues": ["London", "Greater London"] + } + }, + "Toppings": { + "shape": "List", + "value": { + "originalValue": "pepperoni and cheese", + "interpretedValue": "pepperoni and cheese", + "resolvedValues": ["pepperoni and cheese"] + }, + "values": [ + { + "value": { + "originalValue": "pepperoni", + "interpretedValue": "pepperoni", + "resolvedValues": ["pepperoni"] + } + }, + { + "value": { + "originalValue": "cheese", + "interpretedValue": "cheese", + "resolvedValues": ["cheese"] + } + } + ] + } + } + } + }, + "proposedNextState": { + "dialogAction": { + "type": "ElicitSlot", + "slotToElicit": "CheckInDate" + } + } + } + + self.lex_event = LexEvent(self.sample_event) + + def test_get_sentiment_analysis(self): + """Test sentiment analysis retrieval.""" + sentiment = self.lex_event.get_sentiment_analysis() + self.assertIsNotNone(sentiment) + self.assertEqual(sentiment['sentiment'], 'POSITIVE') + self.assertEqual(sentiment['sentimentScore']['positive'], 0.8) + + def test_get_transcription_confidence(self): + """Test transcription confidence retrieval.""" + confidence = self.lex_event.get_transcription_confidence() + self.assertEqual(confidence, 0.85) + + def test_get_slot_values_list(self): + """Test multi-valued slot retrieval.""" + # Test list slot + toppings = self.lex_event.get_slot_values_list("Toppings") + self.assertEqual(toppings, ["pepperoni", "cheese"]) + + # Test scalar slot (should return None) + location_list = self.lex_event.get_slot_values_list("Location") + self.assertIsNone(location_list) + + # Test non-existent slot + non_existent = self.lex_event.get_slot_values_list("NonExistent") + self.assertIsNone(non_existent) + + def test_get_slot_original_value(self): + """Test original value retrieval.""" + original = self.lex_event.get_slot_original_value("Location") + self.assertEqual(original, "London") + + def test_get_slot_resolved_values(self): + """Test resolved values retrieval.""" + resolved = self.lex_event.get_slot_resolved_values("Location") + self.assertEqual(resolved, ["London", "Greater London"]) + + def test_is_multi_valued_slot(self): + """Test multi-valued slot detection.""" + self.assertTrue(self.lex_event.is_multi_valued_slot("Toppings")) + self.assertFalse(self.lex_event.is_multi_valued_slot("Location")) + self.assertFalse(self.lex_event.is_multi_valued_slot("NonExistent")) + + def test_get_active_contexts(self): + """Test active contexts retrieval.""" + contexts = self.lex_event.get_active_contexts() + self.assertEqual(len(contexts), 1) + self.assertEqual(contexts[0]['name'], 'testContext') + + def test_get_context_attributes(self): + """Test context attributes retrieval.""" + attrs = self.lex_event.get_context_attributes("testContext") + self.assertEqual(attrs, {"key1": "value1"}) + + # Test non-existent context + non_existent = self.lex_event.get_context_attributes("nonExistent") + self.assertIsNone(non_existent) + + def test_get_bot_info(self): + """Test bot information retrieval.""" + bot_info = self.lex_event.get_bot_info() + self.assertEqual(bot_info['name'], 'TestBot') + self.assertEqual(bot_info['version'], '1.0') + self.assertEqual(bot_info['locale_id'], 'en_US') + + def test_get_session_id(self): + """Test session ID retrieval.""" + session_id = self.lex_event.get_session_id() + self.assertEqual(session_id, "test-session-456") + + def test_get_user_id(self): + """Test user ID retrieval.""" + user_id = self.lex_event.get_user_id() + self.assertEqual(user_id, "test-user-123") + + def test_has_alternative_intents(self): + """Test alternative intents detection.""" + # Current test data has only one interpretation + self.assertFalse(self.lex_event.has_alternative_intents()) + + def test_get_nlu_confidence_score(self): + """Test NLU confidence score retrieval.""" + confidence = self.lex_event.get_nlu_confidence_score() + self.assertEqual(confidence, 0.85) + + def test_is_low_confidence_intent(self): + """Test low confidence detection.""" + # With confidence 0.85, should not be low confidence with default threshold (0.4) + self.assertFalse(self.lex_event.is_low_confidence_intent()) + # With confidence 0.85 and threshold 0.9, should be low confidence + self.assertTrue(self.lex_event.is_low_confidence_intent(0.9)) # Higher threshold + # With confidence 0.85 and threshold 0.8, should not be low confidence + self.assertFalse(self.lex_event.is_low_confidence_intent(0.8)) # Lower threshold + + def test_input_mode_checks(self): + """Test input mode detection methods.""" + self.assertTrue(self.lex_event.is_voice_input()) + self.assertFalse(self.lex_event.is_text_input()) + self.assertFalse(self.lex_event.is_dtmf_input()) + + +if __name__ == '__main__': + unittest.main() diff --git a/test/test_message_builder.py b/test/test_message_builder.py new file mode 100644 index 0000000..5db6fa7 --- /dev/null +++ b/test/test_message_builder.py @@ -0,0 +1,239 @@ +""" +Test cases for MessageBuilder and related utilities. +""" + +import unittest +from amazon_lex_helper import ( + MessageBuilder, SSMLBuilder, CardBuilder, RuntimeHintsBuilder, + quick_text_message, quick_ssml_message, quick_card_message +) + + +class TestMessageBuilder(unittest.TestCase): + + def test_message_builder_plain_text(self): + """Test MessageBuilder with plain text.""" + messages = MessageBuilder().add_plain_text("Hello World").build() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'PlainText') + self.assertEqual(messages[0]['content'], 'Hello World') + + def test_message_builder_ssml(self): + """Test MessageBuilder with SSML.""" + ssml_content = "Hello World" + messages = MessageBuilder().add_ssml(ssml_content).build() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'SSML') + self.assertEqual(messages[0]['content'], ssml_content) + + def test_message_builder_custom_payload(self): + """Test MessageBuilder with custom payload.""" + payload = {"platform": "test", "data": "value"} + messages = MessageBuilder().add_custom_payload(payload).build() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'CustomPayload') + self.assertEqual(messages[0]['content'], payload) + + def test_message_builder_image_response_card(self): + """Test MessageBuilder with image response card.""" + messages = MessageBuilder().add_image_response_card( + title="Test Card", + subtitle="Test Subtitle", + image_url="https://example.com/image.jpg", + buttons=[{"text": "Button 1", "value": "value1"}] + ).build() + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'ImageResponseCard') + + card = messages[0]['imageResponseCard'] + self.assertEqual(card['title'], 'Test Card') + self.assertEqual(card['subtitle'], 'Test Subtitle') + self.assertEqual(card['imageUrl'], 'https://example.com/image.jpg') + self.assertEqual(len(card['buttons']), 1) + self.assertEqual(card['buttons'][0]['text'], 'Button 1') + + def test_message_builder_chaining(self): + """Test MessageBuilder method chaining.""" + messages = MessageBuilder() \ + .add_plain_text("Hello") \ + .add_ssml("World") \ + .add_image_response_card("Card Title") \ + .build() + + self.assertEqual(len(messages), 3) + self.assertEqual(messages[0]['contentType'], 'PlainText') + self.assertEqual(messages[1]['contentType'], 'SSML') + self.assertEqual(messages[2]['contentType'], 'ImageResponseCard') + + def test_message_builder_clear(self): + """Test MessageBuilder clear functionality.""" + builder = MessageBuilder().add_plain_text("Hello") + messages1 = builder.build() + self.assertEqual(len(messages1), 1) + + builder.clear().add_plain_text("World") + messages2 = builder.build() + self.assertEqual(len(messages2), 1) + self.assertEqual(messages2[0]['content'], 'World') + + +class TestSSMLBuilder(unittest.TestCase): + + def test_ssml_builder_basic(self): + """Test basic SSML building.""" + ssml = SSMLBuilder().speak("Hello World").build() + self.assertEqual(ssml, "Hello World") + + def test_ssml_builder_pause(self): + """Test SSML pause.""" + ssml = SSMLBuilder().speak("Hello").pause("1s").speak("World").build() + self.assertEqual(ssml, 'HelloWorld') + + def test_ssml_builder_emphasis(self): + """Test SSML emphasis.""" + ssml = SSMLBuilder().emphasis("important", level="strong").build() + self.assertEqual(ssml, 'important') + + def test_ssml_builder_prosody(self): + """Test SSML prosody.""" + ssml = SSMLBuilder().prosody("slow speech", rate="slow", volume="soft").build() + expected = 'slow speech' + self.assertEqual(ssml, expected) + + def test_ssml_builder_say_as(self): + """Test SSML say-as.""" + ssml = SSMLBuilder().say_as("12345", "digits").build() + self.assertEqual(ssml, '12345') + + def test_ssml_builder_complex(self): + """Test complex SSML building.""" + ssml = SSMLBuilder() \ + .speak("Welcome") \ + .pause("500ms") \ + .emphasis("valued customer", level="moderate") \ + .speak("to our service") \ + .build() + + expected = 'Welcomevalued customerto our service' + self.assertEqual(ssml, expected) + + +class TestCardBuilder(unittest.TestCase): + + def test_card_builder_basic(self): + """Test basic card building.""" + card = CardBuilder("Test Title").build() + + self.assertEqual(card['contentType'], 'ImageResponseCard') + self.assertEqual(card['imageResponseCard']['title'], 'Test Title') + self.assertNotIn('buttons', card['imageResponseCard']) + + def test_card_builder_with_subtitle(self): + """Test card with subtitle.""" + card = CardBuilder("Title").subtitle("Subtitle").build() + + self.assertEqual(card['imageResponseCard']['subtitle'], 'Subtitle') + + def test_card_builder_with_image(self): + """Test card with image.""" + card = CardBuilder("Title").image_url("https://example.com/image.jpg").build() + + self.assertEqual(card['imageResponseCard']['imageUrl'], 'https://example.com/image.jpg') + + def test_card_builder_with_buttons(self): + """Test card with buttons.""" + card = CardBuilder("Title") \ + .add_button("Button 1", "value1") \ + .add_button("Button 2", "value2") \ + .build() + + buttons = card['imageResponseCard']['buttons'] + self.assertEqual(len(buttons), 2) + self.assertEqual(buttons[0]['text'], 'Button 1') + self.assertEqual(buttons[0]['value'], 'value1') + self.assertEqual(buttons[1]['text'], 'Button 2') + self.assertEqual(buttons[1]['value'], 'value2') + + +class TestRuntimeHintsBuilder(unittest.TestCase): + + def test_runtime_hints_slot_hint(self): + """Test runtime hints with slot hints.""" + hints = RuntimeHintsBuilder() \ + .add_slot_hint("City", ["London", "Paris", "New York"]) \ + .build() + + self.assertIn('slotHints', hints) + self.assertIn('City', hints['slotHints']) + + city_hint = hints['slotHints']['City'] + self.assertIn('runtimeHintValues', city_hint) + self.assertEqual(len(city_hint['runtimeHintValues']), 3) + self.assertEqual(city_hint['runtimeHintValues'][0]['phrase'], 'London') + + def test_runtime_hints_phrase_hints(self): + """Test runtime hints with phrase hints.""" + hints = RuntimeHintsBuilder() \ + .add_phrase_hints(["book a flight", "cancel reservation"]) \ + .build() + + self.assertIn('phraseHints', hints) + self.assertEqual(len(hints['phraseHints']), 2) + self.assertEqual(hints['phraseHints'][0]['value'], 'book a flight') + self.assertEqual(hints['phraseHints'][1]['value'], 'cancel reservation') + + def test_runtime_hints_combined(self): + """Test runtime hints with both slot and phrase hints.""" + hints = RuntimeHintsBuilder() \ + .add_slot_hint("Airport", ["JFK", "LAX"]) \ + .add_phrase_hints(["airport code"]) \ + .build() + + self.assertIn('slotHints', hints) + self.assertIn('phraseHints', hints) + self.assertEqual(len(hints['slotHints']['Airport']['runtimeHintValues']), 2) + self.assertEqual(len(hints['phraseHints']), 1) + + +class TestQuickFunctions(unittest.TestCase): + + def test_quick_text_message(self): + """Test quick text message function.""" + messages = quick_text_message("Hello World") + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'PlainText') + self.assertEqual(messages[0]['content'], 'Hello World') + + def test_quick_ssml_message(self): + """Test quick SSML message function.""" + ssml_content = "Hello World" + messages = quick_ssml_message(ssml_content) + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'SSML') + self.assertEqual(messages[0]['content'], ssml_content) + + def test_quick_card_message(self): + """Test quick card message function.""" + messages = quick_card_message( + "Card Title", + subtitle="Card Subtitle", + buttons=[{"text": "OK", "value": "ok"}] + ) + + self.assertEqual(len(messages), 1) + self.assertEqual(messages[0]['contentType'], 'ImageResponseCard') + + card = messages[0]['imageResponseCard'] + self.assertEqual(card['title'], 'Card Title') + self.assertEqual(card['subtitle'], 'Card Subtitle') + self.assertEqual(len(card['buttons']), 1) + + +if __name__ == '__main__': + unittest.main()