From e5459175217b081f3ebcc779f8f5f5787f36a31c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 24 Nov 2025 08:20:01 -0500 Subject: [PATCH 1/7] Video Indexer, Multi-Modal Enhancements, Scope Bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR Summary: Video Indexer Multi-Modal Enhancements ### Overview This PR introduces significant enhancements to video processing and image analysis capabilities, focusing on multi-modal AI features and improved metadata handling. **Version updated from 0.233.167 to 0.233.172**. ### 🎯 Key Features #### 1. **Multi-Modal Vision Analysis for Images** - Added AI-powered vision analysis for uploaded images using GPT-4 Vision or similar models - Extracts comprehensive image insights including: - AI-generated descriptions - Object detection - Text extraction from images (OCR) - Detailed visual analysis - New admin setting: `enable_multimodal_vision` to control feature availability - Vision analysis results stored in document metadata and included in AI Search indexing - Connection testing endpoint added for vision model validation #### 2. **Enhanced Document Metadata Citations** - Implemented metadata-based citations that surface document keywords, abstracts, and vision analysis - New citation types displayed with distinct visual indicators: - **Keywords**: Tagged with `bi-tags` icon, labeled as "Metadata" - **Abstract**: Document summaries included as contextual citations - **Vision Analysis**: AI-generated image insights labeled as "AI Vision" - Metadata content passed to AI models as additional context for more informed responses - Special modal view for metadata citations (separate from standard document citations) #### 3. **Image Message UI Improvements** - Enhanced display for user-uploaded images vs AI-generated images - Added "View Text" button for uploaded images with extracted content or vision analysis - Collapsible info sections showing: - Extracted OCR text from Document Intelligence - AI Vision Analysis results - Proper avatar distinction between uploaded and generated images - Improved metadata tracking with `is_user_upload` flag #### 4. **Video Indexer Configuration Updates** - **BREAKING CHANGE**: Removed API key authentication support - Now exclusively uses **Managed Identity authentication** for Video Indexer - Updated admin UI documentation to guide managed identity setup: - Enable system-assigned managed identity on App Service - Assign "Video Indexer Restricted Viewer" role - Configure required ARM settings (subscription ID, resource group, account name) - Improved validation for required Video Indexer settings - Enhanced error messaging for missing configuration #### 5. **Search Scope Improvements** - Fixed search behavior when `document_scope='all'` to properly include group documents - Added `active_group_id` to search context when document scope is 'all' and groups are enabled - Conditional group index searching - only queries group index when `active_group_id` is present - Prevents unnecessary searches and potential errors when groups aren't in use #### 6. **Image Context in Conversation History** - Enhanced conversation history to include rich image context for AI models - Extracts and includes: - OCR text from Document Intelligence (up to max content length) - AI Vision analysis (description, objects, text) - Structured prompt formatting for multimodal understanding - **Important**: Base64 image data excluded from conversation history to prevent token overflow - Only metadata and extracted insights passed to models for efficient token usage ### 🔧 Technical Improvements #### Backend Changes - **route_backend_chats.py**: - Added metadata citation extraction logic (~150 lines) - Enhanced conversation history building for image uploads - Improved search argument handling for group contexts - **functions_documents.py**: - New `analyze_image_with_vision_model()` function for AI vision analysis - Enhanced `get_document_metadata_for_citations()` integration - Vision analysis now runs BEFORE chunk saving to include insights in AI Search indexing - Removed redundant blob storage for vision JSON (stored in document metadata) - **route_backend_settings.py**: - New `_test_multimodal_vision_connection()` endpoint for testing vision models - Supports both APIM and direct Azure OpenAI endpoints - Test uses 1x1 pixel sample image for validation - **functions_search.py**: - Added conditional logic for group search execution - Prevents empty `active_group_id` from causing search errors #### Frontend Changes - **chat-messages.js** (~275 lines changed): - Enhanced `appendMessage()` to handle uploaded image metadata - New `toggleImageInfo()` functionality for expandable image details - Improved citation rendering with metadata type indicators - Debug logging for image message processing - **chat-citations.js** (~70 lines added): - New `showMetadataModal()` function for displaying keywords/abstracts/vision analysis - Enhanced citation click handling to detect metadata citations - Separate modal styling and behavior for metadata vs document citations - **admin_settings.html**: - Complete redesign of Video Indexer configuration section - Removed all API key references - Added managed identity setup instructions with step-by-step guidance - Updated configuration display to show resource group and subscription ID - **_video_indexer_info.html**: - Updated modal content to clarify managed identity requirement - Added warning banner about authentication type - Enhanced configuration display with ARM resource details ### 📊 Files Changed - **16 files** modified - **+1,063 insertions**, **-412 deletions** - Net change: **+651 lines** ### 🧪 Testing Considerations - Test multi-modal vision analysis with various image types - Validate metadata citations appear correctly in chat responses - Verify Video Indexer works with managed identity authentication - Test search scope behavior with and without groups enabled - Validate image upload UI shows extracted text and vision analysis - Confirm conversation history properly handles image context without token overflow ### 🔐 Security & Performance - Managed identity authentication improves security posture (no stored API keys) - Image base64 data excluded from conversation history prevents token exhaustion - Metadata citations add minimal overhead while providing rich context - Vision analysis runs efficiently during document processing pipeline ### 📝 Configuration Required Admins must configure: 1. Enable `enable_multimodal_vision` in admin settings 2. Select vision-capable model (e.g., `gpt-4o`, `gpt-4-vision-preview`) 3. For Video Indexer: Configure managed identity and ARM resource details 4. Enable `enable_extract_meta_data` to surface metadata citations --- This PR significantly enhances the application's multi-modal capabilities, providing users with richer context from images and documents while maintaining efficient token usage and robust security practices. --- application/single_app/config.py | 2 +- .../single_app/functions_authentication.py | 20 ++ application/single_app/functions_documents.py | 131 +++----- application/single_app/functions_search.py | 56 ++-- application/single_app/functions_settings.py | 8 +- application/single_app/route_backend_chats.py | 215 +++++++++++- .../single_app/route_backend_conversations.py | 13 + .../single_app/route_backend_documents.py | 27 +- .../single_app/route_backend_settings.py | 83 +++++ .../route_frontend_admin_settings.py | 19 +- .../route_frontend_group_workspaces.py | 15 +- .../single_app/route_frontend_workspace.py | 15 +- .../static/js/chat/chat-citations.js | 70 ++++ .../static/js/chat/chat-messages.js | 275 +++++++++++++++- .../templates/_video_indexer_info.html | 220 +++++++------ .../single_app/templates/admin_settings.html | 306 ++++++++---------- 16 files changed, 1063 insertions(+), 412 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 2c5bf57d..93862155 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -88,7 +88,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.233.167" +VERSION = "0.233.172" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_authentication.py b/application/single_app/functions_authentication.py index 825af1e5..a6716029 100644 --- a/application/single_app/functions_authentication.py +++ b/application/single_app/functions_authentication.py @@ -244,6 +244,26 @@ def get_valid_access_token_for_plugins(scopes=None): } def get_video_indexer_account_token(settings, video_id=None): + """ + Get Video Indexer access token using managed identity authentication. + + This function authenticates with Azure Video Indexer using the App Service's + managed identity. The managed identity must have Contributor role on the + Video Indexer resource. + + Authentication flow: + 1. Acquire ARM access token using DefaultAzureCredential (managed identity) + 2. Call ARM generateAccessToken API to get Video Indexer access token + 3. Use Video Indexer access token for all API operations + """ + from functions_debug import debug_print + + debug_print(f"[VIDEO INDEXER AUTH] Starting token acquisition using managed identity for video_id: {video_id}") + debug_print(f"[VIDEO INDEXER AUTH] Azure environment: {AZURE_ENVIRONMENT}") + + return get_video_indexer_managed_identity_token(settings, video_id) + +def get_video_indexer_managed_identity_token(settings, video_id=None): """ For ARM-based VideoIndexer accounts: 1) Acquire an ARM token with DefaultAzureCredential diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index a64802db..b6432f4e 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -375,33 +375,21 @@ def to_seconds(ts: str) -> float: debug_print(f"[VIDEO INDEXER] Configuration - Endpoint: {vi_ep}, Location: {vi_loc}, Account ID: {vi_acc}") - # Validate required settings based on authentication type - auth_type = settings.get("video_indexer_authentication_type", "managed_identity") - debug_print(f"[VIDEO INDEXER] Using authentication type: {auth_type}") - - # Common required settings for both authentication types + # Validate required settings for managed identity authentication required_settings = { "video_indexer_endpoint": vi_ep, "video_indexer_location": vi_loc, - "video_indexer_account_id": vi_acc + "video_indexer_account_id": vi_acc, + "video_indexer_resource_group": settings.get("video_indexer_resource_group"), + "video_indexer_subscription_id": settings.get("video_indexer_subscription_id"), + "video_indexer_account_name": settings.get("video_indexer_account_name") } - if auth_type == "key": - # For API key authentication, only need API key in addition to common settings - required_settings["video_indexer_api_key"] = settings.get("video_indexer_api_key") - debug_print(f"[VIDEO INDEXER] API key authentication requires: endpoint, location, account_id, api_key") - else: - # For managed identity authentication, need ARM-related settings - required_settings.update({ - "video_indexer_resource_group": settings.get("video_indexer_resource_group"), - "video_indexer_subscription_id": settings.get("video_indexer_subscription_id"), - "video_indexer_account_name": settings.get("video_indexer_account_name") - }) - debug_print(f"[VIDEO INDEXER] Managed identity authentication requires: endpoint, location, account_id, resource_group, subscription_id, account_name") + debug_print(f"[VIDEO INDEXER] Managed identity authentication requires: endpoint, location, account_id, resource_group, subscription_id, account_name") missing_settings = [key for key, value in required_settings.items() if not value] if missing_settings: - debug_print(f"[VIDEO INDEXER] ERROR: Missing required settings for {auth_type} authentication: {missing_settings}") + debug_print(f"[VIDEO INDEXER] ERROR: Missing required settings: {missing_settings}") update_callback(status=f"VIDEO: missing settings - {', '.join(missing_settings)}") return 0 @@ -4292,6 +4280,45 @@ def process_di_document(document_id, user_id, temp_file_path, original_filename, except Exception as e: raise Exception(f"Error extracting content from {chunk_effective_filename} with Azure DI: {str(e)}") + # --- Multi-Modal Vision Analysis (for images only) - Must happen BEFORE save_chunks --- + if is_image and enable_enhanced_citations and idx == 1: # Only run once for first chunk + enable_multimodal_vision = settings.get('enable_multimodal_vision', False) + if enable_multimodal_vision: + try: + update_callback(status="Performing AI vision analysis...") + + vision_analysis = analyze_image_with_vision_model( + chunk_path, + user_id, + document_id, + settings + ) + + if vision_analysis: + print(f"Vision analysis completed for image: {chunk_effective_filename}") + + # Update document with vision analysis results BEFORE saving chunks + # This allows save_chunks() to append vision data to chunk_text for AI Search + update_fields = { + 'vision_analysis': vision_analysis, + 'vision_description': vision_analysis.get('description', ''), + 'vision_objects': vision_analysis.get('objects', []), + 'vision_extracted_text': vision_analysis.get('text', ''), + 'status': "AI vision analysis completed" + } + update_callback(**update_fields) + print(f"Vision analysis saved to document metadata and will be appended to chunk_text for AI Search indexing") + else: + print(f"Vision analysis returned no results for: {chunk_effective_filename}") + update_callback(status="Vision analysis completed (no results)") + + except Exception as e: + print(f"Warning: Error in vision analysis for {document_id}: {str(e)}") + import traceback + traceback.print_exc() + # Don't fail the whole process, just update status + update_callback(status=f"Processing continues (vision analysis warning)") + # Content Chunking Strategy (Word needs specific handling) final_chunks_to_save = [] if is_word: @@ -4403,70 +4430,8 @@ def process_di_document(document_id, user_id, temp_file_path, original_filename, # Don't fail the whole process, just update status update_callback(status=f"Processing complete (metadata extraction warning)") - # --- Multi-Modal Vision Analysis (for images only) --- - if is_image and enable_enhanced_citations: - enable_multimodal_vision = settings.get('enable_multimodal_vision', False) - if enable_multimodal_vision: - try: - update_callback(status="Performing AI vision analysis...") - - vision_analysis = analyze_image_with_vision_model( - temp_file_path, - user_id, - document_id, - settings - ) - - if vision_analysis: - print(f"Vision analysis completed for image: {original_filename}") - - # Update document with vision analysis results - update_fields = { - 'vision_analysis': vision_analysis, - 'vision_description': vision_analysis.get('description', ''), - 'vision_objects': vision_analysis.get('objects', []), - 'vision_extracted_text': vision_analysis.get('text', ''), - 'status': "AI vision analysis completed" - } - update_callback(**update_fields) - - # Save vision analysis as separate blob for citations - vision_json_path = temp_file_path + '_vision.json' - try: - with open(vision_json_path, 'w', encoding='utf-8') as f: - json.dump(vision_analysis, f, indent=2) - - vision_blob_filename = f"{os.path.splitext(original_filename)[0]}_vision_analysis.json" - - upload_blob_args = { - "temp_file_path": vision_json_path, - "user_id": user_id, - "document_id": document_id, - "blob_filename": vision_blob_filename, - "update_callback": update_callback - } - - if is_public_workspace: - upload_blob_args["public_workspace_id"] = public_workspace_id - elif is_group: - upload_blob_args["group_id"] = group_id - - upload_to_blob(**upload_blob_args) - print(f"Vision analysis saved to blob storage: {vision_blob_filename}") - - finally: - if os.path.exists(vision_json_path): - os.remove(vision_json_path) - else: - print(f"Vision analysis returned no results for: {original_filename}") - update_callback(status="Vision analysis completed (no results)") - - except Exception as e: - print(f"Warning: Error in vision analysis for {document_id}: {str(e)}") - import traceback - traceback.print_exc() - # Don't fail the whole process, just update status - update_callback(status=f"Processing complete (vision analysis warning)") + # Note: Vision analysis now happens BEFORE save_chunks (moved earlier in the flow) + # This ensures vision_analysis is available in metadata when chunks are being saved return total_final_chunks_processed diff --git a/application/single_app/functions_search.py b/application/single_app/functions_search.py index 53ba1825..7261de0b 100644 --- a/application/single_app/functions_search.py +++ b/application/single_app/functions_search.py @@ -160,18 +160,22 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) - group_results = search_client_group.search( - search_text=query, - vector_queries=[vector_query], - filter=( - f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved')) and document_id eq '{document_id}'" - ), - query_type="semantic", - semantic_configuration_name="nexus-group-index-semantic-configuration", - query_caption="extractive", - query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] - ) + # Only search group index if active_group_id is provided + if active_group_id: + group_results = search_client_group.search( + search_text=query, + vector_queries=[vector_query], + filter=( + f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved')) and document_id eq '{document_id}'" + ), + query_type="semantic", + semantic_configuration_name="nexus-group-index-semantic-configuration", + query_caption="extractive", + query_answer="extractive", + select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + ) + else: + group_results = [] # Get visible public workspace IDs from user settings visible_public_workspace_ids = get_user_visible_public_workspace_ids_from_settings(user_id) @@ -211,18 +215,22 @@ def hybrid_search(query, user_id, document_id=None, top_n=12, doc_scope="all", a select=["id", "chunk_text", "chunk_id", "file_name", "user_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] ) - group_results = search_client_group.search( - search_text=query, - vector_queries=[vector_query], - filter=( - f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved'))" - ), - query_type="semantic", - semantic_configuration_name="nexus-group-index-semantic-configuration", - query_caption="extractive", - query_answer="extractive", - select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] - ) + # Only search group index if active_group_id is provided + if active_group_id: + group_results = search_client_group.search( + search_text=query, + vector_queries=[vector_query], + filter=( + f"(group_id eq '{active_group_id}' or shared_group_ids/any(g: g eq '{active_group_id},approved'))" + ), + query_type="semantic", + semantic_configuration_name="nexus-group-index-semantic-configuration", + query_caption="extractive", + query_answer="extractive", + select=["id", "chunk_text", "chunk_id", "file_name", "group_id", "version", "chunk_sequence", "upload_date", "document_classification", "page_number", "author", "chunk_keywords", "title", "chunk_summary"] + ) + else: + group_results = [] # Get visible public workspace IDs from user settings visible_public_workspace_ids = get_user_visible_public_workspace_ids_from_settings(user_id) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index a3cfc3ef..9aad820d 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -135,6 +135,11 @@ def get_settings(use_cosmos=False): # Metadata Extraction 'enable_extract_meta_data': False, 'metadata_extraction_model': '', + + # Multimodal Vision + 'enable_multimodal_vision': False, + 'multimodal_vision_model': '', + 'enable_summarize_content_history_for_search': False, 'number_of_historical_messages_to_summarize': 10, 'enable_summarize_content_history_beyond_conversation_history_limit': False, @@ -225,11 +230,10 @@ def get_settings(use_cosmos=False): 'video_indexer_endpoint': video_indexer_endpoint, 'video_indexer_location': '', 'video_indexer_account_id': '', - 'video_indexer_api_key': '', 'video_indexer_resource_group': '', 'video_indexer_subscription_id': '', 'video_indexer_account_name': '', - 'video_indexer_arm_api_version': '2021-11-10-preview', + 'video_indexer_arm_api_version': '2024-01-01', 'video_index_timeout': 600, # Audio file settings with Azure speech service diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index c2d37a4b..b03b27da 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -631,8 +631,10 @@ def chat_api(): "doc_scope": document_scope, } - # Add active_group_id when document scope is 'group' or chat_type is 'group' - if (document_scope == 'group' or chat_type == 'group') and active_group_id: + # Add active_group_id when: + # 1. Document scope is 'group' or chat_type is 'group', OR + # 2. Document scope is 'all' and groups are enabled (so group search can be included) + if active_group_id and (document_scope == 'group' or document_scope == 'all' or chat_type == 'group'): search_args["active_group_id"] = active_group_id @@ -730,6 +732,148 @@ def chat_api(): # Reorder hybrid citations list in descending order based on page_number hybrid_citations_list.sort(key=lambda x: x.get('page_number', 0), reverse=True) + # --- NEW: Extract metadata (keywords/abstract) for additional citations --- + # Only if extract_metadata is enabled + if settings.get('enable_extract_meta_data', False): + from functions_documents import get_document_metadata_for_citations + + # Track which documents we've already processed to avoid duplicates + processed_doc_ids = set() + + for doc in search_results: + # Get document ID (from the chunk's document reference) + # AI Search chunks contain references to their parent document + doc_id = doc.get('id', '').split('_')[0] if doc.get('id') else None + + # Skip if we've already processed this document + if not doc_id or doc_id in processed_doc_ids: + continue + + processed_doc_ids.add(doc_id) + + # Determine workspace type from the search result fields + doc_user_id = doc.get('user_id') + doc_group_id = doc.get('group_id') + doc_public_workspace_id = doc.get('public_workspace_id') + + # Query Cosmos for this document's metadata + metadata = get_document_metadata_for_citations( + document_id=doc_id, + user_id=doc_user_id if doc_user_id else None, + group_id=doc_group_id if doc_group_id else None, + public_workspace_id=doc_public_workspace_id if doc_public_workspace_id else None + ) + + # If we have metadata with content, create additional citations + if metadata: + file_name = metadata.get('file_name', 'Unknown') + keywords = metadata.get('keywords', []) + abstract = metadata.get('abstract', '') + + # Create citation for keywords if they exist + if keywords and len(keywords) > 0: + keywords_text = ', '.join(keywords) if isinstance(keywords, list) else str(keywords) + keywords_citation_id = f"{doc_id}_keywords" + + keywords_citation = { + "file_name": file_name, + "citation_id": keywords_citation_id, + "page_number": "Metadata", # Special page identifier + "chunk_id": keywords_citation_id, + "chunk_sequence": 9999, # High number to sort to end + "score": 0.0, # No relevance score for metadata + "group_id": doc_group_id, + "version": doc.get('version', 'N/A'), + "classification": doc.get('document_classification'), + "metadata_type": "keywords", # Flag this as metadata citation + "metadata_content": keywords_text + } + hybrid_citations_list.append(keywords_citation) + combined_documents.append(keywords_citation) # Add to combined_documents too + + # Add keywords to retrieved content for the model + keywords_context = f"Document Keywords ({file_name}): {keywords_text}" + retrieved_texts.append(keywords_context) + + # Create citation for abstract if it exists + if abstract and len(abstract.strip()) > 0: + abstract_citation_id = f"{doc_id}_abstract" + + abstract_citation = { + "file_name": file_name, + "citation_id": abstract_citation_id, + "page_number": "Metadata", # Special page identifier + "chunk_id": abstract_citation_id, + "chunk_sequence": 9998, # High number to sort to end + "score": 0.0, # No relevance score for metadata + "group_id": doc_group_id, + "version": doc.get('version', 'N/A'), + "classification": doc.get('document_classification'), + "metadata_type": "abstract", # Flag this as metadata citation + "metadata_content": abstract + } + hybrid_citations_list.append(abstract_citation) + combined_documents.append(abstract_citation) # Add to combined_documents too + + # Add abstract to retrieved content for the model + abstract_context = f"Document Abstract ({file_name}): {abstract}" + retrieved_texts.append(abstract_context) + + # Create citation for vision analysis if it exists + vision_analysis = metadata.get('vision_analysis') + if vision_analysis: + vision_citation_id = f"{doc_id}_vision" + + # Format vision analysis for citation display + vision_description = vision_analysis.get('description', '') + vision_objects = vision_analysis.get('objects', []) + vision_text = vision_analysis.get('text', '') + + vision_content = f"AI Vision Analysis:\n" + if vision_description: + vision_content += f"Description: {vision_description}\n" + if vision_objects: + vision_content += f"Objects: {', '.join(vision_objects)}\n" + if vision_text: + vision_content += f"Text in Image: {vision_text}\n" + + vision_citation = { + "file_name": file_name, + "citation_id": vision_citation_id, + "page_number": "AI Vision", # Special page identifier + "chunk_id": vision_citation_id, + "chunk_sequence": 9997, # High number to sort to end (before keywords/abstract) + "score": 0.0, # No relevance score for vision analysis + "group_id": doc_group_id, + "version": doc.get('version', 'N/A'), + "classification": doc.get('document_classification'), + "metadata_type": "vision", # Flag this as vision citation + "metadata_content": vision_content + } + hybrid_citations_list.append(vision_citation) + combined_documents.append(vision_citation) # Add to combined_documents too + + # Add vision analysis to retrieved content for the model + vision_context = f"AI Vision Analysis ({file_name}): {vision_content}" + retrieved_texts.append(vision_context) + + # Update the system prompt with the enhanced content including metadata + if retrieved_texts: + retrieved_content = "\n\n".join(retrieved_texts) + system_prompt_search = f"""You are an AI assistant. Use the following retrieved document excerpts to answer the user's question. Cite sources using the format (Source: filename, Page: page number). + Retrieved Excerpts: + {retrieved_content} + Based *only* on the information provided above, answer the user's query. If the answer isn't in the excerpts, say so. + Example + User: What is the policy on double dipping? + Assistant: The policy prohibits entities from using federal funds received through one program to apply for additional funds through another program, commonly known as 'double dipping' (Source: PolicyDocument.pdf, Page: 12) + """ + # Update the system message with enhanced content and updated documents array + if system_messages_for_augmentation: + system_messages_for_augmentation[-1]['content'] = system_prompt_search + system_messages_for_augmentation[-1]['documents'] = combined_documents + # --- END NEW METADATA CITATIONS --- + # Update conversation classifications if new ones were found if list(classifications_found) != conversation_item.get('classification', []): conversation_item['classification'] = list(classifications_found) @@ -1164,13 +1308,66 @@ def chat_api(): 'role': 'system', # Represent file as system info 'content': f"[User uploaded a file named '{filename}'. Content preview:\n{display_content}]\nUse this file context if relevant." }) - # elif role == 'image': # If you want to represent image generation prompts/results - # prompt = message.get('prompt', 'User generated an image.') - # img_url = message.get('content', '') # URL is in content - # conversation_history_for_api.append({ - # 'role': 'system', - # 'content': f"[Assistant generated an image based on the prompt: '{prompt}'. Image URL: {img_url}]" - # }) + elif role == 'image': # Handle image uploads with extracted text and vision analysis + filename = message.get('filename', 'uploaded_image') + is_user_upload = message.get('metadata', {}).get('is_user_upload', False) + + if is_user_upload: + # This is a user-uploaded image with extracted text and vision analysis + # IMPORTANT: Do NOT include message.get('content') as it contains base64 image data + # which would consume excessive tokens. Only use extracted_text and vision_analysis. + extracted_text = message.get('extracted_text', '') + vision_analysis = message.get('vision_analysis', {}) + + # Build comprehensive context from OCR and vision analysis (NO BASE64!) + image_context_parts = [f"[User uploaded an image named '{filename}'.]"] + + if extracted_text: + # Include OCR text from Document Intelligence + extracted_preview = extracted_text[:max_file_content_length_in_history] + if len(extracted_text) > max_file_content_length_in_history: + extracted_preview += "..." + image_context_parts.append(f"\n\nExtracted Text (OCR):\n{extracted_preview}") + + if vision_analysis: + # Include AI vision analysis + image_context_parts.append("\n\nAI Vision Analysis:") + + if vision_analysis.get('description'): + image_context_parts.append(f"\nDescription: {vision_analysis['description']}") + + if vision_analysis.get('objects'): + objects_str = ', '.join(vision_analysis['objects']) + image_context_parts.append(f"\nObjects detected: {objects_str}") + + if vision_analysis.get('text'): + image_context_parts.append(f"\nText visible in image: {vision_analysis['text']}") + + if vision_analysis.get('contextual_analysis'): + image_context_parts.append(f"\nContextual analysis: {vision_analysis['contextual_analysis']}") + + image_context_content = ''.join(image_context_parts) + "\n\nUse this image information to answer questions about the uploaded image." + + # Verify we're not accidentally including base64 data + if 'data:image/' in image_context_content or ';base64,' in image_context_content: + print(f"WARNING: Base64 image data detected in chat history for {filename}! Removing to save tokens.") + # This should never happen, but safety check just in case + image_context_content = f"[User uploaded an image named '{filename}' - image data excluded from chat history to conserve tokens]" + + debug_print(f"[IMAGE_CONTEXT] Adding user-uploaded image to history: {filename}, context length: {len(image_context_content)} chars") + conversation_history_for_api.append({ + 'role': 'system', + 'content': image_context_content + }) + else: + # This is a system-generated image (DALL-E, etc.) + # Don't include the image data URL in history either + prompt = message.get('prompt', 'User requested image generation.') + debug_print(f"[IMAGE_CONTEXT] Adding system-generated image to history: {prompt[:100]}...") + conversation_history_for_api.append({ + 'role': 'system', + 'content': f"[Assistant generated an image based on the prompt: '{prompt}']" + }) # Ignored roles: 'safety', 'blocked', 'system' (if they are only for augmentation/summary) diff --git a/application/single_app/route_backend_conversations.py b/application/single_app/route_backend_conversations.py index 3d06fd0c..a17b6eef 100644 --- a/application/single_app/route_backend_conversations.py +++ b/application/single_app/route_backend_conversations.py @@ -73,6 +73,12 @@ def api_get_messages(): debug_print(f"Reassembling chunked image {image_id} with {total_chunks} chunks") debug_print(f"Available chunks in chunked_images: {list(chunked_images.get(image_id, {}).keys())}") + # Preserve extracted_text and vision_analysis from main message + extracted_text = message.get('extracted_text') + vision_analysis = message.get('vision_analysis') + + debug_print(f"Image has extracted_text: {bool(extracted_text)}, vision_analysis: {bool(vision_analysis)}") + # Start with the content from the main message (chunk 0) complete_content = message.get('content', '') debug_print(f"Main message content length: {len(complete_content)} bytes") @@ -105,6 +111,13 @@ def api_get_messages(): else: # Small enough to embed directly message['content'] = complete_content + + # IMPORTANT: Preserve extracted_text and vision_analysis in the final message + # These fields are needed by the frontend to display the info drawer + if extracted_text: + message['extracted_text'] = extracted_text + if vision_analysis: + message['vision_analysis'] = vision_analysis return jsonify({'messages': messages}) except CosmosResourceNotFoundError: diff --git a/application/single_app/route_backend_documents.py b/application/single_app/route_backend_documents.py index dd7d572b..0bb51718 100644 --- a/application/single_app/route_backend_documents.py +++ b/application/single_app/route_backend_documents.py @@ -23,11 +23,14 @@ def get_file_content(): user_id = get_current_user_id() conversation_id = data.get('conversation_id') file_id = data.get('file_id') + debug_print(f"[GET_FILE_CONTENT] Starting - user_id={user_id}, conversation_id={conversation_id}, file_id={file_id}") if not user_id: + debug_print(f"[GET_FILE_CONTENT] ERROR: User not authenticated") return jsonify({'error': 'User not authenticated'}), 401 if not conversation_id or not file_id: + debug_print(f"[GET_FILE_CONTENT] ERROR: Missing conversation_id or file_id") return jsonify({'error': 'Missing conversation_id or id'}), 400 try: @@ -60,36 +63,52 @@ def get_file_content(): add_file_task_to_file_processing_log(document_id=file_id, user_id=user_id, content="File not found in conversation") return jsonify({'error': 'File not found in conversation'}), 404 + debug_print(f"[GET_FILE_CONTENT] Found {len(items)} items for file_id={file_id}") + debug_print(f"[GET_FILE_CONTENT] First item structure: {json.dumps(items[0], default=str, indent=2)}") add_file_task_to_file_processing_log(document_id=file_id, user_id=user_id, content="File found, processing content: " + str(items)) items_sorted = sorted(items, key=lambda x: x.get('chunk_index', 0)) filename = items_sorted[0].get('filename', 'Untitled') is_table = items_sorted[0].get('is_table', False) + debug_print(f"[GET_FILE_CONTENT] Filename: {filename}, is_table: {is_table}") add_file_task_to_file_processing_log(document_id=file_id, user_id=user_id, content="Combining file content from chunks, filename: " + filename + ", is_table: " + str(is_table)) combined_parts = [] - for it in items_sorted: + for idx, it in enumerate(items_sorted): fc = it.get('file_content', '') + debug_print(f"[GET_FILE_CONTENT] Chunk {idx}: file_content type={type(fc).__name__}, len={len(fc) if hasattr(fc, '__len__') else 'N/A'}") if isinstance(fc, list): + debug_print(f"[GET_FILE_CONTENT] Processing list of {len(fc)} items") # If file_content is a list of dicts, join their 'content' fields text_chunks = [] - for chunk in fc: - text_chunks.append(chunk.get('content', '')) + for chunk_idx, chunk in enumerate(fc): + debug_print(f"[GET_FILE_CONTENT] List item {chunk_idx} type: {type(chunk).__name__}") + if isinstance(chunk, dict): + text_chunks.append(chunk.get('content', '')) + elif isinstance(chunk, str): + text_chunks.append(chunk) + else: + debug_print(f"[GET_FILE_CONTENT] Unexpected chunk type in list: {type(chunk).__name__}") combined_parts.append("\n".join(text_chunks)) elif isinstance(fc, str): + debug_print(f"[GET_FILE_CONTENT] Processing string content") # If it's already a string, just append combined_parts.append(fc) else: # If it's neither a list nor a string, handle as needed (e.g., skip or log) + debug_print(f"[GET_FILE_CONTENT] WARNING: Unexpected file_content type: {type(fc).__name__}, value: {fc}") pass combined_content = "\n".join(combined_parts) + debug_print(f"[GET_FILE_CONTENT] Combined content length: {len(combined_content)}") if not combined_content: add_file_task_to_file_processing_log(document_id=file_id, user_id=user_id, content="Combined file content is empty") + debug_print(f"[GET_FILE_CONTENT] ERROR: Combined content is empty") return jsonify({'error': 'File content not found'}), 404 + debug_print(f"[GET_FILE_CONTENT] Successfully returning file content") return jsonify({ 'file_content': combined_content, 'filename': filename, @@ -97,6 +116,8 @@ def get_file_content(): }), 200 except Exception as e: + debug_print(f"[GET_FILE_CONTENT] EXCEPTION: {str(e)}") + debug_print(f"[GET_FILE_CONTENT] Traceback: {traceback.format_exc()}") add_file_task_to_file_processing_log(document_id=file_id, user_id=user_id, content="Error retrieving file content: " + str(e)) return jsonify({'error': f'Error retrieving file content: {str(e)}'}), 500 diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index a31d1da8..54914e9e 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -285,6 +285,9 @@ def test_connection(): elif test_type == 'key_vault': return _test_key_vault_connection(data) + + elif test_type == 'multimodal_vision': + return _test_multimodal_vision_connection(data) else: return jsonify({'error': f'Unknown test_type: {test_type}'}), 400 @@ -292,6 +295,86 @@ def test_connection(): except Exception as e: return jsonify({'error': str(e)}), 500 +def _test_multimodal_vision_connection(payload): + """Test multi-modal vision analysis with a sample image.""" + enable_apim = payload.get('enable_apim', False) + vision_model = payload.get('vision_model') + + if not vision_model: + return jsonify({'error': 'No vision model specified'}), 400 + + # Create a simple test image (1x1 red pixel PNG) + test_image_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + + try: + if enable_apim: + apim_data = payload.get('apim', {}) + endpoint = apim_data.get('endpoint') + api_version = apim_data.get('api_version') + subscription_key = apim_data.get('subscription_key') + + gpt_client = AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + api_key=subscription_key + ) + else: + direct_data = payload.get('direct', {}) + endpoint = direct_data.get('endpoint') + api_version = direct_data.get('api_version') + auth_type = direct_data.get('auth_type', 'key') + + if auth_type == 'managed_identity': + token_provider = get_bearer_token_provider( + DefaultAzureCredential(), + cognitive_services_scope + ) + gpt_client = AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + azure_ad_token_provider=token_provider + ) + else: + api_key = direct_data.get('key') + gpt_client = AzureOpenAI( + api_version=api_version, + azure_endpoint=endpoint, + api_key=api_key + ) + + # Test vision analysis with simple prompt + response = gpt_client.chat.completions.create( + model=vision_model, + messages=[ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "What color is this image? Just say the color." + }, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{test_image_base64}" + } + } + ] + } + ], + max_tokens=50 + ) + + result = response.choices[0].message.content + + return jsonify({ + 'message': 'Multi-modal vision connection successful', + 'details': f'Model responded: {result}' + }), 200 + + except Exception as e: + return jsonify({'error': f'Vision test failed: {str(e)}'}), 500 + def get_index_client() -> SearchIndexClient: """ Returns a SearchIndexClient wired up based on: diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 1b4d1af4..5580f7b6 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -164,9 +164,15 @@ def admin_settings(): if 'key_vault_identity' not in settings: settings['key_vault_identity'] = '' - # --- Add defaults for left nav --- + # --- Add defaults for left nav --- if 'enable_left_nav_default' not in settings: settings['enable_left_nav_default'] = True + + # --- Add defaults for multimodal vision --- + if 'enable_multimodal_vision' not in settings: + settings['enable_multimodal_vision'] = False + if 'multimodal_vision_model' not in settings: + settings['multimodal_vision_model'] = '' if request.method == 'GET': # --- Model fetching logic remains the same --- @@ -253,6 +259,10 @@ def admin_settings(): enable_video_file_support = form_data.get('enable_video_file_support') == 'on' enable_audio_file_support = form_data.get('enable_audio_file_support') == 'on' enable_extract_meta_data = form_data.get('enable_extract_meta_data') == 'on' + + # Vision settings + enable_multimodal_vision = form_data.get('enable_multimodal_vision') == 'on' + multimodal_vision_model = form_data.get('multimodal_vision_model', '') require_member_of_create_group = form_data.get('require_member_of_create_group') == 'on' require_member_of_create_public_workspace = form_data.get('require_member_of_create_public_workspace') == 'on' @@ -648,11 +658,10 @@ def is_valid_url(url): 'video_indexer_endpoint': form_data.get('video_indexer_endpoint', video_indexer_endpoint).strip(), 'video_indexer_location': form_data.get('video_indexer_location', '').strip(), 'video_indexer_account_id': form_data.get('video_indexer_account_id', '').strip(), - 'video_indexer_api_key': form_data.get('video_indexer_api_key', '').strip(), 'video_indexer_resource_group': form_data.get('video_indexer_resource_group', '').strip(), 'video_indexer_subscription_id': form_data.get('video_indexer_subscription_id', '').strip(), 'video_indexer_account_name': form_data.get('video_indexer_account_name', '').strip(), - 'video_indexer_arm_api_version': form_data.get('video_indexer_arm_api_version', '2021-11-10-preview').strip(), + 'video_indexer_arm_api_version': form_data.get('video_indexer_arm_api_version', '2024-01-01').strip(), 'video_index_timeout': int(form_data.get('video_index_timeout', 600)), # Audio file settings with Azure speech service @@ -663,6 +672,10 @@ def is_valid_url(url): 'metadata_extraction_model': form_data.get('metadata_extraction_model', '').strip(), + # Multi-modal vision settings + 'enable_multimodal_vision': form_data.get('enable_multimodal_vision') == 'on', + 'multimodal_vision_model': form_data.get('multimodal_vision_model', '').strip(), + # --- Banner fields --- 'classification_banner_enabled': classification_banner_enabled, 'classification_banner_text': classification_banner_text, diff --git a/application/single_app/route_frontend_group_workspaces.py b/application/single_app/route_frontend_group_workspaces.py index 523799e8..1f95a74e 100644 --- a/application/single_app/route_frontend_group_workspaces.py +++ b/application/single_app/route_frontend_group_workspaces.py @@ -47,6 +47,18 @@ def group_workspaces(): ) legacy_count = legacy_docs_from_cosmos[0] if legacy_docs_from_cosmos else 0 + # Build allowed extensions string + allowed_extensions = [ + "txt", "pdf", "doc", "docm", "docx", "xlsx", "xls", "xlsm","csv", "pptx", "html", + "jpg", "jpeg", "png", "bmp", "tiff", "tif", "heif", "md", "json", + "xml", "yaml", "yml", "log" + ] + if enable_video_file_support in [True, 'True', 'true']: + allowed_extensions += ["mp4", "mov", "avi", "wmv", "mkv", "webm"] + if enable_audio_file_support in [True, 'True', 'true']: + allowed_extensions += ["mp3", "wav", "ogg", "aac", "flac", "m4a"] + allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) + return render_template( 'group_workspaces.html', settings=public_settings, @@ -55,7 +67,8 @@ def group_workspaces(): enable_video_file_support=enable_video_file_support, enable_audio_file_support=enable_audio_file_support, enable_file_sharing=enable_file_sharing, - legacy_docs_count=legacy_count + legacy_docs_count=legacy_count, + allowed_extensions=allowed_extensions_str ) @app.route('/set_active_group', methods=['POST']) diff --git a/application/single_app/route_frontend_workspace.py b/application/single_app/route_frontend_workspace.py index 0bdf289a..dc9bb813 100644 --- a/application/single_app/route_frontend_workspace.py +++ b/application/single_app/route_frontend_workspace.py @@ -44,6 +44,18 @@ def workspace(): ) ) legacy_count = legacy_docs_from_cosmos[0] if legacy_docs_from_cosmos else 0 + + # Build allowed extensions string + allowed_extensions = [ + "txt", "pdf", "doc", "docm", "docx", "xlsx", "xls", "xlsm","csv", "pptx", "html", + "jpg", "jpeg", "png", "bmp", "tiff", "tif", "heif", "md", "json", + "xml", "yaml", "yml", "log" + ] + if enable_video_file_support in [True, 'True', 'true']: + allowed_extensions += ["mp4", "mov", "avi", "wmv", "mkv", "webm"] + if enable_audio_file_support in [True, 'True', 'true']: + allowed_extensions += ["mp3", "wav", "ogg", "aac", "flac", "m4a"] + allowed_extensions_str = "Allowed: " + ", ".join(allowed_extensions) return render_template( 'workspace.html', @@ -53,7 +65,8 @@ def workspace(): enable_video_file_support=enable_video_file_support, enable_audio_file_support=enable_audio_file_support, enable_file_sharing=enable_file_sharing, - legacy_docs_count=legacy_count + legacy_docs_count=legacy_count, + allowed_extensions=allowed_extensions_str ) \ No newline at end of file diff --git a/application/single_app/static/js/chat/chat-citations.js b/application/single_app/static/js/chat/chat-citations.js index 9f24d000..a69619c9 100644 --- a/application/single_app/static/js/chat/chat-citations.js +++ b/application/single_app/static/js/chat/chat-citations.js @@ -226,6 +226,64 @@ export function showImagePopup(imageSrc) { modal.show(); } +export function showMetadataModal(metadataType, metadataContent, fileName) { + // Create or reuse the metadata modal + let modalContainer = document.getElementById("metadata-modal"); + if (!modalContainer) { + modalContainer = document.createElement("div"); + modalContainer.id = "metadata-modal"; + modalContainer.classList.add("modal", "fade"); + modalContainer.tabIndex = -1; + modalContainer.setAttribute("aria-hidden", "true"); + + modalContainer.innerHTML = ` + + `; + document.body.appendChild(modalContainer); + } + + // Update modal content + const modalTitle = modalContainer.querySelector("#metadata-modal-title"); + const fileNameEl = modalContainer.querySelector("#metadata-file-name"); + const metadataTypeEl = modalContainer.querySelector("#metadata-type"); + const metadataContentEl = modalContainer.querySelector("#metadata-content"); + + if (modalTitle) { + modalTitle.textContent = `Document Metadata - ${metadataType.charAt(0).toUpperCase() + metadataType.slice(1)}`; + } + if (fileNameEl) { + fileNameEl.textContent = fileName; + } + if (metadataTypeEl) { + metadataTypeEl.textContent = metadataType.charAt(0).toUpperCase() + metadataType.slice(1); + } + if (metadataContentEl) { + metadataContentEl.textContent = metadataContent; + } + + const modal = new bootstrap.Modal(modalContainer); + modal.show(); +} + export function showAgentCitationModal(toolName, toolArgs, toolResult) { // Create or reuse the agent citation modal let modalContainer = document.getElementById("agent-citation-modal"); @@ -460,6 +518,18 @@ if (chatboxEl) { return; } + // Check if this is a metadata citation + const isMetadata = target.getAttribute("data-is-metadata") === "true"; + if (isMetadata) { + // Show metadata content directly in a modal + const metadataType = target.getAttribute("data-metadata-type"); + const metadataContent = target.getAttribute("data-metadata-content"); + const fileName = citationId.split('_')[0]; // Extract filename from citation ID + + showMetadataModal(metadataType, metadataContent, fileName); + return; + } + const { docId, pageNumber } = parseDocIdAndPage(citationId); // Safety check: Ensure docId and pageNumber were parsed correctly diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 61b52e2d..05c5ce4d 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -359,12 +359,21 @@ function createCitationsHtml( const displayText = `${escapeHtml(cite.file_name)}, Page ${ cite.page_number || "N/A" }`; + + // Check if this is a metadata citation + const isMetadata = cite.metadata_type ? true : false; + const metadataType = cite.metadata_type || ''; + const metadataContent = cite.metadata_content || ''; + citationsHtml += ` - ${displayText} + ${displayText} `; }); } @@ -475,7 +484,15 @@ export function loadMessages(conversationId) { } else if (msg.role === "image") { // Validate image URL before calling appendMessage if (msg.content && msg.content !== 'null' && msg.content.trim() !== '') { - appendMessage("image", msg.content, msg.model_deployment_name, msg.id, false, [], [], [], msg.agent_display_name, msg.agent_name); + // Debug logging for image message metadata + console.log(`[loadMessages] Image message ${msg.id}:`, { + hasExtractedText: !!msg.extracted_text, + hasVisionAnalysis: !!msg.vision_analysis, + isUserUpload: msg.metadata?.is_user_upload, + filename: msg.filename + }); + // Pass the full message object for images that may have metadata (uploaded images) + appendMessage("image", msg.content, msg.model_deployment_name, msg.id, false, [], [], [], msg.agent_display_name, msg.agent_name, msg); } else { console.error(`[loadMessages] Invalid image URL for message ${msg.id}: "${msg.content}"`); // Show error message instead of broken image @@ -502,7 +519,8 @@ export function appendMessage( webCitations = [], agentCitations = [], agentDisplayName = null, - agentName = null + agentName = null, + fullMessageObject = null ) { if (!chatbox || sender === "System") return; @@ -764,15 +782,45 @@ export function appendMessage( } else { senderLabel = "Image"; } - - avatarImg = "/static/images/ai-avatar.png"; // Or a specific image icon - avatarAltText = "Generated Image"; + + // Check if this is a user-uploaded image with metadata + const isUserUpload = fullMessageObject?.metadata?.is_user_upload || false; + const hasExtractedText = fullMessageObject?.extracted_text || false; + const hasVisionAnalysis = fullMessageObject?.vision_analysis || false; + + // Use agent display name if available, otherwise show AI with model + if (isUserUpload) { + senderLabel = "Uploaded Image"; + } else if (agentDisplayName) { + senderLabel = agentDisplayName; + } else if (modelName) { + senderLabel = `AI (${modelName})`; + } else { + senderLabel = "Image"; + } + + avatarImg = isUserUpload ? "/static/images/user-avatar.png" : "/static/images/ai-avatar.png"; + avatarAltText = isUserUpload ? "Uploaded Image" : "Generated Image"; // Validate image URL before creating img tag if (messageContent && messageContent !== 'null' && messageContent.trim() !== '') { - messageContentHtml = `Generated Image`; + messageContentHtml = `${isUserUpload ? 'Uploaded' : 'Generated'} Image`; + + // Add info button for uploaded images with extracted text or vision analysis + if (isUserUpload && (hasExtractedText || hasVisionAnalysis)) { + const infoContainerId = `image-info-${messageId || Date.now()}`; + messageContentHtml += ` +
+ +
+ `; + } } else { - messageContentHtml = `
Failed to generate image - invalid response from image service
`; + messageContentHtml = `
Failed to ${isUserUpload ? 'load' : 'generate'} image - invalid response from image service
`; } } else if (sender === "safety") { messageClass = "safety-message"; @@ -858,6 +906,17 @@ export function appendMessage( if (sender === "You") { attachUserMessageEventListeners(messageDiv, messageId, messageContent); } + + // Add event listener for image info button (uploaded images) + if (sender === "image" && fullMessageObject?.metadata?.is_user_upload) { + const imageInfoBtn = messageDiv.querySelector('.image-info-btn'); + if (imageInfoBtn) { + imageInfoBtn.addEventListener('click', () => { + toggleImageInfo(messageDiv, messageId, fullMessageObject); + }); + } + } + scrollChatToBottom(); } // End of the large 'else' block for non-AI messages } @@ -1551,6 +1610,28 @@ function loadUserMessageMetadata(messageId, container, retryCount = 0) { if (data) { console.log(`✅ Successfully loaded metadata for ${messageId}`); container.innerHTML = formatMetadataForDrawer(data); + + // Attach event listeners to View Text buttons + const viewTextButtons = container.querySelectorAll('.view-text-btn'); + viewTextButtons.forEach(btn => { + btn.addEventListener('click', function() { + const imageId = this.getAttribute('data-image-id'); + const collapseElement = document.getElementById(`${imageId}-info`); + + if (collapseElement) { + const bsCollapse = new bootstrap.Collapse(collapseElement, { + toggle: true + }); + + // Update button text + if (collapseElement.classList.contains('show')) { + this.innerHTML = 'View Text'; + } else { + this.innerHTML = 'Hide Text'; + } + } + }); + }); } }) .catch(error => { @@ -1777,6 +1858,62 @@ function formatMetadataForDrawer(metadata) { content += ''; } + // Uploaded Images Section + if (metadata.uploaded_images && metadata.uploaded_images.length > 0) { + content += '
'; + content += ''; + + metadata.uploaded_images.forEach((image, index) => { + const imageId = `image-${messageId || Date.now()}-${index}`; + content += ``; // End metadata-item + }); + + content += '
'; + } + // Chat Context Section if (metadata.chat_context) { content += '
'; @@ -1846,3 +1983,123 @@ if (modelSelect) { saveUserSetting({ 'preferredModelDeployment': selectedModel }); }); } + +/** + * Toggle the image info drawer for uploaded images + * Shows extracted text (OCR) and vision analysis + */ +function toggleImageInfo(messageDiv, messageId, fullMessageObject) { + const toggleBtn = messageDiv.querySelector('.image-info-btn'); + const targetId = toggleBtn.getAttribute('aria-controls'); + const infoContainer = messageDiv.querySelector(`#${targetId}`); + + if (!infoContainer) { + console.error(`Image info container not found for targetId: ${targetId}`); + return; + } + + const isExpanded = infoContainer.style.display !== "none"; + + // Store current scroll position to maintain user's view + const currentScrollTop = document.getElementById('chat-messages-container')?.scrollTop || window.pageYOffset; + + if (isExpanded) { + // Hide the info + infoContainer.style.display = "none"; + toggleBtn.setAttribute("aria-expanded", false); + toggleBtn.title = "View extracted text & analysis"; + toggleBtn.innerHTML = ' View Text'; + } else { + // Show the info + infoContainer.style.display = "block"; + toggleBtn.setAttribute("aria-expanded", true); + toggleBtn.title = "Hide extracted text & analysis"; + toggleBtn.innerHTML = ' Hide Text'; + + // Load image info if not already loaded + const contentDiv = infoContainer.querySelector('.image-info-content'); + if (contentDiv && (contentDiv.innerHTML.trim() === '' || contentDiv.innerHTML.includes('Loading image information...'))) { + loadImageInfo(fullMessageObject, contentDiv); + } + } + + // Restore scroll position after DOM changes + setTimeout(() => { + if (document.getElementById('chat-messages-container')) { + document.getElementById('chat-messages-container').scrollTop = currentScrollTop; + } else { + window.scrollTo(0, currentScrollTop); + } + }, 10); +} + +/** + * Load image extracted text and vision analysis into the info drawer + */ +function loadImageInfo(fullMessageObject, container) { + const extractedText = fullMessageObject?.extracted_text || ''; + const visionAnalysis = fullMessageObject?.vision_analysis || null; + const filename = fullMessageObject?.filename || 'Image'; + + let content = '
'; + + // Filename + content += `
Filename: ${escapeHtml(filename)}
`; + + // Extracted Text (OCR from Document Intelligence) + if (extractedText && extractedText.trim()) { + content += '
'; + content += 'Extracted Text (OCR):'; + content += '
'; + content += escapeHtml(extractedText); + content += '
'; + } + + // Vision Analysis (AI-generated description, objects, text) + if (visionAnalysis) { + content += '
'; + content += 'AI Vision Analysis:'; + + // Model name can be either 'model' or 'model_name' + const modelName = visionAnalysis.model || visionAnalysis.model_name; + if (modelName) { + content += `
Model: ${escapeHtml(modelName)}
`; + } + + if (visionAnalysis.description) { + content += '
Description:
'; + content += escapeHtml(visionAnalysis.description); + content += '
'; + } + + if (visionAnalysis.objects && Array.isArray(visionAnalysis.objects) && visionAnalysis.objects.length > 0) { + content += '
Objects Detected:
'; + content += visionAnalysis.objects.map(obj => `${escapeHtml(obj)}`).join(''); + content += '
'; + } + + if (visionAnalysis.text && visionAnalysis.text.trim()) { + content += '
Text Visible in Image:
'; + content += escapeHtml(visionAnalysis.text); + content += '
'; + } + + // Contextual analysis can be either 'analysis' or 'contextual_analysis' + const analysis = visionAnalysis.analysis || visionAnalysis.contextual_analysis; + if (analysis && analysis.trim()) { + content += '
Contextual Analysis:
'; + content += escapeHtml(analysis); + content += '
'; + } + + content += '
'; + } + + content += '
'; + + if (!extractedText && !visionAnalysis) { + content = '
No extracted text or analysis available for this image.
'; + } + + container.innerHTML = content; +} diff --git a/application/single_app/templates/_video_indexer_info.html b/application/single_app/templates/_video_indexer_info.html index 6bd5f509..84c53d54 100644 --- a/application/single_app/templates/_video_indexer_info.html +++ b/application/single_app/templates/_video_indexer_info.html @@ -12,7 +12,12 @@
+ +
+
+ Multi-Modal Vision Analysis +
+

+ Enable AI-powered vision analysis for images uploaded to chat or workspace. When enabled alongside Document Intelligence OCR, images will receive both text extraction (OCR) and semantic understanding (vision AI). +

+ +
+ How it works: +
    +
  • Document Intelligence: Extracts text from images (OCR)
  • +
  • Vision Model: Provides semantic analysis, object detection, and contextual understanding
  • +
  • Both analyses are combined and available in citations when Enhanced Citations is enabled
  • +
+
+
+ + + +
+ +
+ + +
Select a GPT model with vision capabilities (e.g., gpt-4o, gpt-4-vision). Only vision-capable models are shown.
+ + +
+
+
@@ -1994,9 +2025,12 @@
Classification Categories
+
+ + @@ -2173,7 +2207,8 @@
Audio Files
- + +

@@ -2473,45 +2508,6 @@

Azure AI Search
- -
-
Search Result Caching
-
- - -
- - Caches search results to improve performance and reduce Azure AI Search costs. - Cache is automatically invalidated when documents are uploaded, deleted, or shared. - -
- -
- - - - Time-to-live for cached search results. Default: 300 seconds (5 minutes). - Lower values = fresher results, higher values = better performance. - -
-
@@ -2712,67 +2708,77 @@
Multimedia Support
-
Video Indexer Settings
-

Configure Azure Video Indexer for transcription & indexing.

+
Azure Video Indexer Settings
+

Configure Azure Video Indexer for transcription & indexing using Managed Identity authentication.

+ +
+ Prerequisites: +
    +
  • App Service must have System-assigned Managed Identity enabled
  • +
  • Managed Identity must have Contributor role on the Video Indexer resource
  • +
  • See Azure Video Indexer documentation for setup details
  • +
+
- + +
Default: https://api.videoindexer.ai
- + + id="video_indexer_resource_group" name="video_indexer_resource_group" + value="{{ settings.video_indexer_resource_group or '' }}" + placeholder="e.g., rg-videoindexer-prod"> +
The Azure resource group containing your Video Indexer account
- + + id="video_indexer_subscription_id" name="video_indexer_subscription_id" + value="{{ settings.video_indexer_subscription_id or '' }}" + placeholder="e.g., 12345678-1234-1234-1234-123456789abc"> +
Your Azure subscription ID
- + -
- -
- -
- - -
+ id="video_indexer_account_name" name="video_indexer_account_name" + value="{{ settings.video_indexer_account_name or '' }}" + placeholder="e.g., my-video-indexer"> +
The name of your Video Indexer account resource
- + + id="video_indexer_location" name="video_indexer_location" + value="{{ settings.video_indexer_location or '' }}" + placeholder="e.g., eastus"> +
Azure region where your Video Indexer account is deployed (e.g., eastus, westus2, northeurope)
- + + id="video_indexer_account_id" name="video_indexer_account_id" + value="{{ settings.video_indexer_account_id or '' }}" + placeholder="e.g., 12345678-abcd-1234-abcd-123456789abc"> +
Found in the Video Indexer account Overview page in Azure Portal
- + + id="video_indexer_arm_api_version" name="video_indexer_arm_api_version" + value="{{ settings.video_indexer_arm_api_version or '2024-01-01' }}"> +
Default: 2024-01-01
@@ -2848,62 +2854,7 @@
Speech Service Settings
-
-

- Configure Security Settings. -

-
-
-
- Key Vault -
- -
- -

- Configure Key Vault settings. -

-
- - - -
-
- ⚠️ Warning: Once you enable Key Vault, you should NOT disable it. Disabling Key Vault after enabling WILL cause loss of access to secrets and break application functionality. -
-
- - - -
-
- - - -
- -
-
-
-
-
+
@@ -2924,9 +2875,6 @@
{% include '_health_check_info.html' %} - - {% include '_key_vault_info.html' %} - diff --git a/application/single_app/static/js/chat/chat-conversations.js b/application/single_app/static/js/chat/chat-conversations.js index e17f95f9..b85675c3 100644 --- a/application/single_app/static/js/chat/chat-conversations.js +++ b/application/single_app/static/js/chat/chat-conversations.js @@ -8,6 +8,8 @@ import { toggleConversationInfoButton } from "./chat-conversation-info-button.js const newConversationBtn = document.getElementById("new-conversation-btn"); const deleteSelectedBtn = document.getElementById("delete-selected-btn"); +const pinSelectedBtn = document.getElementById("pin-selected-btn"); +const hideSelectedBtn = document.getElementById("hide-selected-btn"); const conversationsList = document.getElementById("conversations-list"); const currentConversationTitleEl = document.getElementById("current-conversation-title"); const currentConversationClassificationsEl = document.getElementById("current-conversation-classifications"); @@ -19,6 +21,11 @@ let selectedConversations = new Set(); let currentlyEditingId = null; // Track which item is being edited let selectionModeActive = false; // Track if selection mode is active let selectionModeTimer = null; // Timer for auto-hiding checkboxes +let showHiddenConversations = false; // Track if hidden conversations should be shown +let allConversations = []; // Store all conversations for client-side filtering +let isLoadingConversations = false; // Prevent concurrent loads +let showQuickSearch = false; // Track if quick search input is visible +let quickSearchTerm = ""; // Current search term // Clear selected conversations when loading the page document.addEventListener('DOMContentLoaded', () => { @@ -26,19 +33,74 @@ document.addEventListener('DOMContentLoaded', () => { if (deleteSelectedBtn) { deleteSelectedBtn.style.display = "none"; } + + // Set up quick search event listeners + const searchBtn = document.getElementById('sidebar-search-btn'); + const searchInput = document.getElementById('sidebar-search-input'); + const searchClearBtn = document.getElementById('sidebar-search-clear'); + const searchExpandBtn = document.getElementById('sidebar-search-expand'); + + if (searchBtn) { + searchBtn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleQuickSearch(); + }); + } + + if (searchInput) { + searchInput.addEventListener('keyup', (e) => { + quickSearchTerm = e.target.value; + loadConversations(); + }); + + // Prevent conversation toggle when clicking in input + searchInput.addEventListener('click', (e) => { + e.stopPropagation(); + }); + } + + if (searchClearBtn) { + searchClearBtn.addEventListener('click', (e) => { + e.stopPropagation(); + clearQuickSearch(); + }); + } + + if (searchExpandBtn) { + searchExpandBtn.addEventListener('click', (e) => { + e.stopPropagation(); + // Open advanced search modal (will be implemented in chat-search-modal.js) + if (window.chatSearchModal && window.chatSearchModal.openAdvancedSearchModal) { + window.chatSearchModal.openAdvancedSearchModal(); + } + }); + } }); // Function to enter selection mode function enterSelectionMode() { + const wasInactive = !selectionModeActive; selectionModeActive = true; if (conversationsList) { conversationsList.classList.add('selection-mode'); } - // Show delete button + // Show action buttons if (deleteSelectedBtn) { deleteSelectedBtn.style.display = "block"; } + if (pinSelectedBtn) { + pinSelectedBtn.style.display = "block"; + } + if (hideSelectedBtn) { + hideSelectedBtn.style.display = "block"; + } + + // Only reload conversations if we're transitioning from inactive to active + // This shows hidden conversations in selection mode + if (wasInactive) { + loadConversations(); + } // Update sidebar to show selection mode hints if (window.chatSidebarConversations && window.chatSidebarConversations.setSidebarSelectionMode) { @@ -56,10 +118,16 @@ function exitSelectionMode() { conversationsList.classList.remove('selection-mode'); } - // Hide delete button + // Hide action buttons if (deleteSelectedBtn) { deleteSelectedBtn.style.display = "none"; } + if (pinSelectedBtn) { + pinSelectedBtn.style.display = "none"; + } + if (hideSelectedBtn) { + hideSelectedBtn.style.display = "none"; + } // Clear any selections selectedConversations.clear(); @@ -90,6 +158,9 @@ function exitSelectionMode() { clearTimeout(selectionModeTimer); selectionModeTimer = null; } + + // Reload conversations to hide hidden ones if toggle is off + loadConversations(); } // Function to reset the selection mode timer @@ -107,8 +178,68 @@ function resetSelectionModeTimer() { }, 5000); } +// Quick search functions +function toggleQuickSearch() { + const searchContainer = document.getElementById('sidebar-search-container'); + const searchInput = document.getElementById('sidebar-search-input'); + const conversationsSection = document.getElementById('conversations-section'); + const conversationsCaret = document.getElementById('conversations-caret'); + + if (!searchContainer) return; + + showQuickSearch = !showQuickSearch; + + if (showQuickSearch) { + searchContainer.style.display = 'block'; + // Expand conversations section if collapsed + if (conversationsSection) { + const listContainer = document.getElementById('conversations-list-container'); + if (listContainer && listContainer.style.display === 'none') { + listContainer.style.display = 'block'; + if (conversationsCaret) { + conversationsCaret.classList.add('rotate-180'); + } + } + } + // Focus on search input + setTimeout(() => searchInput && searchInput.focus(), 100); + } else { + searchContainer.style.display = 'none'; + clearQuickSearch(); + } +} + +function applyQuickSearchFilter(conversations) { + if (!quickSearchTerm || quickSearchTerm.trim() === '') { + return conversations; + } + + const searchLower = quickSearchTerm.toLowerCase().trim(); + return conversations.filter(convo => { + const titleLower = (convo.title || '').toLowerCase(); + return titleLower.includes(searchLower); + }); +} + +function clearQuickSearch() { + quickSearchTerm = ''; + const searchInput = document.getElementById('sidebar-search-input'); + if (searchInput) { + searchInput.value = ''; + } + loadConversations(); +} + export function loadConversations() { if (!conversationsList) return; + + // Prevent concurrent loads + if (isLoadingConversations) { + console.log('Load already in progress, skipping...'); + return; + } + + isLoadingConversations = true; conversationsList.innerHTML = '
Loading conversations...
'; // Loading state fetch("/api/get_conversations") @@ -117,22 +248,65 @@ export function loadConversations() { conversationsList.innerHTML = ""; // Clear loading state if (!data.conversations || data.conversations.length === 0) { conversationsList.innerHTML = '
No conversations yet.
'; + allConversations = []; + updateHiddenToggleButton(); return; } - data.conversations.forEach(convo => { - conversationsList.appendChild(createConversationItem(convo)); + + // Store all conversations for client-side operations + allConversations = data.conversations; + + // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) + const sortedConversations = [...allConversations].sort((a, b) => { + const aPinned = a.is_pinned || false; + const bPinned = b.is_pinned || false; + + // If pin status differs, pinned comes first + if (aPinned !== bPinned) { + return bPinned ? 1 : -1; + } + + // If same pin status, sort by last_updated (most recent first) + const aDate = new Date(a.last_updated); + const bDate = new Date(b.last_updated); + return bDate - aDate; + }); + + // Filter conversations based on show/hide mode and selection mode + let filteredConversations = sortedConversations.filter(convo => { + const isHidden = convo.is_hidden || false; + // Show hidden conversations if toggle is on OR if we're in selection mode + return !isHidden || showHiddenConversations || selectionModeActive; }); + // Apply quick search filter + filteredConversations = applyQuickSearchFilter(filteredConversations); + + if (filteredConversations.length === 0) { + conversationsList.innerHTML = '
No visible conversations. Click the eye icon to show hidden conversations.
'; + } else { + filteredConversations.forEach(convo => { + conversationsList.appendChild(createConversationItem(convo)); + }); + } + + // Update the show/hide toggle button + updateHiddenToggleButton(); + // Also load sidebar conversations if the sidebar exists if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { window.chatSidebarConversations.loadSidebarConversations(); } + // Reset loading flag + isLoadingConversations = false; + // Optionally, select the first conversation or highlight the active one if ID is known }) .catch(error => { console.error("Error loading conversations:", error); conversationsList.innerHTML = `
Error loading conversations: ${error.error || 'Unknown error'}
`; + isLoadingConversations = false; // Reset flag on error too }); } @@ -215,7 +389,16 @@ export function createConversationItem(convo) { const titleSpan = document.createElement("span"); titleSpan.classList.add("conversation-title", "text-truncate"); // Bold and truncate - titleSpan.textContent = convo.title; + + // Add pin icon if conversation is pinned + const isPinned = convo.is_pinned || false; + if (isPinned) { + const pinIcon = document.createElement("i"); + pinIcon.classList.add("bi", "bi-pin-angle", "me-1"); + titleSpan.appendChild(pinIcon); + } + + titleSpan.appendChild(document.createTextNode(convo.title)); titleSpan.title = convo.title; // Tooltip for full title const dateSpan = document.createElement("small"); @@ -250,6 +433,26 @@ export function createConversationItem(convo) { detailsA.innerHTML = 'Details'; detailsLi.appendChild(detailsA); + // Add Pin option + const pinLi = document.createElement("li"); + const pinA = document.createElement("a"); + pinA.classList.add("dropdown-item", "pin-btn"); + pinA.href = "#"; + // isPinned already declared above for title icon + pinA.innerHTML = `${isPinned ? 'Unpin' : 'Pin'}`; + pinA.setAttribute("data-is-pinned", isPinned); + pinLi.appendChild(pinA); + + // Add Hide option + const hideLi = document.createElement("li"); + const hideA = document.createElement("a"); + hideA.classList.add("dropdown-item", "hide-btn"); + hideA.href = "#"; + const isHidden = convo.is_hidden || false; + hideA.innerHTML = `${isHidden ? 'Unhide' : 'Hide'}`; + hideA.setAttribute("data-is-hidden", isHidden); + hideLi.appendChild(hideA); + // Add Select option const selectLi = document.createElement("li"); const selectA = document.createElement("a"); @@ -273,6 +476,8 @@ export function createConversationItem(convo) { deleteLi.appendChild(deleteA); dropdownMenu.appendChild(detailsLi); + dropdownMenu.appendChild(pinLi); + dropdownMenu.appendChild(hideLi); dropdownMenu.appendChild(selectLi); dropdownMenu.appendChild(editLi); dropdownMenu.appendChild(deleteLi); @@ -323,6 +528,30 @@ export function createConversationItem(convo) { enterSelectionMode(); }); + // Add event listener for the Pin button + pinA.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + toggleConversationPin(convo.id); + }); + + // Add event listener for the Hide button + hideA.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + toggleConversationHide(convo.id); + }); + + // Add event listener for the Details button + detailsA.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + closeDropdownMenu(dropdownBtn); + showConversationDetails(convo.id); + }); + return convoItem; } @@ -531,17 +760,36 @@ export async function selectConversation(conversationId) { const conversationTitle = convoItem.getAttribute("data-conversation-title") || "Conversation"; // Use stored title - // Update Header Title - if (currentConversationTitleEl) { - currentConversationTitleEl.textContent = conversationTitle; - } - - // Fetch the latest conversation metadata to get accurate chat_type + // Fetch the latest conversation metadata to get accurate chat_type, pin, and hide status try { const response = await fetch(`/api/conversations/${conversationId}/metadata`); if (response.ok) { const metadata = await response.json(); + // Update Header Title with pin icon and hidden status + if (currentConversationTitleEl) { + currentConversationTitleEl.innerHTML = ''; + + // Add pin icon if pinned + if (metadata.is_pinned) { + const pinIcon = document.createElement("i"); + pinIcon.classList.add("bi", "bi-pin-angle", "me-2"); + pinIcon.title = "Pinned"; + currentConversationTitleEl.appendChild(pinIcon); + } + + // Add hidden icon if hidden + if (metadata.is_hidden) { + const hiddenIcon = document.createElement("i"); + hiddenIcon.classList.add("bi", "bi-eye-slash", "me-2", "text-muted"); + hiddenIcon.title = "Hidden"; + currentConversationTitleEl.appendChild(hiddenIcon); + } + + // Add title text + currentConversationTitleEl.appendChild(document.createTextNode(conversationTitle)); + } + console.log(`selectConversation: Fetched metadata for ${conversationId}:`, metadata); // Update conversation item with accurate chat_type from metadata @@ -808,11 +1056,109 @@ function updateSelectedConversations(conversationId, isSelected) { window.chatSidebarConversations.updateSidebarDeleteButton(selectedConversations.size); } - // Show/hide the delete button based on selection + // Show/hide the action buttons based on selection if (selectedConversations.size > 0) { - deleteSelectedBtn.style.display = "block"; + if (deleteSelectedBtn) deleteSelectedBtn.style.display = "block"; + if (pinSelectedBtn) pinSelectedBtn.style.display = "block"; + if (hideSelectedBtn) hideSelectedBtn.style.display = "block"; } else { - deleteSelectedBtn.style.display = "none"; + if (deleteSelectedBtn) deleteSelectedBtn.style.display = "none"; + if (pinSelectedBtn) pinSelectedBtn.style.display = "none"; + if (hideSelectedBtn) hideSelectedBtn.style.display = "none"; + } +} + +// Function to bulk pin/unpin conversations +async function bulkPinConversations() { + if (selectedConversations.size === 0) return; + + const action = confirm(`Pin ${selectedConversations.size} conversation(s)?`) ? 'pin' : null; + if (!action) return; + + const conversationIds = Array.from(selectedConversations); + + try { + const response = await fetch('/api/conversations/bulk-pin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + conversation_ids: conversationIds, + action: action + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to pin conversations'); + } + + const result = await response.json(); + + // Clear selections and exit selection mode + selectedConversations.clear(); + exitSelectionMode(); + + // Reload conversations to reflect new sort order + loadConversations(); + + // Also reload sidebar conversations if the sidebar exists + if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations(); + } + + showToast(`${result.updated_count} conversation(s) ${action === 'pin' ? 'pinned' : 'unpinned'}.`, "success"); + } catch (error) { + console.error("Error pinning conversations:", error); + showToast(`Error pinning conversations: ${error.message}`, "danger"); + } +} + +// Function to bulk hide/unhide conversations +async function bulkHideConversations() { + if (selectedConversations.size === 0) return; + + const action = confirm(`Hide ${selectedConversations.size} conversation(s)?`) ? 'hide' : null; + if (!action) return; + + const conversationIds = Array.from(selectedConversations); + + try { + const response = await fetch('/api/conversations/bulk-hide', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + conversation_ids: conversationIds, + action: action + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to hide conversations'); + } + + const result = await response.json(); + + // Clear selections and exit selection mode + selectedConversations.clear(); + exitSelectionMode(); + + // Reload conversations to reflect filtering + loadConversations(); + + // Also reload sidebar conversations if the sidebar exists + if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations(); + } + + showToast(`${result.updated_count} conversation(s) ${action === 'hide' ? 'hidden' : 'unhidden'}.`, "success"); + } catch (error) { + console.error("Error hiding conversations:", error); + showToast(`Error hiding conversations: ${error.message}`, "danger"); } } @@ -858,7 +1204,9 @@ async function deleteSelectedConversations() { // Clear the selected conversations set and exit selection mode selectedConversations.clear(); - deleteSelectedBtn.style.display = "none"; + if (deleteSelectedBtn) deleteSelectedBtn.style.display = "none"; + if (pinSelectedBtn) pinSelectedBtn.style.display = "none"; + if (hideSelectedBtn) hideSelectedBtn.style.display = "none"; exitSelectionMode(); // Also reload sidebar conversations if the sidebar exists @@ -873,6 +1221,109 @@ async function deleteSelectedConversations() { } } +// Toggle conversation pin status +async function toggleConversationPin(conversationId) { + try { + const response = await fetch(`/api/conversations/${conversationId}/pin`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to toggle pin status'); + } + + const data = await response.json(); + + // Reload conversations to reflect new sort order + loadConversations(); + + // Also reload sidebar conversations if the sidebar exists + if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations(); + } + + showToast(data.is_pinned ? "Conversation pinned." : "Conversation unpinned.", "success"); + } catch (error) { + console.error("Error toggling pin status:", error); + showToast(`Error toggling pin: ${error.message}`, "danger"); + } +} + +// Toggle conversation hide status +async function toggleConversationHide(conversationId) { + try { + const response = await fetch(`/api/conversations/${conversationId}/hide`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to toggle hide status'); + } + + const data = await response.json(); + + // Reload conversations to reflect filtering + loadConversations(); + + // Also reload sidebar conversations if the sidebar exists + if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations(); + } + + showToast(data.is_hidden ? "Conversation hidden." : "Conversation unhidden.", "success"); + } catch (error) { + console.error("Error toggling hide status:", error); + showToast(`Error toggling hide: ${error.message}`, "danger"); + } +} + +// Update the show/hide toggle button visibility and badge +function updateHiddenToggleButton() { + let toggleBtn = document.getElementById("toggle-hidden-btn"); + + // Count hidden conversations + const hiddenCount = allConversations.filter(c => c.is_hidden || false).length; + + if (hiddenCount > 0) { + // Create button if it doesn't exist + if (!toggleBtn) { + toggleBtn = document.createElement("button"); + toggleBtn.id = "toggle-hidden-btn"; + toggleBtn.classList.add("btn", "btn-outline-secondary", "btn-sm", "ms-2"); + toggleBtn.title = "Show/hide hidden conversations"; + + // Insert after the new conversation button + if (newConversationBtn && newConversationBtn.parentElement) { + newConversationBtn.parentElement.insertBefore(toggleBtn, newConversationBtn.nextSibling); + } + + // Add click event + toggleBtn.addEventListener("click", () => { + showHiddenConversations = !showHiddenConversations; + loadConversations(); + }); + } + + // Update button content based on current state + const icon = showHiddenConversations ? "bi-eye-slash" : "bi-eye"; + toggleBtn.innerHTML = ` ${hiddenCount}`; + toggleBtn.style.display = "inline-block"; + } else { + // Hide button if no hidden conversations + if (toggleBtn) { + toggleBtn.style.display = "none"; + } + } +} + // --- Event Listeners --- if (newConversationBtn) { newConversationBtn.addEventListener("click", () => { @@ -889,6 +1340,59 @@ if (deleteSelectedBtn) { deleteSelectedBtn.addEventListener("click", deleteSelectedConversations); } +if (pinSelectedBtn) { + pinSelectedBtn.addEventListener("click", bulkPinConversations); +} + +if (hideSelectedBtn) { + hideSelectedBtn.addEventListener("click", bulkHideConversations); +} + +// Helper function to set show hidden conversations state and return a promise +export function setShowHiddenConversations(value) { + showHiddenConversations = value; + + // If enabling hidden conversations and the list is already loaded, just re-render + if (value && allConversations.length > 0) { + // Re-filter and render without fetching + const sortedConversations = [...allConversations].sort((a, b) => { + const aPinned = a.is_pinned || false; + const bPinned = b.is_pinned || false; + if (aPinned !== bPinned) return bPinned ? 1 : -1; + const aDate = new Date(a.last_updated); + const bDate = new Date(b.last_updated); + return bDate - aDate; + }); + + let filteredConversations = sortedConversations.filter(convo => { + const isHidden = convo.is_hidden || false; + return !isHidden || showHiddenConversations || selectionModeActive; + }); + + filteredConversations = applyQuickSearchFilter(filteredConversations); + + if (conversationsList) { + conversationsList.innerHTML = ""; + if (filteredConversations.length === 0) { + conversationsList.innerHTML = '
No visible conversations.
'; + } else { + filteredConversations.forEach(convo => { + conversationsList.appendChild(createConversationItem(convo)); + }); + } + } + + updateHiddenToggleButton(); + + if (window.chatSidebarConversations && window.chatSidebarConversations.loadSidebarConversations) { + window.chatSidebarConversations.loadSidebarConversations(); + } + } else { + // Otherwise do a full reload + loadConversations(); + } +} + // Expose functions globally for sidebar integration window.chatConversations = { selectConversation, @@ -898,10 +1402,14 @@ window.chatConversations = { deleteConversation, toggleConversationSelection, deleteSelectedConversations, + bulkPinConversations, + bulkHideConversations, exitSelectionMode, isSelectionModeActive: () => selectionModeActive, getSelectedConversations: () => Array.from(selectedConversations), getCurrentConversationId: () => currentConversationId, + getQuickSearchTerm: () => quickSearchTerm, + setShowHiddenConversations, updateConversationHeader: (conversationId, newTitle) => { // Update header if this is the currently active conversation if (currentConversationId === conversationId) { diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 05c5ce4d..02b0640b 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -444,6 +444,9 @@ function createCitationsHtml( } export function loadMessages(conversationId) { + // Clear search highlights when loading a different conversation + clearSearchHighlight(); + fetch(`/conversation/${conversationId}/messages`) .then((response) => response.json()) .then((data) => { @@ -506,6 +509,18 @@ export function loadMessages(conversationId) { .catch((error) => { console.error("Error loading messages:", error); if (chatbox) chatbox.innerHTML = `
Error loading messages.
`; + }) + .finally(() => { + // Check if there's a search highlight to apply + if (window.searchHighlight && window.searchHighlight.term) { + const elapsed = Date.now() - window.searchHighlight.timestamp; + if (elapsed < 30000) { // Within 30 seconds + setTimeout(() => applySearchHighlight(window.searchHighlight.term), 100); + } else { + // Clear expired highlight + window.searchHighlight = null; + } + } }); } @@ -2103,3 +2118,111 @@ function loadImageInfo(fullMessageObject, container) { container.innerHTML = content; } + +// Search highlight functions +export function applySearchHighlight(searchTerm) { + if (!searchTerm || searchTerm.trim() === '') return; + + // Clear any existing highlights first + clearSearchHighlight(); + + const chatbox = document.getElementById('chatbox'); + if (!chatbox) return; + + // Find all message content elements + const messageContents = chatbox.querySelectorAll('.message-content, .ai-response'); + + // Escape special regex characters in search term + const escapedTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`(${escapedTerm})`, 'gi'); + + messageContents.forEach(element => { + const walker = document.createTreeWalker( + element, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + if (node.nodeValue.trim() !== '') { + textNodes.push(node); + } + } + + textNodes.forEach(textNode => { + const text = textNode.nodeValue; + if (regex.test(text)) { + const span = document.createElement('span'); + span.innerHTML = text.replace(regex, '$1'); + textNode.parentNode.replaceChild(span, textNode); + } + }); + }); + + // Set timeout to clear highlights after 30 seconds + if (window.searchHighlight) { + if (window.searchHighlight.timeoutId) { + clearTimeout(window.searchHighlight.timeoutId); + } + window.searchHighlight.timeoutId = setTimeout(() => { + clearSearchHighlight(); + window.searchHighlight = null; + }, 30000); + } +} + +export function clearSearchHighlight() { + const chatbox = document.getElementById('chatbox'); + if (!chatbox) return; + + // Find all highlight marks + const highlights = chatbox.querySelectorAll('mark.search-highlight'); + highlights.forEach(mark => { + const text = document.createTextNode(mark.textContent); + mark.parentNode.replaceChild(text, mark); + }); + + // Clear timeout if exists + if (window.searchHighlight && window.searchHighlight.timeoutId) { + clearTimeout(window.searchHighlight.timeoutId); + window.searchHighlight.timeoutId = null; + } +} + +export function scrollToMessageSmooth(messageId) { + if (!messageId) return; + + const chatbox = document.getElementById('chatbox'); + if (!chatbox) return; + + // Find message by data-message-id attribute + const messageElement = chatbox.querySelector(`[data-message-id="${messageId}"]`); + if (!messageElement) { + console.warn(`Message with ID ${messageId} not found`); + return; + } + + // Scroll smoothly to message + messageElement.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + + // Add pulse animation + messageElement.classList.add('message-pulse'); + + // Remove pulse after 2 seconds + setTimeout(() => { + messageElement.classList.remove('message-pulse'); + }, 2000); +} + +// Expose functions globally +window.chatMessages = { + applySearchHighlight, + clearSearchHighlight, + scrollToMessageSmooth +}; diff --git a/application/single_app/static/js/chat/chat-search-modal.js b/application/single_app/static/js/chat/chat-search-modal.js new file mode 100644 index 00000000..b935525f --- /dev/null +++ b/application/single_app/static/js/chat/chat-search-modal.js @@ -0,0 +1,518 @@ +// chat-search-modal.js +// Advanced search modal functionality + +import { showToast } from "./chat-toast.js"; + +let currentSearchParams = null; +let currentPage = 1; +let advancedSearchModal = null; + +// Initialize modal when DOM is ready +document.addEventListener('DOMContentLoaded', () => { + const modalElement = document.getElementById('advancedSearchModal'); + if (modalElement) { + advancedSearchModal = new bootstrap.Modal(modalElement); + + // Set up event listeners + setupEventListeners(); + } +}); + +function setupEventListeners() { + // Search button + const searchBtn = document.getElementById('performSearchBtn'); + if (searchBtn) { + searchBtn.addEventListener('click', () => { + performAdvancedSearch(1); + }); + } + + // Clear filters button + const clearBtn = document.getElementById('clearFiltersBtn'); + if (clearBtn) { + clearBtn.addEventListener('click', clearFilters); + } + + // Clear history button + const clearHistoryBtn = document.getElementById('clearHistoryBtn'); + if (clearHistoryBtn) { + clearHistoryBtn.addEventListener('click', clearSearchHistory); + } + + // Pagination buttons + const prevBtn = document.getElementById('searchPrevBtn'); + const nextBtn = document.getElementById('searchNextBtn'); + + if (prevBtn) { + prevBtn.addEventListener('click', () => { + if (currentPage > 1) { + performAdvancedSearch(currentPage - 1); + } + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', () => { + performAdvancedSearch(currentPage + 1); + }); + } + + // Enter key in search input + const searchInput = document.getElementById('searchMessageInput'); + if (searchInput) { + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + performAdvancedSearch(1); + } + }); + } +} + +export function openAdvancedSearchModal() { + if (advancedSearchModal) { + advancedSearchModal.show(); + + // Load classifications and history when modal opens + loadClassifications(); + loadSearchHistory(); + } +} + +async function loadClassifications() { + try { + const response = await fetch('/api/conversations/classifications', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to load classifications'); + } + + const data = await response.json(); + const select = document.getElementById('searchClassifications'); + + if (select && data.classifications) { + // Clear loading option + select.innerHTML = ''; + + if (data.classifications.length === 0) { + select.innerHTML = ''; + } else { + data.classifications.forEach(classification => { + const option = document.createElement('option'); + option.value = classification; + option.textContent = classification; + select.appendChild(option); + }); + } + } + } catch (error) { + console.error('Error loading classifications:', error); + const select = document.getElementById('searchClassifications'); + if (select) { + select.innerHTML = ''; + } + } +} + +async function loadSearchHistory() { + try { + const response = await fetch('/api/user-settings/search-history', { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to load search history'); + } + + const data = await response.json(); + const historyList = document.getElementById('searchHistoryList'); + + if (historyList && data.history) { + if (data.history.length === 0) { + historyList.innerHTML = ` +
+ +

No search history yet

+
+ `; + } else { + historyList.innerHTML = ''; + const listGroup = document.createElement('div'); + listGroup.className = 'list-group'; + + data.history.forEach(item => { + const listItem = document.createElement('a'); + listItem.href = '#'; + listItem.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'; + listItem.innerHTML = ` + ${escapeHtml(item.term)} + ${formatDate(item.timestamp)} + `; + + listItem.addEventListener('click', (e) => { + e.preventDefault(); + populateSearchFromHistory(item.term); + }); + + listGroup.appendChild(listItem); + }); + + historyList.appendChild(listGroup); + } + } + } catch (error) { + console.error('Error loading search history:', error); + } +} + +function populateSearchFromHistory(searchTerm) { + const searchInput = document.getElementById('searchMessageInput'); + if (searchInput) { + searchInput.value = searchTerm; + } + + // Switch to search tab + const searchTab = document.getElementById('search-tab'); + if (searchTab) { + searchTab.click(); + } + + // Perform search + performAdvancedSearch(1); +} + +async function performAdvancedSearch(page = 1) { + const searchTerm = document.getElementById('searchMessageInput').value.trim(); + + // Validate search term + if (!searchTerm || searchTerm.length < 3) { + showToast('Please enter at least 3 characters to search', 'warning'); + return; + } + + // Collect form values + const dateFrom = document.getElementById('searchDateFrom').value; + const dateTo = document.getElementById('searchDateTo').value; + + const chatTypes = []; + if (document.getElementById('chatTypePersonal').checked) chatTypes.push('personal'); + if (document.getElementById('chatTypeGroupSingle').checked) chatTypes.push('group-single-user'); + if (document.getElementById('chatTypeGroupMulti').checked) chatTypes.push('group-multi-user'); + if (document.getElementById('chatTypePublic').checked) chatTypes.push('public'); + + const classSelect = document.getElementById('searchClassifications'); + const classifications = Array.from(classSelect.selectedOptions).map(opt => opt.value); + + const hasFiles = document.getElementById('searchHasFiles').checked; + const hasImages = document.getElementById('searchHasImages').checked; + + currentSearchParams = { + search_term: searchTerm, + date_from: dateFrom, + date_to: dateTo, + chat_types: chatTypes, + classifications: classifications, + has_files: hasFiles, + has_images: hasImages, + page: page, + per_page: 20 + }; + + currentPage = page; + + // Show loading + const loadingDiv = document.getElementById('searchResultsLoading'); + const contentDiv = document.getElementById('searchResultsContent'); + const emptyDiv = document.getElementById('searchResultsEmpty'); + const paginationDiv = document.getElementById('searchPagination'); + + if (loadingDiv) loadingDiv.style.display = 'block'; + if (contentDiv) contentDiv.innerHTML = ''; + if (emptyDiv) emptyDiv.style.display = 'none'; + if (paginationDiv) paginationDiv.style.display = 'none'; + + try { + const response = await fetch('/api/search_conversations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(currentSearchParams) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Search failed'); + } + + const data = await response.json(); + + // Hide loading + if (loadingDiv) loadingDiv.style.display = 'none'; + + if (data.total_results === 0) { + if (emptyDiv) emptyDiv.style.display = 'block'; + } else { + // Render results + renderSearchResults(data); + + // Save to history (only on first page) + if (page === 1) { + saveSearchToHistory(searchTerm); + } + } + + } catch (error) { + console.error('Search error:', error); + if (loadingDiv) loadingDiv.style.display = 'none'; + showToast(error.message || 'Failed to search conversations', 'error'); + } +} + +function renderSearchResults(data) { + const contentDiv = document.getElementById('searchResultsContent'); + const paginationDiv = document.getElementById('searchPagination'); + + if (!contentDiv) return; + + contentDiv.innerHTML = ''; + + // Show result count + const resultHeader = document.createElement('div'); + resultHeader.className = 'mb-3'; + resultHeader.innerHTML = `
Found ${data.total_results} result${data.total_results !== 1 ? 's' : ''}
`; + contentDiv.appendChild(resultHeader); + + // Render each conversation result + data.results.forEach(result => { + const card = document.createElement('div'); + card.className = 'card mb-3'; + + const cardBody = document.createElement('div'); + cardBody.className = 'card-body'; + + // Conversation title and metadata + const titleDiv = document.createElement('div'); + titleDiv.className = 'd-flex justify-content-between align-items-start mb-2'; + + const titleText = document.createElement('h6'); + titleText.className = 'card-title mb-0'; + titleText.innerHTML = ` + ${result.conversation.is_pinned ? '' : ''} + ${escapeHtml(result.conversation.title)} + `; + + const metaText = document.createElement('small'); + metaText.className = 'text-muted'; + metaText.textContent = formatDate(result.conversation.last_updated); + + titleDiv.appendChild(titleText); + titleDiv.appendChild(metaText); + cardBody.appendChild(titleDiv); + + // Classifications and chat type + if (result.conversation.classification && result.conversation.classification.length > 0) { + const badgesDiv = document.createElement('div'); + badgesDiv.className = 'mb-2'; + result.conversation.classification.forEach(cls => { + const badge = document.createElement('span'); + badge.className = 'badge bg-secondary me-1'; + badge.textContent = cls; + badgesDiv.appendChild(badge); + }); + cardBody.appendChild(badgesDiv); + } + + // Message matches + const matchesDiv = document.createElement('div'); + matchesDiv.className = 'mt-2'; + matchesDiv.innerHTML = `${result.match_count} message${result.match_count !== 1 ? 's' : ''} matched:`; + + result.messages.forEach(msg => { + const msgDiv = document.createElement('div'); + msgDiv.className = 'border-start border-primary border-3 ps-2 py-1 mb-2 mt-2'; + msgDiv.style.cursor = 'pointer'; + msgDiv.innerHTML = highlightSearchTerm(escapeHtml(msg.content_snippet), currentSearchParams.search_term); + + msgDiv.addEventListener('click', () => { + navigateToMessageWithHighlight(result.conversation.id, msg.message_id, currentSearchParams.search_term); + }); + + msgDiv.addEventListener('mouseenter', () => { + msgDiv.classList.add('bg-light'); + }); + msgDiv.addEventListener('mouseleave', () => { + msgDiv.classList.remove('bg-light'); + }); + + matchesDiv.appendChild(msgDiv); + }); + + cardBody.appendChild(matchesDiv); + card.appendChild(cardBody); + contentDiv.appendChild(card); + }); + + // Update pagination + if (paginationDiv && data.total_pages > 1) { + paginationDiv.style.display = 'flex'; + + const prevBtn = document.getElementById('searchPrevBtn'); + const nextBtn = document.getElementById('searchNextBtn'); + const pageInfo = document.getElementById('searchPageInfo'); + + if (prevBtn) { + prevBtn.disabled = currentPage === 1; + } + + if (nextBtn) { + nextBtn.disabled = currentPage === data.total_pages; + } + + if (pageInfo) { + pageInfo.textContent = `Page ${currentPage} of ${data.total_pages}`; + } + } +} + +function highlightSearchTerm(text, searchTerm) { + const escaped = escapeHtml(searchTerm); + const regex = new RegExp(`(${escaped})`, 'gi'); + return text.replace(regex, '$1'); +} + +function navigateToMessageWithHighlight(convId, msgId, searchTerm) { + // Close the modal + if (advancedSearchModal) { + advancedSearchModal.hide(); + } + + // Set global search highlight state + window.searchHighlight = { + term: searchTerm, + timestamp: Date.now(), + timeoutId: null + }; + + // Load the conversation + if (window.chatConversations && window.chatConversations.selectConversation) { + window.chatConversations.selectConversation(convId); + + // Wait for messages to load, then scroll and highlight + setTimeout(() => { + if (window.chatMessages) { + if (window.chatMessages.scrollToMessageSmooth) { + window.chatMessages.scrollToMessageSmooth(msgId); + } + if (window.chatMessages.applySearchHighlight) { + window.chatMessages.applySearchHighlight(searchTerm); + } + } + }, 500); + } +} + +async function saveSearchToHistory(searchTerm) { + try { + await fetch('/api/user-settings/search-history', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ search_term: searchTerm }) + }); + + // Reload history in background + loadSearchHistory(); + } catch (error) { + console.error('Error saving search to history:', error); + } +} + +async function clearSearchHistory() { + if (!confirm('Are you sure you want to clear your search history?')) { + return; + } + + try { + const response = await fetch('/api/user-settings/search-history', { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to clear history'); + } + + showToast('Search history cleared', 'success'); + loadSearchHistory(); + + } catch (error) { + console.error('Error clearing search history:', error); + showToast('Failed to clear search history', 'error'); + } +} + +function clearFilters() { + // Clear search input + const searchInput = document.getElementById('searchMessageInput'); + if (searchInput) searchInput.value = ''; + + // Clear dates + const dateFrom = document.getElementById('searchDateFrom'); + const dateTo = document.getElementById('searchDateTo'); + if (dateFrom) dateFrom.value = ''; + if (dateTo) dateTo.value = ''; + + // Check all chat types + document.getElementById('chatTypePersonal').checked = true; + document.getElementById('chatTypeGroupSingle').checked = true; + document.getElementById('chatTypeGroupMulti').checked = true; + document.getElementById('chatTypePublic').checked = true; + + // Clear classifications + const classSelect = document.getElementById('searchClassifications'); + if (classSelect) { + Array.from(classSelect.options).forEach(opt => opt.selected = false); + } + + // Uncheck filters + document.getElementById('searchHasFiles').checked = false; + document.getElementById('searchHasImages').checked = false; + + // Clear results + const contentDiv = document.getElementById('searchResultsContent'); + const emptyDiv = document.getElementById('searchResultsEmpty'); + const paginationDiv = document.getElementById('searchPagination'); + + if (contentDiv) contentDiv.innerHTML = ''; + if (emptyDiv) emptyDiv.style.display = 'none'; + if (paginationDiv) paginationDiv.style.display = 'none'; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatDate(isoString) { + if (!isoString) return ''; + const date = new Date(isoString); + return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +// Expose function globally +window.chatSearchModal = { + openAdvancedSearchModal +}; diff --git a/application/single_app/static/js/chat/chat-sidebar-conversations.js b/application/single_app/static/js/chat/chat-sidebar-conversations.js index a1d6f70b..eccf040b 100644 --- a/application/single_app/static/js/chat/chat-sidebar-conversations.js +++ b/application/single_app/static/js/chat/chat-sidebar-conversations.js @@ -7,11 +7,20 @@ const sidebarConversationsList = document.getElementById("sidebar-conversations- const sidebarNewChatBtn = document.getElementById("sidebar-new-chat-btn"); let currentActiveConversationId = null; +let sidebarShowHiddenConversations = false; // Track if hidden conversations should be shown in sidebar +let isLoadingSidebarConversations = false; // Prevent concurrent sidebar loads // Load conversations for the sidebar export function loadSidebarConversations() { if (!sidebarConversationsList) return; + // Prevent concurrent loads + if (isLoadingSidebarConversations) { + console.log('Sidebar load already in progress, skipping...'); + return; + } + + isLoadingSidebarConversations = true; sidebarConversationsList.innerHTML = '
Loading conversations...
'; fetch("/api/get_conversations") @@ -22,7 +31,44 @@ export function loadSidebarConversations() { sidebarConversationsList.innerHTML = '
No conversations yet.
'; return; } - data.conversations.forEach(convo => { + + // Sort conversations: pinned first (by last_updated), then unpinned (by last_updated) + const sortedConversations = [...data.conversations].sort((a, b) => { + const aPinned = a.is_pinned || false; + const bPinned = b.is_pinned || false; + + // If pin status differs, pinned comes first + if (aPinned !== bPinned) { + return bPinned ? 1 : -1; + } + + // If same pin status, sort by last_updated (most recent first) + const aDate = new Date(a.last_updated); + const bDate = new Date(b.last_updated); + return bDate - aDate; + }); + + // Filter conversations based on show/hide hidden setting + let visibleConversations = sortedConversations.filter(convo => { + const isHidden = convo.is_hidden || false; + // Show hidden conversations if toggle is on OR if we're in selection mode + const isSelectionMode = window.chatConversations && window.chatConversations.isSelectionModeActive && window.chatConversations.isSelectionModeActive(); + return !isHidden || sidebarShowHiddenConversations || isSelectionMode; + }); + + // Apply quick search filter if active + if (window.chatConversations && window.chatConversations.getQuickSearchTerm) { + const searchTerm = window.chatConversations.getQuickSearchTerm(); + if (searchTerm && searchTerm.trim() !== '') { + const searchLower = searchTerm.toLowerCase().trim(); + visibleConversations = visibleConversations.filter(convo => { + const titleLower = (convo.title || '').toLowerCase(); + return titleLower.includes(searchLower); + }); + } + } + + visibleConversations.forEach(convo => { sidebarConversationsList.appendChild(createSidebarConversationItem(convo)); }); @@ -38,10 +84,14 @@ export function loadSidebarConversations() { }); } } + + // Reset loading flag + isLoadingSidebarConversations = false; }) .catch(error => { console.error("Error loading sidebar conversations:", error); sidebarConversationsList.innerHTML = `
Error loading conversations: ${error.error || 'Unknown error'}
`; + isLoadingSidebarConversations = false; // Reset flag on error too }); } @@ -64,15 +114,22 @@ function createSidebarConversationItem(convo) { convoItem.setAttribute("data-group-name", groupName); } + const isPinned = convo.is_pinned || false; + const isHidden = convo.is_hidden || false; + const pinIcon = isPinned ? '' : ''; + const hiddenIcon = isHidden ? '' : ''; + convoItem.innerHTML = `
- + -
- + +
+ + + +
+
+
+ + diff --git a/application/single_app/templates/_sidebar_short_nav.html b/application/single_app/templates/_sidebar_short_nav.html index 251304d6..0bd88f3c 100644 --- a/application/single_app/templates/_sidebar_short_nav.html +++ b/application/single_app/templates/_sidebar_short_nav.html @@ -26,10 +26,36 @@ Conversations -
+
+ + +
+ + +
+
+
+ +
diff --git a/application/single_app/templates/chats.html b/application/single_app/templates/chats.html index 48fec7ff..3ecc61e8 100644 --- a/application/single_app/templates/chats.html +++ b/application/single_app/templates/chats.html @@ -194,8 +194,14 @@
Conversations
+ +
+ + +
{% endblock %} @@ -495,6 +657,7 @@
`; @@ -699,8 +753,121 @@ export function appendMessage( if (window.Prism) Prism.highlightElement(block); }); + // Apply masked state if message has masking + if (fullMessageObject?.metadata) { + console.log('Applying masked state for AI message:', messageId, fullMessageObject.metadata); + applyMaskedState(messageDiv, fullMessageObject.metadata); + } else { + console.log('No metadata found for AI message:', messageId, 'fullMessageObject:', fullMessageObject); + } + // --- Attach Event Listeners specifically for AI message --- attachCodeBlockCopyButtons(messageDiv.querySelector(".message-text")); + + const metadataBtn = messageDiv.querySelector(".metadata-info-btn"); + if (metadataBtn) { + metadataBtn.addEventListener("click", () => { + const metadataContainer = messageDiv.querySelector('.metadata-container'); + if (metadataContainer) { + const isVisible = metadataContainer.style.display !== 'none'; + metadataContainer.style.display = isVisible ? 'none' : 'block'; + metadataBtn.setAttribute('aria-expanded', !isVisible); + metadataBtn.title = isVisible ? 'Show metadata' : 'Hide metadata'; + + // Toggle icon + const icon = metadataBtn.querySelector('i'); + if (icon) { + icon.className = isVisible ? 'bi bi-info-circle' : 'bi bi-chevron-up'; + } + + // Load metadata if container is empty (first open) + if (!isVisible && metadataContainer.innerHTML.includes('Loading metadata')) { + loadMessageMetadataForDisplay(messageId, metadataContainer); + } + } + }); + } + + const maskBtn = messageDiv.querySelector(".mask-btn"); + if (maskBtn) { + // Update tooltip dynamically on hover + maskBtn.addEventListener("mouseenter", () => { + updateMaskButtonTooltip(maskBtn, messageDiv); + }); + + // Handle mask button click + maskBtn.addEventListener("click", () => { + handleMaskButtonClick(messageDiv, messageId, messageContent); + }); + } + + const dropdownDeleteBtn = messageDiv.querySelector(".dropdown-delete-btn"); + if (dropdownDeleteBtn) { + dropdownDeleteBtn.addEventListener("click", (e) => { + e.preventDefault(); + // Always read the message ID from the DOM attribute dynamically + const currentMessageId = messageDiv.getAttribute('data-message-id'); + console.log(`🗑️ AI Delete button clicked - using message ID from DOM: ${currentMessageId}`); + handleDeleteButtonClick(messageDiv, currentMessageId, 'assistant'); + }); + } + + const dropdownRetryBtn = messageDiv.querySelector(".dropdown-retry-btn"); + if (dropdownRetryBtn) { + dropdownRetryBtn.addEventListener("click", (e) => { + e.preventDefault(); + // Always read the message ID from the DOM attribute dynamically + const currentMessageId = messageDiv.getAttribute('data-message-id'); + console.log(`🔄 AI Retry button clicked - using message ID from DOM: ${currentMessageId}`); + handleRetryButtonClick(messageDiv, currentMessageId, 'assistant'); + }); + } + + // Handle dropdown positioning manually - move to chatbox container + const dropdownToggle = messageDiv.querySelector(".message-actions .dropdown button[data-bs-toggle='dropdown']"); + const dropdownMenu = messageDiv.querySelector(".message-actions .dropdown-menu"); + if (dropdownToggle && dropdownMenu) { + dropdownToggle.addEventListener("show.bs.dropdown", () => { + // Move dropdown menu to chatbox to escape message bubble + const chatbox = document.getElementById('chatbox'); + if (chatbox) { + dropdownMenu.remove(); + chatbox.appendChild(dropdownMenu); + + // Position relative to button + const rect = dropdownToggle.getBoundingClientRect(); + const chatboxRect = chatbox.getBoundingClientRect(); + dropdownMenu.style.position = 'absolute'; + dropdownMenu.style.top = `${rect.bottom - chatboxRect.top + chatbox.scrollTop + 2}px`; + dropdownMenu.style.left = `${rect.left - chatboxRect.left}px`; + dropdownMenu.style.zIndex = '9999'; + } + }); + + // Return menu to original position when closed + dropdownToggle.addEventListener("hidden.bs.dropdown", () => { + const dropdown = messageDiv.querySelector(".message-actions .dropdown"); + if (dropdown && dropdownMenu.parentElement !== dropdown) { + dropdownMenu.remove(); + dropdown.appendChild(dropdownMenu); + } + }); + } + + const carouselPrevBtn = messageDiv.querySelector(".carousel-prev-btn"); + if (carouselPrevBtn) { + carouselPrevBtn.addEventListener("click", () => { + handleCarouselClick(messageId, 'prev'); + }); + } + + const carouselNextBtn = messageDiv.querySelector(".carousel-next-btn"); + if (carouselNextBtn) { + carouselNextBtn.addEventListener("click", () => { + handleCarouselClick(messageId, 'next'); + }); + } + const copyBtn = messageDiv.querySelector(".copy-btn"); copyBtn?.addEventListener("click", () => { /* ... copy logic ... */ @@ -759,6 +926,11 @@ export function appendMessage( // --- Handle ALL OTHER message types --- } else { + // Declare variables for image metadata checks (needed for footer logic) + let isUserUpload = false; + let hasExtractedText = false; + let hasVisionAnalysis = false; + // Determine variables based on sender type if (sender === "You") { messageClass = "user-message"; @@ -799,9 +971,9 @@ export function appendMessage( } // Check if this is a user-uploaded image with metadata - const isUserUpload = fullMessageObject?.metadata?.is_user_upload || false; - const hasExtractedText = fullMessageObject?.extracted_text || false; - const hasVisionAnalysis = fullMessageObject?.vision_analysis || false; + isUserUpload = fullMessageObject?.metadata?.is_user_upload || false; + hasExtractedText = fullMessageObject?.extracted_text || false; + hasVisionAnalysis = fullMessageObject?.vision_analysis || false; // Use agent display name if available, otherwise show AI with model if (isUserUpload) { @@ -820,20 +992,6 @@ export function appendMessage( // Validate image URL before creating img tag if (messageContent && messageContent !== 'null' && messageContent.trim() !== '') { messageContentHtml = `${isUserUpload ? 'Uploaded' : 'Generated'} Image`; - - // Add info button for uploaded images with extracted text or vision analysis - if (isUserUpload && (hasExtractedText || hasVisionAnalysis)) { - const infoContainerId = `image-info-${messageId || Date.now()}`; - messageContentHtml += ` -
- -
- `; - } } else { messageContentHtml = `
Failed to ${isUserUpload ? 'load' : 'generate'} image - invalid response from image service
`; } @@ -869,21 +1027,88 @@ export function appendMessage( // This runs for "You", "File", "image", "safety", "Error", and the fallback "unknown" messageDiv.classList.add(messageClass); // Add the determined class - // Create user message footer if this is a user message + // Create message footer for user, image, and file messages let messageFooterHtml = ""; let metadataContainerHtml = ""; if (sender === "You") { const metadataContainerId = `metadata-${messageId || Date.now()}`; + const isMasked = fullMessageObject?.metadata?.masked || (fullMessageObject?.metadata?.masked_ranges && fullMessageObject.metadata.masked_ranges.length > 0); + const maskIcon = isMasked ? 'bi-front' : 'bi-back'; + const maskTitle = isMasked ? 'Unmask all masked content' : 'Mask entire message'; + messageFooterHtml = ` `; metadataContainerHtml = ``; + } else if (sender === "image" || sender === "File") { + // Image and file messages get mask button on left, metadata button on right side + const metadataContainerId = `metadata-${messageId || Date.now()}`; + + // Check if message is masked + const isMasked = fullMessageObject?.metadata?.masked || (fullMessageObject?.metadata?.masked_ranges && fullMessageObject.metadata.masked_ranges.length > 0); + const maskIcon = isMasked ? 'bi-front' : 'bi-back'; + const maskTitle = isMasked ? 'Unmask all masked content' : 'Mask entire message'; + + // For images with extracted text or vision analysis, add View Text button like citation button + let imageInfoToggleHtml = ''; + let imageInfoContainerHtml = ''; + if (sender === "image" && isUserUpload && (hasExtractedText || hasVisionAnalysis)) { + const infoContainerId = `image-info-${messageId || Date.now()}`; + imageInfoToggleHtml = ``; + imageInfoContainerHtml = ``; + } + + messageFooterHtml = ` + `; + metadataContainerHtml = imageInfoContainerHtml + ``; } // Set innerHTML using the variables determined above @@ -897,7 +1122,11 @@ export function appendMessage( : "" }
-
${senderLabel}
+
+ ${senderLabel} + ${fullMessageObject?.metadata?.edited ? 'Edited' : ''} + ${fullMessageObject?.metadata?.retried ? 'Retried' : ''} +
${messageContentHtml}
${metadataContainerHtml} ${messageFooterHtml} @@ -920,6 +1149,14 @@ export function appendMessage( // Add event listeners for user message buttons if (sender === "You") { attachUserMessageEventListeners(messageDiv, messageId, messageContent); + + // Apply masked state if message has masking + if (fullMessageObject?.metadata) { + console.log('Applying masked state for user message:', messageId, fullMessageObject.metadata); + applyMaskedState(messageDiv, fullMessageObject.metadata); + } else { + console.log('No metadata found for user message:', messageId, 'fullMessageObject:', fullMessageObject); + } } // Add event listener for image info button (uploaded images) @@ -931,6 +1168,95 @@ export function appendMessage( }); } } + + // Add event listener for mask button (image and file messages) + if (sender === "image" || sender === "File") { + const maskBtn = messageDiv.querySelector('.mask-btn'); + if (maskBtn) { + // Update tooltip dynamically on hover + maskBtn.addEventListener("mouseenter", () => { + updateMaskButtonTooltip(maskBtn, messageDiv); + }); + + // Handle mask button click + maskBtn.addEventListener("click", () => { + handleMaskButtonClick(messageDiv, messageId, messageContent); + }); + } + + // Apply masked state if message has masking + if (fullMessageObject?.metadata) { + console.log('Applying masked state for image/file message:', messageId, fullMessageObject.metadata); + applyMaskedState(messageDiv, fullMessageObject.metadata); + } + } + + // Add event listener for metadata button (image and file messages) + if (sender === "image" || sender === "File") { + const metadataBtn = messageDiv.querySelector('.metadata-info-btn'); + if (metadataBtn) { + metadataBtn.addEventListener('click', () => { + const metadataContainer = messageDiv.querySelector('.metadata-container'); + if (metadataContainer) { + const isVisible = metadataContainer.style.display !== 'none'; + metadataContainer.style.display = isVisible ? 'none' : 'block'; + metadataBtn.setAttribute('aria-expanded', !isVisible); + metadataBtn.title = isVisible ? 'Show metadata' : 'Hide metadata'; + + // Toggle icon + const icon = metadataBtn.querySelector('i'); + if (icon) { + icon.className = isVisible ? 'bi bi-info-circle' : 'bi bi-chevron-up'; + } + + // Load metadata if container is empty (first open) + if (!isVisible && metadataContainer.innerHTML.includes('Loading metadata')) { + loadMessageMetadataForDisplay(messageId, metadataContainer); + } + } + }); + } + + // Add delete button event listener from dropdown + const dropdownDeleteBtn = messageDiv.querySelector('.dropdown-delete-btn'); + if (dropdownDeleteBtn) { + dropdownDeleteBtn.addEventListener('click', (e) => { + e.preventDefault(); + // Always read the message ID from the DOM attribute dynamically + const currentMessageId = messageDiv.getAttribute('data-message-id'); + console.log(`🗑️ Image/File Delete button clicked - using message ID from DOM: ${currentMessageId}`); + handleDeleteButtonClick(messageDiv, currentMessageId, sender === "image" ? 'image' : 'file'); + }); + } + + // Handle dropdown positioning manually for image/file messages - move to chatbox + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); + const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); + if (dropdownToggle && dropdownMenu) { + dropdownToggle.addEventListener("show.bs.dropdown", () => { + const chatbox = document.getElementById('chatbox'); + if (chatbox) { + dropdownMenu.remove(); + chatbox.appendChild(dropdownMenu); + + const rect = dropdownToggle.getBoundingClientRect(); + const chatboxRect = chatbox.getBoundingClientRect(); + dropdownMenu.style.position = 'absolute'; + dropdownMenu.style.top = `${rect.bottom - chatboxRect.top + chatbox.scrollTop + 2}px`; + dropdownMenu.style.left = `${rect.left - chatboxRect.left}px`; + dropdownMenu.style.zIndex = '9999'; + } + }); + + dropdownToggle.addEventListener("hidden.bs.dropdown", () => { + const dropdown = messageDiv.querySelector(".message-footer .dropdown"); + if (dropdown && dropdownMenu.parentElement !== dropdown) { + dropdownMenu.remove(); + dropdown.appendChild(dropdownMenu); + } + }); + } + } scrollChatToBottom(); } // End of the large 'else' block for non-AI messages @@ -995,7 +1321,11 @@ export function actuallySendMessage(finalMessageToSend) { userInput.style.height = ""; // Update send button visibility after clearing input updateSendButtonVisibility(); - showLoadingIndicatorInChatbox(); + + // Only show loading indicator if NOT using streaming (streaming creates its own placeholder) + if (!isStreamingEnabled()) { + showLoadingIndicatorInChatbox(); + } const modelDeployment = modelSelect?.value; @@ -1101,26 +1431,50 @@ export function actuallySendMessage(finalMessageToSend) { // Fallback: if group_id is null/empty, use window.activeGroupId const finalGroupId = group_id || window.activeGroupId || null; + + // Prepare message data object + // Get active public workspace ID from user settings (similar to active_group_id) + const finalPublicWorkspaceId = window.activePublicWorkspaceId || null; + + const messageData = { + message: finalMessageToSend, + conversation_id: currentConversationId, + hybrid_search: hybridSearchEnabled, + selected_document_id: selectedDocumentId, + classifications: classificationsToSend, + image_generation: imageGenEnabled, + doc_scope: effectiveDocScope, + chat_type: chat_type, + active_group_id: finalGroupId, + active_public_workspace_id: finalPublicWorkspaceId, + model_deployment: modelDeployment, + prompt_info: promptInfo, + agent_info: agentInfo, + reasoning_effort: getCurrentReasoningEffort() + }; + + // Check if streaming is enabled (but not for image generation) + const agentsEnabled = typeof areAgentsEnabled === 'function' && areAgentsEnabled(); + if (isStreamingEnabled() && !imageGenEnabled) { + const streamInitiated = sendMessageWithStreaming( + messageData, + tempUserMessageId, + currentConversationId + ); + if (streamInitiated) { + return; // Streaming handles the rest + } + // If streaming failed to initiate, fall through to regular fetch + } + + // Regular non-streaming fetch fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "same-origin", - body: JSON.stringify({ - message: finalMessageToSend, - conversation_id: currentConversationId, - hybrid_search: hybridSearchEnabled, - selected_document_id: selectedDocumentId, - classifications: classificationsToSend, - image_generation: imageGenEnabled, - doc_scope: effectiveDocScope, - chat_type: chat_type, - active_group_id: finalGroupId, // for backward compatibility - model_deployment: modelDeployment, - prompt_info: promptInfo, - agent_info: agentInfo - }), + body: JSON.stringify(messageData), }) .then((response) => { if (!response.ok) { @@ -1156,10 +1510,15 @@ export function actuallySendMessage(finalMessageToSend) { console.log("data.web_search_citations:", data.web_search_citations); console.log("data.agent_citations:", data.agent_citations); console.log(`data.message_id: ${data.message_id}`); + console.log(`data.user_message_id: ${data.user_message_id}`); + console.log(`tempUserMessageId: ${tempUserMessageId}`); // Update the user message with the real message ID if (data.user_message_id) { + console.log(`🔄 Calling updateUserMessageId(${tempUserMessageId}, ${data.user_message_id})`); updateUserMessageId(tempUserMessageId, data.user_message_id); + } else { + console.warn(`⚠️ No user_message_id in response! User message will keep temporary ID: ${tempUserMessageId}`); } if (data.reply) { @@ -1413,7 +1772,7 @@ if (promptSelect) { } // Helper function to update user message ID after backend response -function updateUserMessageId(tempId, realId) { +export function updateUserMessageId(tempId, realId) { console.log(`🔄 Updating message ID: ${tempId} -> ${realId}`); // Find the message with the temporary ID @@ -1480,6 +1839,7 @@ function updateUserMessageId(tempId, realId) { function attachUserMessageEventListeners(messageDiv, messageId, messageContent) { const copyBtn = messageDiv.querySelector(".copy-user-btn"); const metadataToggleBtn = messageDiv.querySelector(".metadata-toggle-btn"); + const maskBtn = messageDiv.querySelector(".mask-btn"); if (copyBtn) { copyBtn.addEventListener("click", () => { @@ -1504,6 +1864,99 @@ function attachUserMessageEventListeners(messageDiv, messageId, messageContent) toggleUserMessageMetadata(messageDiv, messageId); }); } + + if (maskBtn) { + // Update tooltip dynamically on hover + maskBtn.addEventListener("mouseenter", () => { + updateMaskButtonTooltip(maskBtn, messageDiv); + }); + + // Handle mask button click + maskBtn.addEventListener("click", () => { + handleMaskButtonClick(messageDiv, messageId, messageContent); + }); + } + + const dropdownDeleteBtn = messageDiv.querySelector(".dropdown-delete-btn"); + if (dropdownDeleteBtn) { + dropdownDeleteBtn.addEventListener("click", (e) => { + e.preventDefault(); + // Always read the message ID from the DOM attribute dynamically + // This ensures we use the updated ID after updateUserMessageId is called + const currentMessageId = messageDiv.getAttribute('data-message-id'); + console.log(`🗑️ Delete button clicked - using message ID from DOM: ${currentMessageId}`); + handleDeleteButtonClick(messageDiv, currentMessageId, 'user'); + }); + } + + const dropdownRetryBtn = messageDiv.querySelector(".dropdown-retry-btn"); + if (dropdownRetryBtn) { + dropdownRetryBtn.addEventListener("click", (e) => { + e.preventDefault(); + // Always read the message ID from the DOM attribute dynamically + const currentMessageId = messageDiv.getAttribute('data-message-id'); + console.log(`🔄 Retry button clicked - using message ID from DOM: ${currentMessageId}`); + handleRetryButtonClick(messageDiv, currentMessageId, 'user'); + }); + } + + const dropdownEditBtn = messageDiv.querySelector(".dropdown-edit-btn"); + if (dropdownEditBtn) { + dropdownEditBtn.addEventListener("click", (e) => { + e.preventDefault(); + // Always read the message ID from the DOM attribute dynamically + const currentMessageId = messageDiv.getAttribute('data-message-id'); + console.log(`✏️ Edit button clicked - using message ID from DOM: ${currentMessageId}`); + // Import chat-edit module dynamically + import('./chat-edit.js').then(module => { + module.handleEditButtonClick(messageDiv, currentMessageId, 'user'); + }).catch(err => { + console.error('❌ Error loading chat-edit module:', err); + }); + }); + } + + // Handle dropdown positioning manually for user messages - move to chatbox + const dropdownToggle = messageDiv.querySelector(".message-footer .dropdown button[data-bs-toggle='dropdown']"); + const dropdownMenu = messageDiv.querySelector(".message-footer .dropdown-menu"); + if (dropdownToggle && dropdownMenu) { + dropdownToggle.addEventListener("show.bs.dropdown", () => { + const chatbox = document.getElementById('chatbox'); + if (chatbox) { + dropdownMenu.remove(); + chatbox.appendChild(dropdownMenu); + + const rect = dropdownToggle.getBoundingClientRect(); + const chatboxRect = chatbox.getBoundingClientRect(); + dropdownMenu.style.position = 'absolute'; + dropdownMenu.style.top = `${rect.bottom - chatboxRect.top + chatbox.scrollTop + 2}px`; + dropdownMenu.style.left = `${rect.left - chatboxRect.left}px`; + dropdownMenu.style.zIndex = '9999'; + } + }); + + dropdownToggle.addEventListener("hidden.bs.dropdown", () => { + const dropdown = messageDiv.querySelector(".message-footer .dropdown"); + if (dropdown && dropdownMenu.parentElement !== dropdown) { + dropdownMenu.remove(); + dropdown.appendChild(dropdownMenu); + } + }); + } + + const carouselPrevBtn = messageDiv.querySelector(".carousel-prev-btn"); + if (carouselPrevBtn) { + carouselPrevBtn.addEventListener("click", () => { + handleCarouselClick(messageId, 'prev'); + }); + } + + const carouselNextBtn = messageDiv.querySelector(".carousel-next-btn"); + if (carouselNextBtn) { + carouselNextBtn.addEventListener("click", () => { + handleCarouselClick(messageId, 'next'); + }); + } } // Function to toggle user message metadata drawer @@ -1700,183 +2153,174 @@ function formatMetadataForDrawer(metadata) { // User Information Section if (metadata.user_info) { - content += ''; + } + + // Thread Information Section (priority display) + if (metadata.thread_info) { + const ti = metadata.thread_info; + content += '
'; + content += '
Thread Information
'; + content += '
'; + + content += `
Thread ID: ${escapeHtml(ti.thread_id || 'N/A')}
`; + + content += `
Previous Thread: ${escapeHtml(ti.previous_thread_id || 'None')}
`; + + const activeThreadBadge = ti.active_thread ? + 'Yes' : + 'No'; + content += `
Active: ${activeThreadBadge}
`; + + content += `
Attempt: ${ti.thread_attempt || 1}
`; + + content += '
'; } // Button States Section if (metadata.button_states) { - content += ''; } // Workspace Search Section if (metadata.workspace_search) { - content += ''; } // Prompt Selection Section if (metadata.prompt_selection) { - content += ''; } // Agent Selection Section if (metadata.agent_selection) { - content += ''; } // Model Selection Section if (metadata.model_selection) { - content += ''; } // Uploaded Images Section if (metadata.uploaded_images && metadata.uploaded_images.length > 0) { - content += '`; // End metadata-item + content += `
`; // End item wrapper }); - content += ''; + content += ''; // End ms-3 small and mb-3 } // Chat Context Section if (metadata.chat_context) { - content += '
'; - content += ''; + content += '
'; + content += '
Chat Context
'; + content += '
'; if (metadata.chat_context.conversation_id) { - content += ``; + content += `
Conversation ID: ${escapeHtml(metadata.chat_context.conversation_id)}
`; } if (metadata.chat_context.chat_type) { - content += ``; + content += `
Chat Type: ${createInfoBadge(metadata.chat_context.chat_type, 'primary')}
`; } // Show context-specific information based on chat type if (metadata.chat_context.chat_type === 'group') { if (metadata.chat_context.group_name) { - content += ``; + content += `
Group: ${escapeHtml(metadata.chat_context.group_name)}
`; } else if (metadata.chat_context.group_id && metadata.chat_context.group_id !== 'None') { - content += ``; + content += `
Group ID: ${escapeHtml(metadata.chat_context.group_id)}
`; } } else if (metadata.chat_context.chat_type === 'public') { if (metadata.chat_context.workspace_context) { - content += ``; + content += `
Workspace: ${createInfoBadge(metadata.chat_context.workspace_context, 'info')}
`; } } // For 'personal' chat type, no additional context needed - content += '
'; + content += '
'; } if (!content) { @@ -2022,14 +2457,14 @@ function toggleImageInfo(messageDiv, messageId, fullMessageObject) { // Hide the info infoContainer.style.display = "none"; toggleBtn.setAttribute("aria-expanded", false); - toggleBtn.title = "View extracted text & analysis"; - toggleBtn.innerHTML = ' View Text'; + toggleBtn.title = "View extracted text"; + toggleBtn.innerHTML = ''; } else { // Show the info infoContainer.style.display = "block"; toggleBtn.setAttribute("aria-expanded", true); - toggleBtn.title = "Hide extracted text & analysis"; - toggleBtn.innerHTML = ' Hide Text'; + toggleBtn.title = "Hide extracted text"; + toggleBtn.innerHTML = ''; // Load image info if not already loaded const contentDiv = infoContainer.querySelector('.image-info-content'); @@ -2048,6 +2483,128 @@ function toggleImageInfo(messageDiv, messageId, fullMessageObject) { }, 10); } +/** + * Toggle the metadata drawer for AI, image, and file messages + */ +function toggleMessageMetadata(messageDiv, messageId) { + const existingDrawer = messageDiv.querySelector('.message-metadata-drawer'); + + if (existingDrawer) { + // Drawer exists, remove it + existingDrawer.remove(); + return; + } + + // Create new drawer + const drawerDiv = document.createElement('div'); + drawerDiv.className = 'message-metadata-drawer mt-2 p-3 border rounded bg-light'; + drawerDiv.innerHTML = '
Loading...
'; + + messageDiv.appendChild(drawerDiv); + + // Load metadata + loadMessageMetadataForDisplay(messageId, drawerDiv); +} + +/** + * Load message metadata into the drawer for AI/image/file messages + */ +function loadMessageMetadataForDisplay(messageId, container) { + fetch(`/api/message/${messageId}/metadata`) + .then(response => { + if (!response.ok) { + throw new Error('Failed to load metadata'); + } + return response.json(); + }) + .then(data => { + if (!data) { + container.innerHTML = '

No metadata available

'; + return; + } + + const metadata = data; + let html = '
'; + + // Thread Information (check both locations for backward compatibility) + const threadInfo = metadata.metadata?.thread_info || { + thread_id: metadata.thread_id, + previous_thread_id: metadata.previous_thread_id, + active_thread: metadata.active_thread, + thread_attempt: metadata.thread_attempt + }; + + if (threadInfo.thread_id) { + html += '
'; + html += '
Thread Information
'; + html += '
'; + html += `
Thread ID: ${threadInfo.thread_id}
`; + html += `
Previous Thread: ${threadInfo.previous_thread_id || 'None (first message)'}
`; + html += `
Active: ${threadInfo.active_thread ? 'Yes' : 'No'}
`; + html += `
Attempt: ${threadInfo.thread_attempt || 1}
`; + html += '
'; + } + + // Message Details + html += '
'; + html += '
Message Details
'; + html += '
'; + if (metadata.id) html += `
Message ID: ${metadata.id}
`; + if (metadata.conversation_id) html += `
Conversation ID: ${metadata.conversation_id}
`; + if (metadata.role) html += `
Role: ${metadata.role}
`; + if (metadata.timestamp) html += `
Timestamp: ${new Date(metadata.timestamp).toLocaleString()}
`; + html += '
'; + + // Image/File specific info + if (metadata.role === 'image') { + html += '
'; + html += '
Image Details
'; + html += '
'; + if (metadata.filename) html += `
Filename: ${metadata.filename}
`; + if (metadata.prompt) html += `
Prompt: ${metadata.prompt}
`; + if (metadata.metadata?.is_chunked !== undefined) html += `
Chunked: ${metadata.metadata.is_chunked ? 'Yes' : 'No'}
`; + if (metadata.metadata?.is_user_upload !== undefined) html += `
User Upload: ${metadata.metadata.is_user_upload ? 'Yes' : 'No'}
`; + html += '
'; + } else if (metadata.role === 'file') { + html += '
'; + html += '
File Details
'; + html += '
'; + if (metadata.filename) html += `
Filename: ${metadata.filename}
`; + if (metadata.is_table !== undefined) html += `
Table Data: ${metadata.is_table ? 'Yes' : 'No'}
`; + html += '
'; + } + + // Generation Details (for assistant, image, and file messages) + if (metadata.role === 'assistant' || metadata.role === 'image' || metadata.role === 'file') { + html += '
'; + html += '
Generation Details
'; + html += '
'; + + // Model and Agent info (for all types) + if (metadata.model_deployment_name) html += `
Model: ${metadata.model_deployment_name}
`; + if (metadata.agent_name) html += `
Agent: ${metadata.agent_name}
`; + if (metadata.agent_display_name) html += `
Agent Display Name: ${metadata.agent_display_name}
`; + + // Assistant-specific info + if (metadata.role === 'assistant') { + if (metadata.augmented !== undefined) html += `
Augmented: ${metadata.augmented ? 'Yes' : 'No'}
`; + if (metadata.metadata?.reasoning_effort) html += `
Reasoning Effort: ${metadata.metadata.reasoning_effort}
`; + if (metadata.hybrid_citations && metadata.hybrid_citations.length > 0) html += `
Document Citations: ${metadata.hybrid_citations.length}
`; + if (metadata.agent_citations && metadata.agent_citations.length > 0) html += `
Agent Citations: ${metadata.agent_citations.length}
`; + } + + html += '
'; + } + + html += '
'; + container.innerHTML = html; + }) + .catch(error => { + console.error('Error loading message metadata:', error); + container.innerHTML = '
Failed to load metadata
'; + }); +} + /** * Load image extracted text and vision analysis into the info drawer */ @@ -2220,9 +2777,499 @@ export function scrollToMessageSmooth(messageId) { }, 2000); } +// ============= Message Masking Functions ============= + +/** + * Apply masked state to a message when loading from database + */ +function applyMaskedState(messageDiv, metadata) { + if (!metadata) return; + + const messageText = messageDiv.querySelector('.message-text'); + const messageFooter = messageDiv.querySelector('.message-footer'); + + if (!messageText) return; + + // Check if entire message is masked + if (metadata.masked) { + messageDiv.classList.add('fully-masked'); + + // Add exclusion badge to footer if not already present + if (messageFooter && !messageFooter.querySelector('.message-exclusion-badge')) { + const badge = document.createElement('div'); + badge.className = 'message-exclusion-badge text-warning small'; + badge.innerHTML = ''; + messageFooter.appendChild(badge); + } + return; + } + + // Apply masked ranges if they exist + if (metadata.masked_ranges && metadata.masked_ranges.length > 0) { + const content = messageText.textContent; + let htmlContent = ''; + let lastIndex = 0; + + // Sort masked ranges by start position + const sortedRanges = [...metadata.masked_ranges].sort((a, b) => a.start - b.start); + + // Build HTML with masked spans + sortedRanges.forEach(range => { + // Add text before masked range + if (range.start > lastIndex) { + htmlContent += escapeHtml(content.substring(lastIndex, range.start)); + } + + // Add masked span + const maskedText = escapeHtml(content.substring(range.start, range.end)); + const timestamp = new Date(range.timestamp).toLocaleDateString(); + htmlContent += `${maskedText}`; + + lastIndex = range.end; + }); + + // Add remaining text after last masked range + if (lastIndex < content.length) { + htmlContent += escapeHtml(content.substring(lastIndex)); + } + + // Update message text with masked content + messageText.innerHTML = htmlContent; + } +} + +/** + * Update mask button tooltip based on current selection and mask state + */ +function updateMaskButtonTooltip(maskBtn, messageDiv) { + const messageBubble = messageDiv.querySelector('.message-bubble'); + if (!messageBubble) return; + + // Check if there's a text selection within this message + const selection = window.getSelection(); + const hasSelection = selection && selection.toString().trim().length > 0; + + // Verify selection is within this message bubble + let selectionInMessage = false; + if (hasSelection && selection.anchorNode) { + selectionInMessage = messageBubble.contains(selection.anchorNode); + } + + // Check current mask state + const isMasked = messageDiv.querySelector('.masked-content') || messageDiv.classList.contains('fully-masked'); + + // Update tooltip based on state + if (isMasked) { + maskBtn.title = 'Unmask all masked content'; + } else if (selectionInMessage) { + maskBtn.title = 'Mask selected content'; + } else { + maskBtn.title = 'Mask entire message'; + } +} + +/** + * Handle mask button click - masks entire message or selected content + */ +function handleMaskButtonClick(messageDiv, messageId, messageContent) { + const messageBubble = messageDiv.querySelector('.message-bubble'); + const messageText = messageDiv.querySelector('.message-text'); + const maskBtn = messageDiv.querySelector('.mask-btn'); + + if (!messageBubble || !messageText || !maskBtn) { + console.error('Required elements not found for masking'); + return; + } + + // Check if message is currently masked + const isMasked = messageDiv.querySelector('.masked-content') || messageDiv.classList.contains('fully-masked'); + + if (isMasked) { + // Unmask all + unmaskMessage(messageDiv, messageId, maskBtn); + return; + } + + // Check for text selection within message + const selection = window.getSelection(); + const hasSelection = selection && selection.toString().trim().length > 0; + + let selectionInMessage = false; + if (hasSelection && selection.anchorNode) { + selectionInMessage = messageBubble.contains(selection.anchorNode); + } + + if (selectionInMessage) { + // Mask selection + maskSelection(messageDiv, messageId, selection, messageText, maskBtn); + } else { + // Mask entire message + maskEntireMessage(messageDiv, messageId, maskBtn); + } +} + +/** + * Mask the entire message + */ +function maskEntireMessage(messageDiv, messageId, maskBtn) { + console.log(`Masking entire message: ${messageId}`); + + // Get user info + const userDisplayName = window.currentUser?.display_name || 'Unknown User'; + const userId = window.currentUser?.id || 'unknown'; + + console.log('Mask entire message - User info:', { userId, userDisplayName, windowCurrentUser: window.currentUser }); + + const payload = { + action: 'mask_all', + user_id: userId, + display_name: userDisplayName + }; + + console.log('Mask entire message - Sending payload:', payload); + + // Call API to mask message + fetch(`/api/message/${messageId}/mask`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }) + .then(response => { + console.log('Mask entire message - Response status:', response.status); + if (!response.ok) { + return response.json().then(err => { + console.error('Mask entire message - Error response:', err); + throw new Error(err.error || 'Failed to mask message'); + }); + } + return response.json(); + }) + .then(data => { + console.log('Mask entire message - Success response:', data); + if (data.success) { + // Add fully-masked class and exclusion badge + messageDiv.classList.add('fully-masked'); + + // Update mask button + const icon = maskBtn.querySelector('i'); + icon.className = 'bi bi-front'; + maskBtn.title = 'Unmask all masked content'; + + // Add exclusion badge to footer if not already present + const messageFooter = messageDiv.querySelector('.message-footer'); + if (messageFooter && !messageFooter.querySelector('.message-exclusion-badge')) { + const badge = document.createElement('div'); + badge.className = 'message-exclusion-badge text-warning small'; + badge.innerHTML = ''; + messageFooter.appendChild(badge); + } + + showToast('Message masked successfully', 'success'); + } else { + showToast('Failed to mask message', 'error'); + } + }) + .catch(error => { + console.error('Error masking message:', error); + showToast('Error masking message', 'error'); + }); +} + +/** + * Mask selected text content + */ +function maskSelection(messageDiv, messageId, selection, messageText, maskBtn) { + const selectedText = selection.toString().trim(); + console.log(`Masking selection in message: ${messageId}`); + + // Get the range and calculate character offsets + const range = selection.getRangeAt(0); + const preSelectionRange = range.cloneRange(); + preSelectionRange.selectNodeContents(messageText); + preSelectionRange.setEnd(range.startContainer, range.startOffset); + const start = preSelectionRange.toString().length; + const end = start + selectedText.length; + + // Get user info + const userDisplayName = window.currentUser?.display_name || 'Unknown User'; + const userId = window.currentUser?.id || 'unknown'; + + console.log('Mask selection - User info:', { userId, userDisplayName, windowCurrentUser: window.currentUser }); + console.log('Mask selection - Range:', { start, end, selectedText }); + + const payload = { + action: 'mask_selection', + selection: { + start: start, + end: end, + text: selectedText + }, + user_id: userId, + display_name: userDisplayName + }; + + console.log('Mask selection - Sending payload:', payload); + + // Call API to mask selection + fetch(`/api/message/${messageId}/mask`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }) + .then(response => { + console.log('Mask selection - Response status:', response.status); + if (!response.ok) { + return response.json().then(err => { + console.error('Mask selection - Error response:', err); + throw new Error(err.error || 'Failed to mask selection'); + }); + } + return response.json(); + }) + .then(data => { + console.log('Mask selection - Success response:', data); + if (data.success) { + // Wrap selected text with masked span + const maskId = data.masked_ranges[data.masked_ranges.length - 1].id; + const span = document.createElement('span'); + span.className = 'masked-content'; + span.setAttribute('data-mask-id', maskId); + span.setAttribute('data-user-id', userId); + span.setAttribute('data-display-name', userDisplayName); + span.title = `Masked by ${userDisplayName}`; + + // Use extractContents and insertNode to handle complex selections + try { + const contents = range.extractContents(); + span.appendChild(contents); + range.insertNode(span); + } catch (e) { + console.error('Error wrapping selection:', e); + // Fallback: reload the message to show the masked content + location.reload(); + return; + } + selection.removeAllRanges(); + + // Update mask button + const icon = maskBtn.querySelector('i'); + icon.className = 'bi bi-front'; + maskBtn.title = 'Unmask all masked content'; + + showToast('Selection masked successfully', 'success'); + } else { + showToast('Failed to mask selection', 'error'); + } + }) + .catch(error => { + console.error('Error masking selection:', error); + showToast('Error masking selection', 'error'); + }); +} + +/** + * Unmask all masked content in a message + */ +function unmaskMessage(messageDiv, messageId, maskBtn) { + console.log(`Unmasking message: ${messageId}`); + + // Call API to unmask + fetch(`/api/message/${messageId}/mask`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + action: 'unmask_all' + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Remove fully-masked class + messageDiv.classList.remove('fully-masked'); + + // Remove all masked-content spans + const maskedSpans = messageDiv.querySelectorAll('.masked-content'); + maskedSpans.forEach(span => { + const text = document.createTextNode(span.textContent); + span.parentNode.replaceChild(text, span); + }); + + // Remove exclusion badge + const badge = messageDiv.querySelector('.message-exclusion-badge'); + if (badge) { + badge.remove(); + } + + // Update mask button + const icon = maskBtn.querySelector('i'); + icon.className = 'bi bi-back'; + maskBtn.title = 'Mask entire message'; + + showToast('Message unmasked successfully', 'success'); + } else { + showToast('Failed to unmask message', 'error'); + } + }) + .catch(error => { + console.error('Error unmasking message:', error); + showToast('Error unmasking message', 'error'); + }); +} + +// ============= Message Deletion Functions ============= + +/** + * Handle delete button click - shows confirmation modal + */ +function handleDeleteButtonClick(messageDiv, messageId, messageType) { + console.log(`Delete button clicked for ${messageType} message: ${messageId}`); + + // Store message info for deletion confirmation + window.pendingMessageDeletion = { + messageDiv, + messageId, + messageType + }; + + // Show appropriate confirmation modal + if (messageType === 'user') { + // User message - offer thread deletion option + const modal = document.getElementById('delete-message-modal'); + if (modal) { + const bsModal = new bootstrap.Modal(modal); + bsModal.show(); + } + } else { + // AI, image, or file message - single confirmation + const modal = document.getElementById('delete-single-message-modal'); + if (modal) { + // Update modal text based on message type + const modalBody = modal.querySelector('.modal-body p'); + if (modalBody) { + if (messageType === 'assistant') { + modalBody.textContent = 'Are you sure you want to delete this AI response? This action cannot be undone.'; + } else if (messageType === 'image') { + modalBody.textContent = 'Are you sure you want to delete this image? This action cannot be undone.'; + } else if (messageType === 'file') { + modalBody.textContent = 'Are you sure you want to delete this file? This action cannot be undone.'; + } + } + const bsModal = new bootstrap.Modal(modal); + bsModal.show(); + } + } +} + +/** + * Execute message deletion via API + */ +function executeMessageDeletion(deleteThread = false) { + const pendingDeletion = window.pendingMessageDeletion; + if (!pendingDeletion) { + console.error('No pending message deletion'); + return; + } + + const { messageDiv, messageId, messageType } = pendingDeletion; + + console.log(`Executing deletion for message ${messageId}, deleteThread: ${deleteThread}`); + console.log(`Message div:`, messageDiv); + console.log(`Message ID from DOM:`, messageDiv ? messageDiv.getAttribute('data-message-id') : 'N/A'); + + // Call delete API + fetch(`/api/message/${messageId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + delete_thread: deleteThread + }) + }) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + const errorMsg = data.error || 'Failed to delete message'; + console.error(`Delete API error (${response.status}):`, errorMsg); + console.error(`Failed message ID:`, messageId); + + // Add specific error message for 404 + if (response.status === 404) { + throw new Error(`Message not found in database. This may happen if the message was just created and hasn't fully synced yet. Try refreshing the page and deleting again.`); + } + throw new Error(errorMsg); + }).catch(jsonError => { + // If response.json() fails, throw a generic error + if (response.status === 404) { + throw new Error(`Message not found in database. Message ID: ${messageId}. Try refreshing the page.`); + } + throw new Error(`Failed to delete message (status ${response.status})`); + }); + } + return response.json(); + }) + .then(data => { + console.log('Delete API response:', data); + + if (data.success) { + // Remove message(s) from DOM + const deletedIds = data.deleted_message_ids || [messageId]; + deletedIds.forEach(id => { + const msgDiv = document.querySelector(`[data-message-id="${id}"]`); + if (msgDiv) { + msgDiv.remove(); + console.log(`Removed message ${id} from DOM`); + } + }); + + // Show success message + const archiveMsg = data.archived ? ' (archived)' : ''; + const countMsg = deletedIds.length > 1 ? `${deletedIds.length} messages` : 'Message'; + showToast(`${countMsg} deleted successfully${archiveMsg}`, 'success'); + + // Clean up pending deletion + delete window.pendingMessageDeletion; + + // Optionally reload conversation list to update preview + if (typeof loadConversations === 'function') { + loadConversations(); + } + } else { + showToast('Failed to delete message', 'error'); + } + }) + .catch(error => { + console.error('Error deleting message:', error); + + // If we got a 404, suggest reloading messages + if (error.message && error.message.includes('not found')) { + showToast(error.message + ' Click here to reload messages.', 'error', 8000, () => { + // Reload messages when toast is clicked + if (window.currentConversationId) { + loadMessages(window.currentConversationId); + } + }); + } else { + showToast(error.message || 'Failed to delete message', 'error'); + } + + // Clean up pending deletion + delete window.pendingMessageDeletion; + }); +} + // Expose functions globally window.chatMessages = { applySearchHighlight, clearSearchHighlight, scrollToMessageSmooth }; + +// Expose deletion function globally for modal buttons +window.executeMessageDeletion = executeMessageDeletion; diff --git a/application/single_app/static/js/chat/chat-onload.js b/application/single_app/static/js/chat/chat-onload.js index e4852f7d..d8a9c332 100644 --- a/application/single_app/static/js/chat/chat-onload.js +++ b/application/single_app/static/js/chat/chat-onload.js @@ -8,6 +8,8 @@ import { loadUserPrompts, loadGroupPrompts, initializePromptInteractions } from import { loadUserSettings } from "./chat-layout.js"; import { showToast } from "./chat-toast.js"; import { initConversationInfoButton } from "./chat-conversation-info-button.js"; +import { initializeStreamingToggle } from "./chat-streaming.js"; +import { initializeReasoningToggle } from "./chat-reasoning.js"; window.addEventListener('DOMContentLoaded', () => { console.log("DOM Content Loaded. Starting initializations."); // Log start @@ -16,6 +18,12 @@ window.addEventListener('DOMContentLoaded', () => { // Initialize the conversation info button initConversationInfoButton(); + + // Initialize streaming toggle + initializeStreamingToggle(); + + // Initialize reasoning toggle + initializeReasoningToggle(); // Grab references to the relevant elements const userInput = document.getElementById("user-input"); diff --git a/application/single_app/static/js/chat/chat-reasoning.js b/application/single_app/static/js/chat/chat-reasoning.js new file mode 100644 index 00000000..252fba91 --- /dev/null +++ b/application/single_app/static/js/chat/chat-reasoning.js @@ -0,0 +1,384 @@ +// chat-reasoning.js +import { loadUserSettings, saveUserSetting } from './chat-layout.js'; +import { showToast } from './chat-toast.js'; + +let reasoningEffortSettings = {}; // Per-model settings: {modelName: 'low', ...} + +/** + * Initialize the reasoning effort toggle button + */ +export function initializeReasoningToggle() { + const reasoningToggleBtn = document.getElementById('reasoning-toggle-btn'); + if (!reasoningToggleBtn) { + console.warn('Reasoning toggle button not found'); + return; + } + + console.log('Initializing reasoning toggle...'); + + // Load initial state from user settings + loadUserSettings().then(settings => { + console.log('Loaded reasoning settings:', settings); + reasoningEffortSettings = settings.reasoningEffortSettings || {}; + console.log('Reasoning effort settings:', reasoningEffortSettings); + + // Update icon based on current model + updateReasoningIconForCurrentModel(); + }).catch(error => { + console.error('Error loading reasoning settings:', error); + }); + + // Handle toggle click - show slider modal + reasoningToggleBtn.addEventListener('click', () => { + showReasoningSlider(); + }); + + // Listen for model changes + const modelSelect = document.getElementById('model-select'); + if (modelSelect) { + modelSelect.addEventListener('change', () => { + updateReasoningIconForCurrentModel(); + updateReasoningButtonVisibility(); + }); + } + + // Listen for image generation toggle - hide reasoning button when image gen is active + const imageGenBtn = document.getElementById('image-generate-btn'); + if (imageGenBtn) { + const observer = new MutationObserver(() => { + updateReasoningButtonVisibility(); + }); + observer.observe(imageGenBtn, { attributes: true, attributeFilter: ['class'] }); + } + + // Listen for agents toggle - hide reasoning button when agents are active + const enableAgentsBtn = document.getElementById('enable-agents-btn'); + if (enableAgentsBtn) { + const observer = new MutationObserver(() => { + updateReasoningButtonVisibility(); + }); + observer.observe(enableAgentsBtn, { attributes: true, attributeFilter: ['class'] }); + } + + updateReasoningButtonVisibility(); +} + +/** + * Update reasoning button visibility based on image generation state, agent state, and model support + */ +function updateReasoningButtonVisibility() { + const reasoningToggleBtn = document.getElementById('reasoning-toggle-btn'); + const imageGenBtn = document.getElementById('image-generate-btn'); + const enableAgentsBtn = document.getElementById('enable-agents-btn'); + + if (!reasoningToggleBtn) return; + + // Hide reasoning button when image generation is active + if (imageGenBtn && imageGenBtn.classList.contains('active')) { + reasoningToggleBtn.style.display = 'none'; + return; + } + + // Hide reasoning button when agents are active + if (enableAgentsBtn && enableAgentsBtn.classList.contains('active')) { + reasoningToggleBtn.style.display = 'none'; + return; + } + + // Hide reasoning button if current model doesn't support reasoning + const modelName = getCurrentModelName(); + if (modelName) { + const supportedLevels = getModelSupportedLevels(modelName); + // If model only supports 'none', hide the button + if (supportedLevels.length === 1 && supportedLevels[0] === 'none') { + reasoningToggleBtn.style.display = 'none'; + return; + } + } + + // Otherwise show the button + reasoningToggleBtn.style.display = 'flex'; +} + +/** + * Get the current model name from the model selector + */ +function getCurrentModelName() { + const modelSelect = document.getElementById('model-select'); + if (!modelSelect || !modelSelect.value) { + return null; + } + return modelSelect.value; +} + +/** + * Determine which reasoning effort levels are supported by a given model + * @param {string} modelName - The name of the model + * @returns {Array} Array of supported effort levels + */ +export function getModelSupportedLevels(modelName) { + if (!modelName) { + return ['none', 'minimal', 'low', 'medium', 'high']; + } + + const lowerModelName = modelName.toLowerCase(); + + // Models without reasoning support: gpt-4o, gpt-4.1, gpt-4.1-mini, gpt-5-chat, gpt-5-codex + if (lowerModelName.includes('gpt-4o') || + lowerModelName.includes('gpt-4.1') || + lowerModelName.includes('gpt-5-chat') || + lowerModelName.includes('gpt-5-codex')) { + return ['none']; + } + + // gpt-5-pro: high only + if (lowerModelName.includes('gpt-5-pro')) { + return ['high']; + } + + // gpt-5.1 series: none, minimal, medium, high (skip low/2 bars) + if (lowerModelName.includes('gpt-5.1')) { + return ['none', 'minimal', 'medium', 'high']; + } + + // gpt-5 series (but not 5.1, 5-pro, 5-chat, or 5-codex): minimal, low, medium, high + // Includes: gpt-5, gpt-5-nano, gpt-5-mini + if (lowerModelName.includes('gpt-5')) { + return ['minimal', 'low', 'medium', 'high']; + } + + // o-series (o1, o3, etc): low, medium, high + if (lowerModelName.match(/\bo[0-9]/)) { + return ['low', 'medium', 'high']; + } + + // Default: all levels + return ['none', 'minimal', 'low', 'medium', 'high']; +} + +/** + * Get the reasoning effort level for the current model + * @returns {string} The effort level (none, minimal, low, medium, high) + */ +export function getCurrentModelReasoningEffort() { + const modelName = getCurrentModelName(); + if (!modelName) { + return 'low'; // Default + } + + const supportedLevels = getModelSupportedLevels(modelName); + const savedEffort = reasoningEffortSettings[modelName]; + + // If gpt-5-pro, always return high + if (modelName.toLowerCase().includes('gpt-5-pro')) { + return 'high'; + } + + // If saved effort exists and is supported, use it + if (savedEffort && supportedLevels.includes(savedEffort)) { + return savedEffort; + } + + // Default to 'low' if supported, otherwise first supported level + if (supportedLevels.includes('low')) { + return 'low'; + } + + return supportedLevels[0]; +} + +/** + * Update the reasoning icon based on the current model's saved effort + */ +function updateReasoningIconForCurrentModel() { + const effort = getCurrentModelReasoningEffort(); + updateReasoningIcon(effort); +} + +/** + * Update the reasoning toggle button icon based on effort level + * @param {string} level - The effort level (none, minimal, low, medium, high) + */ +export function updateReasoningIcon(level) { + const reasoningToggleBtn = document.getElementById('reasoning-toggle-btn'); + if (!reasoningToggleBtn) return; + + const iconElement = reasoningToggleBtn.querySelector('i'); + if (!iconElement) return; + + // Map effort levels to Bootstrap Icons signal strength + const iconMap = { + 'none': 'bi-reception-0', + 'minimal': 'bi-reception-1', + 'low': 'bi-reception-2', + 'medium': 'bi-reception-3', + 'high': 'bi-reception-4' + }; + + // Remove all reception classes + iconElement.className = ''; + + // Add the appropriate icon class + const iconClass = iconMap[level] || 'bi-reception-2'; + iconElement.classList.add('bi', iconClass); + + // Update tooltip + const labelMap = { + 'none': 'No reasoning effort', + 'minimal': 'Minimal reasoning effort', + 'low': 'Low reasoning effort', + 'medium': 'Medium reasoning effort', + 'high': 'High reasoning effort' + }; + reasoningToggleBtn.title = labelMap[level] || 'Configure reasoning effort'; +} + +/** + * Show the reasoning effort slider modal + */ +export function showReasoningSlider() { + const modelName = getCurrentModelName(); + if (!modelName) { + showToast('Please select a model first', 'warning'); + return; + } + + const modal = new bootstrap.Modal(document.getElementById('reasoning-slider-modal')); + const modelNameElement = document.getElementById('reasoning-model-name'); + const levelsContainer = document.querySelector('.reasoning-levels'); + + if (!modelNameElement || !levelsContainer) { + console.error('Reasoning modal elements not found'); + return; + } + + // Set model name + modelNameElement.textContent = modelName; + + // Get supported levels and current effort + const supportedLevels = getModelSupportedLevels(modelName); + const currentEffort = getCurrentModelReasoningEffort(); + + // All possible levels in order (for display from bottom to top) + const allLevels = ['none', 'minimal', 'low', 'medium', 'high']; + const levelLabels = { + 'none': 'None', + 'minimal': 'Minimal', + 'low': 'Low', + 'medium': 'Medium', + 'high': 'High' + }; + const levelIcons = { + 'none': 'bi-reception-0', + 'minimal': 'bi-reception-1', + 'low': 'bi-reception-2', + 'medium': 'bi-reception-3', + 'high': 'bi-reception-4' + }; + const levelDescriptions = { + 'none': 'No additional reasoning - fastest responses, suitable for simple questions', + 'minimal': 'Light reasoning - quick responses with basic logical steps', + 'low': 'Moderate reasoning - balanced speed and thoughtfulness for everyday questions', + 'medium': 'Enhanced reasoning - more deliberate thinking for complex questions', + 'high': 'Maximum reasoning - deepest analysis for challenging problems and nuanced topics' + }; + + // Build level buttons (reversed for bottom-to-top display) + levelsContainer.innerHTML = ''; + allLevels.forEach(level => { + const isSupported = supportedLevels.includes(level); + const isActive = level === currentEffort; + + const levelDiv = document.createElement('div'); + levelDiv.className = `reasoning-level ${isActive ? 'active' : ''} ${!isSupported ? 'disabled' : ''}`; + levelDiv.dataset.level = level; + levelDiv.title = levelDescriptions[level]; + + levelDiv.innerHTML = ` +
+ +
+
${levelLabels[level]}
+ `; + + if (isSupported) { + levelDiv.addEventListener('click', () => { + selectReasoningLevel(level, modelName); + }); + } + + levelsContainer.appendChild(levelDiv); + }); + + modal.show(); +} + +/** + * Handle selection of a reasoning level + * @param {string} level - The selected effort level + * @param {string} modelName - The model name + */ +function selectReasoningLevel(level, modelName) { + // Update the settings + reasoningEffortSettings[modelName] = level; + + // Save to user settings + saveReasoningEffort(modelName, level); + + // Update UI + updateReasoningIcon(level); + + // Update active state in modal + document.querySelectorAll('.reasoning-level').forEach(el => { + el.classList.remove('active'); + if (el.dataset.level === level) { + el.classList.add('active'); + } + }); + + // Show feedback + const levelLabels = { + 'none': 'None', + 'minimal': 'Minimal', + 'low': 'Low', + 'medium': 'Medium', + 'high': 'High' + }; + showToast(`Reasoning effort set to ${levelLabels[level]} for ${modelName}`, 'success'); + + // Close modal after a short delay + setTimeout(() => { + const modal = bootstrap.Modal.getInstance(document.getElementById('reasoning-slider-modal')); + if (modal) { + modal.hide(); + } + }, 500); +} + +/** + * Save the reasoning effort setting for a model + * @param {string} modelName - The model name + * @param {string} effort - The effort level + */ +export function saveReasoningEffort(modelName, effort) { + reasoningEffortSettings[modelName] = effort; + saveUserSetting({ reasoningEffortSettings }); +} + +/** + * Check if reasoning effort is enabled for the current model + * @returns {boolean} True if reasoning effort is enabled + */ +export function isReasoningEffortEnabled() { + const effort = getCurrentModelReasoningEffort(); + return effort && effort !== 'none'; +} + +/** + * Get the current reasoning effort to send to the backend + * @returns {string|null} The effort level or null if 'none' + */ +export function getCurrentReasoningEffort() { + const effort = getCurrentModelReasoningEffort(); + return effort === 'none' ? null : effort; +} diff --git a/application/single_app/static/js/chat/chat-retry.js b/application/single_app/static/js/chat/chat-retry.js new file mode 100644 index 00000000..55cfbf8e --- /dev/null +++ b/application/single_app/static/js/chat/chat-retry.js @@ -0,0 +1,393 @@ +// chat-retry.js +// Handles message retry/regenerate functionality + +import { showToast } from './chat-toast.js'; +import { showLoadingIndicatorInChatbox, hideLoadingIndicatorInChatbox } from './chat-loading-indicator.js'; + +/** + * Populate retry agent dropdown with available agents + */ +async function populateRetryAgentDropdown() { + const retryAgentSelect = document.getElementById('retry-agent-select'); + if (!retryAgentSelect) return; + + try { + // Import agent functions dynamically + const agentsModule = await import('../agents_common.js'); + const { fetchUserAgents, fetchGroupAgentsForActiveGroup, fetchSelectedAgent, populateAgentSelect } = agentsModule; + + // Fetch available agents + const [userAgents, selectedAgent] = await Promise.all([ + fetchUserAgents(), + fetchSelectedAgent() + ]); + const groupAgents = await fetchGroupAgentsForActiveGroup(); + + // Combine and order agents + const combinedAgents = [...userAgents, ...groupAgents]; + const personalAgents = combinedAgents.filter(agent => !agent.is_global && !agent.is_group); + const activeGroupAgents = combinedAgents.filter(agent => agent.is_group); + const globalAgents = combinedAgents.filter(agent => agent.is_global); + const orderedAgents = [...personalAgents, ...activeGroupAgents, ...globalAgents]; + + // Populate retry agent select using shared function + populateAgentSelect(retryAgentSelect, orderedAgents, selectedAgent); + + console.log(`✅ Populated retry agent dropdown with ${orderedAgents.length} agents`); + } catch (error) { + console.error('❌ Error populating retry agent dropdown:', error); + } +} + +/** + * Handle retry button click - opens retry modal + */ +export async function handleRetryButtonClick(messageDiv, messageId, messageType) { + console.log(`🔄 Retry button clicked for ${messageType} message: ${messageId}`); + + // Store message info for retry execution + window.pendingMessageRetry = { + messageDiv, + messageId, + messageType + }; + + // Populate retry modal with current model options + const modelSelect = document.getElementById('model-select'); + const retryModelSelect = document.getElementById('retry-model-select'); + + if (modelSelect && retryModelSelect) { + // Clone model options from main select + retryModelSelect.innerHTML = modelSelect.innerHTML; + retryModelSelect.value = modelSelect.value; // Set to currently selected model + } + + // Populate retry modal with agent options (always load fresh from API) + const retryAgentSelect = document.getElementById('retry-agent-select'); + if (retryAgentSelect) { + await populateRetryAgentDropdown(); + } + + // Determine if original message used agents or models + const enableAgentsBtn = document.getElementById('enable-agents-btn'); + const agentSelectContainer = document.getElementById('agent-select-container'); + const isAgentMode = enableAgentsBtn && enableAgentsBtn.classList.contains('active') && + agentSelectContainer && agentSelectContainer.style.display !== 'none'; + + // Set retry mode based on current state + const retryModeModel = document.getElementById('retry-mode-model'); + const retryModeAgent = document.getElementById('retry-mode-agent'); + const retryModelContainer = document.getElementById('retry-model-container'); + const retryAgentContainer = document.getElementById('retry-agent-container'); + + if (isAgentMode && retryModeAgent) { + retryModeAgent.checked = true; + if (retryModelContainer) retryModelContainer.style.display = 'none'; + if (retryAgentContainer) retryAgentContainer.style.display = 'block'; + } else if (retryModeModel) { + retryModeModel.checked = true; + if (retryModelContainer) retryModelContainer.style.display = 'block'; + if (retryAgentContainer) retryAgentContainer.style.display = 'none'; + } + + // Add event listeners for mode toggle + if (retryModeModel) { + retryModeModel.addEventListener('change', function() { + if (this.checked) { + if (retryModelContainer) retryModelContainer.style.display = 'block'; + if (retryAgentContainer) retryAgentContainer.style.display = 'none'; + updateReasoningVisibility(); + } + }); + } + + if (retryModeAgent) { + retryModeAgent.addEventListener('change', function() { + if (this.checked) { + if (retryModelContainer) retryModelContainer.style.display = 'none'; + if (retryAgentContainer) retryAgentContainer.style.display = 'block'; + updateReasoningVisibility(); + } + }); + } + + // Function to update reasoning visibility based on selected model or agent + function updateReasoningVisibility() { + const retryReasoningContainer = document.getElementById('retry-reasoning-container'); + const retryReasoningLevels = document.getElementById('retry-reasoning-levels'); + + let showReasoning = false; + + if (retryModeModel && retryModeModel.checked) { + const selectedModel = retryModelSelect ? retryModelSelect.value : null; + showReasoning = selectedModel && selectedModel.includes('o1'); + } else if (retryModeAgent && retryModeAgent.checked) { + // Check if agent uses o1 model (you could enhance this by checking agent config) + const selectedAgent = retryAgentSelect ? retryAgentSelect.value : null; + // For now, we'll show reasoning for agents too if they use o1 models + // This could be enhanced by fetching agent model info + showReasoning = false; // Default to false for agents unless we can determine model + } + + if (retryReasoningContainer) { + retryReasoningContainer.style.display = showReasoning ? 'block' : 'none'; + + // Populate reasoning levels if empty and showing + if (showReasoning && retryReasoningLevels && !retryReasoningLevels.hasChildNodes()) { + const levels = [ + { value: 'low', label: 'Low', description: 'Faster responses' }, + { value: 'medium', label: 'Medium', description: 'Balanced' }, + { value: 'high', label: 'High', description: 'More thorough reasoning' } + ]; + + levels.forEach(level => { + const div = document.createElement('div'); + div.className = 'form-check'; + div.innerHTML = ` + + + `; + retryReasoningLevels.appendChild(div); + }); + } + } + } + + // Initial reasoning visibility + updateReasoningVisibility(); + + // Update reasoning visibility when model changes in retry modal + if (retryModelSelect) { + retryModelSelect.addEventListener('change', updateReasoningVisibility); + } + + // Update reasoning visibility when agent changes in retry modal + if (retryAgentSelect) { + retryAgentSelect.addEventListener('change', updateReasoningVisibility); + } + + // Show the retry modal + const retryModal = new bootstrap.Modal(document.getElementById('retry-message-modal')); + retryModal.show(); +} + +/** + * Execute message retry - called when user confirms retry in modal + */ +window.executeMessageRetry = function() { + const pendingRetry = window.pendingMessageRetry; + if (!pendingRetry) { + console.error('❌ No pending retry found'); + return; + } + + const { messageDiv, messageId, messageType } = pendingRetry; + + console.log(`🚀 Executing retry for ${messageType} message: ${messageId}`); + + // Determine retry mode (model or agent) + const retryModeModel = document.getElementById('retry-mode-model'); + const retryModeAgent = document.getElementById('retry-mode-agent'); + const isAgentMode = retryModeAgent && retryModeAgent.checked; + + // Prepare retry request body + const requestBody = {}; + + if (isAgentMode) { + // Agent mode - get agent info + const retryAgentSelect = document.getElementById('retry-agent-select'); + if (retryAgentSelect) { + const selectedOption = retryAgentSelect.options[retryAgentSelect.selectedIndex]; + if (selectedOption) { + requestBody.agent_info = { + id: selectedOption.dataset.agentId || null, + name: selectedOption.dataset.name || '', + display_name: selectedOption.dataset.displayName || selectedOption.textContent || '', + is_global: selectedOption.dataset.isGlobal === 'true', + is_group: selectedOption.dataset.isGroup === 'true', + group_id: selectedOption.dataset.groupId || null, + group_name: selectedOption.dataset.groupName || null + }; + console.log(`🤖 Retry with agent:`, requestBody.agent_info); + } + } + } else { + // Model mode - get model and reasoning effort + const retryModelSelect = document.getElementById('retry-model-select'); + const selectedModel = retryModelSelect ? retryModelSelect.value : null; + requestBody.model = selectedModel; + + let reasoningEffort = null; + const retryReasoningContainer = document.getElementById('retry-reasoning-container'); + if (retryReasoningContainer && retryReasoningContainer.style.display !== 'none') { + const selectedReasoning = document.querySelector('input[name="retry-reasoning-effort"]:checked'); + reasoningEffort = selectedReasoning ? selectedReasoning.value : null; + } + requestBody.reasoning_effort = reasoningEffort; + + console.log(`🧠 Retry with model: ${selectedModel}, Reasoning: ${reasoningEffort}`); + } + + // Close the modal explicitly + const modalElement = document.getElementById('retry-message-modal'); + if (modalElement) { + const modalInstance = bootstrap.Modal.getInstance(modalElement); + if (modalInstance) { + modalInstance.hide(); + } + } + + // Wait a bit for modal to close, then show loading indicator + setTimeout(() => { + console.log('⏰ Modal closed, showing AI typing indicator...'); + + // Show "AI is typing..." indicator + showLoadingIndicatorInChatbox(); + + // Call retry API endpoint + console.log('📡 Calling retry API endpoint...'); + fetch(`/api/message/${messageId}/retry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody) + }) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + throw new Error(data.error || 'Retry failed'); + }); + } + return response.json(); + }) + .then(data => { + console.log('✅ Retry API response:', data); + + if (data.success && data.chat_request) { + console.log('🔄 Retry initiated, calling chat API with:'); + console.log(' retry_user_message_id:', data.chat_request.retry_user_message_id); + console.log(' retry_thread_id:', data.chat_request.retry_thread_id); + console.log(' retry_thread_attempt:', data.chat_request.retry_thread_attempt); + console.log(' Full chat_request:', data.chat_request); + + // Call chat API with the retry parameters + return fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify(data.chat_request) + }); + } else { + throw new Error('Retry response missing chat_request'); + } + }) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + throw new Error(data.error || 'Chat API failed'); + }); + } + return response.json(); + }) + .then(chatData => { + console.log('✅ Chat API response:', chatData); + + // Hide typing indicator + hideLoadingIndicatorInChatbox(); + console.log('🧹 Typing indicator removed'); + + // Get current conversation ID using the proper API + const conversationId = window.chatConversations?.getCurrentConversationId(); + + console.log(`🔍 Current conversation ID: ${conversationId}`); + + // Reload messages to show new attempt (which will automatically hide old attempts) + if (conversationId) { + console.log('🔄 Reloading messages for conversation:', conversationId); + + // Import loadMessages dynamically + import('./chat-messages.js').then(module => { + console.log('📦 chat-messages.js module loaded, calling loadMessages...'); + module.loadMessages(conversationId); + // No toast - the reloaded messages are enough feedback + }).catch(err => { + console.error('❌ Error loading chat-messages module:', err); + showToast('error', 'Failed to reload messages'); + }); + } else { + console.error('❌ No currentConversationId found!'); + + // Try to force a page refresh as fallback + console.log('🔄 Attempting page refresh as fallback...'); + setTimeout(() => { + window.location.reload(); + }, 1000); + } + }) + .catch(error => { + console.error('❌ Retry error:', error); + + // Hide typing indicator on error + hideLoadingIndicatorInChatbox(); + + showToast('error', `Retry failed: ${error.message}`); + }) + .finally(() => { + // Clean up pending retry + window.pendingMessageRetry = null; + }); + + }, 300); // End of setTimeout - wait 300ms for modal to close +}; + +/** + * Handle carousel navigation (switch between retry attempts) + */ +export function handleCarouselNavigation(messageDiv, messageId, direction) { + console.log(`🎠 Carousel ${direction} clicked for message: ${messageId}`); + + // Call switch-attempt API endpoint + fetch(`/api/message/${messageId}/switch-attempt`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + direction: direction // 'prev' or 'next' + }) + }) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + throw new Error(data.error || 'Switch attempt failed'); + }); + } + return response.json(); + }) + .then(data => { + console.log(`✅ Switched to attempt ${data.new_active_attempt}:`, data); + + // Reload messages to show new active attempt + if (window.currentConversationId) { + import('./chat-messages.js').then(module => { + module.loadMessages(window.currentConversationId); + showToast('info', `Switched to attempt ${data.new_active_attempt}`); + }); + } + }) + .catch(error => { + console.error('❌ Carousel navigation error:', error); + showToast('error', `Failed to switch attempt: ${error.message}`); + }); +} + +// Make functions available globally for event handlers in chat-messages.js +window.handleRetryButtonClick = handleRetryButtonClick; +window.handleCarouselNavigation = handleCarouselNavigation; diff --git a/application/single_app/static/js/chat/chat-streaming.js b/application/single_app/static/js/chat/chat-streaming.js new file mode 100644 index 00000000..4092bb84 --- /dev/null +++ b/application/single_app/static/js/chat/chat-streaming.js @@ -0,0 +1,337 @@ +// chat-streaming.js +import { appendMessage, updateUserMessageId } from './chat-messages.js'; +import { hideLoadingIndicatorInChatbox, showLoadingIndicatorInChatbox } from './chat-loading-indicator.js'; +import { loadUserSettings, saveUserSetting } from './chat-layout.js'; +import { showToast } from './chat-toast.js'; +import { updateSidebarConversationTitle } from './chat-sidebar-conversations.js'; + +let streamingEnabled = false; +let currentEventSource = null; + +export function initializeStreamingToggle() { + const streamingToggleBtn = document.getElementById('streaming-toggle-btn'); + if (!streamingToggleBtn) { + console.warn('Streaming toggle button not found'); + return; + } + + console.log('Initializing streaming toggle...'); + + // Load initial state from user settings + loadUserSettings().then(settings => { + console.log('Loaded user settings:', settings); + streamingEnabled = settings.streamingEnabled === true; + console.log('Streaming enabled:', streamingEnabled); + updateStreamingButtonState(); + updateStreamingButtonVisibility(); + }).catch(error => { + console.error('Error loading streaming settings:', error); + }); + + // Handle toggle click + streamingToggleBtn.addEventListener('click', () => { + streamingEnabled = !streamingEnabled; + console.log('Streaming toggled to:', streamingEnabled); + + // Save the setting + console.log('Saving streaming setting...'); + saveUserSetting({ streamingEnabled }); + + updateStreamingButtonState(); + + const message = streamingEnabled + ? 'Streaming enabled - responses will appear in real-time' + : 'Streaming disabled - responses will appear when complete'; + showToast(message, 'info'); + }); + + // Listen for agents toggle - hide streaming button when agents are active + const enableAgentsBtn = document.getElementById('enable-agents-btn'); + if (enableAgentsBtn) { + const observer = new MutationObserver(() => { + updateStreamingButtonVisibility(); + }); + observer.observe(enableAgentsBtn, { attributes: true, attributeFilter: ['class'] }); + } + + updateStreamingButtonVisibility(); +} + +function updateStreamingButtonState() { + const streamingToggleBtn = document.getElementById('streaming-toggle-btn'); + if (!streamingToggleBtn) return; + + if (streamingEnabled) { + streamingToggleBtn.classList.remove('btn-outline-secondary'); + streamingToggleBtn.classList.add('btn-primary'); + streamingToggleBtn.title = 'Streaming enabled - click to disable'; + } else { + streamingToggleBtn.classList.remove('btn-primary'); + streamingToggleBtn.classList.add('btn-outline-secondary'); + streamingToggleBtn.title = 'Streaming disabled - click to enable'; + } +} + +/** + * Update streaming button visibility based on agent state + */ +function updateStreamingButtonVisibility() { + const streamingToggleBtn = document.getElementById('streaming-toggle-btn'); + const enableAgentsBtn = document.getElementById('enable-agents-btn'); + + if (!streamingToggleBtn) return; + + // Show streaming button even when agents are active (agents now support streaming) + streamingToggleBtn.style.display = 'flex'; +} + +export function isStreamingEnabled() { + // Check if image generation is active - streaming is incompatible with image gen + const imageGenBtn = document.getElementById('image-generate-btn'); + if (imageGenBtn && imageGenBtn.classList.contains('active')) { + return false; // Disable streaming when image generation is active + } + return streamingEnabled; +} + +export function sendMessageWithStreaming(messageData, tempUserMessageId, currentConversationId) { + if (!streamingEnabled) { + return null; // Caller should use regular fetch + } + + // Double-check: never stream if image generation is active + const imageGenBtn = document.getElementById('image-generate-btn'); + if (imageGenBtn && imageGenBtn.classList.contains('active')) { + return null; // Force regular fetch for image generation + } + + // Close any existing connection + if (currentEventSource) { + currentEventSource.close(); + currentEventSource = null; + } + + // Create a unique message ID for the AI response + const tempAiMessageId = `temp_ai_${Date.now()}`; + let accumulatedContent = ''; + let streamError = false; + let streamErrorMessage = ''; + + // Create placeholder message with streaming indicator + appendMessage('AI', ' Streaming...', null, tempAiMessageId); + + // Create timeout (5 minutes) + const streamTimeout = setTimeout(() => { + if (currentEventSource) { + currentEventSource.close(); + currentEventSource = null; + streamError = true; + streamErrorMessage = 'Stream timeout (5 minutes exceeded)'; + handleStreamError(tempAiMessageId, accumulatedContent, streamErrorMessage); + } + }, 5 * 60 * 1000); // 5 minutes + + // Use fetch to POST, then read the streaming response + fetch('/api/chat/stream', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify(messageData) + }).then(response => { + if (!response.ok) { + return response.json().then(errData => { + throw new Error(errData.error || `HTTP error! status: ${response.status}`); + }); + } + + // Read the streaming response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + function readStream() { + reader.read().then(({ done, value }) => { + if (done) { + clearTimeout(streamTimeout); + return; + } + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.substring(6); // Remove 'data: ' + const data = JSON.parse(jsonStr); + + if (data.error) { + clearTimeout(streamTimeout); + streamError = true; + streamErrorMessage = data.error; + handleStreamError(tempAiMessageId, data.partial_content || accumulatedContent, data.error); + return; + } + + if (data.content) { + // Append chunk to accumulated content + accumulatedContent += data.content; + updateStreamingMessage(tempAiMessageId, accumulatedContent); + } + + if (data.done) { + clearTimeout(streamTimeout); + + // Update with final metadata + finalizeStreamingMessage( + tempAiMessageId, + tempUserMessageId, + data + ); + + currentEventSource = null; + return; + } + } catch (e) { + console.error('Error parsing SSE data:', e); + } + } + } + + readStream(); // Continue reading + }).catch(err => { + clearTimeout(streamTimeout); + console.error('Stream reading error:', err); + handleStreamError(tempAiMessageId, accumulatedContent, err.message); + }); + } + + readStream(); + + }).catch(error => { + clearTimeout(streamTimeout); + console.error('Streaming request error:', error); + showToast(`Error: ${error.message}`, 'error'); + + // Remove placeholder message + const msgElement = document.querySelector(`[data-message-id="${tempAiMessageId}"]`); + if (msgElement) { + msgElement.remove(); + } + }); + + return true; // Indicates streaming was initiated +} + +function updateStreamingMessage(messageId, content) { + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); + if (!messageElement) return; + + const contentElement = messageElement.querySelector('.message-text'); + if (contentElement) { + // Render markdown during streaming for proper formatting + if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { + const renderedContent = DOMPurify.sanitize(marked.parse(content)); + contentElement.innerHTML = renderedContent; + } else { + contentElement.textContent = content; + } + + // Add subtle streaming cursor indicator + if (!messageElement.querySelector('.streaming-cursor')) { + const cursor = document.createElement('span'); + cursor.className = 'streaming-cursor'; + cursor.innerHTML = ' Streaming'; + contentElement.appendChild(cursor); + } + } +} + +function handleStreamError(messageId, partialContent, errorMessage) { + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); + if (!messageElement) return; + + const contentElement = messageElement.querySelector('.message-text'); + if (contentElement) { + // Remove streaming cursor + const cursor = contentElement.querySelector('.streaming-cursor'); + if (cursor) cursor.remove(); + + // Show partial content with error banner + let finalContent = partialContent || 'Stream interrupted before any content was received.'; + + // Parse markdown for partial content + if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { + finalContent = DOMPurify.sanitize(marked.parse(finalContent)); + } + + contentElement.innerHTML = finalContent; + + // Add error banner + const errorBanner = document.createElement('div'); + errorBanner.className = 'alert alert-warning mt-2 mb-0'; + errorBanner.innerHTML = ` + + Stream interrupted: ${errorMessage} +
+ Response may be incomplete. The partial content above has been saved. + `; + contentElement.appendChild(errorBanner); + } + + showToast(`Stream error: ${errorMessage}`, 'error'); +} + +function finalizeStreamingMessage(messageId, userMessageId, finalData) { + const messageElement = document.querySelector(`[data-message-id="${messageId}"]`); + if (!messageElement) return; + + // Update user message ID first + if (finalData.user_message_id && userMessageId) { + updateUserMessageId(userMessageId, finalData.user_message_id); + } + + // Remove the temporary streaming message + messageElement.remove(); + + // Create proper message with all metadata using appendMessage + appendMessage( + 'AI', + finalData.full_content || '', + finalData.model_deployment_name, + finalData.message_id, + finalData.augmented, + finalData.hybrid_citations || [], + [], + finalData.agent_citations || [], + finalData.agent_display_name || null, + finalData.agent_name || null, + null + ); + + // Update conversation if needed + if (finalData.conversation_id && window.currentConversationId !== finalData.conversation_id) { + window.currentConversationId = finalData.conversation_id; + } + + if (finalData.conversation_title) { + const titleElement = document.getElementById('current-conversation-title'); + if (titleElement && titleElement.textContent === 'New Conversation') { + titleElement.textContent = finalData.conversation_title; + } + + // Update sidebar conversation title in real-time + updateSidebarConversationTitle(finalData.conversation_id, finalData.conversation_title); + } + + showToast('Response complete', 'success'); +} + +export function cancelStreaming() { + if (currentEventSource) { + currentEventSource.close(); + currentEventSource = null; + showToast('Streaming cancelled', 'info'); + } +} diff --git a/application/single_app/static/js/control-center-sidebar-nav.js b/application/single_app/static/js/control-center-sidebar-nav.js index 8ba99a48..1af23e43 100644 --- a/application/single_app/static/js/control-center-sidebar-nav.js +++ b/application/single_app/static/js/control-center-sidebar-nav.js @@ -155,6 +155,14 @@ function showControlCenterTab(tabId) { targetPane.classList.add('show', 'active'); } + // Load tab-specific data when Activity Logs tab is shown + if (tabId === 'activity-logs' && window.controlCenter) { + console.log('Activity Logs tab activated via sidebar, loading logs...'); + setTimeout(() => { + window.controlCenter.loadActivityLogs(); + }, 100); + } + // Update Bootstrap tab buttons (if using top tabs instead of sidebar) const targetTabBtn = document.querySelector(`[data-bs-target="#${tabId}"]`); if (targetTabBtn) { diff --git a/application/single_app/static/js/control-center.js b/application/single_app/static/js/control-center.js index a1848cab..0f64dd37 100644 --- a/application/single_app/static/js/control-center.js +++ b/application/single_app/static/js/control-center.js @@ -15,8 +15,15 @@ class ControlCenter { this.loginsChart = null; this.chatsChart = null; this.documentsChart = null; + this.tokensChart = null; this.currentTrendDays = 30; + // Activity Logs state + this.activityLogsPage = 1; + this.activityLogsPerPage = 50; + this.activityLogsSearch = ''; + this.activityTypeFilter = 'all'; + this.init(); } @@ -47,6 +54,19 @@ class ControlCenter { setTimeout(() => this.loadPublicWorkspaces(), 100); }); + document.getElementById('activity-logs-tab')?.addEventListener('click', () => { + console.log('Activity Logs tab clicked!'); + setTimeout(() => { + console.log('Calling loadActivityLogs...'); + this.loadActivityLogs(); + }, 100); + }); + + // Also use shown.bs.tab as backup + document.getElementById('activity-logs-tab')?.addEventListener('shown.bs.tab', () => { + console.log('Activity Logs tab shown event fired'); + }); + // Search and filter controls document.getElementById('userSearchInput')?.addEventListener('input', this.debounce(() => this.handleSearchChange(), 300)); @@ -154,6 +174,18 @@ class ControlCenter { document.querySelectorAll('input[name="chatTimeWindow"]').forEach(radio => { radio.addEventListener('change', () => this.toggleChatCustomDateRange()); }); + + // Activity Logs event handlers + document.getElementById('activityLogsSearchInput')?.addEventListener('input', + this.debounce(() => this.handleActivityLogsSearchChange(), 300)); + document.getElementById('activityTypeFilterSelect')?.addEventListener('change', + () => this.handleActivityLogsFilterChange()); + document.getElementById('activityLogsPerPageSelect')?.addEventListener('change', + (e) => this.handleActivityLogsPerPageChange(e)); + document.getElementById('exportActivityLogsBtn')?.addEventListener('click', + () => this.exportActivityLogsToCSV()); + document.getElementById('refreshActivityLogsBtn')?.addEventListener('click', + () => this.loadActivityLogs()); } debounce(func, wait) { @@ -1131,10 +1163,11 @@ class ControlCenter { if (response.ok) { console.log('🔍 [Frontend Debug] Activity data received:', data.activity_data); - // Render all three charts + // Render all four charts this.renderLoginsChart(data.activity_data); this.renderChatsChart(data.activity_data); this.renderDocumentsChart(data.activity_data); // Now renders both personal and group + this.renderTokensChart(data.activity_data); // Ensure main loading overlay is hidden after all charts are created this.showLoading(false); } else { @@ -1340,6 +1373,171 @@ class ControlCenter { } } + renderTokensChart(activityData) { + console.log('🔍 [Frontend Debug] Rendering tokens chart with data:', activityData.tokens); + + // Render combined chart with embedding and chat tokens + this.renderCombinedTokensChart('tokensChart', activityData.tokens || {}); + } + + renderCombinedTokensChart(canvasId, tokensData) { + // Check if Chart.js is available + if (typeof Chart === 'undefined') { + console.error(`❌ [Frontend Debug] Chart.js is not loaded. Cannot render tokens chart.`); + this.showChartError(canvasId, 'tokens'); + return; + } + + const canvas = document.getElementById(canvasId); + if (!canvas) { + console.error(`❌ [Frontend Debug] Chart canvas element ${canvasId} not found`); + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + console.error(`❌ [Frontend Debug] Could not get 2D context from ${canvasId} canvas`); + return; + } + + // Show canvas + canvas.style.display = 'block'; + + // Destroy existing chart if it exists + if (this.tokensChart) { + console.log('🔍 [Frontend Debug] Destroying existing tokens chart'); + this.tokensChart.destroy(); + } + + // Prepare data from tokens object (format: { "YYYY-MM-DD": { "embedding": count, "chat": count } }) + const allDates = Object.keys(tokensData).sort(); + console.log('🔍 [Frontend Debug] Token dates:', allDates); + + // Format labels for display + const labels = allDates.map(dateStr => { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }); + + // Extract embedding and chat token counts + const embeddingTokens = allDates.map(date => tokensData[date]?.embedding || 0); + const chatTokens = allDates.map(date => tokensData[date]?.chat || 0); + + console.log('🔍 [Frontend Debug] Embedding tokens:', embeddingTokens); + console.log('🔍 [Frontend Debug] Chat tokens:', chatTokens); + + // Create datasets + const datasets = [ + { + label: 'Embedding Tokens', + data: embeddingTokens, + backgroundColor: 'rgba(111, 66, 193, 0.2)', + borderColor: '#6f42c1', + borderWidth: 2, + fill: false, + tension: 0.4, + pointRadius: 3, + pointHoverRadius: 5, + pointBackgroundColor: '#6f42c1' + }, + { + label: 'Chat Tokens', + data: chatTokens, + backgroundColor: 'rgba(13, 202, 240, 0.2)', + borderColor: '#0dcaf0', + borderWidth: 2, + fill: false, + tension: 0.4, + pointRadius: 3, + pointHoverRadius: 5, + pointBackgroundColor: '#0dcaf0' + } + ]; + + console.log(`🔍 [Frontend Debug] Token datasets prepared:`, datasets); + + // Create new chart + try { + this.tokensChart = new Chart(ctx, { + type: 'line', + data: { + labels: labels, + datasets: datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + usePointStyle: true, + padding: 15 + } + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + title: function(context) { + const dataIndex = context[0].dataIndex; + const dateStr = allDates[dataIndex]; + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }); + }, + label: function(context) { + let label = context.dataset.label || ''; + if (label) { + label += ': '; + } + label += context.parsed.y.toLocaleString() + ' tokens'; + return label; + } + } + } + }, + scales: { + x: { + display: true, + grid: { + display: false + } + }, + y: { + display: true, + beginAtZero: true, + grid: { + color: 'rgba(0, 0, 0, 0.1)' + }, + ticks: { + precision: 0, + callback: function(value) { + return value.toLocaleString(); + } + } + } + }, + interaction: { + intersect: false, + mode: 'index' + } + } + }); + + console.log(`✅ [Frontend Debug] Tokens chart created successfully`); + + } catch (error) { + console.error(`❌ [Frontend Debug] Error creating tokens chart:`, error); + this.showChartError(canvasId, 'tokens'); + } + } + renderSingleChart(canvasId, chartType, chartData, chartConfig) { // Check if Chart.js is available if (typeof Chart === 'undefined') { @@ -1567,6 +1765,7 @@ class ControlCenter { if (document.getElementById('exportPersonalDocuments').checked) selectedCharts.push('personal_documents'); if (document.getElementById('exportGroupDocuments').checked) selectedCharts.push('group_documents'); if (document.getElementById('exportPublicDocuments').checked) selectedCharts.push('public_documents'); + if (document.getElementById('exportTokens').checked) selectedCharts.push('tokens'); if (selectedCharts.length === 0) { alert('Please select at least one chart to export.'); @@ -1659,6 +1858,347 @@ class ControlCenter { } } + // Activity Logs Methods + async loadActivityLogs() { + console.log('=== loadActivityLogs CALLED ==='); + console.log('this:', this); + console.log('State:', { + activityLogsPage: this.activityLogsPage, + activityLogsPerPage: this.activityLogsPerPage, + activityLogsSearch: this.activityLogsSearch, + activityTypeFilter: this.activityTypeFilter + }); + + try { + const params = new URLSearchParams({ + page: this.activityLogsPage, + per_page: this.activityLogsPerPage, + search: this.activityLogsSearch, + activity_type_filter: this.activityTypeFilter + }); + + const url = `/api/admin/control-center/activity-logs?${params}`; + console.log('Fetching from:', url); + + const response = await fetch(url); + console.log('Response received:', response.status); + + if (!response.ok) { + throw new Error('Failed to load activity logs'); + } + + const data = await response.json(); + console.log('Activity logs loaded:', data); + + this.renderActivityLogs(data.logs, data.user_map); + this.renderActivityLogsPagination(data.pagination); + + } catch (error) { + console.error('Error loading activity logs:', error); + this.showActivityLogsError('Failed to load activity logs. Please try again.'); + } + } + + renderActivityLogs(logs, userMap) { + const tbody = document.getElementById('activityLogsTableBody'); + if (!tbody) return; + + if (!logs || logs.length === 0) { + tbody.innerHTML = ` + + +
No activity logs found
+ + + `; + return; + } + + tbody.innerHTML = logs.map(log => { + const user = userMap[log.user_id] || {}; + const userName = user.display_name || user.email || log.user_id; + const timestamp = new Date(log.timestamp).toLocaleString(); + const activityType = this.formatActivityType(log.activity_type); + const details = this.formatActivityDetails(log); + const workspaceType = log.workspace_type || 'N/A'; + + return ` + + ${timestamp} + ${activityType} + ${this.escapeHtml(userName)} + ${details} + ${this.capitalizeFirst(workspaceType)} + + `; + }).join(''); + } + + formatActivityType(activityType) { + const typeMap = { + 'user_login': 'User Login', + 'conversation_creation': 'Conversation Created', + 'document_creation': 'Document Created', + 'token_usage': 'Token Usage', + 'conversation_deletion': 'Conversation Deleted', + 'conversation_archival': 'Conversation Archived' + }; + return typeMap[activityType] || activityType; + } + + formatActivityDetails(log) { + const activityType = log.activity_type; + + switch (activityType) { + case 'user_login': + return `Login method: ${log.login_method || log.details?.login_method || 'N/A'}`; + + case 'conversation_creation': + const convTitle = log.conversation?.title || 'Untitled'; + const convId = log.conversation?.conversation_id || 'N/A'; + return `Title: ${this.escapeHtml(convTitle)}
ID: ${convId}`; + + case 'document_creation': + const fileName = log.document?.file_name || 'Unknown'; + const fileType = log.document?.file_type || ''; + return `File: ${this.escapeHtml(fileName)}
Type: ${fileType}`; + + case 'token_usage': + const tokenType = log.token_type || 'unknown'; + const totalTokens = log.usage?.total_tokens || 0; + const model = log.usage?.model || 'N/A'; + return `Type: ${tokenType}
Tokens: ${totalTokens.toLocaleString()}
Model: ${model}`; + + case 'conversation_deletion': + const delTitle = log.conversation?.title || 'Untitled'; + const delId = log.conversation?.conversation_id || 'N/A'; + return `Deleted: ${this.escapeHtml(delTitle)}
ID: ${delId}`; + + case 'conversation_archival': + const archTitle = log.conversation?.title || 'Untitled'; + const archId = log.conversation?.conversation_id || 'N/A'; + return `Archived: ${this.escapeHtml(archTitle)}
ID: ${archId}`; + + default: + return 'N/A'; + } + } + + renderActivityLogsPagination(pagination) { + const paginationInfo = document.getElementById('activityLogsPaginationInfo'); + const paginationNav = document.getElementById('activityLogsPagination'); + + if (paginationInfo) { + const start = (pagination.page - 1) * pagination.per_page + 1; + const end = Math.min(pagination.page * pagination.per_page, pagination.total_items); + paginationInfo.textContent = `Showing ${start}-${end} of ${pagination.total_items} logs`; + } + + if (paginationNav) { + let paginationHtml = ''; + + // Previous button + paginationHtml += ` +
  • + + + +
  • + `; + + // Page numbers + const startPage = Math.max(1, pagination.page - 2); + const endPage = Math.min(pagination.total_pages, pagination.page + 2); + + if (startPage > 1) { + paginationHtml += ` +
  • + 1 +
  • + `; + if (startPage > 2) { + paginationHtml += '
  • ...
  • '; + } + } + + for (let i = startPage; i <= endPage; i++) { + paginationHtml += ` +
  • + ${i} +
  • + `; + } + + if (endPage < pagination.total_pages) { + if (endPage < pagination.total_pages - 1) { + paginationHtml += '
  • ...
  • '; + } + paginationHtml += ` +
  • + ${pagination.total_pages} +
  • + `; + } + + // Next button + paginationHtml += ` +
  • + + + +
  • + `; + + paginationNav.innerHTML = paginationHtml; + } + } + + goToActivityLogsPage(page) { + this.activityLogsPage = page; + this.loadActivityLogs(); + } + + handleActivityLogsSearchChange() { + const searchInput = document.getElementById('activityLogsSearchInput'); + this.activityLogsSearch = searchInput ? searchInput.value : ''; + this.activityLogsPage = 1; + this.loadActivityLogs(); + } + + handleActivityLogsFilterChange() { + const filterSelect = document.getElementById('activityTypeFilterSelect'); + this.activityTypeFilter = filterSelect ? filterSelect.value : 'all'; + this.activityLogsPage = 1; + this.loadActivityLogs(); + } + + handleActivityLogsPerPageChange(event) { + this.activityLogsPerPage = parseInt(event.target.value); + this.activityLogsPage = 1; + this.loadActivityLogs(); + } + + async exportActivityLogsToCSV() { + try { + // Get current filtered data + const params = new URLSearchParams({ + page: 1, + per_page: 10000, // Get all for export + search: this.activityLogsSearch, + activity_type_filter: this.activityTypeFilter + }); + + const response = await fetch(`/api/admin/control-center/activity-logs?${params}`); + + if (!response.ok) { + throw new Error('Failed to load activity logs for export'); + } + + const data = await response.json(); + + // Convert to CSV + const headers = ['Timestamp', 'Activity Type', 'User ID', 'User Email', 'User Name', 'Details', 'Workspace Type']; + const csvRows = [headers.join(',')]; + + data.logs.forEach(log => { + const user = data.user_map[log.user_id] || {}; + const timestamp = new Date(log.timestamp).toISOString(); + const activityType = log.activity_type; + const userId = log.user_id; + const userEmail = user.email || ''; + const userName = user.display_name || ''; + const details = this.getActivityDetailsForCSV(log); + const workspaceType = log.workspace_type || ''; + + const row = [ + timestamp, + activityType, + userId, + userEmail, + userName, + details, + workspaceType + ].map(field => `"${String(field).replace(/"/g, '""')}"`); + + csvRows.push(row.join(',')); + }); + + // Download CSV + const csvContent = csvRows.join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `activity_logs_${new Date().toISOString().split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + } catch (error) { + console.error('Error exporting activity logs:', error); + alert('Failed to export activity logs. Please try again.'); + } + } + + getActivityDetailsForCSV(log) { + const activityType = log.activity_type; + + switch (activityType) { + case 'user_login': + return `Login method: ${log.login_method || log.details?.login_method || 'N/A'}`; + + case 'conversation_creation': + return `Title: ${log.conversation?.title || 'Untitled'}, ID: ${log.conversation?.conversation_id || 'N/A'}`; + + case 'document_creation': + return `File: ${log.document?.file_name || 'Unknown'}, Type: ${log.document?.file_type || ''}`; + + case 'token_usage': + return `Type: ${log.token_type || 'unknown'}, Tokens: ${log.usage?.total_tokens || 0}, Model: ${log.usage?.model || 'N/A'}`; + + case 'conversation_deletion': + return `Deleted: ${log.conversation?.title || 'Untitled'}, ID: ${log.conversation?.conversation_id || 'N/A'}`; + + case 'conversation_archival': + return `Archived: ${log.conversation?.title || 'Untitled'}, ID: ${log.conversation?.conversation_id || 'N/A'}`; + + default: + return 'N/A'; + } + } + + showActivityLogsError(message) { + const tbody = document.getElementById('activityLogsTableBody'); + if (tbody) { + tbody.innerHTML = ` + + +
    + ${message} +
    + + + `; + } + } + + capitalizeFirst(str) { + if (!str) return ''; + return str.charAt(0).toUpperCase() + str.slice(1); + } + + escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, m => map[m]); + } + toggleChatCustomDateRange() { const customRadio = document.getElementById('chatCustom'); const customDateRange = document.getElementById('chatCustomDateRange'); @@ -1777,6 +2317,10 @@ class ControlCenter { this.documentsChart.destroy(); this.documentsChart = null; } + if (this.tokensChart) { + this.tokensChart.destroy(); + this.tokensChart = null; + } if (this.personalDocumentsChart) { this.personalDocumentsChart.destroy(); this.personalDocumentsChart = null; @@ -1789,10 +2333,11 @@ class ControlCenter { } showAllChartsError() { - // Show error for all three charts + // Show error for all four charts this.showChartError('loginsChart', 'logins'); this.showChartError('chatsChart', 'chats'); this.showChartError('documentsChart', 'documents'); + this.showChartError('tokensChart', 'tokens'); // Ensure main loading overlay is hidden when showing error this.showLoading(false); @@ -2620,6 +3165,165 @@ function showAlert(message, type = 'info') { }, 5000); } +// Activity Log Migration Functions +async function checkMigrationStatus() { + try { + const response = await fetch('/api/admin/control-center/migrate/status'); + if (!response.ok) { + throw new Error('Failed to fetch migration status'); + } + + const data = await response.json(); + + if (data.migration_needed) { + // Update banner with counts + document.getElementById('migrationConversationCount').textContent = data.conversations_without_logs.toLocaleString(); + document.getElementById('migrationDocumentCount').textContent = data.total_documents_without_logs.toLocaleString(); + + // Show the banner + const banner = document.getElementById('migrationBanner'); + if (banner) { + banner.style.display = 'block'; + } + } else { + // Hide banner if no migration needed + const banner = document.getElementById('migrationBanner'); + if (banner) { + banner.style.display = 'none'; + } + } + + return data; + } catch (error) { + console.error('Error checking migration status:', error); + return null; + } +} + +function showMigrationProgress() { + const progressDiv = document.getElementById('migrationProgress'); + const migrateBtn = document.getElementById('migrateBannerBtn'); + + if (progressDiv) { + progressDiv.style.display = 'block'; + } + + if (migrateBtn) { + migrateBtn.disabled = true; + migrateBtn.innerHTML = ' Migrating...'; + } +} + +function hideMigrationProgress() { + const progressDiv = document.getElementById('migrationProgress'); + const migrateBtn = document.getElementById('migrateBannerBtn'); + + if (progressDiv) { + progressDiv.style.display = 'none'; + } + + if (migrateBtn) { + migrateBtn.disabled = false; + migrateBtn.innerHTML = ' Migrate Now'; + } +} + +function updateMigrationProgress(percent, statusText) { + const progressBar = document.getElementById('migrationProgressBar'); + const progressText = document.getElementById('migrationProgressText'); + const statusTextEl = document.getElementById('migrationStatusText'); + + if (progressBar) { + progressBar.style.width = percent + '%'; + progressBar.setAttribute('aria-valuenow', percent); + } + + if (progressText) { + progressText.textContent = percent + '%'; + } + + if (statusTextEl && statusText) { + statusTextEl.textContent = statusText; + } +} + +function hideMigrationBanner() { + const banner = document.getElementById('migrationBanner'); + if (banner) { + banner.style.display = 'none'; + } +} + +async function performMigration() { + // Confirm with user + if (!confirm('This migration may take several minutes and could affect system performance. Are you sure you want to continue?\n\nRecommended to run during off-peak hours.')) { + return; + } + + try { + showMigrationProgress(); + updateMigrationProgress(10, 'Starting migration...'); + + const response = await fetch('/api/admin/control-center/migrate/all', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + updateMigrationProgress(50, 'Processing records...'); + + if (!response.ok) { + throw new Error('Migration request failed'); + } + + const result = await response.json(); + + updateMigrationProgress(90, 'Finalizing...'); + + // Show results + setTimeout(() => { + updateMigrationProgress(100, 'Migration completed!'); + + setTimeout(() => { + hideMigrationProgress(); + hideMigrationBanner(); + + // Show detailed results + const totalMigrated = result.total_migrated || 0; + const totalFailed = result.total_failed || 0; + + let message = `Migration completed successfully!\n\n`; + message += `✓ Conversations migrated: ${result.conversations_migrated || 0}\n`; + message += `✓ Personal documents migrated: ${result.personal_documents_migrated || 0}\n`; + message += `✓ Group documents migrated: ${result.group_documents_migrated || 0}\n`; + message += `✓ Public documents migrated: ${result.public_documents_migrated || 0}\n`; + message += `\nTotal: ${totalMigrated} records migrated`; + + if (totalFailed > 0) { + message += `\n\n⚠ ${totalFailed} records failed to migrate (check logs for details)`; + } + + alert(message); + + // Refresh activity trends to show new data + if (window.controlCenter) { + window.controlCenter.loadActivityTrends(); + } + }, 1500); + }, 500); + + } catch (error) { + console.error('Migration error:', error); + hideMigrationProgress(); + alert('Migration failed: ' + error.message + '\n\nPlease check the console and server logs for details.'); + } +} + +// Make migration functions globally accessible +window.checkMigrationStatus = checkMigrationStatus; +window.performMigration = performMigration; + // Make refresh function globally accessible for debugging window.refreshControlCenterData = refreshControlCenterData; window.loadRefreshStatus = loadRefreshStatus; @@ -2645,5 +3349,8 @@ document.addEventListener('DOMContentLoaded', function() { // Load initial refresh status with a slight delay to ensure elements are rendered setTimeout(() => { loadRefreshStatus(); + + // Check migration status + checkMigrationStatus(); }, 100); }); \ No newline at end of file diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 8904ec81..d017f4c2 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -1513,6 +1513,7 @@ export class PluginModalStepper { } // Store the OpenAPI spec content directly in the plugin config + // IMPORTANT: Set these BEFORE collecting additional fields so they don't get overwritten additionalFields.openapi_spec_content = JSON.parse(specContent); additionalFields.openapi_source_type = 'content'; // Changed from 'file' additionalFields.base_url = endpoint; @@ -1686,9 +1687,12 @@ export class PluginModalStepper { } } - // Collect additional fields from the dynamic UI + // Collect additional fields from the dynamic UI and MERGE with existing additionalFields + // This preserves OpenAPI spec content and other auto-populated fields try { - additionalFields = this.collectAdditionalFields(); + const dynamicFields = this.collectAdditionalFields(); + // Merge dynamicFields into additionalFields (preserving existing values) + additionalFields = { ...additionalFields, ...dynamicFields }; } catch (e) { throw new Error('Invalid additional fields input'); } diff --git a/application/single_app/static/json/schemas/agent.schema.json b/application/single_app/static/json/schemas/agent.schema.json index 69652a15..7ec0eaa6 100644 --- a/application/single_app/static/json/schemas/agent.schema.json +++ b/application/single_app/static/json/schemas/agent.schema.json @@ -57,6 +57,11 @@ "enable_agent_gpt_apim": { "type": "boolean" }, + "reasoning_effort": { + "type": "string", + "enum": ["none", "minimal", "low", "medium", "high"], + "description": "Reasoning effort level for models that support it (e.g., gpt-5, o1, o3)" + }, "default_agent": { "type": "boolean", "description": "(deprecated) Use selected_agent for agent selection." diff --git a/application/single_app/templates/_agent_modal.html b/application/single_app/templates/_agent_modal.html index b22dc789..1f9775bb 100644 --- a/application/single_app/templates/_agent_modal.html +++ b/application/single_app/templates/_agent_modal.html @@ -112,6 +112,19 @@
    Model & Connection
    Inheritable +
    + + Optional + +
    Only applies to models that support reasoning (e.g., gpt-5, o1, o3)
    +
    diff --git a/application/single_app/templates/_sidebar_nav.html b/application/single_app/templates/_sidebar_nav.html index 4e164f1a..44ad22e2 100644 --- a/application/single_app/templates/_sidebar_nav.html +++ b/application/single_app/templates/_sidebar_nav.html @@ -535,6 +535,11 @@ Public Workspaces +
    diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index c0169389..e8dc1fe2 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -1788,6 +1788,23 @@

    + + +
    + + + +
    +

    + When enabled, no users will be able to create new groups, regardless of their role membership. This is a global setting that overrides the 'Require Membership to Create Groups' setting below. +

    +
    {% endfor %} {% endif %} -
    Select a GPT model with vision capabilities (e.g., gpt-4o, gpt-4-vision). Only vision-capable models are shown.
    +
    Select a GPT model with vision capabilities (e.g., gpt-4o, gpt-4-vision, gpt-5, gpt-5-nano, etc.). Only vision-capable models are shown.
    + + + + {% if settings.enable_user_workspace or settings.enable_group_workspaces %}
    + + + + + + + + + + + + + + +
    {% endblock %} @@ -628,6 +822,7 @@
    Recent Searches
    window.enableUserFeedback = "{{ enable_user_feedback }}"; window.activeGroupId = "{{ active_group_id }}"; window.activeGroupName = "{{ active_group_name }}"; + window.activePublicWorkspaceId = "{{ active_public_workspace_id }}"; window.enableEnhancedCitations = "{{ enable_enhanced_citations }}"; window.enable_document_classification = "{{ enable_document_classification }}"; window.classification_categories = JSON.parse('{{ settings.document_classification_categories|tojson(indent=None)|safe }}' || '[]'); @@ -635,6 +830,12 @@
    Recent Searches
    window.classification_categories = []; } + // Current user information for message masking + window.currentUser = { + id: "{{ user_id }}", + display_name: "{{ user_display_name }}" + }; + // Layout related globals (can stay here or move entirely into chat-layout.js if preferred) let splitInstance = null; let currentLayout = 'split'; // Default layout @@ -658,6 +859,8 @@
    Recent Searches
    + + {% if settings.enable_semantic_kernel %} diff --git a/application/single_app/templates/control_center.html b/application/single_app/templates/control_center.html index fcf7629f..9f048c4a 100644 --- a/application/single_app/templates/control_center.html +++ b/application/single_app/templates/control_center.html @@ -367,11 +367,53 @@

    Control Center

    + + + {% set nav_layout = user_settings.get('settings', {}).get('navLayout') %} {% if not (nav_layout == 'sidebar' or (not nav_layout and app_settings.enable_left_nav_default)) %} @@ -400,6 +442,12 @@

    Control Center

    Public Workspaces + {% endif %} @@ -512,6 +560,7 @@
    Activity Trends + Real-time, does not require refresh
    @@ -606,6 +655,24 @@
    + + +
    +
    +
    +
    +
    + Token Usage +
    +
    +
    +
    + +
    +
    +
    +
    +
    @@ -899,6 +966,93 @@
    No Public Workspaces Found
    + + +
    +
    +
    + Activity Logs +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + + + + + + + + + + + + + + +
    TimestampActivity TypeUserDetailsWorkspace Type
    +
    + Loading... +
    +
    Loading activity logs...
    +
    +
    + + +
    +
    + +
    + +
    +
    +
    +
    @@ -1093,6 +1247,12 @@
    Select Charts to Export:
    Public Documents +
    + + +
    @@ -1138,6 +1298,7 @@
    Select Time Window:
  • Personal Documents: Display name, email, user ID, personal document details, sizes, upload date
  • Group Documents: Display name, email, user ID, group document details, sizes, upload date
  • Public Documents: Display name, email, user ID, public document details, sizes, upload date
  • +
  • Token Usage: Display name, email, user ID, token type (chat/embedding), model name, prompt tokens, completion tokens, total tokens, timestamp
  • diff --git a/application/single_app/utils_cache.py b/application/single_app/utils_cache.py index 2b4e6243..c597287f 100644 --- a/application/single_app/utils_cache.py +++ b/application/single_app/utils_cache.py @@ -54,7 +54,7 @@ def get_cache_settings(): return (True, 300) # Default: enabled with 5 minute TTL -def debug_print(message: str, context: str = "CACHE", **kwargs): +def _debug_print(message: str, context: str = "CACHE", **kwargs): """ Conditional debug logging with timestamp and context. @@ -92,7 +92,7 @@ def get_personal_document_fingerprint(user_id: str) -> str: Returns: SHA256 hash of sorted document IDs """ - debug_print("Generating personal document fingerprint", "FINGERPRINT", user_id=user_id[:8]) + _debug_print("Generating personal document fingerprint", "FINGERPRINT", user_id=user_id[:8]) try: query = """ @@ -118,7 +118,7 @@ def get_personal_document_fingerprint(user_id: str) -> str: fingerprint_string = '|'.join(doc_identifiers) fingerprint = hashlib.sha256(fingerprint_string.encode()).hexdigest() - debug_print( + _debug_print( f"Generated personal fingerprint: {fingerprint[:16]}...", "FINGERPRINT", user_id=user_id[:8], @@ -129,7 +129,7 @@ def get_personal_document_fingerprint(user_id: str) -> str: except Exception as e: logger.error(f"Error generating personal document fingerprint for user {user_id}: {e}") - debug_print(f"[DEBUG] ERROR generating fingerprint: {e}", "FINGERPRINT", user_id=user_id[:8]) + _debug_print(f"ERROR generating fingerprint: {e}", "FINGERPRINT", user_id=user_id[:8]) # Return timestamp-based fingerprint to prevent caching on error return hashlib.sha256(str(datetime.now().timestamp()).encode()).hexdigest() @@ -144,7 +144,7 @@ def get_group_document_fingerprint(group_id: str) -> str: Returns: SHA256 hash of sorted document IDs """ - debug_print("Generating group document fingerprint", "FINGERPRINT", group_id=group_id[:8]) + _debug_print("Generating group document fingerprint", "FINGERPRINT", group_id=group_id[:8]) try: query = """ @@ -169,7 +169,7 @@ def get_group_document_fingerprint(group_id: str) -> str: fingerprint_string = '|'.join(doc_identifiers) fingerprint = hashlib.sha256(fingerprint_string.encode()).hexdigest() - debug_print( + _debug_print( f"Generated group fingerprint: {fingerprint[:16]}...", "FINGERPRINT", group_id=group_id[:8], @@ -180,7 +180,7 @@ def get_group_document_fingerprint(group_id: str) -> str: except Exception as e: logger.error(f"Error generating group document fingerprint for group {group_id}: {e}") - debug_print(f"[DEBUG] ERROR generating fingerprint: {e}", "FINGERPRINT", group_id=group_id[:8]) + _debug_print(f"ERROR generating fingerprint: {e}", "FINGERPRINT", group_id=group_id[:8]) return hashlib.sha256(str(datetime.now().timestamp()).encode()).hexdigest() @@ -194,7 +194,7 @@ def get_public_workspace_document_fingerprint(public_workspace_id: str) -> str: Returns: SHA256 hash of sorted document IDs """ - debug_print("Generating public workspace document fingerprint", "FINGERPRINT", workspace_id=public_workspace_id[:8]) + _debug_print("Generating public workspace document fingerprint", "FINGERPRINT", workspace_id=public_workspace_id[:8]) try: query = """ @@ -219,7 +219,7 @@ def get_public_workspace_document_fingerprint(public_workspace_id: str) -> str: fingerprint_string = '|'.join(doc_identifiers) fingerprint = hashlib.sha256(fingerprint_string.encode()).hexdigest() - debug_print( + _debug_print( f"Generated public workspace fingerprint: {fingerprint[:16]}...", "FINGERPRINT", workspace_id=public_workspace_id[:8], @@ -230,7 +230,7 @@ def get_public_workspace_document_fingerprint(public_workspace_id: str) -> str: except Exception as e: logger.error(f"Error generating public workspace document fingerprint for workspace {public_workspace_id}: {e}") - debug_print(f"[DEBUG] ERROR generating fingerprint: {e}", "FINGERPRINT", workspace_id=public_workspace_id[:8]) + _debug_print(f"ERROR generating fingerprint: {e}", "FINGERPRINT", workspace_id=public_workspace_id[:8]) return hashlib.sha256(str(datetime.now().timestamp()).encode()).hexdigest() @@ -352,7 +352,7 @@ def generate_search_cache_key( cache_key_string = '|'.join(cache_key_components) cache_key = hashlib.sha256(cache_key_string.encode()).hexdigest() - debug_print( + _debug_print( f"Generated cache key: {cache_key[:16]}...", "CACHE_KEY", query=query[:40], @@ -387,7 +387,7 @@ def get_cached_search_results( # Check if caching is enabled cache_enabled, ttl_seconds = get_cache_settings() if not cache_enabled: - debug_print("Cache DISABLED - Skipping cache read", "CACHE") + _debug_print("Cache DISABLED - Skipping cache read", "CACHE") return None # Determine correct partition key based on scope for shared cache access @@ -407,7 +407,7 @@ def get_cached_search_results( seconds_remaining = (expiry_time - datetime.now(timezone.utc)).total_seconds() results = cache_item['results'] - debug_print( + _debug_print( "CACHE HIT - Returning cached results from Cosmos DB", "CACHE", cache_key=cache_key[:16], @@ -420,7 +420,7 @@ def get_cached_search_results( return results else: # Expired - delete from cache - debug_print( + _debug_print( "Cache entry EXPIRED - Removing from Cosmos DB", "CACHE", cache_key=cache_key[:16] @@ -432,7 +432,7 @@ def get_cached_search_results( pass # Already deleted by TTL or doesn't exist except CosmosResourceNotFoundError: - debug_print( + _debug_print( "CACHE MISS - Need to execute search", "CACHE", cache_key=cache_key[:16] @@ -440,7 +440,7 @@ def get_cached_search_results( logger.debug(f"Cache miss for key: {cache_key}") except Exception as e: logger.error(f"Error reading cache from Cosmos DB: {e}") - debug_print(f"[DEBUG] Cache read ERROR: {e}", "CACHE", cache_key=cache_key[:16]) + _debug_print(f"Cache read ERROR: {e}", "CACHE", cache_key=cache_key[:16]) return None @@ -462,7 +462,7 @@ def cache_search_results(cache_key: str, results: List[Dict[str, Any]], user_id: # Check if caching is enabled cache_enabled, ttl_seconds = get_cache_settings() if not cache_enabled: - debug_print("Cache DISABLED - Skipping cache write", "CACHE") + _debug_print("Cache DISABLED - Skipping cache write", "CACHE") return # Determine correct partition key based on scope for shared cache storage @@ -483,7 +483,7 @@ def cache_search_results(cache_key: str, results: List[Dict[str, Any]], user_id: try: cosmos_search_cache_container.upsert_item(cache_item) - debug_print( + _debug_print( "Cached search results in Cosmos DB", "CACHE", cache_key=cache_key[:16], @@ -496,7 +496,7 @@ def cache_search_results(cache_key: str, results: List[Dict[str, Any]], user_id: logger.debug(f"Cached search results with key: {cache_key}, scope: {doc_scope}, partition: {partition_key[:25]}, ttl: {ttl_seconds}s, expires at: {expiry_time}") except Exception as e: logger.error(f"Error caching search results to Cosmos DB: {e}") - debug_print(f"[DEBUG] Cache write ERROR: {e}", "CACHE", cache_key=cache_key[:16]) + _debug_print(f"Cache write ERROR: {e}", "CACHE", cache_key=cache_key[:16]) # Cache Expiration Strategy: @@ -522,7 +522,7 @@ def invalidate_personal_search_cache(user_id: str) -> int: Returns: Number of cache entries invalidated """ - debug_print( + _debug_print( "Invalidating personal search cache in Cosmos DB", "INVALIDATION", user_id=user_id[:8] @@ -552,7 +552,7 @@ def invalidate_personal_search_cache(user_id: str) -> int: logger.warning(f"Failed to delete cache item {item['id']}: {e}") if count > 0: - debug_print( + _debug_print( f"Invalidated {count} cache entries from Cosmos DB", "INVALIDATION", user_id=user_id[:8] @@ -563,7 +563,7 @@ def invalidate_personal_search_cache(user_id: str) -> int: except Exception as e: logger.error(f"Error invalidating personal search cache: {e}") - debug_print(f"[DEBUG] Invalidation ERROR: {e}", "INVALIDATION", user_id=user_id[:8]) + _debug_print(f"Invalidation ERROR: {e}", "INVALIDATION", user_id=user_id[:8]) return 0 @@ -585,7 +585,7 @@ def invalidate_group_search_cache(group_id: str) -> int: Returns: Number of cache entries invalidated """ - debug_print( + _debug_print( "Invalidating group search cache in Cosmos DB (affects ALL group members)", "INVALIDATION", group_id=group_id[:8] @@ -616,7 +616,7 @@ def invalidate_group_search_cache(group_id: str) -> int: logger.warning(f"Failed to delete cache item {item['id']}: {e}") if count > 0: - debug_print( + _debug_print( f"Invalidated {count} cache entries from Cosmos DB", "INVALIDATION", group_id=group_id[:8] @@ -627,7 +627,7 @@ def invalidate_group_search_cache(group_id: str) -> int: except Exception as e: logger.error(f"Error invalidating group search cache: {e}") - debug_print(f"[DEBUG] Invalidation ERROR: {e}", "INVALIDATION", group_id=group_id[:8]) + _debug_print(f"Invalidation ERROR: {e}", "INVALIDATION", group_id=group_id[:8]) return 0 @@ -648,7 +648,7 @@ def invalidate_public_workspace_search_cache(public_workspace_id: str) -> int: Returns: Number of cache entries invalidated """ - debug_print( + _debug_print( "Invalidating public workspace cache in Cosmos DB (affects ALL workspace users)", "INVALIDATION", workspace_id=public_workspace_id[:8] @@ -679,7 +679,7 @@ def invalidate_public_workspace_search_cache(public_workspace_id: str) -> int: logger.warning(f"Failed to delete cache item {item['id']}: {e}") if count > 0: - debug_print( + _debug_print( f"Invalidated {count} cache entries from Cosmos DB", "INVALIDATION", workspace_id=public_workspace_id[:8] @@ -690,7 +690,7 @@ def invalidate_public_workspace_search_cache(public_workspace_id: str) -> int: except Exception as e: logger.error(f"Error invalidating public workspace search cache: {e}") - debug_print(f"[DEBUG] Invalidation ERROR: {e}", "INVALIDATION", workspace_id=public_workspace_id[:8]) + _debug_print(f"Invalidation ERROR: {e}", "INVALIDATION", workspace_id=public_workspace_id[:8]) return 0 @@ -737,7 +737,7 @@ def clear_all_cache() -> int: Returns: Number of cache entries cleared """ - debug_print("Clearing ALL cache entries from Cosmos DB", "ADMIN") + _debug_print("Clearing ALL cache entries from Cosmos DB", "ADMIN") try: # Query all items (cross-partition query) @@ -761,12 +761,12 @@ def clear_all_cache() -> int: logger.warning(f"Failed to delete cache item {item['id']}: {e}") logger.info(f"Cleared all search cache ({count} entries)") - debug_print(f"[DEBUG] Cleared {count} cache entries", "ADMIN") + _debug_print(f"Cleared {count} cache entries", "ADMIN") return count except Exception as e: logger.error(f"Error clearing all cache: {e}") - debug_print(f"[DEBUG] Clear cache ERROR: {e}", "ADMIN") + _debug_print(f"Clear cache ERROR: {e}", "ADMIN") return 0 diff --git a/artifacts/private_endpoints.vsdx b/artifacts/private_endpoints.vsdx index 68852e89e6d7d6181e13d931c360f05f5c33203c..acda333b0fe5c8201f6988ce6664f4e538848e8b 100644 GIT binary patch delta 62892 zcmV)NK)1iy(kIN*C$NtP1#~6zrk=Bu2mb_rF=G!2Yuub{kz)Y$bvkXYt{g2cM;9gF z(mp-XeKO=(3mErflhl+(m)GEr%{ogKIRQ)>|L^Sqjey-h>|z5J$Bpy12Vzr+yJ6UW zF5*7>BhqmWHvqUZt_0vF+X46h`{!wr`m0s^%=wGSCFhS-|IDI?$4t-;n_yq9zWNih zxQ6CtOf$L+GuXUB?4jI>t7NmpSchF~vYh*AntYi>zcNl0?o3|ah&@D0PIs5IDcGhU zT-gN0j;@Xu#4Yo68ZJd=7|IHg#R^-0a{as#!WvZUBv3Si?K!X7=tm^-jk+xr?_8JW zW{>kPI#Z)YB?(f$u`((Ghg!rWjaT`bf z+%O)RsesMy!kEEm&XSa)j9!q{jpB_U8!D6J*<

    uH%&5XJ=swCTY{Ia0t17h!7QX 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%0D6w^%~_4t6}-oOU1>B0xvn9uX&Odw1xNMO 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 0: + final_update_args["embedding_tokens"] = total_embedding_tokens +if embedding_model_name: + final_update_args["embedding_model_deployment_name"] = embedding_model_name +``` + +#### 3. `config.py` +- **Change:** Incremented version to `0.233.298` + +### Cosmos DB Schema Updates + +#### Personal Workspace Documents Container +New fields added to document metadata: + +```json +{ + "id": "document-id", + "file_name": "example.txt", + "num_chunks": 15, + "embedding_tokens": 1847, + "embedding_model_deployment_name": "text-embedding-3-small", + "status": "Processing complete", + "percentage_complete": 100, + ... +} +``` + +### Token Usage Tracking Pattern + +This implementation follows the same pattern used for chat message token tracking: + +**Chat Message Example:** +```json +{ + "metadata": { + "token_usage": { + "prompt_tokens": 5423, + "completion_tokens": 1513, + "total_tokens": 6936, + "captured_at": "2025-12-19T04:24:15.122492" + } + } +} +``` + +**Document Embedding Example:** +```json +{ + "embedding_tokens": 1847, + "embedding_model_deployment_name": "text-embedding-3-small" +} +``` + +## Current Implementation Status + +### ✅ Completed (Personal Workspaces) +- ✅ Embedding token capture from Azure OpenAI API +- ✅ Token accumulation across document chunks +- ✅ Storage in personal workspace document metadata +- ✅ Model deployment name tracking +- ✅ Functional testing and validation + +### 🔄 Future Work (Group & Public Workspaces) +The current implementation is scoped to **personal workspaces only**. The following remain to be implemented: + +#### Group Workspaces +- Add `embedding_tokens` and `embedding_model_deployment_name` fields to group workspace document metadata +- Update all group workspace process_* functions to return token data +- Handle group-specific token accumulation + +#### Public Workspaces +- Add `embedding_tokens` and `embedding_model_deployment_name` fields to public workspace document metadata +- Update all public workspace process_* functions to return token data +- Handle public workspace token accumulation + +## Testing + +### Functional Test +**File:** `functional_tests/test_embedding_token_tracking.py` + +**Test Coverage:** +1. ✅ `generate_embedding()` returns token usage tuple +2. ✅ `save_chunks()` returns token usage information +3. ✅ `create_document()` initializes embedding token fields +4. ✅ `process_txt()` returns token data alongside chunks +5. ✅ `update_document()` accepts embedding token fields +6. ✅ Config version incremented + +**Test Results:** All 6 tests passed ✅ + +### Example Test Output +``` +🔍 Testing generate_embedding token usage return... +✅ Embedding vector has 1536 dimensions +✅ Token usage structure correct: + - Prompt tokens: 12 + - Total tokens: 12 + - Model: text-embedding-3-small + +🔍 Testing create_document embedding fields... +✅ Document has embedding_tokens: 0 +✅ Document has embedding_model_deployment_name: None +``` + +## Usage and Benefits + +### Cost Tracking +- Track embedding costs at the document level +- Understand which documents consume the most embedding tokens +- Optimize chunking strategies based on token usage + +### Usage Analytics +- Monitor embedding token consumption trends +- Compare token usage across different document types +- Identify opportunities for cost optimization + +### Model Versioning +- Track which embedding model was used for each document +- Support for model migration and comparison +- Historical record of embedding model deployments + +## Integration Points + +### Backend Only (Current) +Token data is collected and stored in Cosmos DB but not exposed in the UI. This is intentional for the initial implementation. + +### Future UI Integration +When UI integration is added, embedding token data can be displayed in: +- Document details pages +- Workspace metrics dashboards +- Control center analytics +- Cost reporting views + +## Error Handling + +### Missing Token Usage +If the Azure OpenAI API doesn't return token usage (older API versions or different providers), the system gracefully handles this by: +- Defaulting to `None` for `token_usage` +- Checking for `None` before accumulation +- Only updating document metadata if tokens > 0 + +### Backward Compatibility +- Existing documents without embedding token fields will continue to work +- New documents will have the fields initialized +- No migration required for existing documents + +## Dependencies + +### Azure OpenAI API +- Requires `response.usage` to be available from embedding API calls +- Works with Azure OpenAI Embedding API v2023-05-15 and later + +### Cosmos DB +- No schema changes required (dynamic schema) +- New fields added automatically on document creation +- Existing documents remain unchanged + +## Next Steps + +1. **Monitor production usage** - Validate token tracking accuracy in production +2. **Extend to other file types** - Update remaining process_* functions (XML, JSON, PDF, etc.) +3. **Add group workspace support** - Implement for group documents +4. **Add public workspace support** - Implement for public documents +5. **UI integration** - Display token usage in user-facing dashboards +6. **Cost reporting** - Build reports and analytics on embedding costs + +## Related Documentation + +- [Tabular Data CSV Storage Fix](../fixes/TABULAR_DATA_CSV_STORAGE_FIX.md) +- [Agent Model Display Fixes](../fixes/AGENT_MODEL_DISPLAY_FIXES.md) +- Main codebase: `application/single_app/` + +## Implementation Notes + +### Why Start with Personal Workspaces? +Personal workspaces were chosen as the initial implementation target because: +1. Lower complexity (single user partition) +2. Easier testing and validation +3. Pattern can be replicated for group/public workspaces +4. Most common use case for document uploads + +### Token Accumulation Strategy +Tokens are accumulated during chunk processing rather than calculated afterward because: +1. Token usage is only available at generation time +2. Avoids need for re-calling the API +3. Real-time tracking as processing occurs +4. No additional cost or latency + +### Model Deployment Name +The model deployment name is captured alongside tokens to: +1. Support multiple embedding models +2. Track which model was used for each document +3. Enable cost analysis by model type +4. Support future model migration scenarios diff --git a/docs/features/MESSAGE_METADATA_DISPLAY.md b/docs/features/MESSAGE_METADATA_DISPLAY.md new file mode 100644 index 00000000..fed8c8c4 --- /dev/null +++ b/docs/features/MESSAGE_METADATA_DISPLAY.md @@ -0,0 +1,243 @@ +# Message Metadata Display Feature + +**Version:** 0.233.209 +**Implemented in:** 0.233.209 +**Date:** January 2025 + +## Overview + +Extension of the message threading system to expose message metadata through the user interface. This feature adds metadata display capabilities for assistant (AI), image, and file messages, and enhances user message metadata to include thread information. + +## Purpose + +- **User Visibility**: Provide users with access to message metadata including thread relationships +- **Debugging Support**: Enable users to understand conversation flow and message connections +- **Transparency**: Show technical details like model information, timestamps, and threading data +- **Thread Navigation**: Allow users to see how messages are linked through thread IDs + +## Implementation Details + +### Frontend Changes (chat-messages.js) + +#### 1. Metadata Display Buttons + +**AI Messages:** +- Added metadata button with gear icon after copy/feedback buttons +- Button triggers metadata drawer toggle +- Location: Lines 615-623 + +**Image Messages:** +- Added metadata button after image content +- Works for both generated and uploaded images +- Location: Lines 897-903 + +**File Messages:** +- Added metadata button after file link +- Displays threading and upload information +- Location: Lines 843-848 + +#### 2. Metadata Display Functions + +**toggleMessageMetadata(messageDiv, messageId):** +- Creates and manages metadata drawer for assistant/image/file messages +- Dynamically creates drawer on first click +- Toggles visibility with appropriate ARIA attributes +- Location: Lines 2320-2346 + +**loadMessageMetadataForDisplay(messageId, container):** +- Fetches metadata from backend API endpoint +- Formats and displays comprehensive metadata information +- Handles thread info, role, timestamps, model, agent, citations, tokens +- Location: Lines 2348-2413 + +#### 3. Enhanced User Message Metadata + +**formatMetadataForDrawer(metadata):** +- Added thread information section at priority position +- Displays thread_id, previous_thread_id, active_thread, thread_attempt +- Uses badges and icons for visual clarity +- Location: Lines 1869-1897 + +#### 4. Event Listeners + +- Unified event listener for AI, image, and file metadata buttons +- Checks sender type and attaches click handler +- Location: Lines 1020-1028 + +### Backend Integration + +Uses existing backend endpoints and metadata structure: +- `/api/message//metadata` - Fetch message metadata +- Thread info added to user_metadata in route_backend_chats.py (v0.233.208) + +## Metadata Display Format + +### Thread Information Section +``` +Thread Information +├── Thread ID: [UUID] +├── Previous Thread: [UUID or None] +├── Active Thread: [Active/Inactive badge] +└── Thread Attempt: [Number badge] +``` + +### Additional Metadata (AI/Image/File messages) +``` +Role: [badge] +Timestamp: [formatted date/time] +Model: [model name] +Agent: [agent name if applicable] +Agent Display Name: [display name if applicable] +Citations: [count if applicable] +Token Usage: Input: X, Output: Y +``` + +### User Message Metadata +``` +User Information +├── User: [display name] +├── Email: [email] +├── Username: [username] +└── Timestamp: [date/time] + +Thread Information +├── Thread ID: [UUID] +├── Previous Thread: [UUID or None] +├── Active Thread: [Active/Inactive] +└── Thread Attempt: [Number] + +[... other existing sections ...] +``` + +## UI Components + +### Button Styling +- **Class:** `btn btn-sm btn-outline-secondary metadata-info-btn` +- **Icon:** `` for AI/image/file messages +- **Icon:** `` for user messages (existing) +- **Title:** "View message metadata" + +### Drawer Styling +- **Class:** `message-metadata-drawer mt-2 pt-2 border-top` +- **Display:** Toggle between `none` and `block` +- **Loading State:** "Loading metadata..." text while fetching + +### Badge Components +- **Active Thread:** Green badge (bg-success) or gray (bg-secondary) +- **Thread Attempt:** Blue badge (bg-info) +- **Role:** Primary badge (bg-primary) + +## User Workflow + +### Viewing AI Message Metadata +1. User sees gear icon next to AI message +2. User clicks gear icon +3. Drawer opens showing metadata +4. Thread info displayed at top +5. Click again to close drawer + +### Viewing Image Message Metadata +1. User sees image generated or uploaded +2. Metadata button appears below image +3. Click to view metadata including thread info +4. Works alongside existing "View Text" button for uploads + +### Viewing File Message Metadata +1. User uploads file to conversation +2. File link displayed with metadata button +3. Click to view file message metadata +4. Shows thread connection to related messages + +### Viewing User Message Thread Info +1. User clicks info icon on their message +2. Existing metadata drawer opens +3. **New:** Thread Information section appears first +4. Shows how message connects in conversation flow + +## Benefits + +### For Users +- **Understand Conversations:** See how messages connect through threads +- **Debug Issues:** Identify which agent/model generated responses +- **Track Context:** View thread chains and message relationships +- **Transparency:** Access to technical details when needed + +### For Developers +- **Testing:** Validate threading implementation through UI +- **Debugging:** Inspect message structure without database queries +- **Monitoring:** See token usage and model information +- **Support:** Help users understand system behavior + +## Technical Notes + +### API Integration +- Uses existing `/api/message//metadata` endpoint +- Fetches metadata on-demand (not preloaded) +- Credentials included for authentication +- Error handling with user-friendly messages + +### Performance Considerations +- Metadata loaded only when drawer opened +- Cached in DOM after first load +- Minimal impact on page load time +- Drawer created dynamically on first click + +### Browser Compatibility +- Uses modern JavaScript (ES6) +- Bootstrap 5 icons for UI elements +- ARIA attributes for accessibility +- Works across modern browsers + +## Testing + +### Manual Testing Checklist +- [ ] AI message metadata button appears and works +- [ ] Image message metadata button appears for generated images +- [ ] Image message metadata button appears for uploaded images +- [ ] File message metadata button appears +- [ ] User message metadata shows thread information +- [ ] Thread info displays correctly for chained messages +- [ ] Metadata drawers toggle properly +- [ ] Loading states display correctly +- [ ] Error handling works for failed fetches +- [ ] Thread badges show correct status + +### Test Scenarios +1. **Create new conversation** - Verify first message has no previous_thread_id +2. **Continue thread** - Verify subsequent messages link correctly +3. **Generate image** - Check metadata button and thread info +4. **Upload file** - Verify file metadata displays threading +5. **View user message** - Confirm thread info in drawer + +## Future Enhancements + +### Potential Improvements +1. **Thread Navigation:** Click thread ID to jump to previous message +2. **Visual Thread Map:** Graph view showing thread relationships +3. **Export Metadata:** Download metadata as JSON +4. **Filter by Thread:** Show only messages in specific thread +5. **Thread Analytics:** Statistics about thread depth and branching + +### API Enhancements +1. **Bulk Metadata Fetch:** Get metadata for multiple messages +2. **Thread History:** Fetch entire thread chain in one request +3. **Metadata Search:** Find messages by thread properties + +## Related Documentation + +- **MESSAGE_THREADING_SYSTEM.md** - Core threading implementation +- **route_backend_chats.py** - Backend message creation with threading +- **functions_chat.py** - sort_messages_by_thread() function + +## Version History + +- **0.233.208:** Added thread_info to user_metadata in backend +- **0.233.209:** Added metadata display buttons and UI components + +## Support + +For issues or questions about message metadata display: +1. Verify backend threading is working correctly +2. Check browser console for JavaScript errors +3. Verify API endpoint `/api/message//metadata` is accessible +4. Confirm user has proper permissions to view messages diff --git a/docs/features/MESSAGE_THREADING_SYSTEM.md b/docs/features/MESSAGE_THREADING_SYSTEM.md new file mode 100644 index 00000000..589261ac --- /dev/null +++ b/docs/features/MESSAGE_THREADING_SYSTEM.md @@ -0,0 +1,299 @@ +# Message Threading System + +## Overview +Version: **0.233.208** +Implemented: December 4, 2025 + +This feature implements a linked-list threading system for chat messages that establishes proper relationships between user messages, system messages, AI responses, image generations, and file uploads. Messages are now ordered by thread chains rather than just timestamps, ensuring proper conversation flow and message association. + +## Purpose +The threading system solves several key problems: +- **Message Association**: Links user messages to their corresponding AI responses and system augmentations +- **Proper Ordering**: Ensures messages are displayed in logical conversation order, not just temporal order +- **File Upload Tracking**: Properly sequences uploaded files within the conversation flow +- **Image Generation Tracking**: Associates generated images with the messages that requested them +- **Legacy Support**: Gracefully handles existing messages without thread information + +## Thread Fields + +Each message now includes four new fields: + +### `thread_id` +- **Type**: String (UUID) +- **Purpose**: Unique identifier for this message in the thread chain +- **Generated**: For every new message (user, system, assistant, image, file) + +### `previous_thread_id` +- **Type**: String (UUID) or `None` +- **Purpose**: Links to the previous message's `thread_id` +- **Value**: `None` for the first message in a conversation or when following a legacy message + +### `active_thread` +- **Type**: Boolean +- **Purpose**: Indicates if this thread is currently active +- **Value**: Always `True` in current implementation (reserved for future retry/edit functionality) + +### `thread_attempt` +- **Type**: Integer +- **Purpose**: Tracks the attempt number for retries or edits +- **Value**: Always `1` in current implementation (reserved for future retry functionality) + +## Message Flow Examples + +### Standard Chat Interaction + +``` +User Message (Thread 1) +├─ thread_id: "abc-123" +├─ previous_thread_id: None +├─ active_thread: True +└─ thread_attempt: 1 + │ + ↓ +System Message (Thread 2) [Optional - if RAG/search enabled] +├─ thread_id: "def-456" +├─ previous_thread_id: "abc-123" +├─ active_thread: True +└─ thread_attempt: 1 + │ + ↓ +AI Response (Thread 3) +├─ thread_id: "ghi-789" +├─ previous_thread_id: "def-456" (or "abc-123" if no system message) +├─ active_thread: True +└─ thread_attempt: 1 +``` + +### Image Generation + +``` +User Message (Thread 1) +├─ thread_id: "aaa-111" +├─ previous_thread_id: None +├─ active_thread: True +└─ thread_attempt: 1 + │ + ↓ +Image Message (Thread 2) +├─ thread_id: "bbb-222" +├─ previous_thread_id: "aaa-111" +├─ active_thread: True +├─ thread_attempt: 1 +└─ role: "image" +``` + +### File Upload + +``` +(Previous conversation messages...) + │ + ↓ +File Upload (New Thread) +├─ thread_id: "ccc-333" +├─ previous_thread_id: "zzz-999" (last message in conversation) +├─ active_thread: True +├─ thread_attempt: 1 +├─ role: "image" (for images) or "file" (for documents) +└─ filename: "document.pdf" +``` + +## Implementation Details + +### Modified Files + +1. **functions_chat.py** + - Added `sort_messages_by_thread()` function + - Implements linked-list traversal algorithm + - Handles both legacy (timestamp-ordered) and threaded messages + +2. **route_backend_chats.py** + - Updated `chat_api()` endpoint + - Updated `chat_stream_api()` endpoint + - Added threading to user messages, system messages, and assistant messages + - Added threading to generated images (chunked and non-chunked) + - Queries last message's `thread_id` before creating new messages + +3. **route_frontend_chats.py** + - Updated file upload handler (`/upload`) + - Added threading to uploaded images (chunked and non-chunked) + - Added threading to uploaded files + +4. **route_frontend_conversations.py** + - Updated `get_conversation_messages()` endpoint + - Applies `sort_messages_by_thread()` before returning messages + +5. **config.py** + - Updated version to `0.233.208` + +### Sorting Algorithm + +The `sort_messages_by_thread()` function: + +1. **Separates messages** into legacy (no `thread_id`) and threaded messages +2. **Sorts legacy messages** by timestamp +3. **Builds thread chain**: + - Creates a map of `thread_id` → message + - Creates a map of `previous_thread_id` → children +4. **Finds root messages**: Messages with no `previous_thread_id` or where `previous_thread_id` doesn't exist in current set +5. **Traverses chains**: Recursively follows the linked list structure +6. **Returns ordered list**: Legacy messages first, then threaded messages in chain order + +### Thread Chain Establishment + +When creating a new message: + +```python +# Query for the last message's thread_id +last_msg_query = """ + SELECT TOP 1 c.thread_id + FROM c + WHERE c.conversation_id = '{conversation_id}' + ORDER BY c.timestamp DESC +""" +last_msgs = list(cosmos_messages_container.query_items( + query=last_msg_query, + partition_key=conversation_id +)) +previous_thread_id = last_msgs[0].get('thread_id') if last_msgs else None + +# Generate new thread_id and create message +current_thread_id = str(uuid.uuid4()) +message = { + 'thread_id': current_thread_id, + 'previous_thread_id': previous_thread_id, + 'active_thread': True, + 'thread_attempt': 1, + # ... other message fields +} +``` + +## Legacy Message Support + +The system is fully backward compatible: + +- **Existing messages** without `thread_id` are sorted by timestamp +- **Legacy messages** are placed **before** threaded messages +- **No migration required** - threading applies only to new messages +- **Gradual adoption** - conversations naturally transition to threaded ordering + +## Frontend Impact + +**No frontend changes required.** The frontend continues to: +- Fetch messages from the backend +- Display them in the order received +- The backend now returns messages in thread order instead of timestamp order + +## Performance Considerations + +### Database Queries +- Single additional query per message creation (to get last `thread_id`) +- Query is optimized: `SELECT TOP 1` with `ORDER BY timestamp DESC` +- Uses partition key for efficient lookup + +### Sorting Performance +- O(n log n) for legacy message sorting (timestamp-based) +- O(n) for building thread chain maps +- O(n) for traversing chains +- Overall complexity: O(n log n) where n = number of messages + +### Indexing Recommendations +Consider adding indexes for: +- `conversation_id` + `timestamp` (DESC) - for last message lookup +- `conversation_id` + `thread_id` - for thread chain traversal + +## Future Enhancements + +### Retry/Edit Support +The `thread_attempt` field enables future retry functionality: +``` +User Message (Thread 1, Attempt 1) - active_thread: False + │ + ↓ +Assistant Response (Thread 2, Attempt 1) - active_thread: False + │ + ↓ +User Message (Thread 1, Attempt 2) - active_thread: True + │ + ↓ +Assistant Response (Thread 2, Attempt 2) - active_thread: True +``` + +### Branching Conversations +The linked-list structure supports conversation branches: +- Multiple messages can share the same `previous_thread_id` +- UI could display conversation tree +- Users could explore different conversation paths + +### Thread Metadata +Additional thread-level metadata could include: +- Thread creation timestamp +- Thread type (chat, image generation, file upload) +- Thread tags or labels +- Thread-level citations or sources + +## Testing + +### Functional Tests Needed +1. **New conversation** - verify first message has `previous_thread_id = None` +2. **Multi-turn conversation** - verify thread chain is established +3. **Image generation** - verify image links to user message +4. **File upload** - verify file links to previous message +5. **Legacy messages** - verify old messages sort by timestamp +6. **Mixed conversation** - verify legacy + threaded messages sort correctly +7. **Message retrieval** - verify frontend receives correctly ordered messages + +### Test Scenarios +- Create new conversation → verify threading +- Upload file mid-conversation → verify threading +- Generate image → verify threading +- Load conversation with 50+ messages → verify performance +- Load legacy conversation → verify backward compatibility + +## Troubleshooting + +### Messages Out of Order +**Symptom**: Messages appear in wrong order +**Check**: Verify `sort_messages_by_thread()` is called before returning messages +**Solution**: Ensure all message retrieval endpoints apply sorting + +### Broken Thread Chain +**Symptom**: Messages missing or duplicated +**Check**: Verify `previous_thread_id` references exist in database +**Solution**: Check thread chain integrity with query: +```sql +SELECT m.thread_id, m.previous_thread_id, m.timestamp, m.role +FROM c m +WHERE m.conversation_id = '{conversation_id}' +ORDER BY m.timestamp ASC +``` + +### Performance Issues +**Symptom**: Slow message loading +**Check**: Number of messages in conversation +**Solution**: +- Add database indexes +- Implement pagination for large conversations +- Cache sorted message lists + +## Configuration + +No configuration changes required. The feature is enabled by default for all new messages. + +## Security Considerations + +- Thread IDs use UUIDs - not guessable +- Thread relationships maintained per conversation +- No cross-conversation thread linking +- Thread fields included in normal message access control + +## Related Documentation + +- [Message Management Architecture](./MESSAGE_MANAGEMENT_ARCHITECTURE.md) +- [Conversation Metadata](./CONVERSATION_METADATA.md) +- [Message Masking](../fixes/MESSAGE_MASKING_FIX.md) + +## References + +- Implementation: `functions_chat.py::sort_messages_by_thread()` +- Usage: `route_backend_chats.py`, `route_frontend_chats.py`, `route_frontend_conversations.py` +- Version: `config.py::VERSION` diff --git a/docs/fixes/AGENT_STREAMING_PLUGIN_FIX.md b/docs/fixes/AGENT_STREAMING_PLUGIN_FIX.md new file mode 100644 index 00000000..59c1b0d8 --- /dev/null +++ b/docs/fixes/AGENT_STREAMING_PLUGIN_FIX.md @@ -0,0 +1,194 @@ +# Agent Streaming Plugin Execution Fix + +**Version:** 0.233.281 +**Fixed in:** December 19, 2025 +**Issue Type:** Bug Fix +**Severity:** High + +## Problem + +Agent streaming was failing when agents attempted to execute plugins (tools/functions) during streaming. The SmartHttpPlugin and other async plugins would work correctly in non-streaming mode but failed in streaming mode due to improper async event loop management. + +### Symptoms +- Agent streaming worked for simple responses (no plugin calls) +- When agent tried to use plugins (e.g., SmartHttpPlugin.get_web_content_async), streaming would fail +- Non-streaming mode worked perfectly with the same plugins +- No error displayed to user, stream would just stop + +### Example from Logs +``` +DEBUG: [Log] [Plugin SUCCESS] SmartHttpPlugin.get_web_content_async (10535.3ms) +``` +This shows the plugin worked in non-streaming mode, taking 10.5 seconds to download and process a PDF. + +## Root Cause + +The initial streaming implementation used `loop.run_until_complete(async_gen.__anext__())` in a while loop, attempting to iterate an async generator one item at a time. This approach: + +1. **Created event loop conflicts** - New event loop per stream interfered with plugin async execution +2. **Broke async generator protocol** - Calling `__anext__()` directly bypassed proper async context +3. **Prevented plugin execution** - Plugins couldn't properly execute their async operations within the fragmented event loop +4. **Closed loop prematurely** - `loop.close()` in finally block prevented cleanup + +### Original Problematic Code +```python +# ❌ BROKEN - tried to iterate async generator manually +async def stream_agent(): + async for response in selected_agent.invoke_stream(...): + yield response.content + +loop = asyncio.new_event_loop() +asyncio.set_event_loop(loop) + +try: + async_gen = stream_agent() + while True: + try: + chunk_content = loop.run_until_complete(async_gen.__anext__()) + yield f"data: {json.dumps({'content': chunk_content})}\n\n" + except StopAsyncIteration: + break +finally: + loop.close() # ❌ Closes loop too early +``` + +## Solution + +Changed to collect all streaming chunks within a single async context, then yield them to the SSE stream. This allows: + +1. **Proper async execution** - Plugins run in a stable event loop +2. **Complete agent lifecycle** - Agent can execute all plugins before streaming to frontend +3. **Cleaner error handling** - Errors captured and reported properly +4. **Event loop reuse** - Attempts to use existing loop before creating new one + +### Fixed Code +```python +# ✅ FIXED - collect chunks in single async context +async def stream_agent_async(): + """Collect all streaming chunks from agent""" + chunks = [] + async for response in selected_agent.invoke_stream(messages=agent_message_history, thread=thread): + if hasattr(response, 'content') and response.content: + chunks.append(response.content) + return chunks + +# Execute async streaming with proper loop management +import asyncio +try: + # Try to get existing event loop + loop = asyncio.get_event_loop() + if loop.is_closed(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) +except RuntimeError: + # No event loop in current thread + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + +try: + # Run streaming and collect chunks + chunks = loop.run_until_complete(stream_agent_async()) + + # Yield chunks to frontend + for chunk_content in chunks: + accumulated_content += chunk_content + yield f"data: {json.dumps({'content': chunk_content})}\n\n" +except Exception as stream_error: + print(f"❌ Agent streaming error: {stream_error}") + import traceback + traceback.print_exc() + yield f"data: {json.dumps({'error': f'Agent streaming failed: {str(stream_error)}'})}\n\n" + return +``` + +## Technical Details + +### Why This Works + +1. **Single Async Context**: All async operations (agent streaming, plugin execution) happen in one `run_until_complete` call +2. **Plugin-Friendly**: Plugins can execute their async operations without loop conflicts +3. **Event Loop Reuse**: Attempts to use existing event loop before creating new one +4. **Error Isolation**: Exceptions during plugin execution are caught and reported +5. **No Premature Cleanup**: Event loop not closed, allowing proper async cleanup + +### Trade-offs + +**Before Fix:** +- ✅ Attempted true streaming (chunk-by-chunk) +- ❌ Broke plugin execution +- ❌ Complex event loop management +- ❌ Poor error handling + +**After Fix:** +- ✅ Plugins work correctly +- ✅ Simpler event loop management +- ✅ Better error handling +- ⚠️ Collects chunks first, then streams (small delay before first chunk) + +### Performance Impact + +- **Latency**: Slight increase in time-to-first-token (waits for agent to complete all plugin calls) +- **Throughput**: No change - same total processing time +- **Memory**: Negligible - chunks accumulated in memory briefly +- **Reliability**: Significantly improved - plugins now work + +## Affected Components + +### Backend +- `route_backend_chats.py` - Agent streaming logic (lines ~3055-3095) + +### User Experience +- ✅ Agents with plugins now work in streaming mode +- ✅ Long-running plugin calls (10+ seconds) complete successfully +- ✅ Error messages displayed if streaming fails +- ⚠️ Slight delay before first chunk appears (waits for plugins to complete) + +## Testing Results + +### Test Case: PDF Download and Summary +**Agent:** Default +**Plugin:** SmartHttpPlugin.get_web_content_async +**Action:** Download and extract 7-page PDF from whitehouse.gov +**Plugin Duration:** 10.5 seconds + +**Before Fix:** +- ❌ Non-streaming: Worked perfectly +- ❌ Streaming: Failed silently + +**After Fix:** +- ✅ Non-streaming: Still works +- ✅ Streaming: Now works correctly + +### Validation +From logs showing successful execution: +``` +[DEBUG] [INFO]: [Plugin SUCCESS] SmartHttpPlugin.get_web_content_async (10535.3ms) +DEBUG: [Log] [Enhanced Agent Citations] Extracted 1 detailed plugin invocations +[DEBUG] [INFO]: Service aoai-chat-Default prompt_tokens: 8000, completion_tokens: 2016, total_tokens: 10016 +``` + +## Future Improvements + +1. **True Streaming**: Implement real chunk-by-chunk streaming without breaking plugins + - Requires deeper integration with Semantic Kernel's async architecture + - May need custom async generator wrapper + +2. **Progress Indicators**: Show plugin execution status during the "waiting" period + - "Agent is downloading PDF..." + - "Agent is processing document (10.5s)..." + +3. **Incremental Streaming**: Stream agent reasoning/thoughts while plugins execute + - Show thinking process in real-time + - Stream final response after plugins complete + +4. **Event Loop Pooling**: Reuse event loops across requests for better performance + +## Related Documentation + +- Initial feature: `docs/features/AGENT_STREAMING_SUPPORT.md` (v0.233.280) +- This fix: `docs/fixes/AGENT_STREAMING_PLUGIN_FIX.md` (v0.233.281) + +## Version History + +- **v0.233.280** - Initial agent streaming implementation (broken with plugins) +- **v0.233.281** - Fixed plugin execution in streaming mode diff --git a/docs/fixes/ALL_FILE_TYPES_EMBEDDING_TOKEN_TRACKING_FIX.md b/docs/fixes/ALL_FILE_TYPES_EMBEDDING_TOKEN_TRACKING_FIX.md new file mode 100644 index 00000000..92f8aef1 --- /dev/null +++ b/docs/fixes/ALL_FILE_TYPES_EMBEDDING_TOKEN_TRACKING_FIX.md @@ -0,0 +1,306 @@ +# All File Types Embedding Token Tracking Fix + +**Version: 0.233.300** +**Fixed in version: 0.233.300** +**Date: December 19, 2024** + +## Overview + +Extended embedding token tracking to **all supported file types** in personal workspaces. Previously, only TXT files (v0.233.298) and Document Intelligence files like PDF, DOCX, PPTX, Images (v0.233.299) tracked embedding tokens. This update ensures comprehensive token tracking across the entire document upload system. + +## Problem Statement + +After implementing embedding token tracking for TXT and PDF files, the system needed to track tokens for all remaining supported file types to provide complete usage analytics: + +- XML files (.xml) +- YAML files (.yaml, .yml) +- Log files (.log) +- Legacy Word files (.doc, .docm) +- HTML files (.html) +- Markdown files (.md) +- JSON files (.json) +- Tabular files (.csv, .xlsx, .xls, .xlsm) + +Without this tracking, embedding token usage data would be incomplete and inconsistent across different document types. + +## Files Modified + +### 1. `functions_documents.py` + +#### Updated Functions: +All document processor functions now implement the complete token tracking pattern: + +1. **`process_xml()`** (Lines ~3385-3480) + - Initialize token tracking variables + - Capture token_usage from save_chunks() calls + - Accumulate tokens across chunks + - Return tuple: (chunks, tokens, model_name) + +2. **`process_yaml()`** (Lines ~3482-3575) + - Same pattern as process_xml + - Handles both .yaml and .yml extensions + +3. **`process_log()`** (Lines ~3575-3672) + - Tracks tokens for log file chunks + - Returns tuple with token data + +4. **`process_doc()`** (Lines ~3672-3764) + - Handles legacy .doc and .docm files + - Uses docx2txt library for extraction + - Tracks embedding tokens + +5. **`process_html()`** (Lines ~3764-3894) + - Processes HTML files + - Includes metadata extraction if enabled + - Tracks embedding tokens for all chunks + +6. **`process_md()`** (Lines ~3894-4030) + - Processes Markdown files + - Metadata extraction support + - Complete token tracking + +7. **`process_json()`** (Lines ~4030-4168) + - JSON file processing + - Metadata extraction enabled + - Token tracking implemented + +8. **`process_tabular()`** (Lines ~4255-4395) + - Handles CSV, XLSX, XLS, XLSM files + - Processes multiple Excel sheets + - Aggregates tokens across all sheets + - Already had tuple handling from `process_single_tabular_sheet()` + +9. **`process_document_upload_background()`** (Lines ~4855-5100) + - **DISPATCHER UPDATE**: Modified to handle tuple returns from all processors + - Added `isinstance(result, tuple)` checks for: + - .xml files + - .yaml/.yml files + - .log files + - .doc/.docm files + - .html files + - .md files + - .json files + - Tabular extensions (.csv, .xlsx, .xls, .xlsm) + - Unpacks tuples into: `total_chunks_saved, total_embedding_tokens, embedding_model_name` + - Includes token data in final update callback + +### 2. `config.py` + +```python +VERSION = "0.233.300" # Incremented from 0.233.299 +``` + +## Technical Implementation + +### Token Tracking Pattern + +Each processor follows this consistent pattern: + +```python +def process_[file_type](...): + # 1. Initialize tracking variables + total_chunks_saved = 0 + total_embedding_tokens = 0 + embedding_model_name = None + + # 2. Process chunks and capture token usage + for chunk in chunks: + token_usage = save_chunks( + page_text_content=chunk_content, + page_number=total_chunks_saved + 1, + file_name=original_filename, + user_id=user_id, + document_id=document_id + ) + total_chunks_saved += 1 + + # 3. Accumulate tokens + if token_usage: + total_embedding_tokens += token_usage.get('total_tokens', 0) + if not embedding_model_name: + embedding_model_name = token_usage.get('model_deployment_name') + + # 4. Return tuple + return total_chunks_saved, total_embedding_tokens, embedding_model_name +``` + +### Dispatcher Pattern + +The dispatcher handles both old (integer) and new (tuple) return formats: + +```python +if file_ext == '.xml': + result = process_xml(**args) + if isinstance(result, tuple) and len(result) == 3: + total_chunks_saved, total_embedding_tokens, embedding_model_name = result + else: + total_chunks_saved = result # Backward compatibility +``` + +### Final Update with Token Data + +```python +final_update_args = { + "number_of_pages": total_chunks_saved, + "status": final_status, + "percentage_complete": 100, + "current_file_chunk": None +} + +# Add embedding token data if available +if total_embedding_tokens > 0: + final_update_args["embedding_tokens"] = total_embedding_tokens +if embedding_model_name: + final_update_args["embedding_model_deployment_name"] = embedding_model_name + +update_doc_callback(**final_update_args) +``` + +## Supported File Types + +### Now Tracking Embedding Tokens (Complete List): + +| File Type | Extensions | Processor Function | Status | +|-----------|-----------|-------------------|---------| +| Text | .txt | `process_txt()` | ✅ v0.233.298 | +| PDF | .pdf | `process_di_document()` | ✅ v0.233.299 | +| Word (Modern) | .docx | `process_di_document()` | ✅ v0.233.299 | +| PowerPoint | .pptx, .ppt | `process_di_document()` | ✅ v0.233.299 | +| Images | .jpg, .jpeg, .png, .bmp, .tiff, .tif, .heif | `process_di_document()` | ✅ v0.233.299 | +| XML | .xml | `process_xml()` | ✅ v0.233.300 | +| YAML | .yaml, .yml | `process_yaml()` | ✅ v0.233.300 | +| Log | .log | `process_log()` | ✅ v0.233.300 | +| Word (Legacy) | .doc, .docm | `process_doc()` | ✅ v0.233.300 | +| HTML | .html | `process_html()` | ✅ v0.233.300 | +| Markdown | .md | `process_md()` | ✅ v0.233.300 | +| JSON | .json | `process_json()` | ✅ v0.233.300 | +| CSV | .csv | `process_tabular()` | ✅ v0.233.300 | +| Excel | .xlsx, .xls, .xlsm | `process_tabular()` | ✅ v0.233.300 | + +### Not Yet Implemented: +- Video files (.mp4, .avi, .mov, .mkv, .webm) - `process_video_document()` +- Audio files (.mp3, .wav, .m4a, .flac, .ogg, .aac) - `process_audio_document()` + +## Data Structure + +### Token Usage Dictionary (from generate_embedding) + +```python +{ + 'prompt_tokens': 12, + 'total_tokens': 12, + 'model_deployment_name': 'text-embedding-3-small' +} +``` + +### Document Metadata (in Cosmos DB) + +```python +{ + "id": "doc-xyz", + "user_id": "user-123", + "file_name": "document.xml", + "number_of_pages": 5, # chunks saved + "embedding_tokens": 1250, # NEW: total tokens used + "embedding_model_deployment_name": "text-embedding-3-small", # NEW: model name + "status": "Processing complete", + # ... other fields +} +``` + +## Testing + +All existing functional tests continue to pass: + +```bash +python functional_tests/test_embedding_token_tracking.py +``` + +**Results: 6/6 tests passed** + +Tests verify: +1. ✅ Config version updated (0.233.300) +2. ✅ `generate_embedding()` returns token usage +3. ✅ `save_chunks()` signature verified +4. ✅ `create_document()` initializes embedding fields +5. ✅ `process_txt()` returns tuple +6. ✅ `update_document()` accepts token fields + +## Validation Steps + +To validate the fix: + +1. **Upload different file types** to a personal workspace: + - XML file + - YAML file + - Log file + - .doc file + - HTML file + - Markdown file + - JSON file + - CSV file + - Excel file + +2. **Check application logs** for token usage: + ``` + Document doc-xyz (filename.xml) processed successfully with 5 chunks saved and 1250 embedding tokens used. + ``` + +3. **Verify Cosmos DB document** contains: + ```json + { + "embedding_tokens": 1250, + "embedding_model_deployment_name": "text-embedding-3-small" + } + ``` + +4. **Confirm non-zero values** for each file type + +## Impact + +### Positive Changes +- ✅ **Complete token tracking** across all supported file types +- ✅ **Consistent implementation** using standard pattern +- ✅ **Backward compatible** with old integer returns +- ✅ **Accurate usage analytics** for Azure OpenAI embedding API +- ✅ **Foundation for cost analysis** across document types +- ✅ **Prepared for group/public workspace extension** + +### No Breaking Changes +- Dispatcher handles both old and new return formats +- Existing functionality preserved +- All tests continue to pass + +## Next Steps + +1. **Video & Audio Processors** + - Extend token tracking to `process_video_document()` + - Extend token tracking to `process_audio_document()` + +2. **Group Workspaces** + - Extend embedding token tracking to group workspace document uploads + - Update group container queries to include token fields + +3. **Public Workspaces** + - Extend embedding token tracking to public workspace document uploads + - Update public container queries to include token fields + +4. **UI Integration** + - Display embedding token usage in document details + - Show aggregated token usage per workspace + - Create analytics dashboard for token consumption + +5. **Cost Analysis** + - Calculate embedding costs based on token usage + - Provide per-user and per-workspace cost reports + - Track trends over time + +## Related Documentation + +- [EMBEDDING_TOKEN_TRACKING.md](EMBEDDING_TOKEN_TRACKING.md) - Original feature documentation (v0.233.298) +- [PDF_EMBEDDING_TOKEN_TRACKING_FIX.md](PDF_EMBEDDING_TOKEN_TRACKING_FIX.md) - PDF fix documentation (v0.233.299) +- [Functional Test: test_embedding_token_tracking.py](../../functional_tests/test_embedding_token_tracking.py) + +## Conclusion + +Version 0.233.300 completes the comprehensive embedding token tracking implementation for personal workspace document uploads. All 14 supported file types now track and report embedding token usage, providing complete visibility into Azure OpenAI API consumption for document processing. diff --git a/docs/fixes/FILE_MESSAGE_METADATA_LOADING_FIX.md b/docs/fixes/FILE_MESSAGE_METADATA_LOADING_FIX.md new file mode 100644 index 00000000..49e0c543 --- /dev/null +++ b/docs/fixes/FILE_MESSAGE_METADATA_LOADING_FIX.md @@ -0,0 +1,102 @@ +# File Message Metadata Loading Fix + +**Fixed in version: 0.233.232** + +## Issue Description + +When clicking the metadata info button (ℹ️) on file messages in the chat interface, the system was failing to load metadata with a 404 error: + +``` +GET https://127.0.0.1:5000/api/message/null/metadata 404 (NOT FOUND) +Error loading message metadata: Error: Failed to load metadata +``` + +The error occurred because the message ID was not being properly passed when loading file messages, resulting in `null` being used in the API endpoint URL. + +## Root Cause + +In the `loadMessages` function in `chat-messages.js`, file messages were being loaded with only 2 parameters: + +```javascript +} else if (msg.role === "file") { + appendMessage("File", msg); +} +``` + +This contrasts with other message types (user, assistant, image) which properly pass the message ID as the 4th parameter to `appendMessage`. Without the message ID parameter, the metadata button's event listener couldn't retrieve the correct message ID, causing it to be `null` when constructing the API URL. + +## Solution + +Updated the file message loading to pass all required parameters, including the message ID: + +```javascript +} else if (msg.role === "file") { + // Pass file message with proper parameters including message ID + appendMessage("File", msg, null, msg.id, false, [], [], [], null, null, msg); +} +``` + +### Parameters passed: +1. `"File"` - sender type +2. `msg` - the full message object (contains filename and id) +3. `null` - model name (not applicable for files) +4. `msg.id` - **the message ID** (critical for metadata loading) +5. `false` - augmented flag +6. `[]` - hybrid citations +7. `[]` - web citations +8. `[]` - agent citations +9. `null` - agent display name +10. `null` - agent name +11. `msg` - full message object for additional context + +## Files Modified + +- **application/single_app/static/js/chat/chat-messages.js** (line ~489) + - Updated file message loading to include message ID parameter + +- **application/single_app/config.py** + - Updated VERSION from "0.233.231" to "0.233.232" + +## Testing + +To verify the fix: + +1. Upload a file to a conversation +2. Click the info button (ℹ️) on the file message +3. Verify that metadata loads successfully showing: + - Thread Information (thread ID, previous thread, active status, attempt) + - Message Details (message ID, conversation ID, role, timestamp) + - File Details (filename, table data status) + +## Sample Working Metadata + +```json +{ + "id": "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b_file_1765039677_2894", + "conversation_id": "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b", + "role": "file", + "filename": "Connect-2025-05.pdf", + "is_table": false, + "timestamp": "2025-12-06T16:47:57.048838", + "metadata": { + "thread_info": { + "thread_id": "8f0c3b8d-6770-4569-aafd-f20cbe7ce3ed", + "previous_thread_id": "17074c81-ee9a-4a2e-8505-0665252313e1", + "active_thread": true, + "thread_attempt": 1 + } + } +} +``` + +## Impact + +- **User Experience**: Users can now successfully view file message metadata +- **Debugging**: Proper metadata access enables better troubleshooting of file upload and processing issues +- **Consistency**: File messages now behave consistently with other message types (images, user messages, assistant messages) + +## Related Features + +- File upload system +- Message metadata display system +- Thread tracking and management diff --git a/docs/fixes/PDF_EMBEDDING_TOKEN_TRACKING_FIX.md b/docs/fixes/PDF_EMBEDDING_TOKEN_TRACKING_FIX.md new file mode 100644 index 00000000..e3996c4a --- /dev/null +++ b/docs/fixes/PDF_EMBEDDING_TOKEN_TRACKING_FIX.md @@ -0,0 +1,167 @@ +# PDF Embedding Token Tracking Fix + +## Issue +**Version:** 0.233.299 +**Date:** December 19, 2025 +**Related Feature:** Embedding Token Tracking (0.233.298) + +## Problem Description +After implementing embedding token tracking in version 0.233.298, PDF document uploads were showing **0 embedding tokens** even though embeddings were being generated for all chunks. + +### Error Observed +``` +Document c3076b27-e867-4081-a092-8ca1b2b46a1b (test.pdf) processed successfully with 7 chunks saved and 0 embedding tokens used. +``` + +### Root Cause +The initial implementation only updated `process_txt()` to accumulate and return embedding token data. PDF files are processed through `process_di_document()` (Document Intelligence pathway), which was not updated to: +1. Capture token_usage from `save_chunks()` calls +2. Accumulate tokens across all chunks +3. Return token data as a tuple + +## Solution + +### Files Modified + +#### `functions_documents.py` + +**1. Added token tracking initialization in `process_di_document()`** +```python +def process_di_document(...): + # --- Token tracking initialization --- + total_embedding_tokens = 0 + embedding_model_name = None +``` + +**2. Updated `save_chunks()` call to capture token usage** +```python +# Before +save_chunks(**args) +total_final_chunks_processed += 1 + +# After +token_usage = save_chunks(**args) + +# Accumulate embedding tokens +if token_usage: + total_embedding_tokens += token_usage.get('total_tokens', 0) + if not embedding_model_name: + embedding_model_name = token_usage.get('model_deployment_name') + +total_final_chunks_processed += 1 +``` + +**3. Updated return statement to include token data** +```python +# Before +return total_final_chunks_processed + +# After +return total_final_chunks_processed, total_embedding_tokens, embedding_model_name +``` + +**4. Updated `process_document_upload_background()` to handle tuple return** +```python +elif file_ext in di_supported_extensions: + result = process_di_document(**args) + # Handle tuple return (chunks, tokens, model_name) + if isinstance(result, tuple) and len(result) == 3: + total_chunks_saved, total_embedding_tokens, embedding_model_name = result + else: + total_chunks_saved = result +``` + +#### `config.py` +- Version incremented to `0.233.299` + +#### `test_embedding_token_tracking.py` +- Updated version to `0.233.299` + +## Impact + +### Document Types Now Tracking Tokens +- ✅ **PDF** files (via Document Intelligence) +- ✅ **DOCX** files (via Document Intelligence) +- ✅ **PPTX** files (via Document Intelligence) +- ✅ **Images** (JPG, PNG, etc. via Document Intelligence) +- ✅ **TXT** files (direct processing) + +### Expected Behavior +When a PDF or other Document Intelligence-processed file is uploaded: +``` +Document abc123 (test.pdf) processed successfully with 7 chunks saved and 1847 embedding tokens used. +``` + +The document metadata in Cosmos DB will now contain: +```json +{ + "embedding_tokens": 1847, + "embedding_model_deployment_name": "text-embedding-3-small" +} +``` + +## Testing + +### Manual Testing +Upload a PDF file and verify: +1. Document processes successfully +2. Console shows non-zero embedding tokens +3. Cosmos DB document metadata contains `embedding_tokens` > 0 +4. Cosmos DB document metadata contains `embedding_model_deployment_name` + +### Automated Testing +Run existing functional test: +```bash +python functional_tests\test_embedding_token_tracking.py +``` + +## Related Issues + +### VectorizedQuery Serialization Warning +During testing, this warning may appear: +``` +Error processing Hybrid search for document xxx: Unable to serialize value: [] as type: '[VectorQuery]'. +``` + +**Status:** This is a non-blocking warning in the metadata extraction phase. The document processing continues successfully, and this error is caught and handled gracefully. This is a separate issue related to the hybrid search functionality used for metadata extraction, not related to embedding token tracking. + +## Remaining Work + +### Other File Types to Update +The following process functions still need to be updated to track embedding tokens: +- `process_xml()` +- `process_yaml()` +- `process_log()` +- `process_doc()` (legacy .doc files) +- `process_html()` +- `process_md()` +- `process_json()` +- `process_tabular()` (CSV, XLSX, etc.) +- `process_video_document()` +- `process_audio_document()` + +### Group and Public Workspaces +Token tracking still needs to be implemented for: +- Group workspace documents +- Public workspace documents + +## Verification + +After this fix, PDF uploads in personal workspaces should correctly track embedding tokens: + +**Before Fix:** +``` +embedding_tokens: 0 +embedding_model_deployment_name: None +``` + +**After Fix:** +``` +embedding_tokens: 1847 +embedding_model_deployment_name: "text-embedding-3-small" +``` + +## Related Documentation +- [Embedding Token Tracking Feature](../features/EMBEDDING_TOKEN_TRACKING.md) +- Main implementation: `application/single_app/functions_documents.py` +- Test: `functional_tests/test_embedding_token_tracking.py` diff --git a/docs/fixes/VISION_ANALYSIS_DEBUG_LOGGING.md b/docs/fixes/VISION_ANALYSIS_DEBUG_LOGGING.md new file mode 100644 index 00000000..be30e970 --- /dev/null +++ b/docs/fixes/VISION_ANALYSIS_DEBUG_LOGGING.md @@ -0,0 +1,318 @@ +# Enhanced Vision Analysis Debug Logging + +**Version**: 0.233.202 +**Enhancement Type**: Diagnostic Logging +**Purpose**: Diagnose GPT-5 vision analysis issues where no errors are thrown but results are incomplete + +--- + +## Problem + +GPT-5 vision analysis was not throwing errors but wasn't working properly: +- Backend logs showed "Vision response not valid JSON, using raw text" +- GPT-4o worked correctly +- No detailed information about what was happening during the vision analysis process + +--- + +## Solution + +Added comprehensive `debug_print()` logging throughout the `analyze_image_with_vision_model()` function to provide detailed visibility into every step of the vision analysis process. + +### Enhanced Logging Categories + +#### 1. Image Conversion & Preparation +``` +[VISION_ANALYSIS] Image conversion for {document_id}: + Image path: /path/to/image.png + Original size: 8,340,622 bytes (7.95 MB) + Base64 size: 11,120,828 characters + MIME type: image/png +``` + +**What to look for**: +- Very large images might cause issues (> 20 MB) +- Incorrect MIME type detection +- Base64 encoding problems + +#### 2. Model Configuration +``` +[VISION_ANALYSIS] Vision model selected: gpt-5 +[VISION_ANALYSIS] Using APIM: False +``` + +**What to look for**: +- Correct model name +- APIM vs Direct connection method + +#### 3. Client Initialization +``` +[VISION_ANALYSIS] Direct Azure OpenAI Configuration: + Endpoint: https://your-resource.openai.azure.com/ + API Version: 2024-02-15-preview + Auth Type: key +``` + +**What to look for**: +- Correct endpoint for the model deployment +- API version compatibility (vision requires 2024-02-15-preview or later) +- Authentication method matches configuration + +#### 4. API Parameter Selection +``` +[VISION_ANALYSIS] Building API request parameters: + Model (lowercase): gpt-5 + Uses max_completion_tokens: True + Detection: o1=False, o3=False, gpt-5=True + Token parameter: max_completion_tokens = 1000 +``` + +**What to look for**: +- Correct detection of model type +- Proper parameter selection (max_completion_tokens for gpt-5, max_tokens for gpt-4o) +- Token limit appropriate for response + +#### 5. Request Details +``` +[VISION_ANALYSIS] Sending request to Azure OpenAI... + Message content types: text + image_url + Image data URL prefix: data:image/png;base64,... (11120828 chars) +``` + +**What to look for**: +- Proper message structure +- Base64 data being sent + +#### 6. Response Metadata +``` +[VISION_ANALYSIS] Response received from gpt-5 + Response ID: chatcmpl-ABC123XYZ + Model used: gpt-5-2024-11-20 + Token usage: prompt=1245, completion=156, total=1401 +``` + +**What to look for**: +- Actual model used (might differ from deployment name) +- Token usage patterns (high prompt tokens = large image) +- Response ID for tracking + +#### 7. Response Content Analysis +``` +[VISION_ANALYSIS] Raw response received: + Length: 823 characters + First 500 chars: The image is a stylized promotional graphic... + Last 100 chars: ...emphasizing the prestige and excitement of the event. + Starts with JSON bracket: False + Contains code fence: False +``` + +**What to look for**: +- **CRITICAL**: If `Starts with JSON bracket: False`, the response is NOT in JSON format +- If `Contains code fence: True`, response might be wrapped in markdown +- Response length (too short might indicate truncation) + +#### 8. JSON Parsing Attempt +``` +[VISION_ANALYSIS] Attempting to clean JSON code fences... + Cleaned length: 823 characters + Cleaned first 200 chars: The image is a stylized promotional... +[VISION_ANALYSIS] Attempting to parse as JSON... +[VISION_ANALYSIS] ❌ JSON parsing failed! + Error type: JSONDecodeError + Error message: Expecting value: line 1 column 1 (char 0) + Content that failed to parse (first 1000 chars): The image is a stylized... +``` + +**What to look for**: +- **CRITICAL**: If JSON parsing fails, shows WHY it failed +- Shows the exact content that couldn't be parsed +- Indicates if the model returned plain text instead of JSON + +#### 9. Successful JSON Parsing +``` +[VISION_ANALYSIS] ✅ Successfully parsed JSON response! + JSON keys: ['description', 'objects', 'text', 'analysis'] +``` + +**What to look for**: +- All expected keys present: description, objects, text, analysis +- Missing keys indicate incomplete response + +#### 10. Final Analysis Structure +``` +[VISION_ANALYSIS] Final analysis structure for {document_id}: + Model: gpt-5 + Has 'description': True + Has 'objects': True + Has 'text': True + Has 'analysis': True + Description length: 234 chars + Description preview: The image is a stylized promotional graphic... + Objects count: 4 + Objects: ['jockeys', 'horses', 'artistic brushstrokes', 'text block'] + Text length: 523 chars + Text preview: The 149th PREAKNESS May 18, 2024... +``` + +**What to look for**: +- All expected fields populated +- Reasonable content in each field +- Objects list populated (indicates vision working) +- Text extracted (indicates OCR working) + +--- + +## Diagnostic Workflow + +### When GPT-5 Shows "Vision response not valid JSON" + +1. **Check Response Format**: + ``` + Starts with JSON bracket: False ← Problem! + ``` + - If False, GPT-5 is returning plain text, not JSON + - This is the most common issue + +2. **Check Response Content**: + ``` + First 500 chars: The image is a stylized promotional graphic... + ``` + - Does it look like a description (plain text)? + - Or does it look like JSON structure? + +3. **Check Token Usage**: + ``` + Token usage: prompt=15234, completion=89, total=15323 + ``` + - Very high prompt tokens (> 10k) = large image + - Low completion tokens (< 100) might indicate truncated response + +4. **Check Model Version**: + ``` + Model used: gpt-5-2024-11-20 + ``` + - Might reveal model doesn't support JSON mode + - Or model is preview version with different behavior + +5. **Check API Version**: + ``` + API Version: 2024-02-15-preview + ``` + - Older API versions might not support certain features + - Try newer version if available + +--- + +## Common Issues & Solutions + +### Issue 1: GPT-5 Returns Plain Text Instead of JSON + +**Symptoms**: +``` +Starts with JSON bracket: False +JSON parsing failed: Expecting value: line 1 column 1 +``` + +**Possible Causes**: +1. **GPT-5 doesn't support JSON mode** - Some preview models don't support structured output +2. **Prompt needs adjustment** - Model not following JSON format instruction +3. **Model interprets vision differently** - Reasoning models might need different prompts + +**Solutions**: +- Add `response_format={"type": "json_object"}` parameter (if supported) +- Modify prompt to be more explicit about JSON requirement +- Use post-processing to convert plain text to JSON structure + +### Issue 2: Large Image Causing Issues + +**Symptoms**: +``` +Original size: 8,340,622 bytes (7.95 MB) +Token usage: prompt=18945, completion=45, total=18990 +``` + +**Solutions**: +- Image too large for context window +- Compress/resize image before analysis +- Split into multiple smaller images + +### Issue 3: Model Not Supporting Vision + +**Symptoms**: +``` +Error: Model does not support image inputs +``` + +**Solutions**: +- Verify model deployment supports vision +- Check API version is 2024-02-15-preview or later +- Confirm deployment region supports vision models + +--- + +## Testing GPT-5 vs GPT-4o + +With enhanced logging, you can now compare: + +### GPT-4o Successful Response: +``` +✅ Successfully parsed JSON response! +JSON keys: ['description', 'objects', 'text', 'analysis'] +Objects count: 4 +``` + +### GPT-5 Problem Response: +``` +❌ JSON parsing failed! +Starts with JSON bracket: False +Content that failed to parse: The image is a stylized promotional graphic... +``` + +This clearly shows GPT-5 is returning plain text descriptions instead of JSON structure. + +--- + +## Next Steps + +### If GPT-5 Returns Plain Text: + +1. **Modify Prompt for GPT-5** - Add stricter JSON formatting requirement +2. **Enable JSON Mode** - If model supports it: `response_format={"type": "json_object"}` +3. **Post-Process Response** - Parse plain text response into JSON structure +4. **Use Different Approach** - Some models prefer different instruction formats + +### If Model Doesn't Support JSON Mode: + +Create fallback logic: +```python +if 'gpt-5' in model_name and not response_is_json: + # Parse natural language response into structured format + vision_analysis = parse_natural_language_vision_response(content) +``` + +--- + +## Files Modified + +- **`functions_documents.py`**: Enhanced `analyze_image_with_vision_model()` with comprehensive logging +- **`config.py`**: Version updated to 0.233.202 + +--- + +## Enabling Debug Output + +Debug logging uses `debug_print()` which respects the application's debug settings: + +1. **Enable Debug Mode** in application settings +2. **Check Terminal Output** where backend is running +3. **Review Logs** for `[VISION_ANALYSIS]` entries + +All debug logs are prefixed with `[VISION_ANALYSIS]` for easy filtering. + +--- + +## References + +- Vision Model Parameter Fix (v0.233.201) +- Multi-Modal Vision Analysis Feature (v0.229.088) +- Vision Model Detection Expansion (v0.229.089) diff --git a/docs/fixes/VISION_DEBUG_QUICK_REFERENCE.md b/docs/fixes/VISION_DEBUG_QUICK_REFERENCE.md new file mode 100644 index 00000000..f23247fe --- /dev/null +++ b/docs/fixes/VISION_DEBUG_QUICK_REFERENCE.md @@ -0,0 +1,79 @@ +# Vision Analysis Debug Log Quick Reference + +## Key Indicators to Check + +### ✅ GPT-4o Working (Expected Output) +``` +[VISION_ANALYSIS] Vision model selected: gpt-4o +[VISION_ANALYSIS] Uses max_completion_tokens: False +[VISION_ANALYSIS] Token parameter: max_tokens = 1000 +[VISION_ANALYSIS] Starts with JSON bracket: True +[VISION_ANALYSIS] ✅ Successfully parsed JSON response! +[VISION_ANALYSIS] JSON keys: ['description', 'objects', 'text', 'analysis'] +``` + +### ❌ GPT-5 Problem (What You're Seeing) +``` +[VISION_ANALYSIS] Vision model selected: gpt-5 +[VISION_ANALYSIS] Uses max_completion_tokens: True +[VISION_ANALYSIS] Token parameter: max_completion_tokens = 1000 +[VISION_ANALYSIS] Starts with JSON bracket: False ← PROBLEM HERE +[VISION_ANALYSIS] ❌ JSON parsing failed! +[VISION_ANALYSIS] Error message: Expecting value: line 1 column 1 (char 0) +[VISION_ANALYSIS] Content that failed to parse: The image is a stylized promotional graphic... +``` + +## What to Look For in Logs + +### 1. Parameter Selection (Should be TRUE for GPT-5) +``` +Uses max_completion_tokens: True ← Must be True for gpt-5 +``` + +### 2. Response Format (CRITICAL) +``` +Starts with JSON bracket: False ← This is why it's failing! +``` + +If this is `False`, GPT-5 is returning plain text instead of JSON. + +### 3. Response Content Preview +``` +First 500 chars: The image is a stylized promotional graphic for the 149th Preakness Stakes... +``` + +If this looks like natural language description (not JSON), that's the problem. + +### 4. Parse Error Details +``` +Error type: JSONDecodeError +Error message: Expecting value: line 1 column 1 (char 0) +``` + +This confirms the response doesn't start with JSON. + +## Likely Root Cause + +**GPT-5 reasoning models might not follow JSON format instructions the same way GPT-4o does.** + +Possible reasons: +1. GPT-5 interprets the vision prompt differently +2. Reasoning models prioritize natural language over structured output +3. Model needs explicit JSON mode parameter (not currently set) + +## Recommended Fix + +Try adding `response_format` parameter for GPT-5: + +```python +if uses_completion_tokens: + api_params["max_completion_tokens"] = 1000 + # Try adding JSON mode for GPT-5/o-series + api_params["response_format"] = {"type": "json_object"} +``` + +Or modify the prompt to be more explicit: + +```python +"You MUST respond with valid JSON only. Do not include any text outside the JSON structure..." +``` diff --git a/docs/fixes/VISION_MODEL_PARAMETER_FIX.md b/docs/fixes/VISION_MODEL_PARAMETER_FIX.md new file mode 100644 index 00000000..557f0974 --- /dev/null +++ b/docs/fixes/VISION_MODEL_PARAMETER_FIX.md @@ -0,0 +1,257 @@ +# Vision Model Parameter Fix for GPT-5 and O-Series Models + +**Version**: 0.233.201 +**Fixed in**: 0.233.201 +**Issue**: GPT-5 and o-series models failed vision analysis tests with "Unsupported parameter: 'max_tokens'" error + +--- + +## Problem + +When testing Multi-Modal Vision Analysis with GPT-5 models (e.g., `gpt-5-nano`) or o-series models (e.g., `o1`, `o3`), the test would fail with: + +``` +Vision test failed: Error code: 400 - {'error': {'message': +"Unsupported parameter: 'max_tokens' is not supported with this model. +Use 'max_completion_tokens' instead.", 'type': 'invalid_request_error', +'param': 'max_tokens', 'code': 'unsupported_parameter'}} +``` + +### Root Cause + +Both the vision test endpoint (`route_backend_settings.py`) and the image analysis function (`functions_documents.py`) were using the `max_tokens` parameter unconditionally: + +```python +response = gpt_client.chat.completions.create( + model=vision_model, + messages=[...], + max_tokens=50 # ❌ Not supported by o-series and gpt-5 models +) +``` + +However, **o-series reasoning models** (o1, o3, etc.) and **gpt-5 models** require the `max_completion_tokens` parameter instead of `max_tokens`. + +--- + +## Solution + +### Dynamic Parameter Selection + +Implemented model-aware parameter selection in both vision test and vision analysis functions: + +```python +# Determine which token parameter to use based on model type +vision_model_lower = vision_model.lower() +api_params = { + "model": vision_model, + "messages": [...] +} + +# Use max_completion_tokens for o-series and gpt-5 models, max_tokens for others +if ('o1' in vision_model_lower or 'o3' in vision_model_lower or 'gpt-5' in vision_model_lower): + api_params["max_completion_tokens"] = 1000 +else: + api_params["max_tokens"] = 1000 + +response = gpt_client.chat.completions.create(**api_params) +``` + +### Detection Logic + +**Uses `max_completion_tokens`**: +- All o1 models: `o1`, `o1-preview`, `o1-mini` +- All o3 models: `o3`, `o3-mini`, `o3-preview` +- All gpt-5 models: `gpt-5`, `gpt-5-turbo`, `gpt-5-nano` + +**Uses `max_tokens`** (standard): +- gpt-4o models: `gpt-4o`, `gpt-4o-mini` +- Legacy vision models: `gpt-4-vision-preview`, `gpt-4-turbo-vision` +- GPT-4.1 and GPT-4.5 series + +**Case-Insensitive**: Detection works regardless of model name casing (`GPT-5-NANO`, `gpt-5-nano`, `O1-PREVIEW`, etc.) + +--- + +## Files Modified + +### 1. `route_backend_settings.py` + +**Function**: `_test_multimodal_vision_connection()` + +**Changes**: +- Added model type detection +- Dynamic API parameter building +- Conditional use of `max_completion_tokens` vs `max_tokens` +- Removed static `max_tokens=50` parameter + +**Line**: ~299-370 + +### 2. `functions_documents.py` + +**Function**: `analyze_image_with_vision_model()` + +**Changes**: +- Added model type detection +- Dynamic API parameter building +- Conditional use of `max_completion_tokens` vs `max_tokens` +- Removed static `max_tokens=1000` parameter + +**Line**: ~2974-3075 + +### 3. `config.py` + +**Version Update**: `0.233.200` → `0.233.201` + +--- + +## Testing + +### Functional Test + +Created `functional_tests/test_vision_model_parameter_fix.py` to validate: + +1. **Vision Test Parameter Handling** + - Dynamic parameter building + - Model detection for o-series and gpt-5 + - Correct parameter selection + - Old static parameter removed + +2. **Vision Analysis Parameter Handling** + - Dynamic parameter building + - Model detection for o-series and gpt-5 + - Correct parameter selection + - Old static parameter removed + +3. **Model Detection Coverage** + - 16 test cases covering all model families + - Case-insensitive detection + - Correct parameter selection for each model type + +### Running the Test + +```bash +cd functional_tests +python test_vision_model_parameter_fix.py +``` + +**Expected Output**: +``` +🚀 Testing Multi-Modal Vision Analysis Parameter Fix +================================================================= +🔍 Testing vision test parameter handling... + ✅ Vision test uses dynamic API parameter building + ✅ Model detection for o-series and gpt-5 + ✅ max_completion_tokens for o-series/gpt-5 models + ✅ max_tokens for other models + ✅ Old static parameter removed +✅ Vision test parameter handling is correct! + +🔍 Testing vision analysis parameter handling... + ✅ Vision analysis uses dynamic API parameter building + ... +✅ All vision parameter fix tests passed! +``` + +--- + +## Impact + +### Before Fix +- ❌ GPT-5 models: Vision test **failed** with parameter error +- ❌ o1/o3 models: Vision test **failed** with parameter error +- ✅ GPT-4o models: Vision test worked +- ✅ Legacy vision models: Vision test worked + +### After Fix +- ✅ GPT-5 models: Vision test **passes** with `max_completion_tokens` +- ✅ o1/o3 models: Vision test **passes** with `max_completion_tokens` +- ✅ GPT-4o models: Vision test still works with `max_tokens` +- ✅ Legacy vision models: Vision test still works with `max_tokens` + +### User Experience +- Users can now select and test GPT-5 models for vision analysis +- Users can now select and test o-series models for vision analysis +- No breaking changes for existing deployments +- Automatic parameter selection based on model type + +--- + +## Technical Details + +### API Parameter Differences + +**Standard Vision Models** (GPT-4o, GPT-4 Vision): +```python +{ + "model": "gpt-4o", + "messages": [...], + "max_tokens": 1000, # ✅ Supported + "temperature": 0.7 # ✅ Supported +} +``` + +**Reasoning Models** (o1, o3, GPT-5): +```python +{ + "model": "o1-preview", + "messages": [...], + "max_completion_tokens": 1000, # ✅ Required instead of max_tokens + # temperature NOT supported for reasoning models +} +``` + +### Why Different Parameters? + +Reasoning models (o-series, GPT-5) use a different API contract: +- **`max_completion_tokens`**: Limits the completion length only +- **No `max_tokens`**: This parameter is not supported +- **No `temperature`**: Reasoning models don't support temperature adjustment + +Standard vision models use the traditional parameters: +- **`max_tokens`**: Limits both prompt and completion tokens combined +- **`temperature`**: Controls randomness in responses + +--- + +## Related Features + +- **Multi-Modal Vision Analysis** (v0.229.088) +- **Vision Model Detection Expansion** (v0.229.089) +- **Document Intelligence OCR Integration** +- **Enhanced Citations with Vision Data** + +--- + +## References + +- [Azure OpenAI API - Chat Completions](https://learn.microsoft.com/azure/ai-services/openai/reference) +- [GPT-4o Vision Documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/gpt-with-vision) +- [O-Series Reasoning Models](https://learn.microsoft.com/azure/ai-services/openai/concepts/models#o-series-models) + +--- + +## Troubleshooting + +### If Vision Test Still Fails + +1. **Check Model Name**: Ensure model deployment name matches expected patterns +2. **Check API Version**: Use `2024-02-15-preview` or later for vision support +3. **Check Region Availability**: Not all models are available in all regions +4. **Check Deployment Status**: Ensure model is successfully deployed in Azure + +### If Wrong Parameter Used + +The detection logic checks for: +- `'o1'` in model name (case-insensitive) +- `'o3'` in model name (case-insensitive) +- `'gpt-5'` in model name (case-insensitive) + +If your model name doesn't match these patterns but needs `max_completion_tokens`, contact support or adjust the detection logic. + +--- + +## Version History + +- **v0.233.201**: Fixed parameter selection for GPT-5 and o-series models +- **v0.229.089**: Expanded vision model detection to include GPT-5 and o-series +- **v0.229.088**: Initial Multi-Modal Vision Analysis feature diff --git a/functional_tests/test_embedding_token_tracking.py b/functional_tests/test_embedding_token_tracking.py new file mode 100644 index 00000000..da72c123 --- /dev/null +++ b/functional_tests/test_embedding_token_tracking.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Functional test for embedding token tracking in personal workspace documents. +Version: 0.233.299 +Implemented in: 0.233.298 + +This test ensures that embedding tokens are correctly captured and stored +when documents are uploaded and processed in personal workspaces. +""" + +import sys +import os +sys.path.append(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", "application", "single_app" +)) + +def test_generate_embedding_returns_token_usage(): + """Test that generate_embedding returns both embedding vector and token usage.""" + print("🔍 Testing generate_embedding token usage return...") + + try: + from functions_content import generate_embedding + + # Test with sample text + test_text = "This is a test document for embedding generation and token tracking." + + result = generate_embedding(test_text) + + # Should return a tuple: (embedding, token_usage) + if not isinstance(result, tuple): + print(f"❌ generate_embedding should return tuple, got {type(result)}") + return False + + if len(result) != 2: + print(f"❌ generate_embedding should return 2 values, got {len(result)}") + return False + + embedding, token_usage = result + + # Check embedding is a list/array + if not isinstance(embedding, (list, tuple)): + print(f"❌ Embedding should be list/tuple, got {type(embedding)}") + return False + + if len(embedding) == 0: + print(f"❌ Embedding should not be empty") + return False + + print(f"✅ Embedding vector has {len(embedding)} dimensions") + + # Check token_usage structure + if token_usage is None: + print("⚠️ Token usage is None (may be acceptable if API doesn't return usage)") + return True + + if not isinstance(token_usage, dict): + print(f"❌ Token usage should be dict, got {type(token_usage)}") + return False + + required_keys = ['prompt_tokens', 'total_tokens', 'model_deployment_name'] + for key in required_keys: + if key not in token_usage: + print(f"❌ Token usage missing key: {key}") + return False + + print(f"✅ Token usage structure correct:") + print(f" - Prompt tokens: {token_usage['prompt_tokens']}") + print(f" - Total tokens: {token_usage['total_tokens']}") + print(f" - Model: {token_usage['model_deployment_name']}") + + return True + + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_save_chunks_returns_token_usage(): + """Test that save_chunks returns token usage information.""" + print("\n🔍 Testing save_chunks token usage return...") + + try: + from functions_documents import save_chunks + import uuid + + # Create test data + test_user_id = f"test-user-{uuid.uuid4()}" + test_document_id = f"test-doc-{uuid.uuid4()}" + test_content = "This is test content for a document chunk that will be embedded." + + # Note: This will actually call Azure OpenAI and create records + # In a real test environment, you might want to mock these calls + print("⚠️ Note: This test makes real API calls and database writes") + print("⚠️ Skipping actual save_chunks call to avoid side effects") + print("✅ save_chunks function signature verified") + + # Verify function exists and has correct signature + import inspect + sig = inspect.signature(save_chunks) + print(f"✅ save_chunks signature: {sig}") + + return True + + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_create_document_has_embedding_fields(): + """Test that create_document initializes embedding token fields.""" + print("\n🔍 Testing create_document embedding fields...") + + try: + from functions_documents import create_document + import uuid + + # Create a test document + test_user_id = f"test-user-{uuid.uuid4()}" + test_document_id = f"test-doc-{uuid.uuid4()}" + test_filename = "test_embedding_tracking.txt" + + print("⚠️ Note: This test makes real database writes") + print("⚠️ Attempting to create test document...") + + try: + create_document( + file_name=test_filename, + user_id=test_user_id, + document_id=test_document_id, + num_file_chunks=1, + status="Queued for processing" + ) + + # Retrieve the document to verify fields + from functions_documents import get_document_metadata + metadata = get_document_metadata( + document_id=test_document_id, + user_id=test_user_id + ) + + if not metadata: + print("❌ Failed to retrieve created document") + return False + + # Check for embedding fields + if 'embedding_tokens' not in metadata: + print("❌ Document missing 'embedding_tokens' field") + return False + + if 'embedding_model_deployment_name' not in metadata: + print("❌ Document missing 'embedding_model_deployment_name' field") + return False + + print(f"✅ Document has embedding_tokens: {metadata['embedding_tokens']}") + print(f"✅ Document has embedding_model_deployment_name: {metadata['embedding_model_deployment_name']}") + + # Clean up test document + from config import cosmos_user_documents_container + try: + cosmos_user_documents_container.delete_item( + item=test_document_id, + partition_key=test_user_id + ) + print("✅ Test document cleaned up") + except Exception as cleanup_error: + print(f"⚠️ Failed to clean up test document: {cleanup_error}") + + return True + + except Exception as doc_error: + print(f"❌ Error creating/verifying document: {doc_error}") + return False + + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_process_txt_returns_token_data(): + """Test that process_txt returns token data alongside chunks.""" + print("\n🔍 Testing process_txt token data return...") + + try: + from functions_documents import process_txt + import inspect + + # Verify function signature + sig = inspect.signature(process_txt) + print(f"✅ process_txt signature: {sig}") + + # Check that the function returns the expected tuple format + # Note: We're not actually calling it to avoid side effects + print("✅ process_txt function verified - should return (chunks, tokens, model_name)") + + return True + + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_update_document_accepts_embedding_fields(): + """Test that update_document can update embedding token fields.""" + print("\n🔍 Testing update_document with embedding fields...") + + try: + from functions_documents import update_document + import inspect + + # Verify function signature + sig = inspect.signature(update_document) + print(f"✅ update_document signature: {sig}") + print("✅ update_document uses **kwargs, can accept embedding_tokens and embedding_model_deployment_name") + + return True + + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_config_version_updated(): + """Test that config.py VERSION was incremented.""" + print("\n🔍 Testing config.py version update...") + + try: + from config import VERSION + + print(f"✅ Current VERSION: {VERSION}") + + # Check version format + parts = VERSION.split('.') + if len(parts) != 3: + print(f"❌ VERSION should have 3 parts, got {len(parts)}") + return False + + # Check that version is 0.233.298 or higher + if VERSION < "0.233.298": + print(f"❌ VERSION should be 0.233.298 or higher, got {VERSION}") + return False + + print("✅ VERSION updated for embedding token tracking feature") + + return True + + except Exception as e: + print(f"❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + print("=" * 70) + print("EMBEDDING TOKEN TRACKING FUNCTIONAL TESTS") + print("=" * 70) + + tests = [ + test_config_version_updated, + test_generate_embedding_returns_token_usage, + test_save_chunks_returns_token_usage, + test_create_document_has_embedding_fields, + test_process_txt_returns_token_data, + test_update_document_accepts_embedding_fields + ] + + results = [] + for test in tests: + result = test() + results.append(result) + + print("\n" + "=" * 70) + print(f"📊 RESULTS: {sum(results)}/{len(results)} tests passed") + print("=" * 70) + + if all(results): + print("✅ All tests passed!") + sys.exit(0) + else: + print("❌ Some tests failed") + sys.exit(1) diff --git a/functional_tests/test_file_message_metadata_fix.py b/functional_tests/test_file_message_metadata_fix.py new file mode 100644 index 00000000..c2105a51 --- /dev/null +++ b/functional_tests/test_file_message_metadata_fix.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +Functional test for file message metadata loading fix. +Version: 0.233.232 +Implemented in: 0.233.232 + +This test ensures that file messages properly store and can retrieve their metadata, +including message ID, thread information, and file details. This prevents the 404 +error that occurred when the message ID was null. +""" + +import sys +import os + +# Add the application directory to the path +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +def test_file_message_metadata_structure(): + """ + Test that file messages have the correct structure for metadata retrieval. + + This test verifies: + 1. File messages have an 'id' field + 2. File messages have 'role' set to 'file' + 3. File messages have required metadata fields + 4. Thread information is properly stored in metadata + """ + print("🔍 Testing File Message Metadata Structure...") + + # Sample file message structure based on the Cosmos DB record + test_file_message = { + "id": "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b_file_1765039677_2894", + "conversation_id": "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b", + "role": "file", + "filename": "Connect-2025-05.pdf", + "file_content": "[Page 1]\nYear Performance Review...", + "is_table": False, + "timestamp": "2025-12-06T16:47:57.048838", + "model_deployment_name": None, + "metadata": { + "thread_info": { + "thread_id": "8f0c3b8d-6770-4569-aafd-f20cbe7ce3ed", + "previous_thread_id": "17074c81-ee9a-4a2e-8505-0665252313e1", + "active_thread": True, + "thread_attempt": 1 + } + }, + "thread_id": "8f0c3b8d-6770-4569-aafd-f20cbe7ce3ed", + "previous_thread_id": "17074c81-ee9a-4a2e-8505-0665252313e1", + "active_thread": True, + "thread_attempt": 1 + } + + try: + # Test 1: Verify message has an ID + assert "id" in test_file_message, "File message must have an 'id' field" + assert test_file_message["id"] is not None, "File message ID cannot be None" + assert test_file_message["id"] != "", "File message ID cannot be empty" + print("✅ Test 1 passed: File message has valid ID") + + # Test 2: Verify role is 'file' + assert test_file_message["role"] == "file", "File message role must be 'file'" + print("✅ Test 2 passed: File message has correct role") + + # Test 3: Verify required metadata fields + assert "conversation_id" in test_file_message, "File message must have conversation_id" + assert "filename" in test_file_message, "File message must have filename" + assert "timestamp" in test_file_message, "File message must have timestamp" + print("✅ Test 3 passed: File message has required metadata fields") + + # Test 4: Verify thread information in metadata + assert "metadata" in test_file_message, "File message must have metadata object" + assert "thread_info" in test_file_message["metadata"], "Metadata must have thread_info" + + thread_info = test_file_message["metadata"]["thread_info"] + assert "thread_id" in thread_info, "Thread info must have thread_id" + assert "previous_thread_id" in thread_info, "Thread info must have previous_thread_id" + assert "active_thread" in thread_info, "Thread info must have active_thread" + assert "thread_attempt" in thread_info, "Thread info must have thread_attempt" + print("✅ Test 4 passed: File message has complete thread information") + + # Test 5: Verify thread info values are also at root level (backward compatibility) + assert test_file_message["thread_id"] == thread_info["thread_id"], "Root thread_id must match metadata" + assert test_file_message["active_thread"] == thread_info["active_thread"], "Root active_thread must match metadata" + assert test_file_message["thread_attempt"] == thread_info["thread_attempt"], "Root thread_attempt must match metadata" + print("✅ Test 5 passed: Thread information properly duplicated at root level") + + # Test 6: Verify ID structure (conversation_id_file_timestamp_random) + id_parts = test_file_message["id"].split("_file_") + assert len(id_parts) == 2, "File message ID should have format: conversation_id_file_timestamp_random" + assert id_parts[0] == test_file_message["conversation_id"], "ID should start with conversation_id" + print("✅ Test 6 passed: File message ID follows correct format") + + print("\n✅ All tests passed! File message metadata structure is correct.") + return True + + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + return False + + +def test_metadata_api_url_construction(): + """ + Test that the metadata API URL is constructed correctly with a valid message ID. + + This simulates what happens in the JavaScript when the info button is clicked. + """ + print("\n🔍 Testing Metadata API URL Construction...") + + try: + # Simulate the JavaScript variables + message_id = "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b_file_1765039677_2894" + + # Simulate the API URL construction from chat-messages.js:2266 + api_url = f"/api/message/{message_id}/metadata" + + # Test 1: URL should not contain 'null' + assert "null" not in api_url, "API URL should not contain 'null'" + print("✅ Test 1 passed: API URL does not contain 'null'") + + # Test 2: URL should have correct structure + expected_url = f"/api/message/{message_id}/metadata" + assert api_url == expected_url, f"API URL should be {expected_url}" + print("✅ Test 2 passed: API URL has correct structure") + + # Test 3: Message ID should not be None or empty + assert message_id is not None, "Message ID should not be None" + assert message_id != "", "Message ID should not be empty" + print("✅ Test 3 passed: Message ID is valid") + + print("\n✅ All URL construction tests passed!") + return True + + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + return False + + +def test_append_message_parameters(): + """ + Test that appendMessage receives correct parameters for file messages. + + This simulates the fix where file messages now pass all 11 parameters + including the message ID as the 4th parameter. + """ + print("\n🔍 Testing appendMessage Parameter Passing...") + + try: + # Simulate the message object from loadMessages + msg = { + "id": "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b_file_1765039677_2894", + "conversation_id": "bbf4ba02-f75b-4323-bfa2-6e7cea78b95b", + "role": "file", + "filename": "Connect-2025-05.pdf", + "timestamp": "2025-12-06T16:47:57.048838", + "metadata": { + "thread_info": { + "thread_id": "8f0c3b8d-6770-4569-aafd-f20cbe7ce3ed", + "previous_thread_id": "17074c81-ee9a-4a2e-8505-0665252313e1", + "active_thread": True, + "thread_attempt": 1 + } + } + } + + # Simulate the corrected appendMessage call from line 489 + # appendMessage("File", msg, null, msg.id, false, [], [], [], null, null, msg) + + params = { + "sender": "File", + "messageContent": msg, + "modelName": None, + "messageId": msg["id"], # This is the critical 4th parameter + "augmented": False, + "hybridCitations": [], + "webCitations": [], + "agentCitations": [], + "agentDisplayName": None, + "agentName": None, + "fullMessageObject": msg + } + + # Test 1: Verify sender is correct + assert params["sender"] == "File", "Sender should be 'File'" + print("✅ Test 1 passed: Sender parameter is correct") + + # Test 2: Verify messageContent is the full message object + assert params["messageContent"] == msg, "messageContent should be the full message object" + assert "filename" in params["messageContent"], "messageContent should contain filename" + assert "id" in params["messageContent"], "messageContent should contain id" + print("✅ Test 2 passed: messageContent parameter is correct") + + # Test 3: Verify messageId is explicitly passed (THE FIX) + assert params["messageId"] is not None, "messageId should not be None" + assert params["messageId"] == msg["id"], "messageId should match msg.id" + assert params["messageId"] != "", "messageId should not be empty" + print("✅ Test 3 passed: messageId parameter is explicitly passed (FIX VERIFIED)") + + # Test 4: Verify fullMessageObject is passed for metadata access + assert params["fullMessageObject"] is not None, "fullMessageObject should not be None" + assert params["fullMessageObject"] == msg, "fullMessageObject should be the message object" + print("✅ Test 4 passed: fullMessageObject parameter is passed") + + # Test 5: Simulate what happens when metadata button is clicked + # In the event listener, it uses the messageId from the data-message-id attribute + # which is set from the messageId parameter + data_message_id = params["messageId"] + + # This is what would be used to construct the API URL + metadata_api_url = f"/api/message/{data_message_id}/metadata" + + assert "null" not in metadata_api_url, "Metadata API URL should not contain 'null'" + assert data_message_id in metadata_api_url, "Metadata API URL should contain the message ID" + print("✅ Test 5 passed: Metadata button will use correct message ID") + + print("\n✅ All parameter passing tests passed!") + return True + + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + print("=" * 70) + print("FILE MESSAGE METADATA LOADING FIX - FUNCTIONAL TEST") + print("Version: 0.233.232") + print("=" * 70) + + tests = [ + test_file_message_metadata_structure, + test_metadata_api_url_construction, + test_append_message_parameters + ] + + results = [] + for test in tests: + print() + result = test() + results.append(result) + print() + + print("=" * 70) + print(f"📊 RESULTS: {sum(results)}/{len(results)} tests passed") + print("=" * 70) + + if all(results): + print("✅ ALL TESTS PASSED - Fix verified!") + sys.exit(0) + else: + print("❌ SOME TESTS FAILED - Please review") + sys.exit(1) diff --git a/functional_tests/test_fraud_analysis_actual_document_content_fix.py b/functional_tests/test_fraud_analysis_actual_document_content_fix.py index 4cb3cd5c..8ec1e3c9 100644 --- a/functional_tests/test_fraud_analysis_actual_document_content_fix.py +++ b/functional_tests/test_fraud_analysis_actual_document_content_fix.py @@ -157,10 +157,10 @@ def test_content_reconstruction(): # Check for proper error handling error_handling = [ 'except Exception as parse_error:', - 'debug_debug_print(f"[DEBUG]:: Error parsing document response: {parse_error}")', + 'debug_debug_print(f"Error parsing document response: {parse_error}")', 'except Exception as e:', 'import traceback', - 'debug_debug_print(f"[DEBUG]:: Traceback: {traceback.format_exc()}")' + 'debug_debug_print(f"Traceback: {traceback.format_exc()}")' ] missing_error_handling = [] @@ -196,14 +196,14 @@ def test_debug_output_improvements(): # Check for improved debug statements debug_statements = [ - 'debug_debug_print(f"[DEBUG]:: Processing clean document {i+1}:")', - 'debug_debug_print(f"[DEBUG]:: - ID: {doc_id}")', - 'debug_debug_print(f"[DEBUG]:: - Title: {doc_title}")', - 'debug_debug_print(f"[DEBUG]:: - Filename: {doc_filename}")', - 'debug_debug_print(f"[DEBUG]:: - Content length: {len(doc_content)} characters")', - 'debug_debug_print(f"[DEBUG]:: - Size: {doc_size} bytes")', - 'debug_debug_print(f"[DEBUG]:: Created {len(actual_documents)} clean documents with actual content")', - 'debug_debug_print(f"[DEBUG]:: Failed to get document content, status: {status_code}")' + 'debug_debug_print(f"Processing clean document {i+1}:")', + 'debug_debug_print(f"- ID: {doc_id}")', + 'debug_debug_print(f"- Title: {doc_title}")', + 'debug_debug_print(f"- Filename: {doc_filename}")', + 'debug_debug_print(f"- Content length: {len(doc_content)} characters")', + 'debug_debug_print(f"- Size: {doc_size} bytes")', + 'debug_debug_print(f"Created {len(actual_documents)} clean documents with actual content")', + 'debug_debug_print(f"Failed to get document content, status: {status_code}")' ] missing_debug = [] diff --git a/functional_tests/test_message_ordering_with_retry.py b/functional_tests/test_message_ordering_with_retry.py new file mode 100644 index 00000000..119ec472 --- /dev/null +++ b/functional_tests/test_message_ordering_with_retry.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +""" +Functional test for message ordering with thread retry. +Version: 0.233.259 +Implemented in: 0.233.259 + +This test ensures that when a message thread is retried, the retried message +maintains its original position in the conversation based on the thread chain +(thread_id and previous_thread_id), not the timestamp. This prevents retried +messages from appearing out of order due to their newer timestamps. + +Test scenario: +1. Create thread 1 (previous_thread_id: None) +2. Create thread 2 (previous_thread_id: thread_1) +3. Retry thread 1 with a newer timestamp +4. Verify thread 1 still appears before thread 2 despite newer timestamp +""" + +import sys +import os +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + +from functions_chat import sort_messages_by_thread +from datetime import datetime, timedelta + + +def test_message_ordering_with_retry(): + """ + Test that retried messages maintain correct order based on thread chain, + not timestamp. + """ + print("🧪 Testing message ordering with thread retry...") + + try: + # Create base timestamp + base_time = datetime(2024, 1, 1, 12, 0, 0) + + # Simulate the scenario: + # 1. User sends message (thread 1, no previous) + # 2. Assistant responds to thread 1 + # 3. User sends another message (thread 2, previous = thread 1) + # 4. Assistant responds to thread 2 + # 5. User retries thread 1 (same thread_id, same previous_thread_id, but newer timestamp) + # 6. Assistant responds to retried thread 1 + + messages = [ + # Original thread 1 - user message + { + 'id': 'msg1', + 'role': 'user', + 'content': 'First message', + 'timestamp': (base_time + timedelta(seconds=0)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + }, + # Original thread 1 - assistant response + { + 'id': 'msg2', + 'role': 'assistant', + 'content': 'Response to first', + 'timestamp': (base_time + timedelta(seconds=1)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + }, + # Thread 2 - user message (comes after thread 1) + { + 'id': 'msg3', + 'role': 'user', + 'content': 'Second message', + 'timestamp': (base_time + timedelta(seconds=2)).isoformat(), + 'thread_id': 'thread_2', + 'previous_thread_id': 'thread_1' + }, + # Thread 2 - assistant response + { + 'id': 'msg4', + 'role': 'assistant', + 'content': 'Response to second', + 'timestamp': (base_time + timedelta(seconds=3)).isoformat(), + 'thread_id': 'thread_2', + 'previous_thread_id': 'thread_1' + }, + # RETRY of thread 1 - user message (newer timestamp but same thread_id/previous_thread_id) + { + 'id': 'msg5', + 'role': 'user', + 'content': 'First message (retry)', + 'timestamp': (base_time + timedelta(seconds=10)).isoformat(), # Much newer timestamp + 'thread_id': 'thread_1', # Same thread_id + 'previous_thread_id': None # Same previous_thread_id + }, + # RETRY of thread 1 - assistant response + { + 'id': 'msg6', + 'role': 'assistant', + 'content': 'New response to first', + 'timestamp': (base_time + timedelta(seconds=11)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + } + ] + + print(f"\n📋 Input messages (as stored, unsorted):") + for msg in messages: + print(f" {msg['id']}: thread_id={msg['thread_id']}, " + f"prev={msg['previous_thread_id']}, " + f"timestamp={msg['timestamp']}") + + # Sort messages + sorted_messages = sort_messages_by_thread(messages) + + print(f"\n✅ Sorted messages:") + for i, msg in enumerate(sorted_messages): + print(f" {i+1}. {msg['id']}: thread_id={msg['thread_id']}, " + f"prev={msg['previous_thread_id']}, " + f"content='{msg['content']}'") + + # Verify order + # Expected order: + # 1. msg1 or msg5 (thread 1, first occurrence based on earliest timestamp) + # 2. msg2 or msg6 (thread 1, response) + # 3. msg3 (thread 2, user) + # 4. msg4 (thread 2, assistant) + # Then the retry messages that weren't shown yet + + # The key assertion: All thread_1 messages should come before all thread_2 messages + # because thread_2 has previous_thread_id = thread_1 + + thread_1_indices = [i for i, msg in enumerate(sorted_messages) if msg['thread_id'] == 'thread_1'] + thread_2_indices = [i for i, msg in enumerate(sorted_messages) if msg['thread_id'] == 'thread_2'] + + print(f"\n🔍 Thread 1 positions: {thread_1_indices}") + print(f"🔍 Thread 2 positions: {thread_2_indices}") + + # All thread_1 messages should come before all thread_2 messages + max_thread_1_index = max(thread_1_indices) + min_thread_2_index = min(thread_2_indices) + + if max_thread_1_index < min_thread_2_index: + print(f"\n✅ PASS: Thread 1 (max index {max_thread_1_index}) comes before Thread 2 (min index {min_thread_2_index})") + print("✅ Message ordering correctly preserves thread chain despite retry timestamps!") + return True + else: + print(f"\n❌ FAIL: Thread ordering is incorrect!") + print(f" Thread 1 max index: {max_thread_1_index}") + print(f" Thread 2 min index: {min_thread_2_index}") + print(" Thread 1 should come entirely before Thread 2") + return False + + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + + +def test_legacy_messages_ordering(): + """ + Test that legacy messages (without thread_id) are sorted by timestamp + and come before threaded messages. + """ + print("\n🧪 Testing legacy message ordering...") + + try: + base_time = datetime(2024, 1, 1, 12, 0, 0) + + messages = [ + # Threaded message + { + 'id': 'msg3', + 'role': 'user', + 'content': 'Threaded message', + 'timestamp': (base_time + timedelta(seconds=1)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + }, + # Legacy message (earlier timestamp, no thread_id) + { + 'id': 'msg1', + 'role': 'user', + 'content': 'Legacy message 1', + 'timestamp': (base_time + timedelta(seconds=0)).isoformat() + }, + # Legacy message (later timestamp, no thread_id) + { + 'id': 'msg2', + 'role': 'assistant', + 'content': 'Legacy message 2', + 'timestamp': (base_time + timedelta(seconds=0.5)).isoformat() + } + ] + + sorted_messages = sort_messages_by_thread(messages) + + print(f"✅ Sorted messages:") + for i, msg in enumerate(sorted_messages): + has_thread = 'thread_id' in msg + print(f" {i+1}. {msg['id']}: {'threaded' if has_thread else 'legacy'}") + + # Verify legacy messages come first + if (sorted_messages[0]['id'] == 'msg1' and + sorted_messages[1]['id'] == 'msg2' and + sorted_messages[2]['id'] == 'msg3'): + print("✅ PASS: Legacy messages come before threaded messages and are sorted by timestamp") + return True + else: + print("❌ FAIL: Message ordering is incorrect") + return False + + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + + +def test_multiple_retry_attempts(): + """ + Test that multiple retry attempts of the same thread maintain correct order. + """ + print("\n🧪 Testing multiple retry attempts ordering...") + + try: + base_time = datetime(2024, 1, 1, 12, 0, 0) + + messages = [ + # Thread 1 - attempt 1 + { + 'id': 'msg1', + 'role': 'user', + 'content': 'First message - attempt 1', + 'timestamp': (base_time + timedelta(seconds=0)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + }, + # Thread 2 - follows thread 1 + { + 'id': 'msg2', + 'role': 'user', + 'content': 'Second message', + 'timestamp': (base_time + timedelta(seconds=1)).isoformat(), + 'thread_id': 'thread_2', + 'previous_thread_id': 'thread_1' + }, + # Thread 1 - attempt 2 (retry) + { + 'id': 'msg3', + 'role': 'user', + 'content': 'First message - attempt 2', + 'timestamp': (base_time + timedelta(seconds=5)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + }, + # Thread 1 - attempt 3 (another retry) + { + 'id': 'msg4', + 'role': 'user', + 'content': 'First message - attempt 3', + 'timestamp': (base_time + timedelta(seconds=10)).isoformat(), + 'thread_id': 'thread_1', + 'previous_thread_id': None + } + ] + + sorted_messages = sort_messages_by_thread(messages) + + print(f"✅ Sorted messages:") + for i, msg in enumerate(sorted_messages): + print(f" {i+1}. {msg['id']}: thread_id={msg['thread_id']}, content='{msg['content']}'") + + # All thread_1 messages should come before thread_2 + thread_1_count = sum(1 for msg in sorted_messages if msg['thread_id'] == 'thread_1') + thread_2_index = next(i for i, msg in enumerate(sorted_messages) if msg['thread_id'] == 'thread_2') + + if thread_2_index == thread_1_count: + print(f"✅ PASS: All {thread_1_count} thread_1 messages come before thread_2") + return True + else: + print(f"❌ FAIL: Thread_2 at index {thread_2_index}, but expected after {thread_1_count} thread_1 messages") + return False + + except Exception as e: + print(f"❌ Test failed with exception: {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + print("=" * 70) + print("MESSAGE ORDERING WITH RETRY - FUNCTIONAL TEST") + print("=" * 70) + + results = [] + + # Run all tests + results.append(("Message ordering with retry", test_message_ordering_with_retry())) + results.append(("Legacy messages ordering", test_legacy_messages_ordering())) + results.append(("Multiple retry attempts", test_multiple_retry_attempts())) + + # Summary + print("\n" + "=" * 70) + print("TEST SUMMARY") + print("=" * 70) + + passed = sum(1 for _, result in results if result) + total = len(results) + + for test_name, result in results: + status = "✅ PASS" if result else "❌ FAIL" + print(f"{status}: {test_name}") + + print(f"\n📊 Results: {passed}/{total} tests passed") + + success = all(result for _, result in results) + sys.exit(0 if success else 1) diff --git a/functional_tests/test_message_threading_system.py b/functional_tests/test_message_threading_system.py new file mode 100644 index 00000000..5e0cbadb --- /dev/null +++ b/functional_tests/test_message_threading_system.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +Functional test for message threading system. +Version: 0.233.208 +Implemented in: 0.233.208 + +This test ensures that the message threading system correctly orders messages +and establishes thread chains between related messages. +""" + +import sys +import os +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# Add parent directory to path to import from single_app +parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +app_dir = os.path.join(parent_dir, 'single_app') +sys.path.insert(0, app_dir) + +def test_sort_messages_by_thread(): + """Test the sort_messages_by_thread function with various message configurations.""" + from functions_chat import sort_messages_by_thread + + print("🧪 Testing sort_messages_by_thread function...") + + # Test 1: Legacy messages only (no thread_id) + print("\n📝 Test 1: Legacy messages (timestamp-based sorting)") + legacy_messages = [ + {'id': '3', 'timestamp': '2024-01-03T10:00:00', 'content': 'Third'}, + {'id': '1', 'timestamp': '2024-01-01T10:00:00', 'content': 'First'}, + {'id': '2', 'timestamp': '2024-01-02T10:00:00', 'content': 'Second'}, + ] + sorted_legacy = sort_messages_by_thread(legacy_messages) + assert sorted_legacy[0]['id'] == '1', "First message should be oldest" + assert sorted_legacy[1]['id'] == '2', "Second message should be middle" + assert sorted_legacy[2]['id'] == '3', "Third message should be newest" + print("✅ Legacy messages sorted correctly by timestamp") + + # Test 2: Threaded messages only + print("\n📝 Test 2: Threaded messages (chain-based sorting)") + threaded_messages = [ + {'id': '3', 'thread_id': 'thread-3', 'previous_thread_id': 'thread-2', 'timestamp': '2024-01-03T10:00:00', 'role': 'assistant'}, + {'id': '1', 'thread_id': 'thread-1', 'previous_thread_id': None, 'timestamp': '2024-01-01T10:00:00', 'role': 'user'}, + {'id': '2', 'thread_id': 'thread-2', 'previous_thread_id': 'thread-1', 'timestamp': '2024-01-02T10:00:00', 'role': 'system'}, + ] + sorted_threaded = sort_messages_by_thread(threaded_messages) + assert sorted_threaded[0]['id'] == '1', "User message should be first (root)" + assert sorted_threaded[1]['id'] == '2', "System message should be second (child of user)" + assert sorted_threaded[2]['id'] == '3', "Assistant message should be third (child of system)" + print("✅ Threaded messages sorted correctly by chain") + + # Test 3: Mixed legacy and threaded messages + print("\n📝 Test 3: Mixed legacy and threaded messages") + mixed_messages = [ + {'id': '5', 'thread_id': 'thread-5', 'previous_thread_id': 'thread-4', 'timestamp': '2024-01-05T10:00:00', 'role': 'assistant'}, + {'id': '2', 'timestamp': '2024-01-02T10:00:00', 'content': 'Legacy second'}, + {'id': '4', 'thread_id': 'thread-4', 'previous_thread_id': None, 'timestamp': '2024-01-04T10:00:00', 'role': 'user'}, + {'id': '1', 'timestamp': '2024-01-01T10:00:00', 'content': 'Legacy first'}, + ] + sorted_mixed = sort_messages_by_thread(mixed_messages) + assert sorted_mixed[0]['id'] == '1', "Legacy messages should come first" + assert sorted_mixed[1]['id'] == '2', "Legacy messages should be sorted by timestamp" + assert sorted_mixed[2]['id'] == '4', "Threaded messages should come after legacy" + assert sorted_mixed[3]['id'] == '5', "Threaded chain should be maintained" + print("✅ Mixed messages sorted correctly (legacy first, then threaded)") + + # Test 4: Multiple thread chains + print("\n📝 Test 4: Multiple independent thread chains") + multi_chain = [ + {'id': '2', 'thread_id': 'thread-2', 'previous_thread_id': 'thread-1', 'timestamp': '2024-01-02T10:00:00', 'role': 'assistant'}, + {'id': '4', 'thread_id': 'thread-4', 'previous_thread_id': 'thread-3', 'timestamp': '2024-01-04T10:00:00', 'role': 'assistant'}, + {'id': '1', 'thread_id': 'thread-1', 'previous_thread_id': None, 'timestamp': '2024-01-01T10:00:00', 'role': 'user'}, + {'id': '3', 'thread_id': 'thread-3', 'previous_thread_id': None, 'timestamp': '2024-01-03T10:00:00', 'role': 'user'}, + ] + sorted_multi = sort_messages_by_thread(multi_chain) + # First chain (older timestamp): thread-1 -> thread-2 + # Second chain (newer timestamp): thread-3 -> thread-4 + assert sorted_multi[0]['id'] == '1', "First chain root (older)" + assert sorted_multi[1]['id'] == '2', "First chain child" + assert sorted_multi[2]['id'] == '3', "Second chain root (newer)" + assert sorted_multi[3]['id'] == '4', "Second chain child" + print("✅ Multiple thread chains sorted correctly") + + # Test 5: Empty list + print("\n📝 Test 5: Empty message list") + empty_messages = [] + sorted_empty = sort_messages_by_thread(empty_messages) + assert len(sorted_empty) == 0, "Empty list should return empty list" + print("✅ Empty list handled correctly") + + # Test 6: Complex conversation with system messages + print("\n📝 Test 6: Complex conversation (user -> system -> assistant)") + complex_conversation = [ + {'id': '3', 'thread_id': 'thread-3', 'previous_thread_id': 'thread-2', 'timestamp': '2024-01-03T10:00:00', 'role': 'assistant'}, + {'id': '1', 'thread_id': 'thread-1', 'previous_thread_id': None, 'timestamp': '2024-01-01T10:00:00', 'role': 'user'}, + {'id': '2', 'thread_id': 'thread-2', 'previous_thread_id': 'thread-1', 'timestamp': '2024-01-02T10:00:00', 'role': 'system'}, + ] + sorted_complex = sort_messages_by_thread(complex_conversation) + assert sorted_complex[0]['role'] == 'user', "User message first" + assert sorted_complex[1]['role'] == 'system', "System message second" + assert sorted_complex[2]['role'] == 'assistant', "Assistant message third" + print("✅ Complex conversation flow maintained correctly") + + # Test 7: Image generation thread + print("\n📝 Test 7: Image generation thread (user -> image)") + image_thread = [ + {'id': '2', 'thread_id': 'thread-2', 'previous_thread_id': 'thread-1', 'timestamp': '2024-01-02T10:00:00', 'role': 'image'}, + {'id': '1', 'thread_id': 'thread-1', 'previous_thread_id': None, 'timestamp': '2024-01-01T10:00:00', 'role': 'user'}, + ] + sorted_image = sort_messages_by_thread(image_thread) + assert sorted_image[0]['role'] == 'user', "User request first" + assert sorted_image[1]['role'] == 'image', "Generated image second" + print("✅ Image generation thread ordered correctly") + + # Test 8: File upload thread + print("\n📝 Test 8: File upload mid-conversation") + file_upload = [ + {'id': '3', 'thread_id': 'thread-3', 'previous_thread_id': 'thread-2', 'timestamp': '2024-01-03T10:00:00', 'role': 'file', 'filename': 'doc.pdf'}, + {'id': '1', 'thread_id': 'thread-1', 'previous_thread_id': None, 'timestamp': '2024-01-01T10:00:00', 'role': 'user'}, + {'id': '2', 'thread_id': 'thread-2', 'previous_thread_id': 'thread-1', 'timestamp': '2024-01-02T10:00:00', 'role': 'assistant'}, + ] + sorted_file = sort_messages_by_thread(file_upload) + assert sorted_file[0]['role'] == 'user', "User message first" + assert sorted_file[1]['role'] == 'assistant', "Assistant response second" + assert sorted_file[2]['role'] == 'file', "File upload third" + print("✅ File upload thread ordered correctly") + + print("\n✅ All sort_messages_by_thread tests passed!") + return True + +def test_thread_field_structure(): + """Test that thread fields have the correct structure.""" + print("\n🧪 Testing thread field structure...") + + # Example message with threading fields + test_message = { + 'id': 'msg-123', + 'conversation_id': 'conv-456', + 'role': 'user', + 'content': 'Test message', + 'timestamp': '2024-01-01T10:00:00', + 'thread_id': 'thread-abc-123', + 'previous_thread_id': 'thread-xyz-789', + 'active_thread': True, + 'thread_attempt': 1 + } + + # Verify required fields exist + assert 'thread_id' in test_message, "thread_id field should exist" + assert 'previous_thread_id' in test_message, "previous_thread_id field should exist" + assert 'active_thread' in test_message, "active_thread field should exist" + assert 'thread_attempt' in test_message, "thread_attempt field should exist" + + # Verify field types + assert isinstance(test_message['thread_id'], str), "thread_id should be string" + assert isinstance(test_message['previous_thread_id'], (str, type(None))), "previous_thread_id should be string or None" + assert isinstance(test_message['active_thread'], bool), "active_thread should be boolean" + assert isinstance(test_message['thread_attempt'], int), "thread_attempt should be integer" + + # Verify field values + assert test_message['active_thread'] == True, "active_thread should be True" + assert test_message['thread_attempt'] == 1, "thread_attempt should be 1" + + print("✅ Thread field structure validated correctly") + return True + +def main(): + """Run all threading system tests.""" + print("=" * 60) + print("MESSAGE THREADING SYSTEM - FUNCTIONAL TESTS") + print("Version: 0.233.208") + print("=" * 60) + + try: + # Run tests + test_sort_messages_by_thread() + test_thread_field_structure() + + print("\n" + "=" * 60) + print("✅ ALL TESTS PASSED") + print("=" * 60) + print("\n📊 Test Summary:") + print(" ✓ Message sorting algorithm validated") + print(" ✓ Thread field structure validated") + print(" ✓ Legacy message support confirmed") + print(" ✓ Multiple thread chains handled correctly") + print(" ✓ Complex conversation flows maintained") + print(" ✓ Image and file upload threading verified") + + return True + + except AssertionError as e: + print(f"\n❌ Test failed: {e}") + import traceback + traceback.print_exc() + return False + except Exception as e: + print(f"\n❌ Unexpected error: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/functional_tests/test_vision_model_parameter_fix.py b/functional_tests/test_vision_model_parameter_fix.py new file mode 100644 index 00000000..104a6196 --- /dev/null +++ b/functional_tests/test_vision_model_parameter_fix.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Functional test for Multi-Modal Vision Analysis parameter fix. +Version: 0.233.201 +Implemented in: 0.233.201 + +This test ensures that vision analysis correctly uses max_completion_tokens +for o-series and gpt-5 models instead of max_tokens. +""" + +import sys +import os +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'application', 'single_app')) + +def test_vision_test_parameter_handling(): + """Test that vision test in route_backend_settings.py uses correct parameter.""" + print("🔍 Testing vision test parameter handling...") + + try: + settings_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', 'application', 'single_app', 'route_backend_settings.py' + ) + + with open(settings_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for correct parameter handling + required_patterns = [ + 'vision_model_lower = vision_model.lower()', # Model name lowercasing + 'api_params = {', # Dynamic parameter building + '"model": vision_model,', # Model parameter + 'api_params["max_completion_tokens"] = 50', # o-series/gpt-5 parameter + 'api_params["max_tokens"] = 50', # Other models parameter + "if ('o1' in vision_model_lower or 'o3' in vision_model_lower or 'gpt-5' in vision_model_lower):", # Model detection + 'gpt_client.chat.completions.create(**api_params)' # Dynamic parameter usage + ] + + missing_patterns = [] + for pattern in required_patterns: + if pattern not in content: + missing_patterns.append(pattern) + + if missing_patterns: + raise Exception(f"Missing vision test parameter patterns: {missing_patterns}") + + # Check that old static max_tokens parameter is removed from vision test + if 'max_tokens=50\n )' in content: + raise Exception("Old static max_tokens parameter still present in vision test") + + print(" ✅ Vision test uses dynamic API parameter building") + print(" ✅ Model detection for o-series and gpt-5") + print(" ✅ max_completion_tokens for o-series/gpt-5 models") + print(" ✅ max_tokens for other models") + print(" ✅ Old static parameter removed") + print("✅ Vision test parameter handling is correct!") + return True + + except Exception as e: + print(f"❌ Vision test parameter test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_vision_analysis_parameter_handling(): + """Test that vision analysis in functions_documents.py uses correct parameter.""" + print("\n🔍 Testing vision analysis parameter handling...") + + try: + functions_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', 'application', 'single_app', 'functions_documents.py' + ) + + with open(functions_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Check for correct parameter handling in analyze_image_with_vision_model + required_patterns = [ + 'vision_model_lower = vision_model.lower()', # Model name lowercasing + 'api_params = {', # Dynamic parameter building + '"model": vision_model,', # Model parameter + 'api_params["max_completion_tokens"] = 1000', # o-series/gpt-5 parameter + 'api_params["max_tokens"] = 1000', # Other models parameter + "if ('o1' in vision_model_lower or 'o3' in vision_model_lower or 'gpt-5' in vision_model_lower):", # Model detection + 'gpt_client.chat.completions.create(**api_params)' # Dynamic parameter usage + ] + + missing_patterns = [] + for pattern in required_patterns: + if pattern not in content: + missing_patterns.append(pattern) + + if missing_patterns: + raise Exception(f"Missing vision analysis parameter patterns: {missing_patterns}") + + # Check that old static max_tokens parameter is removed from vision analysis + if 'max_tokens=1000\n )' in content: + raise Exception("Old static max_tokens parameter still present in vision analysis") + + print(" ✅ Vision analysis uses dynamic API parameter building") + print(" ✅ Model detection for o-series and gpt-5") + print(" ✅ max_completion_tokens for o-series/gpt-5 models") + print(" ✅ max_tokens for other models") + print(" ✅ Old static parameter removed") + print("✅ Vision analysis parameter handling is correct!") + return True + + except Exception as e: + print(f"❌ Vision analysis parameter test failed: {e}") + import traceback + traceback.print_exc() + return False + +def test_model_detection_coverage(): + """Test that model detection covers all necessary model families.""" + print("\n🔍 Testing model detection coverage...") + + try: + # Define test cases + test_models = [ + # Should use max_completion_tokens + ('o1', True, 'o1 base model'), + ('o1-preview', True, 'o1-preview'), + ('o1-mini', True, 'o1-mini'), + ('o3', True, 'o3 base model'), + ('o3-mini', True, 'o3-mini'), + ('gpt-5', True, 'gpt-5 base model'), + ('gpt-5-turbo', True, 'gpt-5-turbo'), + ('gpt-5-nano', True, 'gpt-5-nano'), + ('GPT-5-NANO', True, 'GPT-5-NANO (uppercase)'), + ('O1-PREVIEW', True, 'O1-PREVIEW (uppercase)'), + + # Should use max_tokens + ('gpt-4o', False, 'gpt-4o'), + ('gpt-4o-mini', False, 'gpt-4o-mini'), + ('gpt-4-vision-preview', False, 'gpt-4-vision-preview'), + ('gpt-4-turbo-vision', False, 'gpt-4-turbo-vision'), + ('gpt-4.1', False, 'gpt-4.1'), + ('gpt-4.5', False, 'gpt-4.5'), + ] + + # Test the detection logic + failed_tests = [] + for model, should_use_completion_tokens, description in test_models: + model_lower = model.lower() + uses_completion_tokens = ('o1' in model_lower or 'o3' in model_lower or 'gpt-5' in model_lower) + + if uses_completion_tokens != should_use_completion_tokens: + failed_tests.append(f"{description}: expected {'max_completion_tokens' if should_use_completion_tokens else 'max_tokens'}, got {'max_completion_tokens' if uses_completion_tokens else 'max_tokens'}") + + if failed_tests: + raise Exception(f"Model detection failures: {', '.join(failed_tests)}") + + print(f" ✅ Tested {len(test_models)} model patterns") + print(" ✅ o-series models correctly detected") + print(" ✅ gpt-5 models correctly detected") + print(" ✅ Other vision models use standard parameter") + print(" ✅ Case-insensitive detection works") + print("✅ Model detection coverage is complete!") + return True + + except Exception as e: + print(f"❌ Model detection coverage test failed: {e}") + import traceback + traceback.print_exc() + return False + +def main(): + """Run all vision parameter fix tests.""" + print("🚀 Testing Multi-Modal Vision Analysis Parameter Fix") + print("=" * 65) + + results = [] + + # Run tests + results.append(test_vision_test_parameter_handling()) + results.append(test_vision_analysis_parameter_handling()) + results.append(test_model_detection_coverage()) + + print("\n" + "=" * 65) + if all(results): + print("🎉 All vision parameter fix tests passed!") + print("\n📝 Summary:") + print(" - Vision test uses correct parameters based on model type") + print(" - Vision analysis uses correct parameters based on model type") + print(" - o-series models (o1, o3) use max_completion_tokens") + print(" - gpt-5 models use max_completion_tokens") + print(" - Other vision models use max_tokens") + print(" - Detection is case-insensitive") + print("\n✅ GPT-5 and o-series models will now work with vision analysis!") + return True + else: + print("⚠️ Some vision parameter fix tests failed - check output above") + return False + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/functional_tests/test_workflow_pdf_iframe_fix.py b/functional_tests/test_workflow_pdf_iframe_fix.py index f6a194d6..d374faf5 100644 --- a/functional_tests/test_workflow_pdf_iframe_fix.py +++ b/functional_tests/test_workflow_pdf_iframe_fix.py @@ -119,10 +119,10 @@ def test_debug_logging_added(): content = f.read() debug_patterns = [ - 'debug_debug_print(f"[DEBUG]:: Enhanced citations PDF request', - 'debug_debug_print(f"[DEBUG]:: serve_enhanced_citation_pdf_content', - 'debug_debug_print(f"[DEBUG]:: Setting CSP headers for iframe embedding', - 'debug_debug_print(f"[DEBUG]:: serve_workflow_pdf_content', + 'debug_debug_print(f"Enhanced citations PDF request', + 'debug_debug_print(f"serve_enhanced_citation_pdf_content', + 'debug_debug_print(f"Setting CSP headers for iframe embedding', + 'debug_debug_print(f"serve_workflow_pdf_content', ] missing_debug = [] From c91d55539f5a2e7c3a86f21f6c882c68a5a9afd9 Mon Sep 17 00:00:00 2001 From: Steve Carroll <37545884+SteveCInVA@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:35:14 -0500 Subject: [PATCH 4/7] Configure Application from AZD Up command (#548) * Add Cosmos DB post-configuration script and update requirements - Initial POC * post deploy configure services in cosmosdb * refactor to prevent post deploy configuration + begin support of key based auth. * Add additional parameter validation for creating entra app * Refactor Bicep modules for improved authentication and key management - Added keyVault-Secrets.bicep module for storing secrets in Key Vault. - Modified keyVault.bicep to remove enterprise app client secret handling and commented out managed identity role assignments. - Removed openAI-existing.bicep and refactored openAI.bicep to handle model deployments dynamically. - Added setPermissions.bicep for managing role assignments for various resources. - Updated postconfig.py to reflect changes in environment variable handling for authentication type. * Refactor Bicep modules to conditionally add settings based on authentication type and enable resource declarations for services * initial support for VideoIndexer service * Refactor Bicep modules to enhance VideoIndexer service integration and update diagnostic settings configurations * move from using chainguard-dev builder image to python slim image. * Updates to support post deployment app config * Add post-deployment permissions script for CosmosDB and update authentication type handling * fix typo in enhanced citation deployment config * Refactor Dockerfile to use Python 3.13-slim and streamline build process * restart web application after deployment settings applied * remove setting for disableLocalAuth * update to latest version of bicep deployment * remove dead code * code cleanup / formatting * removed unnecessary content from readme.md * fix token scope for commericial search service * set permission correctly for lookup of openAI models * fixes required to configure search with managed identity --- application/single_app/Dockerfile | 14 +- application/single_app/config.py | 1 + .../single_app/route_backend_settings.py | 3 +- deployers/Initialize-EntraApplication.ps1 | 4 + deployers/azure.yaml | 42 ++ deployers/bicep/README.md | 38 +- deployers/bicep/README_orig.md | 156 ------- deployers/bicep/cosmosDb-postDeployPerms.sh | 48 +++ deployers/bicep/main.bicep | 408 +++++++++--------- deployers/bicep/main.parameters.json | 23 +- deployers/bicep/modules/aiModel.bicep | 1 - deployers/bicep/modules/appService.bicep | 196 ++++++--- .../modules/appServiceAuthentication.bicep | 103 ----- ...sights.bicep => applicationInsights.bicep} | 0 .../azureContainerRegistry-existing.bicep | 18 - ...zureContainerRegistry-roleAssignment.bicep | 27 -- .../modules/azureContainerRegistry.bicep | 26 +- deployers/bicep/modules/contentSafety.bicep | 36 +- deployers/bicep/modules/cosmosDb.bicep | 46 +- deployers/bicep/modules/createAppSecret.bicep | 113 ----- .../bicep/modules/diagnosticSettings.bicep | 14 +- .../bicep/modules/documentIntelligence.bicep | 36 +- .../bicep/modules/enterpriseApplication.bicep | 91 ---- .../bicep/modules/keyVault-Secrets.bicep | 23 + deployers/bicep/modules/keyVault.bicep | 38 -- ...tics.bicep => logAnalyticsWorkspace.bicep} | 0 deployers/bicep/modules/managedIdentity.bicep | 2 +- deployers/bicep/modules/openAI-existing.bicep | 40 -- deployers/bicep/modules/openAI.bicep | 71 ++- deployers/bicep/modules/redisCache.bicep | 36 +- deployers/bicep/modules/search.bicep | 39 +- ...Permissions.bicep => setPermissions.bicep} | 130 +++++- deployers/bicep/modules/speechService.bicep | 42 +- deployers/bicep/modules/storageAccount.bicep | 35 +- deployers/bicep/modules/videoIndexer.bicep | 63 +++ deployers/bicep/postconfig.py | 201 +++++++++ deployers/bicep/requirements.txt | 4 + 37 files changed, 1075 insertions(+), 1093 deletions(-) delete mode 100644 deployers/bicep/README_orig.md create mode 100644 deployers/bicep/cosmosDb-postDeployPerms.sh delete mode 100644 deployers/bicep/modules/appServiceAuthentication.bicep rename deployers/bicep/modules/{appInsights.bicep => applicationInsights.bicep} (100%) delete mode 100644 deployers/bicep/modules/azureContainerRegistry-existing.bicep delete mode 100644 deployers/bicep/modules/azureContainerRegistry-roleAssignment.bicep delete mode 100644 deployers/bicep/modules/createAppSecret.bicep delete mode 100644 deployers/bicep/modules/enterpriseApplication.bicep create mode 100644 deployers/bicep/modules/keyVault-Secrets.bicep rename deployers/bicep/modules/{logAnalytics.bicep => logAnalyticsWorkspace.bicep} (100%) delete mode 100644 deployers/bicep/modules/openAI-existing.bicep rename deployers/bicep/modules/{enterpriseAppPermissions.bicep => setPermissions.bicep} (54%) create mode 100644 deployers/bicep/modules/videoIndexer.bicep create mode 100644 deployers/bicep/postconfig.py create mode 100644 deployers/bicep/requirements.txt diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 8178f898..56c8c145 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -1,5 +1,6 @@ # Builder stage: install dependencies in a virtualenv -FROM cgr.dev/chainguard/python:latest-dev AS builder +# FROM cgr.dev/chainguard/python:latest-dev AS builder +FROM python:3.13-slim AS builder USER root @@ -12,14 +13,19 @@ WORKDIR /app RUN python -m venv /app/venv # Create and permission the flask_session directory -RUN mkdir -p /app/flask_session && chown -R nonroot:nonroot /app/flask_session +#RUN mkdir -p /app/flask_session && chown -R nonroot:nonroot /app/flask_session +RUN mkdir -p /app/flask_session # Copy requirements and install them into the virtualenv COPY application/single_app/requirements.txt . ENV PATH="/app/venv/bin:$PATH" RUN pip install --no-cache-dir -r requirements.txt -FROM cgr.dev/chainguard/python:latest +#FROM cgr.dev/chainguard/python:latest +FROM python:3.13-slim + +# Create nonroot user +RUN useradd -m -u 1000 nonroot WORKDIR /app @@ -40,4 +46,4 @@ EXPOSE 5000 USER nonroot:nonroot -ENTRYPOINT [ "python", "/app/app.py" ] +ENTRYPOINT [ "python", "/app/app.py" ] \ No newline at end of file diff --git a/application/single_app/config.py b/application/single_app/config.py index e12f5f07..dbea89df 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -184,6 +184,7 @@ credential_scopes=[resource_manager + "/.default"] cognitive_services_scope = "https://cognitiveservices.azure.com/.default" video_indexer_endpoint = "https://api.videoindexer.ai" + search_resource_manager = "https://search.azure.com" KEY_VAULT_DOMAIN = ".vault.azure.net" def get_redis_cache_infrastructure_endpoint(redis_hostname: str) -> str: diff --git a/application/single_app/route_backend_settings.py b/application/single_app/route_backend_settings.py index 9f6b3102..6855ea65 100644 --- a/application/single_app/route_backend_settings.py +++ b/application/single_app/route_backend_settings.py @@ -694,8 +694,7 @@ def _test_azure_ai_search_connection(payload): url = f"{endpoint.rstrip('/')}/indexes?api-version=2023-11-01" if direct_data.get('auth_type') == 'managed_identity': - if AZURE_ENVIRONMENT in ("usgovernment", "custom"): # change credential scopes for US Gov or custom environments - credential_scopes=search_resource_manager + "/.default" + credential_scopes=search_resource_manager + "/.default" arm_scope = credential_scopes credential = DefaultAzureCredential() arm_token = credential.get_token(arm_scope).token diff --git a/deployers/Initialize-EntraApplication.ps1 b/deployers/Initialize-EntraApplication.ps1 index 13b5ae8f..5a411be0 100644 --- a/deployers/Initialize-EntraApplication.ps1 +++ b/deployers/Initialize-EntraApplication.ps1 @@ -41,9 +41,13 @@ [CmdletBinding()] param( [Parameter(Mandatory = $true)] + [ValidateLength(3, 12)] # Length between 3 and 12 + [ValidatePattern('^[a-zA-Z0-9]+$')] # Only letters and numbers [string]$AppName, [Parameter(Mandatory = $true)] + [ValidateLength(2, 10)] # Length between 2 and 10 + [ValidatePattern('^[a-zA-Z0-9]+$')] # Only letters and numbers [string]$Environment, [Parameter(Mandatory = $false)] diff --git a/deployers/azure.yaml b/deployers/azure.yaml index ae9a6840..90a40cc9 100644 --- a/deployers/azure.yaml +++ b/deployers/azure.yaml @@ -5,6 +5,48 @@ infra: provider: bicep path: bicep hooks: + postprovision: + posix: + shell: sh + run: | + # Set up variables + + export var_configureApplication=${var_configureApplication} + export var_cosmosDb_uri=${var_cosmosDb_uri} + export var_subscriptionId=${AZURE_SUBSCRIPTION_ID} + export var_rgName=${var_rgName} + export var_keyVaultUri=${var_keyVaultUri} + + export var_authenticationType=${var_authenticationType} + + export var_openAIEndpoint=${var_openAIEndpoint} + export var_openAIResourceGroup=${var_openAIResourceGroup} + export var_openAIGPTModel=${var_openAIGPTModel} + export var_openAITextEmbeddingModel=${var_openAITextEmbeddingModel} + export var_blobStorageEndpoint=${var_blobStorageEndpoint} + export var_contentSafetyEndpoint=${var_contentSafetyEndpoint} + export var_searchServiceEndpoint=${var_searchServiceEndpoint} + export var_documentIntelligenceServiceEndpoint=${var_documentIntelligenceServiceEndpoint} + export var_videoIndexerName=${var_videoIndexerName} + export var_deploymentLocation=${var_deploymentLocation} + export var_videoIndexerAccountId=${var_videoIndexerAccountId} + export var_speechServiceEndpoint=${var_speechServiceEndpoint} + + # Execute post-configuration script if enabled + if [ "${var_configureApplication}" = "true" ]; then + echo "Grant permissions to CosmosDB for post deployment steps..." + bash ./bicep/cosmosDb-postDeployPerms.sh + echo "Running post-deployment configuration..." + python3 -m pip install --user -r ./bicep/requirements.txt + python3 ./bicep/postconfig.py + echo "Post-deployment configuration completed." + echo "Restarting web service to apply new settings..." + az webapp restart --name ${var_webService} --resource-group ${var_rgName} + echo "Web service restarted." + else + echo "Skipping post-deployment configuration (var_configureApplication is not true)" + fi + predeploy: posix: shell: sh diff --git a/deployers/bicep/README.md b/deployers/bicep/README.md index 7a76363b..492d569e 100644 --- a/deployers/bicep/README.md +++ b/deployers/bicep/README.md @@ -25,16 +25,6 @@ The folloiwng variables will be used within this document: - *\* - Should be presented in the form *imageName:label* **Example:** *simple-chat:latest* -The following variables may be entered with a blank depending on the response to other parameters: - -If *\* = *true* then the following variables need to be set with applicable values, if *false* a blank is permitted -- *\* - Resource group name for the existing Azure Container Registry. -- *\* - Azure Container Registry name - -if *\* = *true* then the following variables need to be set with applicable values, if *false* a blank is permitted. -- *\* - Resource group name for the existing Azure OpenAI service. -- *\* - Azure OpenAI service name. - ## Deployment Process The below steps cover the process to deploy the Simple Chat application to an Azure Subscription. It is assumed the user has administrative rights to the subscription for deployment. If the user does not also have permissions to create an Application Registration in Entra, a stand-alone script can be provided to an administrator with the correct permissions. @@ -113,26 +103,19 @@ Using the bash terminal in Visual Studio Code - Select an Azure Subscription to use: *\* -- Enter a value for the 'useExistingAcr' infrastructure parameter: *\* -- Enter a value for the 'useExistingOpenAISvc' infrastructure parameter: *\* Provisioning may take between 10-40 minutes depending on the options selected. @@ -142,31 +125,24 @@ On the completion of the deployment, a URL will be presented, the user may use t ### Post Deployment Tasks: -Once logged in to the newly deployed application with admin credentials, the application will need to be configured with several configurations: +Once logged in to the newly deployed application with admin credentials, the application will need to be set up with several configurations: -1. Admin Settings > Health Check > "Enable External Health Check Endpoint" - Set to "ON" -1. AI Models > GPT Configuration & Embeddings Configuration. Use managed Identity. Configure the subscription and resource group. Click Save +1. AI Models > GPT Configuration & Embeddings Configuration. Application is pre-configured with the chosen security model (key / managed identity). Select "Test GPT Connection" and "Test Embedding Connection" to verify connection. > Known Bug: User will be unable to Fetch GPT or Embedding models.
    Workaround: Set configurations in CosmosDB. For details see [Workarounds](##Workarounds) below. -1. Agents and Actions > Agents Configuration > "Enable Agents" - Set to "ON" 1. Logging > Application Insights Logging > "Enable Application Insights Global Logging - Set to "ON" 1. Citations > Ehnahced Citations > "Enable Enhanced Citations" - Set to "ON" - Configure "All Filetypes" - "Storage Account Authentication Type" = Managed Identity - "Storage Account Blob Endpoint" = "https://\\sa.blob.core.windows.net" (or appropiate domain if in Azure Gov.) -1. Workflow > Workflow Settings > "Enable Workflow" - Set to "ON" - > Note if the deployment option for "deployContentSafety" was set to true follow the next step. -1. Safety > Content Safety > "Enable Content Safety" - Set to "ON" - - "Content Safety Endpoint" - "https://\-\-contentsafety.cognitiveservices.azure.com/" (or appropiate domain if in Azure Gov.) 1. Safety > Conversation Archiving > "Enable Conversation Archiving" - Set to "ON" -1. PII Analysis > PII Analysis > "Enable PII Analysis" - Set to "ON" 1. Search & Extract > Azure AI Search - "Search Endpoint" = "https://\-\-search.search.windows.net" (or appropiate domain if in Azure Gov.) > Known Bug: Unable to configure "Managed Identity" authentication type. Must use "Key" - "Authentication Type" - Key - - "Search Key" - Retreive from the deployed search service. + - "Search Key" - *Pre-populated from key vault value*. - At the top of the Admin Page you'll see warning boxes indicating Index Schema Mismatch. - Click "Create user Index" - Click "Create group Index" diff --git a/deployers/bicep/README_orig.md b/deployers/bicep/README_orig.md deleted file mode 100644 index 4ded5491..00000000 --- a/deployers/bicep/README_orig.md +++ /dev/null @@ -1,156 +0,0 @@ -# Simple Chat - Deployment using BICEP - -[Return to Main](../README.md) - -## Manual Pre-Requisites (Critically Important) - -Create Entra ID App Registration: - -Go to Azure portal > Microsoft Entra ID > App registrations > New registration. - -- Provide a name (e.g., $appRegistrationName from the script's logic). -- Supported account types: Usually "Accounts in this organizational directory only." -- Do not configure Redirect URI yet. You will get these from the Bicep output. -- Once created, note down the Application (client) ID (this is appRegistrationClientId parameter). -- Go to "Certificates & secrets" > "New client secret" > Create a secret and copy its Value immediately (this is appRegistrationClientSecret parameter). -- **** NO **** Go to "Token configuration" and enable "ID tokens" and "Access tokens" for implicit grant and hybrid flows if needed by your app (the script attempts az ad app update --enable-id-token-issuance true --enable-access-token-issuance true). -- The script also adds API permissions for Microsoft Graph and attempts to add owners. These should be configured manually on the App Registration. - - User.Read, Delegated - - profile, Delegated - - email, Delegated - - Group.Read.All, Delegated - - offline_access, Delegated - - openid, Delegated - - People.Read.All, Delegated - - User.ReadBasic.All, Delegated -- The script also references appRegistrationRoles.json. If your application defines app roles, configure these in the App Registration manifest. -- Obtain the Object ID of the Service Principal associated with this App Registration: az ad sp show --id --query id -o tsv. This will be the appRegistrationSpObjectId parameter. - -Create Entra ID Security Groups: If your application relies on the security groups ($global_EntraSecurityGroupNames), create them manually in Entra ID. - -Azure Container Registry (ACR): Ensure the ACR specified by acrName exists and the image imageName is pushed to it. - -Azure OpenAI Access: If useExistingOpenAiInstance is true, ensure the specified existing OpenAI resource exists and you have its name and resource group. If false, ensure your subscription is approved for Azure OpenAI and the chosen SKU and region support it. - -## Deploy - -(Optional) Create a resource group if you don't have one: az group create --name MySimpleChatRG --location usgovvirginia - -Deploy the Bicep file. - -### azure cli - -#### validate before deploy - -az bicep build --file main.bicep - -az deployment group validate ` ---resource-group MySimpleChatRG ` ---template-file main.bicep ` ---parameters main.json - -az deployment group create ` ---resource-group MySimpleChatRG ` ---template-file main.bicep ` ---parameters main.bicepparam ` ---parameters appRegistrationClientSecret="YOUR_APP_REG_SECRET_VALUE" - -## Post-Deployment Manual Steps (from Bicep outputs and script) - -### App Registration - -- Manage > Authentication - - Web Redirect Url example: - - - - - - Front-channel logout URL: - - Implicit grant and hyrbid flows: - - Access tokens: Check this - - ID tokens: Check this - - Supported account types: Accounts in this organization directly only - - Advanced Settings > Allow public client flows > Enable the following mobile and desktop flows: No - -- Manage > Certificates & secrets - - You will see 2 secrets here in the end. One created by you pre-deployment and one created when you add Authentication to the App Service. - -- Manage > Token configuration: Nothing to do here. Leave empty. - -- Manage > API Permissions: Click "Grant Admin Consent for tenant" to all deletgated permissions - -- Manage > Expose an API: Nothing to do here. Leave empty. - -- Manage > App Roles: You should see the following app roles: [FeedbackAdmin, Safety Violation Admin, Create Group, Users, Admins] - -### Entra Security Groups - -- Assignments: If you created security groups, assign them to the corresponding Enterprise Application application roles and add members to the security groups. - -### App Service - -- Authentication - - Identity Provider: Microsoft - - Choose a tenant for your application and its users: Workforce configuration (current tenant) - - Pick an existing app registration in this directory: Select the app registration you created pre-deployment - - Client secret expiration: Recommended 180 days - - *** Leave all other values default - - Note: Check App Setting "MICROSOFT_PROVIDER_AUTHENTICATION_SECRET" for a secret value created by configuring the Authentication. This secret will be added to your App Registration as well. - -- Deployment Center > Registry Settings (These sometimes get screwed up during a deploy. Make sure these values are correct.) - - The deployer can get messed up here. Make sure the correct values are being displayed for your registry settings. - - Container Type: Single Container - - Registry source: Azure Container Registry - - Subscription Id: [Your subscription] - - Authentication: Managed Identity - - Identity: Managed identity deployer deployed - - Registry: [Name of the ACR: e.g. SomeRegistry] - - Image: simplechat - - Tag: 2025-05-29_1 - - Startup file or command: [Blank] - -- Restart & Test: Restart the App Service and test the Web UI. - -- Open Monitoring > Log stream and make sure the container has loaded and is ready. - -### Azure AI Search - -- Manually create 2 Indexes: Deploy your search index schemas (ai_search-index-group.json, ai_search-index-user.json) using Index as Json in the Azure portal. - - Note: These files can be found in GitHub repository folder /deployers/bicep/artifacts - -### Existing Open AI (Option) - -- Make sure the Managed Idenity and the Entra App Registration have been added to the Open AI Instance IAM with RBAC Roles [Cognitive Services Contributor, Cognitive Services OpenAI User, Cognitive Services User] - -### Admin center in Web UI application - -- Open a browser and navigate to the url of the Azure App Service default domain. - -- Once you have logged into the application, navigate to "Admin" and configure the settings. - - Note: If you cannot login or see the Admin link, make sure you have added yourself to the Enterprise Application (Assigned users and groups) users for the App Registration you created. Make sure you have assigned your user account to the "Admin" app role. diff --git a/deployers/bicep/cosmosDb-postDeployPerms.sh b/deployers/bicep/cosmosDb-postDeployPerms.sh new file mode 100644 index 00000000..c4d3c311 --- /dev/null +++ b/deployers/bicep/cosmosDb-postDeployPerms.sh @@ -0,0 +1,48 @@ + +#!/usr/bin/env bash +set -euo pipefail + +RG_NAME="${var_rgName}" + +COSMOS_URI="${var_cosmosDb_uri}" +ACCOUNT_NAME=$(echo "$COSMOS_URI" | sed -E 's#https://([^.]*)\.documents\.azure\.com.*#\1#') + +echo "===============================" +echo "Cosmos DB Account Name: $ACCOUNT_NAME" + +UPN=$(az account show --query user.name -o tsv) +OBJECT_ID=$(az ad signed-in-user show --query id -o tsv) +SUBSCRIPTION_ID=$(az account show --query id -o tsv) + +# Control-plane assignment +SCOPE="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG_NAME/providers/Microsoft.DocumentDB/databaseAccounts/$ACCOUNT_NAME" + +ROLE_NAME="Contributor" +ROLE_ID=$(az role definition list --name "$ROLE_NAME" --query "[0].id" -o tsv) + +echo "Assigning role '$ROLE_NAME' to user '$UPN' on scope '$SCOPE'..." +az role assignment create \ + --assignee-object-id "$OBJECT_ID" \ + --assignee-principal-type "User" \ + --role "$ROLE_ID" \ + --scope "$SCOPE" || echo "Control-plane role may already exist." + + +# Data-plane assignment +DP_ROLE_NAME="Cosmos DB Built-in Data Contributor" +DP_ROLE_ID=$(az cosmosdb sql role definition list \ + --account-name "$ACCOUNT_NAME" \ + --resource-group "$RG_NAME" \ + --query "[?roleName=='$DP_ROLE_NAME'].id | [0]" -o tsv) + +echo "Assigning data-plane role '$DP_ROLE_NAME' to user '$UPN' on Cosmos DB account '$ACCOUNT_NAME'..." + +az cosmosdb sql role assignment create \ + --account-name "$ACCOUNT_NAME" \ + --resource-group "$RG_NAME" \ + --scope "/" \ + --principal-id "$OBJECT_ID" \ + --role-definition-id "$DP_ROLE_ID" || echo "Data-plane role may already exist." + +echo "Assigned Cosmos roles to $UPN ($OBJECT_ID)." +echo "===============================" \ No newline at end of file diff --git a/deployers/bicep/main.bicep b/deployers/bicep/main.bicep index a65d0d4c..86e0cfaa 100644 --- a/deployers/bicep/main.bicep +++ b/deployers/bicep/main.bicep @@ -29,29 +29,23 @@ param appName string @maxLength(10) param environment string -@description('Optional object containing additional tags to apply to all resources.') -param specialTags object = {} - @minLength(1) @maxLength(64) @description('Name of the AZD environment') param azdEnvironmentName string -@description('''Enable diagnostic logging for resources deployed in the resource group. -- All content will be sent to the deployed Log Analytics workspace -- Default is false''') -param enableDiagLogging bool - -@description('''Enable enterprise application (Azure AD App Registration) configuration. -- Enables SSO, conditional access, and centralized identity management -- Default is true''') -param enableEnterpriseApp bool = true +@description('''The name of the container image to deploy to the web app. +- should be in the format :''') +param imageName string @description('''Azure AD Application Client ID for enterprise authentication. -- Required if enableEnterpriseApp is true - Should be the client ID of the registered Azure AD application''') param enterpriseAppClientId string +@description('''Azure AD Application Service Principal Id for the enterprise application. +- Should be the Service Principal ID of the registered Azure AD application''') +param enterpriseAppServicePrincipalId string + @description('''Azure AD Application Client Secret for enterprise authentication. - Required if enableEnterpriseApp is true - Should be created in Azure AD App Registration and passed via environment variable @@ -59,19 +53,62 @@ param enterpriseAppClientId string @secure() param enterpriseAppClientSecret string -@description('''Use existing Azure Container Registry -- Default is false''') -param useExistingAcr bool +//---------------- +// configurations +@description('''Authentication type for resources that support Managed Identity or Key authentication. +- Key: Use access keys for authentication (application keys will be stored in Key Vault) +- managed_identity: Use Managed Identity for authentication''') +@allowed([ + 'key' + 'managed_identity' +]) +param authenticationType string + +@description('''Configure permissions (based on authenticationType) for the deployed web application to access required resources. +''') +param configureApplicationPermissions bool + +@description('Optional object containing additional tags to apply to all resources.') +param specialTags object = {} -@description('''The name of the existing Azure Container Registry containing the container image to deploy to the web app. -- Required if useExistingAcr is true -- should be in the format -- Do not include any domain suffix such as .azurecr.io''') -param existingAcrResourceName string +@description('''Enable diagnostic logging for resources deployed in the resource group. +- All content will be sent to the deployed Log Analytics workspace +- Default is false''') +param enableDiagLogging bool -@description('''The name of the Azure Container Registry resource group. -- Required if useExistingAcr is true''') -param existingAcrResourceGroup string +@description('''Array of GPT model names to deploy to the OpenAI resource.''') +param gptModels array = [ + { + modelName: 'gpt-4.1' + modelVersion: '2025-04-14' + skuName: 'GlobalStandard' + skuCapacity: 150 + } + { + modelName: 'gpt-4o' + modelVersion: '2024-11-20' + skuName: 'GlobalStandard' + skuCapacity: 100 + } +] + +@description('''Array of embedding model names to deploy to the OpenAI resource.''') +param embeddingModels array = [ + { + modelName: 'text-embedding-3-small' + modelVersion: '1' + skuName: 'GlobalStandard' + skuCapacity: 150 + } + { + modelName: 'text-embedding-3-large' + modelVersion: '1' + skuName: 'GlobalStandard' + skuCapacity: 150 + } +] +//---------------- +// optional services @description('''Enable deployment of Content Safety service and related resources. - Default is false''') @@ -85,43 +122,19 @@ param deployRedisCache bool - Default is false''') param deploySpeechService bool -@description('''Use existing Azure OpenAI resource''') -param useExistingOpenAISvc bool - -@description('''Existing Azure OpenAI Resource Group Name -- Required if useExistingOpenAISvc is true''') -param existingOpenAIResourceGroupName string - -@description('''Existing Azure OpenAI Resource Name -- Required if useExistingOpenAISvc is true''') -param existingOpenAIResourceName string - -@description('''The name of the container image to deploy to the web app. -- should be in the format :''') -param imageName string - -@description('''Unauthenticated client action for enterprise application. -- RedirectToLoginPage: Redirect unauthenticated users to login -- Return401: Return 401 Unauthorized for unauthenticated requests -- AllowAnonymous: Allow anonymous access''') -@allowed([ - 'AllowAnonymous' - 'RedirectToLoginPage' - 'Return401' - 'Return403' -]) -param unauthenticatedClientAction string = 'RedirectToLoginPage' +@description('''Enable deployment of Azure Video Indexer service and related resources. +- Default is false''') +param deployVideoIndexerService bool //========================================================= // variable declarations for the main deployment //========================================================= - var rgName = '${appName}-${environment}-rg' var requiredTags = { application: appName, environment: environment, 'azd-env-name': azdEnvironmentName } var tags = union(requiredTags, specialTags) var acrCloudSuffix = cloudEnvironment == 'AzureCloud' ? '.azurecr.io' : '.azurecr.us' +var acrName = toLower('${appName}${environment}acr') var containerRegistry = '${acrName}${acrCloudSuffix}' -var acrName = useExistingAcr ? existingAcrResourceName : toLower('${appName}${environment}acr') var containerImageName = '${containerRegistry}/${imageName}' //========================================================= @@ -134,10 +147,10 @@ resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { } //========================================================= -// Create managed identity +// Create log analytics workspace //========================================================= -module managedIdentity 'modules/managedIdentity.bicep' = { - name: 'managedIdentity' +module logAnalytics 'modules/logAnalyticsWorkspace.bicep' = { + name: 'logAnalytics' scope: rg params: { location: location @@ -148,16 +161,17 @@ module managedIdentity 'modules/managedIdentity.bicep' = { } //========================================================= -// Create log analytics workspace +// Create application insights //========================================================= -module logAnalytics 'modules/logAnalytics.bicep' = { - name: 'logAnalytics' +module applicationInsights 'modules/applicationInsights.bicep' = { + name: 'applicationInsights' scope: rg params: { location: location appName: appName environment: environment tags: tags + logAnalyticsId: logAnalytics.outputs.logAnalyticsId } } @@ -172,46 +186,21 @@ module keyVault 'modules/keyVault.bicep' = { appName: appName environment: environment tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId - enableEnterpriseApp: enableEnterpriseApp - enterpriseAppClientId: enterpriseAppClientId - enterpriseAppClientSecret: enterpriseAppClientSecret - } -} - -//========================================================= -// Create application insights -//========================================================= -module appInsights 'modules/appInsights.bicep' = { - name: 'appInsights' - scope: rg - params: { - location: location - appName: appName - environment: environment - tags: tags - logAnalyticsId: logAnalytics.outputs.logAnalyticsId } } //========================================================= -// Create storage account +// Store enterprise app client secret in key vault //========================================================= -module storageAccount 'modules/storageAccount.bicep' = { - name: 'storageAccount' +module storeEnterpriseAppSecret 'modules/keyVault-Secrets.bicep' = if (!empty(enterpriseAppClientSecret)) { + name: 'storeEnterpriseAppSecret' scope: rg params: { - location: location - appName: appName - environment: environment - tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId - enableDiagLogging: enableDiagLogging - logAnalyticsId: logAnalytics.outputs.logAnalyticsId + keyVaultName: keyVault.outputs.keyVaultName + secretName: 'enterprise-app-client-secret' + secretValue: enterpriseAppClientSecret } } @@ -226,55 +215,31 @@ module cosmosDB 'modules/cosmosDb.bicep' = { appName: appName environment: environment tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId - } -} -//========================================================= -// Create Document Intelligence resource -//========================================================= -module docIntel 'modules/documentIntelligence.bicep' = { - name: 'docIntel' - scope: rg - params: { - location: location - appName: appName - environment: environment - tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId - enableDiagLogging: enableDiagLogging - logAnalyticsId: logAnalytics.outputs.logAnalyticsId + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } //========================================================= -// Create or get Azure Container Registry +// Create Azure Container Registry //========================================================= -module acr_create 'modules/azureContainerRegistry.bicep' = if (!useExistingAcr) { - name: 'azureContainerRegistry_create' +module acr 'modules/azureContainerRegistry.bicep' = { + name: 'azureContainerRegistry' scope: rg params: { location: location acrName: acrName tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId - } -} -module acr_existing 'modules/azureContainerRegistry-existing.bicep' = if (useExistingAcr) { - name: 'acr-existing' - scope: rg - params: { - acrName: existingAcrResourceName - acrResourceGroup: existingAcrResourceGroup - managedIdentityPrincipalId: managedIdentity.outputs.principalId + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } @@ -289,72 +254,75 @@ module searchService 'modules/search.bicep' = { appName: appName environment: environment tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } //========================================================= -// Create or get Optional Resource - OpenAI Service +// Create Document Intelligence resource //========================================================= -module openAI_create 'modules/openAI.bicep' = if (!useExistingOpenAISvc) { - name: 'openAICreate' +module docIntel 'modules/documentIntelligence.bicep' = { + name: 'docIntel' scope: rg params: { location: location appName: appName environment: environment tags: tags - //managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId - } -} -module openAI_existing 'modules/openAI-existing.bicep' = if (useExistingOpenAISvc) { - name: 'openAIExisting' - scope: resourceGroup(useExistingOpenAISvc ? existingOpenAIResourceGroupName : rgName) - params: { - openAIName: existingOpenAIResourceName + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } //========================================================= -// Create Optional Resource - Content Safety +// Create storage account //========================================================= -module contentSafety 'modules/contentSafety.bicep' = if (deployContentSafety) { - name: 'contentSafety' +module storageAccount 'modules/storageAccount.bicep' = { + name: 'storageAccount' scope: rg params: { location: location appName: appName environment: environment tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } //========================================================= -// Create Optional Resource - Redis Cache +// Create - OpenAI Service //========================================================= -module redisCache 'modules/redisCache.bicep' = if (deployRedisCache) { - name: 'redisCache' +module openAI 'modules/openAI.bicep' = { + name: 'openAI' scope: rg params: { location: location appName: appName environment: environment tags: tags - //managedIdentityPrincipalId: managedIdentity.outputs.principalId - //managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions + + gptModels: gptModels + embeddingModels: embeddingModels } } @@ -385,10 +353,7 @@ module appService 'modules/appService.bicep' = { appName: appName environment: environment tags: tags - #disable-next-line BCP318 // expect one value to be null - acrName: useExistingAcr ? acr_existing.outputs.acrName : acr_create.outputs.acrName - managedIdentityId: managedIdentity.outputs.resourceId - managedIdentityClientId: managedIdentity.outputs.clientId + acrName: acr.outputs.acrName enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId appServicePlanId: appServicePlan.outputs.appServicePlanId @@ -396,52 +361,58 @@ module appService 'modules/appService.bicep' = { azurePlatform: cloudEnvironment cosmosDbName: cosmosDB.outputs.cosmosDbName searchServiceName: searchService.outputs.searchServiceName - #disable-next-line BCP318 // expect one value to be null - openAiServiceName: useExistingOpenAISvc ? openAI_existing.outputs.openAIName : openAI_create.outputs.openAIName - #disable-next-line BCP318 // expect one value to be null - openAiResourceGroupName: useExistingOpenAISvc - ? existingOpenAIResourceGroupName - #disable-next-line BCP318 // expect one value to be null - : openAI_create.outputs.openAIResourceGroup + openAiServiceName: openAI.outputs.openAIName + openAiResourceGroupName: openAI.outputs.openAIResourceGroup documentIntelligenceServiceName: docIntel.outputs.documentIntelligenceServiceName - appInsightsName: appInsights.outputs.appInsightsName + appInsightsName: applicationInsights.outputs.appInsightsName enterpriseAppClientId: enterpriseAppClientId - enterpriseAppClientSecret: '' + enterpriseAppClientSecret: enterpriseAppClientSecret + authenticationType: authenticationType keyVaultUri: keyVault.outputs.keyVaultUri } } //========================================================= -// Create Enterprise Application Configuration +// configure optional services +//========================================================= + +//========================================================= +// Create Optional Resource - Content Safety //========================================================= -module enterpriseApp 'modules/enterpriseApplication.bicep' = if (enableEnterpriseApp) { - name: 'enterpriseApplication' +module contentSafety 'modules/contentSafety.bicep' = if (deployContentSafety) { + name: 'contentSafety' scope: rg params: { + location: location appName: appName environment: environment - redirectUri: 'https://${appService.outputs.defaultHostName}' + tags: tags + enableDiagLogging: enableDiagLogging + logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } //========================================================= -// Configure App Service Authentication with Enterprise App +// Create Optional Resource - Redis Cache //========================================================= -module appServiceAuth 'modules/appServiceAuthentication.bicep' = if (enableEnterpriseApp && !empty(enterpriseAppClientId)) { - name: 'appServiceAuthentication' +module redisCache 'modules/redisCache.bicep' = if (deployRedisCache) { + name: 'redisCache' scope: rg - dependsOn: [ - enterpriseApp - ] params: { - webAppName: appService.outputs.name - clientId: enterpriseAppClientId - // Use the auto-generated secret URI if no manual secret was provided, otherwise use the manual one - clientSecretKeyVaultUri: !empty(enterpriseAppClientSecret) ? keyVault.outputs.enterpriseAppClientSecretUri : '' - tenantId: tenant().tenantId - enableAuthentication: enableEnterpriseApp - unauthenticatedClientAction: unauthenticatedClientAction - tokenStoreEnabled: true + location: location + appName: appName + environment: environment + tags: tags + enableDiagLogging: enableDiagLogging + logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } @@ -456,25 +427,49 @@ module speechService 'modules/speechService.bicep' = if (deploySpeechService) { appName: appName environment: environment tags: tags - managedIdentityPrincipalId: managedIdentity.outputs.principalId - managedIdentityId: managedIdentity.outputs.resourceId enableDiagLogging: enableDiagLogging logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + keyVault: keyVault.outputs.keyVaultName + authenticationType: authenticationType + configureApplicationPermissions: configureApplicationPermissions } } //========================================================= -// Resource to Configure Enterprise App Permissions +// Create Optional Resource - Video Indexer Service //========================================================= -module enterpriseAppPermissions 'modules/enterpriseAppPermissions.bicep' = if (enableEnterpriseApp) { - name: 'enterpriseAppPermissions' +module videoIndexerService 'modules/videoIndexer.bicep' = if (deployVideoIndexerService) { + name: 'videoIndexerService' scope: rg params: { + location: location + appName: appName + environment: environment + tags: tags + enableDiagLogging: enableDiagLogging + logAnalyticsId: logAnalytics.outputs.logAnalyticsId + + storageAccount: storageAccount.outputs.name + openAiServiceName: openAI.outputs.openAIName + } +} + +//========================================================= +// configure permissions for managed identity to access resources +//========================================================= +module setPermissions 'modules/setPermissions.bicep' = if (configureApplicationPermissions) { + name: 'setPermissions' + scope: rg + params: { + webAppName: appService.outputs.name + authenticationType: authenticationType + enterpriseAppServicePrincipalId: enterpriseAppServicePrincipalId keyVaultName: keyVault.outputs.keyVaultName cosmosDBName: cosmosDB.outputs.cosmosDbName - #disable-next-line BCP318 // expect one value to be null - openAIName: useExistingOpenAISvc ? '' : openAI_create.outputs.openAIName + acrName: acr.outputs.acrName + openAIName: openAI.outputs.openAIName docIntelName: docIntel.outputs.documentIntelligenceServiceName storageAccountName: storageAccount.outputs.name #disable-next-line BCP318 // expect one value to be null @@ -482,18 +477,47 @@ module enterpriseAppPermissions 'modules/enterpriseAppPermissions.bicep' = if (e searchServiceName: searchService.outputs.searchServiceName #disable-next-line BCP318 // expect one value to be null contentSafetyName: deployContentSafety ? contentSafety.outputs.contentSafetyName : '' + #disable-next-line BCP318 // expect one value to be null + videoIndexerName: deployVideoIndexerService ? videoIndexerService.outputs.videoIndexerServiceName : '' } } - //========================================================= -// Outputs for deployment of container image +// output values //========================================================= - +// output required for both predeploy and postprovision scripts in azure.yaml output var_rgName string = rgName -output var_acrName string = useExistingAcr ? existingAcrResourceName : toLower('${appName}${environment}acr') -output var_containerRegistry string = containerRegistry -output var_imageName string = contains(imageName, ':') ? split(imageName, ':')[0] : imageName -output var_imageTag string = split(imageName, ':')[1] -output var_specialImage bool = contains(imageName, ':') ? split(imageName, ':')[1] != 'latest' : false + +// output values required for predeploy script in azure.yaml output var_webService string = appService.outputs.name +output var_imageName string = contains(imageName, ':') ? split(imageName, ':')[0] : imageName +output var_imageTag string = split(imageName, ':')[1] +output var_containerRegistry string = containerRegistry +output var_acrName string = toLower('${appName}${environment}acr') + +// output values required for postprovision script in azure.yaml +output var_configureApplication bool = configureApplicationPermissions +output var_keyVaultUri string = keyVault.outputs.keyVaultUri +output var_cosmosDb_uri string = cosmosDB.outputs.cosmosDbUri +output var_subscriptionId string = subscription().subscriptionId +output var_authenticationType string = toLower(authenticationType) +output var_openAIEndpoint string = openAI.outputs.openAIEndpoint +output var_openAIResourceGroup string = openAI.outputs.openAIResourceGroup //may be able to remove +output var_openAIGPTModels array = gptModels +output var_openAIEmbeddingModels array = embeddingModels +output var_blobStorageEndpoint string = storageAccount.outputs.endpoint +#disable-next-line BCP318 // expect one value to be null +output var_contentSafetyEndpoint string = deployContentSafety ? contentSafety.outputs.contentSafetyEndpoint : '' +output var_deploymentLocation string = rg.location +output var_searchServiceEndpoint string = searchService.outputs.searchServiceEndpoint +output var_documentIntelligenceServiceEndpoint string = docIntel.outputs.documentIntelligenceServiceEndpoint +output var_videoIndexerName string = deployVideoIndexerService +#disable-next-line BCP318 // expect one value to be null + ? videoIndexerService.outputs.videoIndexerServiceName + : '' +output var_videoIndexerAccountId string = deployVideoIndexerService +#disable-next-line BCP318 // expect one value to be null + ? videoIndexerService.outputs.videoIndexerAccountId + : '' +#disable-next-line BCP318 // expect one value to be null +output var_speechServiceEndpoint string = deploySpeechService ? speechService.outputs.speechServiceEndpoint : '' diff --git a/deployers/bicep/main.parameters.json b/deployers/bicep/main.parameters.json index 61a30105..e819bca9 100644 --- a/deployers/bicep/main.parameters.json +++ b/deployers/bicep/main.parameters.json @@ -15,10 +15,10 @@ "value": "${AZURE_ENV_NAME}" }, "specialTags": { - "value": { - "Project": "SimpleChat", - "SystemOwner": "Steve Carroll" - } + "value": { + "Project": "SimpleChat", + "SystemOwner": "Steve Carroll" + } }, "enterpriseAppClientId": { "value": "${ENTERPRISE_APP_CLIENT_ID}" @@ -26,20 +26,11 @@ "enterpriseAppClientSecret": { "value": "${ENTERPRISE_APP_CLIENT_SECRET}" }, - "existingAcrResourceName": { - "value": "${EXISTING_ACR_RESOURCE_NAME}" - }, - "existingAcrResourceGroup": { - "value": "${EXISTING_ACR_RESOURCE_GROUP}" - }, - "existingOpenAIResourceName": { - "value": "${EXISTING_OPENAI_RESOURCE_NAME}" - }, - "existingOpenAIResourceGroupName": { - "value": "${EXISTING_OPENAI_RESOURCE_GROUP}" - }, "imageName": { "value": "${CONTAINER_IMAGE_NAME}" + }, + "authenticationType": { + "value": "${AUTHENTICATION_TYPE}" } } } \ No newline at end of file diff --git a/deployers/bicep/modules/aiModel.bicep b/deployers/bicep/modules/aiModel.bicep index 3c60ee04..b972ee3a 100644 --- a/deployers/bicep/modules/aiModel.bicep +++ b/deployers/bicep/modules/aiModel.bicep @@ -1,4 +1,3 @@ - param parent string param modelName string param modelVersion string diff --git a/deployers/bicep/modules/appService.bicep b/deployers/bicep/modules/appService.bicep index b3af02ab..4f70f03a 100644 --- a/deployers/bicep/modules/appService.bicep +++ b/deployers/bicep/modules/appService.bicep @@ -5,8 +5,6 @@ param appName string param environment string param tags object -param managedIdentityId string -param managedIdentityClientId string param enableDiagLogging bool param logAnalyticsId string @@ -20,16 +18,12 @@ param openAiServiceName string param openAiResourceGroupName string param documentIntelligenceServiceName string param appInsightsName string - -@description('Enterprise application client ID for Azure AD authentication') param enterpriseAppClientId string = '' +param authenticationType string -@description('Enterprise application client secret for Azure AD authentication') @secure() param enterpriseAppClientSecret string = '' - -@description('Key Vault URI for secret references') -param keyVaultUri string = '' +param keyVaultUri string // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { @@ -40,7 +34,7 @@ resource acrService 'Microsoft.ContainerRegistry/registries@2025-04-01' existing name: acrName } -resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' existing = { +resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' existing = { name: cosmosDbName } @@ -53,7 +47,7 @@ resource openAiService 'Microsoft.CognitiveServices/accounts@2024-10-01' existin } resource documentIntelligence 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { - name: documentIntelligenceServiceName + name: documentIntelligenceServiceName } resource appInsights 'Microsoft.Insights/components@2020-02-02' existing = { name: appInsightsName @@ -71,57 +65,93 @@ resource webApp 'Microsoft.Web/sites@2022-03-01' = { siteConfig: { linuxFxVersion: 'DOCKER|${containerImageName}' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: managedIdentityClientId + acrUserManagedIdentityID: '' // managedIdentityId alwaysOn: true ftpsState: 'Disabled' healthCheckPath: '/external/healthcheck' appSettings: [ - - {name: 'AZURE_ENDPOINT', value: azurePlatform == 'AzureUSGovernment' ? 'usgovernment' : 'public'} - {name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'false'} - {name: 'AZURE_COSMOS_ENDPOINT', value: cosmosDb.properties.documentEndpoint} - {name: 'AZURE_COSMOS_AUTHENTICATION_TYPE', value: 'managed_identity'} - - //{name: 'AZURE_COSMOS_AUTHENTICATION_TYPE', value: 'key'} - //{name: 'AZURE_COSMOS_KEY', value: cosmosDb.listKeys().primaryMasterKey} - - {name: 'TENANT_ID', value: tenant().tenantId } - {name: 'CLIENT_ID', value: enterpriseAppClientId } - {name: 'SECRET_KEY', value: !empty(enterpriseAppClientSecret) ? enterpriseAppClientSecret : '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/enterprise-app-client-secret)' } - {name: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET', value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/enterprise-app-client-secret)'} - {name: 'DOCKER_REGISTRY_SERVER_URL', value: 'https://${acrService.name}${acrDomain}' } - //{name: 'DOCKER_REGISTRY_SERVER_USERNAME', value: acrService.listCredentials().username } - //{name: 'DOCKER_REGISTRY_SERVER_PASSWORD', value: acrService.listCredentials().passwords[0].value } - {name: 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS', value: tenant().tenantId } - {name: 'AZURE_OPENAI_RESOURCE_NAME', value: openAiService.name} - {name: 'AZURE_OPENAI_RESOURCE_GROUP_NAME', value: openAiResourceGroupName} - {name: 'AZURE_OPENAI_URL', value: openAiService.properties.endpoint} - {name: 'AZURE_SEARCH_SERVICE_NAME', value: searchService.name} - {name: 'AZURE_SEARCH_API_KEY', value: searchService.listAdminKeys().primaryKey} - {name: 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT', value: documentIntelligence.properties.endpoint} - {name: 'AZURE_DOCUMENT_INTELLIGENCE_API_KEY', value: documentIntelligence.listKeys().key1} - {name: 'APPINSIGHTS_INSTRUMENTATIONKEY', value: appInsights.properties.InstrumentationKey} - {name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString} - {name: 'APPINSIGHTS_PROFILERFEATURE_VERSION', value: '1.0.0'} - {name: 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION', value: '1.0.0'} - {name: 'APPLICATIONINSIGHTS_CONFIGURATION_CONTENT', value: ''} - {name: 'ApplicationInsightsAgent_EXTENSION_VERSION', value: '~3'} - {name: 'DiagnosticServices_EXTENSION_VERSION', value: '~3'} - {name: 'InstrumentationEngine_EXTENSION_VERSION', value: 'disabled'} - {name: 'SnapshotDebugger_EXTENSION_VERSION', value: 'disabled'} - {name: 'XDT_MicrosoftApplicationInsights_BaseExtensions', value: 'disabled'} - {name: 'XDT_MicrosoftApplicationInsights_Mode', value: 'recommended'} - {name: 'XDT_MicrosoftApplicationInsights_PreemptSdk', value: 'disabled'} + { name: 'AZURE_ENDPOINT', value: azurePlatform == 'AzureUSGovernment' ? 'usgovernment' : 'public' } + { name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'false' } + { name: 'AZURE_COSMOS_ENDPOINT', value: cosmosDb.properties.documentEndpoint } + { name: 'AZURE_COSMOS_AUTHENTICATION_TYPE', value: toLower(authenticationType) } + + // Only add this setting if authenticationType is 'key' + ...(authenticationType == 'key' + ? [{ name: 'AZURE_COSMOS_KEY', value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/cosmos-db-key)' }] + : []) + + { name: 'TENANT_ID', value: tenant().tenantId } + { name: 'CLIENT_ID', value: enterpriseAppClientId } + { + name: 'SECRET_KEY' + value: !empty(enterpriseAppClientSecret) + ? enterpriseAppClientSecret + : '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/enterprise-app-client-secret)' + } + { + name: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/enterprise-app-client-secret)' + } + { name: 'DOCKER_REGISTRY_SERVER_URL', value: 'https://${acrService.name}${acrDomain}' } + + // Only add this setting if authenticationType is 'key' + ...(authenticationType == 'key' + ? [{ name: 'DOCKER_REGISTRY_SERVER_USERNAME', value: acrService.listCredentials().username }] + : []) + + // Only add this setting if authenticationType is 'key' + ...(authenticationType == 'key' + ? [ + { + name: 'DOCKER_REGISTRY_SERVER_PASSWORD' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/container-registry-key)' + } + ] + : []) + + { name: 'WEBSITE_AUTH_AAD_ALLOWED_TENANTS', value: tenant().tenantId } + { name: 'AZURE_OPENAI_RESOURCE_NAME', value: openAiService.name } + { name: 'AZURE_OPENAI_RESOURCE_GROUP_NAME', value: openAiResourceGroupName } + { name: 'AZURE_OPENAI_URL', value: openAiService.properties.endpoint } + { name: 'AZURE_SEARCH_SERVICE_NAME', value: searchService.name } + // Only add this setting if authenticationType is 'key' + ...(authenticationType == 'key' + ? [ + { + name: 'AZURE_SEARCH_API_KEY' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/search-service-key)' + } + ] + : []) + { name: 'AZURE_DOCUMENT_INTELLIGENCE_ENDPOINT', value: documentIntelligence.properties.endpoint } + // Only add this setting if authenticationType is 'key' + ...(authenticationType == 'key' + ? [ + { + name: 'AZURE_DOCUMENT_INTELLIGENCE_API_KEY' + value: '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/document-intelligence-key)' + } + ] + : []) + { name: 'APPINSIGHTS_INSTRUMENTATIONKEY', value: appInsights.properties.InstrumentationKey } + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsights.properties.ConnectionString } + { name: 'APPINSIGHTS_PROFILERFEATURE_VERSION', value: '1.0.0' } + { name: 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION', value: '1.0.0' } + { name: 'APPLICATIONINSIGHTS_CONFIGURATION_CONTENT', value: '' } + { name: 'ApplicationInsightsAgent_EXTENSION_VERSION', value: '~3' } + { name: 'DiagnosticServices_EXTENSION_VERSION', value: '~3' } + { name: 'InstrumentationEngine_EXTENSION_VERSION', value: 'disabled' } + { name: 'SnapshotDebugger_EXTENSION_VERSION', value: 'disabled' } + { name: 'XDT_MicrosoftApplicationInsights_BaseExtensions', value: 'disabled' } + { name: 'XDT_MicrosoftApplicationInsights_Mode', value: 'recommended' } + { name: 'XDT_MicrosoftApplicationInsights_PreemptSdk', value: 'disabled' } ] } clientAffinityEnabled: false httpsOnly: true } identity: { - type: 'SystemAssigned, UserAssigned' - userAssignedIdentities: { - '${managedIdentityId}': {} - } + type: 'SystemAssigned' } tags: union(tags, { 'azd-service-name': 'web' }) } @@ -141,8 +171,6 @@ resource webAppLogging 'Microsoft.Web/sites/config@2022-03-01' = { } } -// prepare to add in app servce to have key vault secrets users rbac role. - // configure diagnostic settings for web app resource webAppDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${webApp.name}-diagnostics') @@ -156,6 +184,68 @@ resource webAppDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-pre } } +// Configure authentication settings for the web app +resource authSettings 'Microsoft.Web/sites/config@2022-03-01' = { + name: 'authsettingsV2' + parent: webApp + properties: { + globalValidation: { + requireAuthentication: true + unauthenticatedClientAction: 'RedirectToLoginPage' + redirectToProvider: 'azureActiveDirectory' + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + openIdIssuer: 'https://sts.windows.net/${tenant().tenantId}/' + clientId: enterpriseAppClientId + clientSecretSettingName: 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' + } + validation: { + jwtClaimChecks: {} + allowedAudiences: [ + 'api://${enterpriseAppClientId}' + enterpriseAppClientId + ] + } + isAutoProvisioned: false + } + } + login: { + routes: { + logoutEndpoint: '/.auth/logout' + } + tokenStore: { + enabled: true + tokenRefreshExtensionHours: 72 + fileSystem: { + directory: '/home/data/.auth' + } + } + preserveUrlFragmentsForLogins: false + allowedExternalRedirectUrls: [] + cookieExpiration: { + convention: 'FixedTime' + timeToExpiration: '08:00:00' + } + nonce: { + validateNonce: true + nonceExpirationInterval: '00:05:00' + } + } + httpSettings: { + requireHttps: true + routes: { + apiPrefix: '/.auth' + } + forwardProxy: { + convention: 'NoProxy' + } + } + } +} + // Outputs output name string = webApp.name output defaultHostName string = webApp.properties.defaultHostName diff --git a/deployers/bicep/modules/appServiceAuthentication.bicep b/deployers/bicep/modules/appServiceAuthentication.bicep deleted file mode 100644 index 0a484a03..00000000 --- a/deployers/bicep/modules/appServiceAuthentication.bicep +++ /dev/null @@ -1,103 +0,0 @@ -targetScope = 'resourceGroup' - -@description('The name of the web app to configure authentication for') -param webAppName string - -@description('Client ID of the Azure AD application') -param clientId string - -@description('Key Vault secret URI for client secret (recommended approach)') -#disable-next-line secure-secrets-in-params // Doesn't contain a secret -param clientSecretKeyVaultUri string = '' - -@description('Azure AD tenant ID') -param tenantId string - -@description('Allowed token audiences') -param allowedAudiences array = [] - -@description('Enable Azure AD authentication') -param enableAuthentication bool = true - -@description('Authentication action when request is not authenticated') -@allowed([ - 'AllowAnonymous' - 'RedirectToLoginPage' - 'Return401' - 'Return403' -]) -param unauthenticatedClientAction string = 'RedirectToLoginPage' - -@description('Token store enabled') -param tokenStoreEnabled bool = true - -resource webApp 'Microsoft.Web/sites@2022-03-01' existing = { - name: webAppName -} - -// Configure authentication settings for the web app -resource authSettings 'Microsoft.Web/sites/config@2022-03-01' = if (enableAuthentication && !empty(clientId)) { - name: 'authsettingsV2' - parent: webApp - properties: { - globalValidation: { - requireAuthentication: unauthenticatedClientAction != 'AllowAnonymous' - unauthenticatedClientAction: unauthenticatedClientAction - redirectToProvider: 'azureActiveDirectory' - } - identityProviders: { - azureActiveDirectory: { - enabled: true - registration: { - openIdIssuer: 'https://sts.windows.net/${tenantId}/' - clientId: clientId - clientSecretSettingName: !empty(clientSecretKeyVaultUri) ? 'MICROSOFT_PROVIDER_AUTHENTICATION_SECRET' : null - } - validation: { - jwtClaimChecks: {} - allowedAudiences: !empty(allowedAudiences) ? allowedAudiences : [ - 'api://${clientId}' - clientId - ] - } - isAutoProvisioned: false - } - } - login: { - routes: { - logoutEndpoint: '/.auth/logout' - } - tokenStore: { - enabled: tokenStoreEnabled - tokenRefreshExtensionHours: 72 - fileSystem: { - directory: '/home/data/.auth' - } - } - preserveUrlFragmentsForLogins: false - allowedExternalRedirectUrls: [] - cookieExpiration: { - convention: 'FixedTime' - timeToExpiration: '08:00:00' - } - nonce: { - validateNonce: true - nonceExpirationInterval: '00:05:00' - } - } - httpSettings: { - requireHttps: true - routes: { - apiPrefix: '/.auth' - } - forwardProxy: { - convention: 'NoProxy' - } - } - } -} - -// Output authentication configuration details -output authenticationEnabled bool = enableAuthentication && !empty(clientId) -output loginUrl string = enableAuthentication && !empty(clientId) ? 'https://${webApp.properties.defaultHostName}/.auth/login/aad' : '' -output logoutUrl string = enableAuthentication && !empty(clientId) ? 'https://${webApp.properties.defaultHostName}/.auth/logout' : '' diff --git a/deployers/bicep/modules/appInsights.bicep b/deployers/bicep/modules/applicationInsights.bicep similarity index 100% rename from deployers/bicep/modules/appInsights.bicep rename to deployers/bicep/modules/applicationInsights.bicep diff --git a/deployers/bicep/modules/azureContainerRegistry-existing.bicep b/deployers/bicep/modules/azureContainerRegistry-existing.bicep deleted file mode 100644 index a9c76808..00000000 --- a/deployers/bicep/modules/azureContainerRegistry-existing.bicep +++ /dev/null @@ -1,18 +0,0 @@ -targetScope = 'resourceGroup' - -param acrName string -param acrResourceGroup string -param managedIdentityPrincipalId string - -// Deploy role assignment to the ACR's resource group -module roleAssignment 'azureContainerRegistry-roleAssignment.bicep' = { - name: 'acr-role-assignment' - scope: resourceGroup(acrResourceGroup) - params: { - acrName: acrName - managedIdentityPrincipalId: managedIdentityPrincipalId - } -} - -output acrName string = roleAssignment.outputs.acrName -output acrResourceGroup string = acrResourceGroup diff --git a/deployers/bicep/modules/azureContainerRegistry-roleAssignment.bicep b/deployers/bicep/modules/azureContainerRegistry-roleAssignment.bicep deleted file mode 100644 index 2065d6dd..00000000 --- a/deployers/bicep/modules/azureContainerRegistry-roleAssignment.bicep +++ /dev/null @@ -1,27 +0,0 @@ -targetScope = 'resourceGroup' - -param acrName string -param managedIdentityPrincipalId string - -resource existingACR 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { - name: acrName -} - -// Built-in role definition ID for AcrPull -var acrPullRoleDefinitionId = subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' -) - -// grant the managed identity access to azure container registry as a pull contributor -resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(existingACR.id, managedIdentityPrincipalId, acrPullRoleDefinitionId) - scope: existingACR - properties: { - roleDefinitionId: acrPullRoleDefinitionId - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - -output acrName string = existingACR.name diff --git a/deployers/bicep/modules/azureContainerRegistry.bicep b/deployers/bicep/modules/azureContainerRegistry.bicep index 41f3b64b..4023447e 100644 --- a/deployers/bicep/modules/azureContainerRegistry.bicep +++ b/deployers/bicep/modules/azureContainerRegistry.bicep @@ -4,11 +4,13 @@ param location string param acrName string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -29,17 +31,15 @@ resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' = { tags: tags } -// grant the managed identity access to azure container registry as a pull contributor -resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(acr.id, managedIdentityId, 'acr-acrpull') - scope: acr - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '7f951dda-4ed3-4680-a7ca-43fe172d538d' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' +//========================================================= +// store container registry keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module containerRegistrySecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeContainerRegistrySecret' + params: { + keyVaultName: keyVault + secretName: 'container-registry-key' + secretValue: acr.listCredentials().passwords[0].value } } diff --git a/deployers/bicep/modules/contentSafety.bicep b/deployers/bicep/modules/contentSafety.bicep index 040af5fd..59c40125 100644 --- a/deployers/bicep/modules/contentSafety.bicep +++ b/deployers/bicep/modules/contentSafety.bicep @@ -5,11 +5,13 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -25,28 +27,13 @@ resource contentSafety 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } properties: { publicNetworkAccess: 'Enabled' - disableLocalAuth: false customSubDomainName: toLower('${appName}-${environment}-contentsafety') } tags: tags } -// grant the managed identity access to content safety as a Cognitive Services User -resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(contentSafety.id, managedIdentityId, 'content-safety-user') - scope: contentSafety - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'a97b65f3-24c7-4388-baec-2e87135dc908' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - // configure diagnostic settings for content safety -resource contentSafetyDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { +resource contentSafetyDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${contentSafety.name}-diagnostics') scope: contentSafety properties: { @@ -58,4 +45,17 @@ resource contentSafetyDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05 } } +//========================================================= +// store contentSafety keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module contentSafetySecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeContentSafetySecret' + params: { + keyVaultName: keyVault + secretName: 'content-safety-key' + secretValue: contentSafety.listKeys().key1 + } +} + output contentSafetyName string = contentSafety.name +output contentSafetyEndpoint string = contentSafety.properties.endpoint diff --git a/deployers/bicep/modules/cosmosDb.bicep b/deployers/bicep/modules/cosmosDb.bicep index 67af0d4f..67ca669a 100644 --- a/deployers/bicep/modules/cosmosDb.bicep +++ b/deployers/bicep/modules/cosmosDb.bicep @@ -5,11 +5,13 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -22,7 +24,6 @@ resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = { kind: 'GlobalDocumentDB' properties: { databaseAccountOfferType: 'Standard' - disableLocalAuth: true capabilities: [ { name: 'EnableServerless' @@ -69,32 +70,6 @@ resource cosmosContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/con } } -// grant the managed identity access to cosmos db as a contributor -resource cosmosContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(cosmosDb.id, managedIdentityId, 'cosmos-contributor') - scope: cosmosDb - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'b24988ac-6180-42a0-ab88-20f7382dd24c' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - -// Grant the managed identity Cosmos DB Built-in Data Contributor role -resource cosmosDataContributorRole 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = { - name: guid(cosmosDb.id, managedIdentityPrincipalId, 'cosmos-data-contributor') - parent: cosmosDb - properties: { - // Cosmos DB Built-in Data Contributor role definition ID - roleDefinitionId: '${cosmosDb.id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002' - principalId: managedIdentityPrincipalId - scope: cosmosDb.id - } -} - // configure diagnostic settings for cosmos db resource cosmosDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${cosmosDb.name}-diagnostics') @@ -107,4 +82,17 @@ resource cosmosDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-pre } } +//========================================================= +// store cosmos db keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module storeEnterpriseAppSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeEnterpriseAppSecret' + params: { + keyVaultName: keyVault + secretName: 'cosmos-db-key' + secretValue: cosmosDb.listKeys().primaryMasterKey + } +} + output cosmosDbName string = cosmosDb.name +output cosmosDbUri string = cosmosDb.properties.documentEndpoint diff --git a/deployers/bicep/modules/createAppSecret.bicep b/deployers/bicep/modules/createAppSecret.bicep deleted file mode 100644 index 7b24aedf..00000000 --- a/deployers/bicep/modules/createAppSecret.bicep +++ /dev/null @@ -1,113 +0,0 @@ -targetScope = 'resourceGroup' - -@description('Location for the deployment script') -param location string - -@description('Azure AD Application (Client) ID') -param applicationId string - -@description('Key Vault name where the secret will be stored') -param keyVaultName string - -@description('Name of the secret to create in Key Vault') -param secretName string = 'enterprise-app-client-secret' - -@description('Managed identity ID for the deployment script') -param managedIdentityId string - -@description('Display name for the client secret in Azure AD') -param secretDisplayName string = 'Deployment-Generated-Secret' - -@description('Number of months until the secret expires (max 24)') -@minValue(1) -@maxValue(24) -param secretExpirationMonths int = 12 - -resource createSecretScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { - name: 'create-app-secret-${uniqueString(applicationId, secretName)}' - location: location - kind: 'AzureCLI' - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentityId}': {} - } - } - properties: { - azCliVersion: '2.59.0' - retentionInterval: 'PT1H' - timeout: 'PT10M' - cleanupPreference: 'OnSuccess' - environmentVariables: [ - { - name: 'APPLICATION_ID' - value: applicationId - } - { - name: 'KEY_VAULT_NAME' - value: keyVaultName - } - { - name: 'SECRET_NAME' - value: secretName - } - { - name: 'SECRET_DISPLAY_NAME' - value: secretDisplayName - } - { - name: 'EXPIRATION_MONTHS' - value: string(secretExpirationMonths) - } - ] - scriptContent: ''' - #!/bin/bash - set -e - - echo "Creating client secret for Azure AD application: $APPLICATION_ID" - - # Calculate expiration date - EXPIRATION_DATE=$(date -u -d "+${EXPIRATION_MONTHS} months" +"%Y-%m-%dT%H:%M:%SZ") - - # Create the client secret using Microsoft Graph API - # Note: This requires the managed identity to have appropriate Microsoft Graph permissions - echo "Creating client secret with expiration: $EXPIRATION_DATE" - - SECRET_RESPONSE=$(az rest \ - --method POST \ - --uri "https://graph.microsoft.com/v1.0/applications(appId='$APPLICATION_ID')/addPassword" \ - --body "{\"passwordCredential\": {\"displayName\": \"$SECRET_DISPLAY_NAME\", \"endDateTime\": \"$EXPIRATION_DATE\"}}" \ - --headers "Content-Type=application/json") - - # Extract the secret value from the response - SECRET_VALUE=$(echo "$SECRET_RESPONSE" | jq -r '.secretText') - - if [ -z "$SECRET_VALUE" ] || [ "$SECRET_VALUE" = "null" ]; then - echo "Failed to create client secret" - exit 1 - fi - - echo "Client secret created successfully" - - # Store the secret in Key Vault - echo "Storing secret in Key Vault: $KEY_VAULT_NAME" - az keyvault secret set \ - --vault-name "$KEY_VAULT_NAME" \ - --name "$SECRET_NAME" \ - --value "$SECRET_VALUE" \ - --description "Client secret for Azure AD application $APPLICATION_ID (expires: $EXPIRATION_DATE)" - - echo "Secret stored successfully in Key Vault" - - # Output the secret URI (not the value) - SECRET_URI=$(az keyvault secret show \ - --vault-name "$KEY_VAULT_NAME" \ - --name "$SECRET_NAME" \ - --query id -o tsv) - - echo "{\"secretUri\": \"$SECRET_URI\"}" > $AZ_SCRIPTS_OUTPUT_PATH - ''' - } -} - -output secretUri string = createSecretScript.properties.outputs.secretUri diff --git a/deployers/bicep/modules/diagnosticSettings.bicep b/deployers/bicep/modules/diagnosticSettings.bicep index 08003eac..4951f861 100644 --- a/deployers/bicep/modules/diagnosticSettings.bicep +++ b/deployers/bicep/modules/diagnosticSettings.bicep @@ -23,6 +23,15 @@ var standardLogCategories = [ } ] +// Standard log categories using category groups (recommended for most resources) +var limitedLogCategories = [ + { + categoryGroup: 'allLogs' + enabled: true + retentionPolicy: standardRetentionPolicy + } +] + // Standard metrics configuration var standardMetricsCategories = [ { @@ -91,8 +100,9 @@ var webAppLogCategories = [ ] // Export configurations as outputs so they can be used by other templates +output limitedLogCategories array = limitedLogCategories output standardRetentionPolicy object = standardRetentionPolicy -output standardLogCategories array = standardLogCategories +output standardLogCategories array = standardLogCategories output standardMetricsCategories array = standardMetricsCategories output transactionMetricsCategories array = transactionMetricsCategories -output webAppLogCategories array = webAppLogCategories \ No newline at end of file +output webAppLogCategories array = webAppLogCategories diff --git a/deployers/bicep/modules/documentIntelligence.bicep b/deployers/bicep/modules/documentIntelligence.bicep index e11bb707..70b343e3 100644 --- a/deployers/bicep/modules/documentIntelligence.bicep +++ b/deployers/bicep/modules/documentIntelligence.bicep @@ -5,13 +5,15 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations -module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging){ +module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' } @@ -25,26 +27,11 @@ resource docIntel 'Microsoft.CognitiveServices/accounts@2025-06-01' = { } properties: { publicNetworkAccess: 'Enabled' - disableLocalAuth: false customSubDomainName: toLower('${appName}-${environment}-docintel') } tags: tags } -// grant the managed identity access to document intelligence as a Cognitive Services User -resource docIntelUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(docIntel.id, managedIdentityId, 'doc-intel-user') - scope: docIntel - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'a97b65f3-24c7-4388-baec-2e87135dc908' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - // configure diagnostic settings for document intelligence resource docIntelDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${docIntel.name}-diagnostics') @@ -58,5 +45,18 @@ resource docIntelDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-p } } +//========================================================= +// store document intelligence keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module documentIntelligenceSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeDocumentIntelligenceSecret' + params: { + keyVaultName: keyVault + secretName: 'document-intelligence-key' + secretValue: docIntel.listKeys().key1 + } +} + output documentIntelligenceServiceName string = docIntel.name output diagnosticLoggingEnabled bool = enableDiagLogging +output documentIntelligenceServiceEndpoint string = docIntel.properties.endpoint diff --git a/deployers/bicep/modules/enterpriseApplication.bicep b/deployers/bicep/modules/enterpriseApplication.bicep deleted file mode 100644 index 30077aec..00000000 --- a/deployers/bicep/modules/enterpriseApplication.bicep +++ /dev/null @@ -1,91 +0,0 @@ -targetScope = 'resourceGroup' - -@description('The name of the application to be deployed') -param appName string - -@description('The environment name (dev/test/prod)') -param environment string - -@description('The redirect URI for the application') -param redirectUri string - -@description('Application description') -param appDescription string = 'Enterprise application for ${appName} ${environment} environment' - -@description('Application display name') -param displayName string = '${appName}-${environment}-app' - -@description('Required application permissions/scopes') -param requiredResourceAccess array = [ - { - resourceAppId: '00000003-0000-0000-c000-000000000000' // Microsoft Graph - resourceAccess: [ - { - id: 'e1fe6dd8-ba31-4d61-89e7-88639da4683d' // User.Read - type: 'Scope' - } - { - id: '14dad69e-099b-42c9-810b-d002981feec1' // Profile.Read - type: 'Scope' - } - { - id: '37f7f235-527c-4136-accd-4a02d197296e' // openid - type: 'Scope' - } - { - id: '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' // offline_access - type: 'Scope' - } - { - id: '64a6cdd6-aab1-4aaf-94b8-3cc8405e90d0' // email - type: 'Scope' - } - { - id: '5f8c59db-677d-491f-a6b8-5f174b11ec1d' // Group.Read.All - type: 'Scope' - } - ] - } -] - -@description('Supported account types for the application') -@allowed([ - 'AzureADMyOrg' - 'AzureADMultipleOrgs' - 'AzureADandPersonalMicrosoftAccount' -]) -param signInAudience string = 'AzureADMyOrg' - -// Note: Azure AD App Registration requires Microsoft Graph API which is not directly supported in Bicep -// This module creates the configuration that can be used by Azure CLI or PowerShell scripts -// The actual app registration will need to be created using az ad app create or equivalent - -var appRegistrationConfig = { - displayName: displayName - description: appDescription - signInAudience: signInAudience - web: { - redirectUris: [ - redirectUri - '${redirectUri}/.auth/login/aad/callback' - ] - implicitGrantSettings: { - enableAccessTokenIssuance: false - enableIdTokenIssuance: true - } - } - requiredResourceAccess: requiredResourceAccess - api: { - requestedAccessTokenVersion: 2 - } -} - -// Output the configuration that can be used for app registration -output appRegistrationConfig object = appRegistrationConfig -output displayName string = displayName -output redirectUri string = redirectUri -output callbackUri string = '${redirectUri}/.auth/login/aad/callback' - -// Output placeholder values that would be populated after app registration -output clientId string = '' // Will be populated after app registration -output tenantId string = tenant().tenantId diff --git a/deployers/bicep/modules/keyVault-Secrets.bicep b/deployers/bicep/modules/keyVault-Secrets.bicep new file mode 100644 index 00000000..acd09328 --- /dev/null +++ b/deployers/bicep/modules/keyVault-Secrets.bicep @@ -0,0 +1,23 @@ +targetScope = 'resourceGroup' + +param keyVaultName string +param secretName string +@secure() +param secretValue string + +resource kv 'Microsoft.KeyVault/vaults@2025-05-01' existing = { + name: keyVaultName +} + +resource secret 'Microsoft.KeyVault/vaults/secrets@2025-05-01' = { + name: secretName + parent: kv + properties: { + value: secretValue + } +} + +//------------------------------------------------ +// output values +//------------------------------------------------ +output SecretUri string = '${kv.properties.vaultUri}secrets/${secretName}' diff --git a/deployers/bicep/modules/keyVault.bicep b/deployers/bicep/modules/keyVault.bicep index 194ca583..6b2786de 100644 --- a/deployers/bicep/modules/keyVault.bicep +++ b/deployers/bicep/modules/keyVault.bicep @@ -5,21 +5,9 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string -@description('Enable enterprise app authentication') -param enableEnterpriseApp bool - -@description('Enterprise app client ID - used for documentation') -param enterpriseAppClientId string - -@description('Enterprise app client secret to store in Key Vault') -@secure() -param enterpriseAppClientSecret string - // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -45,21 +33,6 @@ resource kv 'Microsoft.KeyVault/vaults@2024-11-01' = { tags: tags } -// grant the managed identity access to the key vault -resource kvSecretsUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(kv.id, managedIdentityId, 'kv-secrets-user') - scope: kv - properties: { - // Built-in role definition id for "Key Vault Secrets User" - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '4633458b-17de-408a-b874-0445c86b69e6' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - // configure diagnostic settings for key vault resource kvDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${kv.name}-diagnostics') @@ -73,17 +46,6 @@ resource kvDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview } } -// Store the enterprise app client secret in Key Vault if provided -resource enterpriseAppSecret 'Microsoft.KeyVault/vaults/secrets@2024-11-01' = if (enableEnterpriseApp && !empty(enterpriseAppClientSecret)) { - name: 'enterprise-app-client-secret' - parent: kv - properties: { - value: enterpriseAppClientSecret - contentType: 'Client secret for Azure AD enterprise app ${enterpriseAppClientId}' - } -} - output keyVaultId string = kv.id output keyVaultName string = kv.name output keyVaultUri string = kv.properties.vaultUri -output enterpriseAppClientSecretUri string = enableEnterpriseApp ? '${kv.properties.vaultUri}secrets/enterprise-app-client-secret' : '' diff --git a/deployers/bicep/modules/logAnalytics.bicep b/deployers/bicep/modules/logAnalyticsWorkspace.bicep similarity index 100% rename from deployers/bicep/modules/logAnalytics.bicep rename to deployers/bicep/modules/logAnalyticsWorkspace.bicep diff --git a/deployers/bicep/modules/managedIdentity.bicep b/deployers/bicep/modules/managedIdentity.bicep index be883e74..01c6234b 100644 --- a/deployers/bicep/modules/managedIdentity.bicep +++ b/deployers/bicep/modules/managedIdentity.bicep @@ -12,6 +12,6 @@ resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023- tags: tags } +output clientId string = managedIdentity.properties.clientId output principalId string = managedIdentity.properties.principalId output resourceId string = managedIdentity.id -output clientId string = managedIdentity.properties.clientId diff --git a/deployers/bicep/modules/openAI-existing.bicep b/deployers/bicep/modules/openAI-existing.bicep deleted file mode 100644 index 28cb92a4..00000000 --- a/deployers/bicep/modules/openAI-existing.bicep +++ /dev/null @@ -1,40 +0,0 @@ -targetScope = 'resourceGroup' - -param openAIName string - -resource existingOpenAI 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { - name: openAIName -} - -// deploy GPT-4o model to the new OpenAI resource -module aiModel_gpt4o 'aiModel.bicep' = { - name: 'gpt-4o' - params: { - parent: existingOpenAI.name - modelName: 'gpt-4o' - modelVersion: '2024-11-20' - skuName: 'GlobalStandard' - skuCapacity: 100 - } -} - -// deploy Text Embedding model to the new OpenAI resource -module aiModel_textEmbedding 'aiModel.bicep' = { - name: 'text-embedding' - params: { - parent: existingOpenAI.name - modelName: 'text-embedding-3-small' - modelVersion: '1' - skuName: 'GlobalStandard' - skuCapacity: 150 - } -dependsOn: [ - aiModel_gpt4o - ] -} - -output openAIName string = existingOpenAI.name -output openAIResourceGroup string = resourceGroup().name - - - diff --git a/deployers/bicep/modules/openAI.bicep b/deployers/bicep/modules/openAI.bicep index d31337ad..9a04b79d 100644 --- a/deployers/bicep/modules/openAI.bicep +++ b/deployers/bicep/modules/openAI.bicep @@ -5,18 +5,23 @@ param appName string param environment string param tags object -//param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + +param gptModels array +param embeddingModels array + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' } // deploy new Azure OpenAI Resource -resource newOpenAI 'Microsoft.CognitiveServices/accounts@2024-10-01' = { +resource openAI 'Microsoft.CognitiveServices/accounts@2024-10-01' = { name: toLower('${appName}-${environment}-openai') location: location kind: 'OpenAI' @@ -24,23 +29,19 @@ resource newOpenAI 'Microsoft.CognitiveServices/accounts@2024-10-01' = { name: 'S0' } identity: { - type: 'SystemAssigned, UserAssigned' - userAssignedIdentities: { - '${managedIdentityId}': {} - } + type: 'SystemAssigned' } properties: { publicNetworkAccess: 'Enabled' - disableLocalAuth: false customSubDomainName: toLower('${appName}-${environment}-openai') } tags: tags } // configure diagnostic settings for OpenAI Resource if required -resource openAIDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { - name: toLower('${newOpenAI.name}-diagnostics') - scope: newOpenAI +resource openAIDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { + name: toLower('${openAI.name}-diagnostics') + scope: openAI properties: { workspaceId: logAnalyticsId #disable-next-line BCP318 // expect one value to be null @@ -50,35 +51,33 @@ resource openAIDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-pre } } -// deploy GPT-4o model to the new OpenAI resource -module aiModel_gpt4o 'aiModel.bicep' = { - name: 'gpt-4o' - params: { - parent: newOpenAI.name - modelName: 'gpt-4o' - modelVersion: '2024-11-20' - skuName: 'GlobalStandard' - skuCapacity: 100 +// deploy AI models defined in the input arrays +@batchSize(1) +module aiModel 'aiModel.bicep' = [ + for (model, i) in concat(gptModels, embeddingModels): { + name: 'model-${replace(model.modelName, '.', '-')}-${i}' + params: { + parent: openAI.name + modelName: model.modelName + modelVersion: model.modelVersion + skuName: model.skuName + skuCapacity: model.skuCapacity + } } -} +] -// deploy Text Embedding model to the new OpenAI resource -module aiModel_textEmbedding 'aiModel.bicep' = { - name: 'text-embedding' +//========================================================= +// store openAI keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module openAISecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeOpenAISecret' params: { - parent: newOpenAI.name - modelName: 'text-embedding-3-small' - modelVersion: '1' - skuName: 'GlobalStandard' - skuCapacity: 150 + keyVaultName: keyVault + secretName: 'openAi-key' + secretValue: openAI.listKeys().key1 } -dependsOn: [ - aiModel_gpt4o - ] } -output openAIName string = newOpenAI.name +output openAIName string = openAI.name output openAIResourceGroup string = resourceGroup().name - - - +output openAIEndpoint string = openAI.properties.endpoint diff --git a/deployers/bicep/modules/redisCache.bicep b/deployers/bicep/modules/redisCache.bicep index 796a2c1a..faab2e23 100644 --- a/deployers/bicep/modules/redisCache.bicep +++ b/deployers/bicep/modules/redisCache.bicep @@ -5,11 +5,13 @@ param appName string param environment string param tags object -//param managedIdentityPrincipalId string -//param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -34,22 +36,6 @@ resource redisCache 'Microsoft.Cache/Redis@2024-11-01' = { tags: tags } -// todo: grant the managed identity access to content safety as a Cognitive Services User -/* -resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (deployContentSafety) { - name: guid(contentSafety.id, managedIdentity.id, 'content-safety-user') - scope: contentSafety - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'a97b65f3-24c7-4388-baec-2e87135dc908' - ) - principalId: managedIdentity.properties.principalId - principalType: 'ServicePrincipal' - } -} -*/ - // configure diagnostic settings for redis cache resource redisCacheDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${redisCache.name}-diagnostics') @@ -62,3 +48,17 @@ resource redisCacheDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01 metrics: diagnosticConfigs.outputs.standardMetricsCategories } } + +//========================================================= +// store redis cache keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module redisCacheSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeRedisCacheSecret' + params: { + keyVaultName: keyVault + secretName: 'redis-cache-key' + secretValue: redisCache.listKeys().primaryKey + } +} + +output redisCacheName string = redisCache.name diff --git a/deployers/bicep/modules/search.bicep b/deployers/bicep/modules/search.bicep index 5ec5acec..2786b91e 100644 --- a/deployers/bicep/modules/search.bicep +++ b/deployers/bicep/modules/search.bicep @@ -5,11 +5,13 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' @@ -23,28 +25,19 @@ resource searchService 'Microsoft.Search/searchServices@2025-05-01' = { name: 'basic' } properties: { + #disable-next-line BCP036 // template is incorrect hostingMode: 'default' publicNetworkAccess: 'Enabled' replicaCount: 1 partitionCount: 1 + authOptions: { + aadOrApiKey: {aadAuthFailureMode: 'http403' } + } + disableLocalAuth: false } tags: tags } -// grant the managed identity access to search service as a search index data contributor -resource searchContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(searchService.id, managedIdentityId, 'search-contributor') - scope: searchService - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - '8ebe5a00-799e-43f5-93ac-243d3dce84a7' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - // configure diagnostic settings for search service resource searchDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${searchService.name}-diagnostics') @@ -58,4 +51,18 @@ resource searchDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-pre } } +//========================================================= +// store search Service keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module searchServiceSecret 'keyVault-Secrets.bicep' = if (configureApplicationPermissions) { + name: 'storeSearchServiceSecret' + params: { + keyVaultName: keyVault + secretName: 'search-service-key' + secretValue: searchService.listAdminKeys().primaryKey + } +} + output searchServiceName string = searchService.name +output searchServiceEndpoint string = searchService.properties.endpoint +output searchServiceAuthencationType string = authenticationType diff --git a/deployers/bicep/modules/enterpriseAppPermissions.bicep b/deployers/bicep/modules/setPermissions.bicep similarity index 54% rename from deployers/bicep/modules/enterpriseAppPermissions.bicep rename to deployers/bicep/modules/setPermissions.bicep index 07943cc7..33564d8e 100644 --- a/deployers/bicep/modules/enterpriseAppPermissions.bicep +++ b/deployers/bicep/modules/setPermissions.bicep @@ -1,14 +1,18 @@ targetScope = 'resourceGroup' param webAppName string +param authenticationType string param keyVaultName string +param enterpriseAppServicePrincipalId string param cosmosDBName string +param acrName string param openAIName string param docIntelName string param storageAccountName string param speechServiceName string param searchServiceName string param contentSafetyName string +param videoIndexerName string resource webApp 'Microsoft.Web/sites@2022-03-01' existing = { name: webAppName @@ -22,7 +26,11 @@ resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' existing = name: cosmosDBName } -resource openAiService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = if (openAIName != '') { +resource acr 'Microsoft.ContainerRegistry/registries@2025-04-01' existing = { + name: acrName +} + +resource openAiService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { name: openAIName } @@ -34,11 +42,6 @@ resource storageAccount 'Microsoft.Storage/storageAccounts@2022-09-01' existing name: storageAccountName } -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = { - name: 'default' - parent: storageAccount -} - resource speechService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = if (speechServiceName != '') { name: speechServiceName } @@ -51,6 +54,10 @@ resource contentSafety 'Microsoft.CognitiveServices/accounts@2025-06-01' existin name: contentSafetyName } +resource videoIndexerService 'Microsoft.VideoIndexer/accounts@2025-04-01' existing = if (videoIndexerName != '') { + name: videoIndexerName +} + // grant the webApp access to the key vault resource kvSecretsUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(kv.id, webApp.id, 'kv-secrets-user') @@ -67,7 +74,7 @@ resource kvSecretsUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' } // grant the webApp access to cosmos db as a contributor -resource cosmosContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +resource cosmosContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { name: guid(cosmosDb.id, webApp.id, 'cosmos-contributor') scope: cosmosDb properties: { @@ -81,7 +88,7 @@ resource cosmosContributorRole 'Microsoft.Authorization/roleAssignments@2022-04- } // Grant the managed identity Cosmos DB Built-in Data Contributor role -resource cosmosDataContributorRole 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = { +resource cosmosDataContributorRole 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2023-04-15' = if (authenticationType == 'managed_identity') { name: guid(cosmosDb.id, webApp.id, 'cosmos-data-contributor') parent: cosmosDb properties: { @@ -92,10 +99,24 @@ resource cosmosDataContributorRole 'Microsoft.DocumentDB/databaseAccounts/sqlRol } } +// grant the webApp access to the ACR with acrpull role +resource acrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(acr.id, webApp.id, 'acr-pull-role') + scope: acr + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' // ACR Pull role + ) + principalId: webApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + // Grant the openai service access cognitive services openai user -resource openAIUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (openAIName != '') { - name: guid(openAiService.id, webApp.id, 'openai-user') +resource openAIUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { scope: openAiService + name: guid(openAiService.id, webApp.id, 'openai-user') properties: { roleDefinitionId: subscriptionResourceId( 'Microsoft.Authorization/roleDefinitions', @@ -106,8 +127,22 @@ resource openAIUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = i } } +// Grant the enterprise application access to the cognitive services openai user +resource openAIenterpriseAppUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { + scope: openAiService + name: guid(openAiService.id, webApp.id, 'enterpriseApp-CognitiveServicesOpenAIUserRole') + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + ) + principalId: enterpriseAppServicePrincipalId + principalType: 'ServicePrincipal' + } +} + // grant the managed identity access to document intelligence as a Cognitive Services User -resource docIntelUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (docIntelName != '') { +resource docIntelUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { name: guid(docIntelService.id, webApp.id, 'doc-intel-user') scope: docIntelService properties: { @@ -121,7 +156,7 @@ resource docIntelUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = } // grant the managed identity access to the storage account as a blob data contributor -resource storageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +resource storageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { name: guid(storageAccount.id, webApp.id, 'storage-blob-data-contributor') scope: storageAccount properties: { @@ -135,7 +170,7 @@ resource storageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments } // grant the managed identity access to speech service as a Cognitive Services User -resource speechServiceUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (speechServiceName != '') { +resource speechServiceUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (speechServiceName != '' && authenticationType == 'managed_identity') { name: guid(speechService.id, webApp.id, 'speech-service-user') scope: speechService properties: { @@ -149,8 +184,8 @@ resource speechServiceUserRole 'Microsoft.Authorization/roleAssignments@2022-04- } // grant the managed identity access to search service as a Search Service Contributor -resource searchServiceContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(searchService.id, webApp.id, 'search-service-contributor') +resource searchIndexDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { + name: guid(searchService.id, webApp.id, 'search-index-data-contributor') scope: searchService properties: { roleDefinitionId: subscriptionResourceId( @@ -160,10 +195,24 @@ resource searchServiceContributorRole 'Microsoft.Authorization/roleAssignments@2 principalId: webApp.identity.principalId principalType: 'ServicePrincipal' } -} +} + +// grant the managed identity access to search service as a Search Service Contributor +resource searchServiceContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (authenticationType == 'managed_identity') { + name: guid(searchService.id, webApp.id, 'search-service-contributor') + scope: searchService + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7ca78c08-252a-4471-8644-bb5ff32d4ba0' + ) + principalId: webApp.identity.principalId + principalType: 'ServicePrincipal' + } +} // grant the managed identity access to content safety as a Cognitive Services User -resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (contentSafetyName != '') { +resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (contentSafetyName != '' && authenticationType == 'managed_identity') { name: guid(contentSafety.id, webApp.id, 'content-safety-user') scope: contentSafety properties: { @@ -174,4 +223,49 @@ resource contentSafetyUserRole 'Microsoft.Authorization/roleAssignments@2022-04- principalId: webApp.identity.principalId principalType: 'ServicePrincipal' } -} +} + +// grant the video indexer service access to storage account as a Storage Blob Data Contributor +resource videoIndexerStorageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (videoIndexerName != '') { + name: guid(storageAccount.id, videoIndexerService.id, 'video-indexer-storage-blob-data-contributor') + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' + ) + #disable-next-line BCP318 // may be null if video indexer not deployed + principalId: videoIndexerService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// grant the video indexer service access to OpenAI service as cognitive services Contributor +resource videoIndexerStorageCogServicesContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (videoIndexerName != '') { + name: guid(openAiService.id, videoIndexerService.id, 'video-indexer-cog-services-contributor') + scope: openAiService + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68' + ) + #disable-next-line BCP318 // may be null if video indexer not deployed + principalId: videoIndexerService.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// grant the video indexer service access to OpenAI service as cognitive services user +resource videoIndexerStorageCogServicesUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (videoIndexerName != '') { + name: guid(openAiService.id, videoIndexerService.id, 'video-indexer-cog-services-user') + scope: openAiService + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + 'a97b65f3-24c7-4388-baec-2e87135dc908' + ) + #disable-next-line BCP318 // may be null if video indexer not deployed + principalId: videoIndexerService.identity.principalId + principalType: 'ServicePrincipal' + } +} diff --git a/deployers/bicep/modules/speechService.bicep b/deployers/bicep/modules/speechService.bicep index e5c70389..17391ac7 100644 --- a/deployers/bicep/modules/speechService.bicep +++ b/deployers/bicep/modules/speechService.bicep @@ -5,13 +5,15 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations -module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging){ +module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' } @@ -24,33 +26,15 @@ resource speechService 'Microsoft.CognitiveServices/accounts@2024-10-01' = { name: 'S0' } identity: { - type: 'SystemAssigned, UserAssigned' - userAssignedIdentities: { - '${managedIdentityId}': {} - } + type: 'SystemAssigned' } properties: { publicNetworkAccess: 'Enabled' - disableLocalAuth: false customSubDomainName: toLower('${appName}-${environment}-speech') } tags: tags } -// grant the managed identity access to speech service as a Cognitive Services User -resource speechServiceUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(speechService.id, managedIdentityId, 'speech-service-user') - scope: speechService - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'a97b65f3-24c7-4388-baec-2e87135dc908' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - // configure diagnostic settings for speech service resource speechServiceDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${speechService.name}-diagnostics') @@ -64,4 +48,18 @@ resource speechServiceDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05 } } +//========================================================= +// store speech Service keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module speechServiceSecret 'keyVault-Secrets.bicep' = if ((authenticationType == 'key') && (configureApplicationPermissions)) { + name: 'storeSpeechServiceSecret' + params: { + keyVaultName: keyVault + secretName: 'speech-service-key' + secretValue: speechService.listKeys().key1 + } +} + output speechServiceName string = speechService.name +output speechServiceEndpoint string = speechService.properties.endpoint +output speechServiceAuthenticationType string = authenticationType diff --git a/deployers/bicep/modules/storageAccount.bicep b/deployers/bicep/modules/storageAccount.bicep index 436957f54..7e10ccae 100644 --- a/deployers/bicep/modules/storageAccount.bicep +++ b/deployers/bicep/modules/storageAccount.bicep @@ -5,13 +5,15 @@ param appName string param environment string param tags object -param managedIdentityPrincipalId string -param managedIdentityId string param enableDiagLogging bool param logAnalyticsId string +param keyVault string +param authenticationType string +param configureApplicationPermissions bool + // Import diagnostic settings configurations -module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging){ +module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { name: 'diagnosticConfigs' } @@ -56,20 +58,6 @@ resource groupDocumentsContainer 'Microsoft.Storage/storageAccounts/blobServices } } -// grant the managed identity access to the storage account as a blob data contributor -resource storageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(storageAccount.id, managedIdentityId, 'storage-blob-data-contributor') - scope: storageAccount - properties: { - roleDefinitionId: subscriptionResourceId( - 'Microsoft.Authorization/roleDefinitions', - 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' - ) - principalId: managedIdentityPrincipalId - principalType: 'ServicePrincipal' - } -} - // configure diagnostic settings for storage account resource storageDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { name: toLower('${storageAccount.name}-diagnostics') @@ -94,4 +82,17 @@ resource storageDiagnosticsBlob 'Microsoft.Insights/diagnosticSettings@2021-05-0 } } +//========================================================= +// store storage keys in key vault if using key authentication and configure app permissions = true +//========================================================= +module storageAccountSecret 'keyVault-Secrets.bicep' = if (authenticationType == 'key' && configureApplicationPermissions) { + name: 'storeStorageAccountSecret' + params: { + keyVaultName: keyVault + secretName: 'storage-account-key' + secretValue: storageAccount.listKeys().keys[0].value + } +} + output name string = storageAccount.name +output endpoint string = storageAccount.properties.primaryEndpoints.blob diff --git a/deployers/bicep/modules/videoIndexer.bicep b/deployers/bicep/modules/videoIndexer.bicep new file mode 100644 index 00000000..8f41544a --- /dev/null +++ b/deployers/bicep/modules/videoIndexer.bicep @@ -0,0 +1,63 @@ +targetScope = 'resourceGroup' + +param location string +param appName string +param environment string +param tags object + +param enableDiagLogging bool +param logAnalyticsId string + +param storageAccount string +param openAiServiceName string + +// Import diagnostic settings configurations +module diagnosticConfigs 'diagnosticSettings.bicep' = if (enableDiagLogging) { + name: 'diagnosticConfigs' +} + +resource storage 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { + name: storageAccount +} + +resource openAiService 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { + name: openAiServiceName +} + +// deploy video indexer service if required +resource videoIndexerService 'Microsoft.VideoIndexer/accounts@2025-04-01' = { + name: toLower('${appName}-${environment}-video') + location: location + + identity: { + type: 'SystemAssigned' + } + properties: { + publicNetworkAccess: 'Enabled' + storageServices: { + resourceId: storage.id + } + openAiServices: { + resourceId: openAiService.id + } + } + tags: tags + dependsOn: [ + storage + openAiService + ] +} + +// configure diagnostic settings for video indexer service +resource videoIndexerServiceDiagnostics 'Microsoft.Insights/diagnosticSettings@2021-05-01-preview' = if (enableDiagLogging) { + name: toLower('${videoIndexerService.name}-diagnostics') + scope: videoIndexerService + properties: { + workspaceId: logAnalyticsId + #disable-next-line BCP318 // expect one value to be null + logs: diagnosticConfigs.outputs.limitedLogCategories + } +} + +output videoIndexerServiceName string = videoIndexerService.name +output videoIndexerAccountId string = videoIndexerService.properties.accountId diff --git a/deployers/bicep/postconfig.py b/deployers/bicep/postconfig.py new file mode 100644 index 00000000..44406da2 --- /dev/null +++ b/deployers/bicep/postconfig.py @@ -0,0 +1,201 @@ +from azure.cosmos import CosmosClient +from azure.cosmos.exceptions import CosmosResourceNotFoundError +from azure.identity import DefaultAzureCredential +from azure.keyvault.secrets import SecretClient +import os +import json + +credential = DefaultAzureCredential() +token = credential.get_token("https://cosmos.azure.com/.default") + +cosmosEndpoint = os.getenv("var_cosmosDb_uri") +client = CosmosClient(cosmosEndpoint, credential=credential) + +database_name = "SimpleChat" +container_name = "settings" + +database = client.get_database_client(database_name) +container = database.get_container_client(container_name) + +# Read the existing item by ID and partition key +item_id = "app_settings" +partition_key = "app_settings" +try: + item = container.read_item(item=item_id, partition_key=partition_key) + print(f"Found existing app_setting document") +except CosmosResourceNotFoundError: + print(f"app_setting document not found.") + item = { + "id": item_id, + "partition_key": partition_key + } + +# Get values from environment variables +var_authenticationType = os.getenv("var_authenticationType") +var_keyVaultUri = os.getenv("var_keyVaultUri") + +var_openAIEndpoint = os.getenv("var_openAIEndpoint") +var_openAIResourceGroup = os.getenv("var_openAIResourceGroup") +var_subscriptionId = os.getenv("var_subscriptionId") +var_rgName = os.getenv("var_rgName") +var_openAIGPTModels = os.getenv("var_openAIGPTModels") +gpt_models_list = json.loads(var_openAIGPTModels) +var_openAIEmbeddingModels = os.getenv("var_openAIEmbeddingModels") +embedding_models_list = json.loads(var_openAIEmbeddingModels) +var_blobStorageEndpoint = os.getenv("var_blobStorageEndpoint") +var_contentSafetyEndpoint = os.getenv("var_contentSafetyEndpoint") +var_searchServiceEndpoint = os.getenv("var_searchServiceEndpoint") +var_documentIntelligenceServiceEndpoint = os.getenv( + "var_documentIntelligenceServiceEndpoint") +var_videoIndexerName = os.getenv("var_videoIndexerName") +var_videoIndexerLocation = os.getenv("var_deploymentLocation") +var_videoIndexerAccountId = os.getenv("var_videoIndexerAccountId") +var_speechServiceEndpoint = os.getenv("var_speechServiceEndpoint") +var_speechServiceLocation = os.getenv("var_deploymentLocation") + +# Initialize Key Vault client if Key Vault URI is provided +if var_keyVaultUri: + keyvault_client = SecretClient( + vault_url=var_keyVaultUri, credential=credential) +else: + keyvault_client = None + +# 4. Update the Configurations + +# General > Health Check +item["enable_external_healthcheck"] = True + +# AI Models +item["azure_openai_gpt_endpoint"] = var_openAIEndpoint +item["azure_openai_gpt_authentication_type"] = var_authenticationType +item["azure_openai_gpt_subscription_id"] = var_subscriptionId +item["azure_openai_gpt_resource_group"] = var_openAIResourceGroup +item["gpt_model"] = { + "selected": [ + { + "deploymentName": gpt_models_list[0]["modelName"], + "modelName": gpt_models_list[0]["modelName"] + } + ], + "all": [ + { + "deploymentName": model["modelName"], + "modelName": model["modelName"] + } + for model in gpt_models_list + ] +} + +item["azure_openai_embedding_endpoint"] = var_openAIEndpoint +item["azure_openai_embedding_authentication_type"] = var_authenticationType +item["azure_openai_embedding_subscription_id"] = var_subscriptionId +item["azure_openai_embedding_resource_group"] = var_openAIResourceGroup +item["embedding_model"] = { + "selected": [ + { + "deploymentName": embedding_models_list[0]["modelName"], + "modelName": embedding_models_list[0]["modelName"] + } + ], + "all": [ + { + "deploymentName": model["modelName"], + "modelName": model["modelName"] + } + for model in embedding_models_list + ] +} + +# Agents and Actions > Agents Configuration +item["enable_semantic_kernel"] = False + +# Logging > Application Insights Logging +item["enable_appinsights_global_logging"] = True + +# Scale > Redis Cache +# todo support redis cache configuration + +# Workspaces > Metadata Extraction +item["enable_extract_meta_data"] = True +item["metadata_extraction_model"] = gpt_models_list[0]["modelName"] + +# Workspaces > Multimodal Vision Analysis +item["enable_multimodal_vision"] = True +item["multimodal_vision_model"] = gpt_models_list[0]["modelName"] + +# Citations > Enhanced Citations +item["enable_enhanced_citations"] = True +item["office_docs_authentication_type"] = var_authenticationType +item["office_docs_storage_account_blob_endpoint"] = var_blobStorageEndpoint + +# Safety > Content Safety +if var_contentSafetyEndpoint and var_contentSafetyEndpoint.strip(): + item["enable_content_safety"] = True +item["content_safety_endpoint"] = var_contentSafetyEndpoint +item["content_safety_authentication_type"] = var_authenticationType +if keyvault_client: + try: + contentSafety_key_secret = keyvault_client.get_secret( + "content-safety-key") + item["content_safety_key"] = contentSafety_key_secret.value + print("Retrieved contentSafety service key from Key Vault") + except Exception as e: + print( + f"Warning: Could not retrieve content-safety-key from Key Vault: {e}") + +# Safety > Conversation Archiving +item["enable_conversation_archiving"] = True + +# Search and Extract > Azure AI Search +item["azure_ai_search_endpoint"] = var_searchServiceEndpoint +item["azure_ai_search_authentication_type"] = var_authenticationType +if keyvault_client: + try: + search_key_secret = keyvault_client.get_secret("search-service-key") + item["azure_ai_search_key"] = search_key_secret.value + print("Retrieved search service key from Key Vault") + except Exception as e: + print( + f"Warning: Could not retrieve search-service-key from Key Vault: {e}") + +# Search and Extract > Azure Document Intelligence +item["azure_document_intelligence_endpoint"] = var_documentIntelligenceServiceEndpoint +item["azure_document_intelligence_authentication_type"] = var_authenticationType +if keyvault_client: + try: + documentIntelligence_key_secret = keyvault_client.get_secret( + "document-intelligence-key") + item["azure_document_intelligence_key"] = documentIntelligence_key_secret.value + print("Retrieved document intelligence service key from Key Vault") + except Exception as e: + print( + f"Warning: Could not retrieve document-intelligence-key from Key Vault: {e}") + +# Search and Extract > Multimedia Support +# Video Indexer Configuration +if var_videoIndexerName and var_videoIndexerName.strip(): + item["enable_video_file_support"] = True +item["video_indexer_resource_group"] = var_rgName +item["video_indexer_subscription_id"] = var_subscriptionId +item["video_indexer_account_name"] = var_videoIndexerName +item["video_indexer_location"] = var_videoIndexerLocation +item["video_indexer_account_id"] = var_videoIndexerAccountId + +# Speech Service Configuration +if var_speechServiceEndpoint and var_speechServiceEndpoint.strip(): + item["enable_audio_file_support"] = True +item["speech_service_endpoint"] = var_speechServiceEndpoint +item["speech_service_location"] = var_speechServiceLocation +if keyvault_client: + try: + speech_key_secret = keyvault_client.get_secret("speech-service-key") + item["speech_service_key"] = speech_key_secret.value + print("Retrieved speech service key from Key Vault") + except Exception as e: + print( + f"Warning: Could not retrieve speech-service-key from Key Vault: {e}") + +# 5. Upsert the updated items back into Cosmos DB +response = container.upsert_item(item) +print( + f"Updated item: {response['id']} with enable_external_healthcheck = {response['enable_external_healthcheck']}") diff --git a/deployers/bicep/requirements.txt b/deployers/bicep/requirements.txt new file mode 100644 index 00000000..e6e1fd60 --- /dev/null +++ b/deployers/bicep/requirements.txt @@ -0,0 +1,4 @@ +# requirements.txt +azure-identity>=1.15.0 +azure-cosmos>=4.5.0 +azure-keyvault-secrets>=4.4.0 \ No newline at end of file From f7afced6d160dbe1e28138bbf80d5b16f1fcd18c Mon Sep 17 00:00:00 2001 From: Bionic711 <13358952+Bionic711@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:54:10 -0600 Subject: [PATCH 5/7] Adds Azure Billing Plugin in Community Customizations (#546) * add crude keyvault base impl * upd actions for MAG * add settings to fix * upd secret naming convention * upd auth types to include conn string/basic(un/pw) * fix method name * add get agent helper * add ui trigger word and get agent helper * upd function imports * upd agents call * add desc of plugins * fix for admin modal loading * upd default agent handling * rmv unneeded file * rmv extra imp statements * add new cosmos container script * upd instructions for consistency of code * adds safe calls for akv functions * adds akv to personal agents * fix for user agents boot issue * fix global set * upd azure function plugin to super init * upd to clean imports * add keyvault to global actions loading * add plugin loading docs * rmv secret leak via logging * rmv displaying of token in logs * fix not loading global actions for personal agents * rmv unsupported characters from logging * fix chat links in dark mode * chg order of css for links in dark mode * fix chat color * add default plugin print logging * rmv default check for nonsql plugins * upd requirements * add keyvault and dynamic addsetting ui * fix for agents/plugins with invalid akv chars * add imp to appins logging * add security tab UI + key vault UI * add keyvault settings * fix for copilot findings. * fix for resaving plugin without changing secret * init azure billing plugin * add app settings cache * upd to azure billing plugin * upd to msgraph plugin * init community customizations * add module * add key vault config modal * add logging and functions to math * rmv extra telemetry, add appcache * upd billing plugin * add/upd key vault, admin settings, agents, max tokens * Remove abp for pr * disable static logging for development * rmv dup import * add note on pass * added notes * rmv dup decl * add semicolon * rmv unused variable add agent name to log * add actions migration back in * add notes and copilot fixes * add abp back in * upd abp/seperate graph from query * rmv missed merge lines * fix for AL * upd for consistency testing * upd abp to community * fix copilot findings #1 * fix plotting conflict * fix exception handling * fix static max function invokes * rmv unneeded decl * rmv unneeded imports * fix grouping dimensions * fix abp copilot suggestions #2 * simplify methods for message reload * upd dockerfile to google distroless * add pipelines * add modifications to container * upd to build * add missing arg * add arg for major/minor/patch python version * upd python paths and pip install * add perms to /app for user * chg back to root * rmv python3 * rmv not built python * add shared * add path and home * upd for stdlib paths * fix user input filesystem path vulns * fix to consecutive dots * upd pipeline to include branch name in image * add abp to deploy * upd instructions name/rmv abp from deploy * fix pipeline * mov back to Comm Cust for main inclusion --------- Co-authored-by: Bionic711 --- .../docker_image_publish_nadoyle.yml | 21 +- .gitignore | 6 +- .../agent.instructions.md | 103 + .../azure_billing_plugin.py | 1716 +++++++++++++++++ .../actions/azure_billing_retriever/readme.md | 53 + application/single_app/.gitignore | 2 +- application/single_app/Dockerfile | 104 +- application/single_app/app.py | 1 - application/single_app/functions_plugins.py | 4 + application/single_app/functions_security.py | 24 + application/single_app/route_backend_chats.py | 69 + application/single_app/route_openapi.py | 13 + .../single_app/semantic_kernel_loader.py | 6 +- .../azure_function_plugin.py | 91 - .../static/js/chat/chat-messages.js | 5 + 15 files changed, 2090 insertions(+), 128 deletions(-) create mode 100644 application/community_customizations/actions/azure_billing_retriever/agent.instructions.md create mode 100644 application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.py create mode 100644 application/community_customizations/actions/azure_billing_retriever/readme.md create mode 100644 application/single_app/functions_security.py delete mode 100644 application/single_app/semantic_kernel_plugins/azure_function_plugin.py diff --git a/.github/workflows/docker_image_publish_nadoyle.yml b/.github/workflows/docker_image_publish_nadoyle.yml index aebfe45a..4aa90f7b 100644 --- a/.github/workflows/docker_image_publish_nadoyle.yml +++ b/.github/workflows/docker_image_publish_nadoyle.yml @@ -6,6 +6,9 @@ on: branches: - nadoyle - feature/group-agents-actions + - security/containerBuild + - feature/aifoundryagents + - azureBillingPlugin workflow_dispatch: @@ -25,11 +28,21 @@ jobs: password: ${{ secrets.ACR_PASSWORD_NADOYLE }} # Container registry server url login-server: ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }} - + + - name: Normalize branch name for tag + run: | + REF="${GITHUB_REF_NAME}" + SAFE=$(echo "$REF" \ + | tr '[:upper:]' '[:lower:]' \ + | sed 's#[^a-z0-9._-]#-#g' \ + | sed 's/^-*//;s/-*$//' \ + | cut -c1-128) + echo "BRANCH_TAG=$SAFE" >> "$GITHUB_ENV" + - uses: actions/checkout@v3 - name: Build the Docker image run: - docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; - docker tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; - docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_$GITHUB_RUN_NUMBER; + docker build . --file application/single_app/Dockerfile --tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; + docker tag ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; + docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:$(date +'%Y-%m-%d')_${BRANCH_TAG}_$GITHUB_RUN_NUMBER; docker push ${{ secrets.ACR_LOGIN_SERVER_NADOYLE }}/simple-chat-dev:latest; diff --git a/.gitignore b/.gitignore index 98ba158b..8a9839df 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,8 @@ flask_session application/single_app/static/.DS_Store application/external_apps/bulkloader/map.csv -flask_session \ No newline at end of file +flask_session +**/abd_proto.py +**/my_chart.png +**/sample_pie.csv +**/sample_stacked_column.csv diff --git a/application/community_customizations/actions/azure_billing_retriever/agent.instructions.md b/application/community_customizations/actions/azure_billing_retriever/agent.instructions.md new file mode 100644 index 00000000..a0eaf84b --- /dev/null +++ b/application/community_customizations/actions/azure_billing_retriever/agent.instructions.md @@ -0,0 +1,103 @@ +You are an Azure Billing Agent designed to autonomously obtain and visualize Azure cost data using the Azure Billing plugin. Your purpose is to generate accurate cost insights and visualizations without unnecessary user re-prompting. Your behavior is stateless but resilient: if recoverable input or formatting errors occur, you must automatically correct and continue execution rather than asking the user for clarification. When your response completes, your turn ends; the user must explicitly invoke you again to continue. + +azure_billing_plugin + +Core Capabilities +List subscriptions and resource groups. +Use list_subscriptions_and_resourcegroups() to list both. +Use list_subscriptions() for subscriptions only. +Use list_resource_groups() for resource groups under a given subscription. +Retrieve current and historical charges. +Generate cost forecasts for future periods. +Display budgets and cost alerts. +Produce Matplotlib (pyplot) visualizations for actual, forecast, or combined datasets, using only the dedicated graphing functions. +Use run_data_query(...) exclusively for data retrieval. +When a visualization is requested, in the same turn: +Execute run_data_query(...) with the appropriate parameters. +Use the returned csv, rows, and plot_hints (x_keys, y_keys, recommended graph types) as inputs to plot_chart(...). +Select a sensible graph type and axes from plot_hints without re-prompting the user. +Do not send graphing-related parameters to run_data_query. Keep query and graph responsibilities strictly separated. +Export and present data as CSV for analysis. +Query Configuration and Formats +Use get_query_configuration_options() to discover available parameters. +Use get_run_data_query_format() and get_plot_chart_format() to understand required input schemas. +Unless the user specifies overrides, apply: +granularity = "Monthly" +group_by = "ResourceType" (Dimension) +output_format = CSV +run_data_query(...) requires: +start_datetime and end_datetime as ISO-8601 timestamps with a time component (e.g., 2025-11-01T00:00:00Z). +At least one aggregation entry (name, function, column). +At least one grouping entry (type, name). +Reject or auto-correct any inputs that omit these required fields before calling the function. +Time and Date Handling +You may determine the current date and time using time functions. +Custom timeframes must use ISO 8601 extended timestamps with time components (e.g., YYYY-MM-DDTHH:mm:ssZ or YYYY-MM-DDTHH:mm:ss±HH:MM). Date-only strings are invalid. +When users provide partial or ambiguous hints (e.g., "September 2025", "2025-09", "last month", "this quarter"), infer: +Month inputs ⇒ first day 00:00:00 to last day 23:59:59 of that month. +Multi-month ranges ⇒ first day of first month 00:00:00 to last day of last month 23:59:59. +"last month", "this month", "last quarter", "this quarter" ⇒ resolve using the America/Chicago time zone and calendar quarters unless otherwise specified. +Before executing a query, ensure both start_datetime and end_datetime are resolved, valid, and include time components. If missing, infer them per the rules above. +Scope Resolution +If a user provides a subscription or resource group name: +Prefer an exact, case-insensitive match. +If multiple exact matches exist, choose the one with the lowest subscription GUID lexicographically. +If no exact match exists, attempt a case-insensitive contains match; if multiple results remain, choose the lowest GUID and record the choice in the response. +Output Rules +Do not truncate data unless the user explicitly requests it. +When displaying tables, render full Markdown tables with all rows/columns. +When producing CSV output, return the full CSV without truncation. +Do not embed binary data or raw images. The backend stores PNG outputs automatically; describe generated charts (title, axes, graph type) in text instead. +For every visualization request: +Call run_data_query(...) to obtain rows, csv, and plot_hints. +Immediately call plot_chart(...) (or plot_custom_chart(...)) with: +conversation_id +data = the returned rows or csv +x_keys/y_keys chosen from plot_hints +An appropriate graph_type from the recommended options +Do not ask the user to restate parameters already inferred or used. +Error Handling and Recovery +Classify errors using: MissingParameter, BadDateFormat, UnknownEnum, NotFound, Authz, Throttle, ServiceError. +Auto-recoverable: MissingParameter, BadDateFormat, UnknownEnum, NotFound (when deterministic fallback exists). +For these, infer/correct values (dates, enums, defaults, scope) and retry exactly once within the same turn. +Non-recoverable (Authz, Throttle, ServiceError, or unresolved NotFound): +Return a concise diagnostic message. +Provide a suggested next step (e.g., request access, narrow the timeframe, wait before retrying). +Append an "Auto-repairs applied" note listing each modification (e.g., normalized dates, defaulted granularity, resolved scope). +Data Integrity and Determinism +Preserve stable CSV schema and column order; include a schema version comment when practical. +If the agent performs any internal resampling or currency normalization, state the exact rule used. +All numeric calculations must be explicit and reproducible. +Session Behavior +Each response is a single turn. After responding, end with a readiness line such as "Ready and waiting." +The user must invoke the agent again for further actions. +Messaging Constraints +Use past tense or present simple to describe actions that already occurred this turn. +Acceptable: "I normalized dates and executed the query." / "I set start_datetime to 2025-05-01T00:00:00Z." +If a retry happened: "I corrected parameter types and retried once in this turn; the query succeeded." +If a retry could not occur: "I did not execute a retry because authorization failed." +Prohibited phrases about your own actions: "I will …", "Executing now …", "Retrying now …", "I am executing …", "I am retrying …". +Replace with: "I executed …", "I retried once …", "I set …". +Before sending the final message, ensure none of the prohibited future/progressive phrases remain. +Response Templates +Success (auto-repair applied) +Auto-recoverable error detected: . I corrected the inputs and retried once in this turn. +Auto-repairs applied: + + + +Result: . + +Ready for your next command. +Success (no error) +Operation completed. + +Ready for your next command. + +Failure (after retry) +Auto-recoverable error detected: . I applied corrections and attempted one retry in this turn, but it failed. +Diagnostics:

    +Suggested next step: +Ready for your next command. + +Ready and waiting. \ No newline at end of file diff --git a/application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.py b/application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.py new file mode 100644 index 00000000..ea224df7 --- /dev/null +++ b/application/community_customizations/actions/azure_billing_retriever/azure_billing_plugin.py @@ -0,0 +1,1716 @@ +# azure_billing_plugin.py +""" +Azure Billing Plugin for Semantic Kernel +- Supports user (Entra ID) and service principal authentication +- Uses Azure Cost Management REST API for billing, budgets, alerts, forecasting +- Renders graphs server-side as PNG (base64 for web, downloadable) +- Returns tabular data as CSV for minimal token usage +- Requires user_impersonation for user auth on 40a69793-8fe6-4db1-9591-dbc5c57b17d8 (Azure Service Management) +""" + +import io +import base64 +import requests +import csv +import matplotlib.pyplot as plt +import logging +import time +import random +import re +import numpy as np +import datetime +import textwrap +from typing import Dict, Any, List, Optional, Union +import json +from collections import defaultdict +from semantic_kernel_plugins.base_plugin import BasePlugin +from semantic_kernel.functions import kernel_function +from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger +from functions_authentication import get_valid_access_token_for_plugins +from functions_debug import debug_print +from azure.core.credentials import AccessToken, TokenCredential +from config import cosmos_messages_container, cosmos_conversations_container + + +RESOURCE_ID_REGEX = r"^/subscriptions/(?P[a-fA-F0-9-]+)/?(?:resourceGroups/(?P[^/]+))?$" +TIME_FRAME_TYPE = ["MonthToDate", "BillingMonthToDate", "WeekToDate", "Custom"] # "TheLastMonth, TheLastBillingMonth" are not supported in MAG +QUERY_TYPE = ["Usage", "ActualCost", "AmortizedCost"] +GRANULARITY_TYPE = ["None", "Daily", "Monthly", "Accumulated"] +GROUPING_TYPE = ["Dimension", "TagKey"] +AGGREGATION_FUNCTIONS = ["Sum"] #, "Average", "Min", "Max", "Count", "None"] +AGGREGATION_COLUMNS= ["Cost", "CostUSD", "PreTaxCost", "PreTaxCostUSD"] +DEFAULT_GROUPING_DIMENSIONS = ["None", "BillingPeriod", "ChargeType", "Frequency", "MeterCategory", "MeterId", "MeterSubCategory", "Product", "ResourceGroupName", "ResourceLocation", "ResourceType", "ServiceFamily", "ServiceName", "SubscriptionId", "SubscriptionName", "Tag"] +SUPPORTED_GRAPH_TYPES = ["pie", "column_stacked", "column_grouped", "line", "area"] + +class AzureBillingPlugin(BasePlugin): + def __init__(self, manifest: Dict[str, Any]): + super().__init__(manifest) + self.manifest = manifest + self.additionalFields = manifest.get('additionalFields', {}) + self.auth = manifest.get('auth', {}) + endpoint = manifest.get('endpoint', 'https://management.azure.com').rstrip('/') + if not endpoint.startswith('https://'): + # Remove any leading http:// and force https:// + endpoint = 'https://' + endpoint.lstrip('http://').lstrip('https://') + self.endpoint = endpoint + self.metadata_dict = manifest.get('metadata', {}) + self.api_version = self.additionalFields.get('apiVersion', '2023-03-01') + self.grouping_dimensions: List[str] = list(DEFAULT_GROUPING_DIMENSIONS) + + def _get_token(self) -> Optional[str]: + """Get an access token for Azure REST API calls.""" + auth_type = self.auth.get('type') + if auth_type == 'servicePrincipal': + # Service principal: use client credentials + tenant_id = self.auth.get('tenantId') + client_id = self.auth.get('identity') + client_secret = self.auth.get('key') + + # Determine AAD authority host based on management endpoint (public, gov, china) + host = self.endpoint.lower() + if "management.usgovcloudapi.net" in host: + aad_authority_host = "login.microsoftonline.us" + elif "management.azure.com" in host: + aad_authority_host = "login.microsoftonline.com" + else: + aad_authority_host = "login.microsoftonline.com" + + if not tenant_id or not client_id or not client_secret: + raise ValueError("Service principal auth requires tenantId, identity (client id), and key (client secret) in manifest 'auth'.") + + token_url = f"https://{aad_authority_host}/{tenant_id}/oauth2/v2.0/token" + data = { + 'grant_type': 'client_credentials', + 'client_id': client_id, + 'client_secret': client_secret, + 'scope': f'{self.endpoint.rstrip('/')}/.default' + } + try: + resp = requests.post(token_url, data=data, timeout=10) + resp.raise_for_status() + except requests.exceptions.HTTPError as e: + # Log the response text for diagnostics and raise a clear error + resp_text = getattr(e.response, 'text', '') if hasattr(e, 'response') else '' + logging.error("Failed to obtain service principal token. URL=%s, Error=%s, Response=%s", token_url, e, resp_text) + raise RuntimeError(f"Failed to obtain service principal token: {e}. Response: {resp_text}") + except requests.exceptions.RequestException as e: + logging.error("Error requesting service principal token: %s", e) + raise + try: + token = resp.json().get('access_token') + except ValueError: + logging.error("Invalid JSON returned from token endpoint: %s", resp.text) + raise RuntimeError(f"Invalid JSON returned from token endpoint: {resp.text}") + if not token: + logging.error("Token endpoint did not return access_token. Response: %s", resp.text) + raise RuntimeError(f"Token endpoint did not return access_token. Response: {resp.text}") + return token + else: + class UserTokenCredential(TokenCredential): + def __init__(self, scope): + self.scope = scope + + def get_token(self, *args, **kwargs): + token_result = get_valid_access_token_for_plugins(scopes=[self.scope]) + if isinstance(token_result, dict) and token_result.get("access_token"): + token = token_result["access_token"] + elif isinstance(token_result, dict) and token_result.get("error"): + # Propagate error up to plugin + raise Exception(token_result) + else: + raise RuntimeError("Could not acquire user access token for Log Analytics API.") + expires_on = int(time.time()) + 300 + return AccessToken(token, expires_on) + # User: use session token helper + scope = f"{self.endpoint.rstrip('/')}/.default" + credential = UserTokenCredential(scope) + return credential.get_token(scope).token + + def _get_headers(self) -> Dict[str, str]: + token = self._get_token() + if isinstance(token, dict) and ("error" in token or "consent_url" in token): + return token + return { + 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json' + } + + def _get(self, url: str, params: Dict[str, Any] = None) -> Any: + headers = self._get_headers() + if isinstance(headers, dict) and ("error" in headers or "consent_url" in headers): + return headers + if params: + debug_print(f"GET {url} with params: {params}") + resp = requests.get(url, headers=headers, params=params) + else: + debug_print(f"GET {url} without params") + resp = requests.get(url, headers=headers) + resp.raise_for_status() + return resp.json() + + def _post(self, url: str, data: Dict[str, Any]) -> Any: + headers = self._get_headers() + resp = requests.post(url, headers=headers, json=data) + resp.raise_for_status() + return resp.json() + + def _csv_from_table(self, rows: List[Dict[str, Any]]) -> str: + if not rows: + return '' + all_keys = set() + for row in rows: + all_keys.update(row.keys()) + fieldnames = list(all_keys) + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return output.getvalue() + + def _flatten_dict(self, d: Dict[str, Any], parent_key: str = '', sep: str = '.') -> Dict[str, Any]: + """Flatten a nested dict into a single-level dict with dotted keys. + + Example: {'properties': {'details': {'threshold': 0.8}}} => {'properties.details.threshold': 0.8} + """ + items = {} + for k, v in (d or {}).items(): + new_key = f"{parent_key}{sep}{k}" if parent_key else k + if isinstance(v, dict): + items.update(self._flatten_dict(v, new_key, sep=sep)) + else: + items[new_key] = v + return items + + def _fig_to_base64_dict(self, fig, filename: str = "chart.png") -> Dict[str, str]: + """Convert a matplotlib Figure to a structured base64 dict. + + Returns: {"mime": "image/png", "filename": filename, "base64": , "image_url": "data:image/png;base64,"} + """ + buf = io.BytesIO() + fig.savefig(buf, format='png', bbox_inches='tight') + fig.clf() + buf.seek(0) + img_b64 = base64.b64encode(buf.read()).decode('utf-8') + return { + "mime": "image/png", + "filename": filename, + "base64": img_b64, + "image_url": f"data:image/png;base64,{img_b64}" + } + + def _parse_csv_to_rows(self, data_csv: Union[str, List[str]]) -> List[Dict[str, Any]]: + """Parse CSV content (string or list-of-lines) into list[dict]. + + - Accepts a CSV string or a list of CSV lines. + - Converts numeric-looking fields to float where possible. + """ + # Accept list of lines or full string + if isinstance(data_csv, list): + csv_text = "\n".join(data_csv) + else: + csv_text = str(data_csv) + + f = io.StringIO(csv_text) + reader = csv.DictReader(f) + rows = [] + for row in reader: + parsed = {} + for k, v in row.items(): + if v is None: + parsed[k] = None + continue + s = v.strip() + # Try int then float conversion; leave as string if neither + if s == '': + parsed[k] = '' + else: + # remove thousands separators + s_clean = s.replace(',', '') + try: + if re.match(r'^-?\d+$', s_clean): + parsed[k] = int(s_clean) + else: + # float detection (handles scientific notation) + if re.match(r'^-?\d*\.?\d+(e[-+]?\d+)?$', s_clean, re.IGNORECASE): + parsed[k] = float(s_clean) + else: + parsed[k] = s + except Exception: + parsed[k] = s + rows.append(parsed) + return rows + + def _coerce_rows_for_plot(self, data) -> List[Dict[str, Any]]: + """Normalize incoming data into a list of row dictionaries for plotting.""" + if isinstance(data, list): + if not data: + raise ValueError("No data provided for plotting") + first = data[0] + if isinstance(first, dict): + try: + return [dict(row) for row in data] + except Exception as exc: + raise ValueError("data must contain serializable dictionaries") from exc + if isinstance(first, str): + return self._parse_csv_to_rows(data) + raise ValueError("data must be a list of dicts, a CSV string, or a list of CSV lines") + if isinstance(data, str): + if not data.strip(): + raise ValueError("No data provided for plotting") + return self._parse_csv_to_rows(data) + raise ValueError("data must be a list of dicts, a CSV string, or a list of CSV lines") + + def _build_plot_hints(self, rows: List[Dict[str, Any]], columns: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + """Generate plotting hints based on the returned Cost Management rows.""" + hints: Dict[str, Any] = { + "available_graph_types": SUPPORTED_GRAPH_TYPES, + "row_count": len(rows or []), + "label_candidates": [], + "numeric_candidates": [], + "recommended": {} + } + + if not rows: + return hints + + sample = rows[0] + numeric_candidates = [k for k, v in sample.items() if isinstance(v, (int, float))] + resource_preferred = [ + "ResourceType", + "ResourceGroupName", + "ResourceName", + "ResourceLocation", + "ServiceName", + "Product", + "MeterCategory", + "MeterSubCategory", + "SubscriptionName", + "SubscriptionId" + ] + temporal_terms = ("date", "time", "month", "period") + temporal_candidates: List[str] = [] + label_candidates: List[str] = [] + + for key, value in sample.items(): + if isinstance(value, (int, float)): + continue + if key not in label_candidates: + label_candidates.append(key) + lowered = key.lower() + if any(term in lowered for term in temporal_terms) and key not in temporal_candidates: + temporal_candidates.append(key) + + ordered_labels: List[str] = [] + for preferred in resource_preferred: + if preferred in sample and preferred not in ordered_labels: + ordered_labels.append(preferred) + + for key in label_candidates: + if key not in ordered_labels and key not in temporal_candidates: + ordered_labels.append(key) + + for temporal in temporal_candidates: + if temporal not in ordered_labels: + ordered_labels.append(temporal) + + hints["label_candidates"] = ordered_labels or label_candidates + hints["numeric_candidates"] = numeric_candidates + + cost_focused = [k for k in numeric_candidates if "cost" in k.lower()] + if cost_focused: + y_keys = cost_focused[:3] + else: + y_keys = numeric_candidates[:3] + + pie_label = next((k for k in ordered_labels if "resource" in k.lower()), None) + if not pie_label and ordered_labels: + pie_label = ordered_labels[0] + + pie_value = y_keys[0] if y_keys else None + hints["recommended"]["pie"] = { + "graph_type": "pie", + "x_keys": [pie_label] if pie_label else [], + "y_keys": [pie_value] if pie_value else [] + } + + temporal_primary = next((k for k in ordered_labels if any(term in k.lower() for term in temporal_terms)), None) + stack_candidate = next((k for k in ordered_labels if k != temporal_primary), None) + default_x_keys: List[str] = [] + + if temporal_primary: + default_x_keys.append(temporal_primary) + if stack_candidate: + default_x_keys.append(stack_candidate) + default_graph_type = "line" if len(y_keys) <= 2 else "column_grouped" + else: + if ordered_labels: + default_x_keys.append(ordered_labels[0]) + default_graph_type = "column_stacked" if len(y_keys) > 1 else "pie" + + hints["recommended"]["default"] = { + "graph_type": default_graph_type, + "x_keys": default_x_keys, + "y_keys": y_keys + } + + if columns: + column_summary = [] + for column in columns: + if not isinstance(column, dict): + continue + column_summary.append({ + "name": column.get("name") or column.get("displayName"), + "type": column.get("type") or column.get("dataType"), + }) + hints["columns"] = column_summary + + return hints + + def _iso_utc(self, dt: datetime.datetime) -> str: + return dt.astimezone(datetime.timezone.utc).isoformat() + + def _add_months(self, dt: datetime.datetime, months: int) -> datetime.datetime: + # Add (or subtract) months without external deps. + year = dt.year + (dt.month - 1 + months) // 12 + month = (dt.month - 1 + months) % 12 + 1 + day = min(dt.day, [31, + 29 if (year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)) else 28, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month-1]) + return dt.replace(year=year, month=month, day=day) + + def _first_day_of_month(self, dt: datetime.datetime) -> datetime.datetime: + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + def _last_day_of_month(self, dt: datetime.datetime) -> datetime.datetime: + # move to first of next month then subtract one second + next_month = self._add_months(self._first_day_of_month(dt), 1) + return next_month - datetime.timedelta(seconds=1) + + def _last_n_months_timeperiod(self, n: int): + now = datetime.datetime.now(datetime.timezone.utc) + start = self._add_months(now, -n) + return {"from": self._iso_utc(start), "to": self._iso_utc(now)} + + def _previous_n_months_timeperiod(self, n: int): + today = datetime.datetime.now(datetime.timezone.utc) + first_this_month = self._first_day_of_month(today) + last_of_prev = first_this_month - datetime.timedelta(seconds=1) + first_of_earliest = self._first_day_of_month(self._add_months(first_this_month, -n)) + # ensure full days for readability + return { + "from": self._iso_utc(first_of_earliest), + "to": self._iso_utc(last_of_prev.replace(hour=23, minute=59, second=59, microsecond=0)) + } + + def _parse_datetime_to_utc( + self, + value: Union[str, datetime.datetime, datetime.date], + field_name: str, + ) -> datetime.datetime: + """Normalize supported datetime inputs into timezone-aware UTC datetimes.""" + + if value is None: + raise ValueError(f"{field_name} must be provided when using a custom range.") + + if isinstance(value, datetime.datetime): + dt_value = value + elif isinstance(value, datetime.date): + dt_value = datetime.datetime.combine(value, datetime.time.min) + elif isinstance(value, str): + text = value.strip() + if not text: + raise ValueError(f"{field_name} must be a non-empty ISO-8601 string.") + normalized = text[:-1] + "+00:00" if text[-1] in {"Z", "z"} else text + if "T" not in normalized and " " not in normalized: + raise ValueError( + f"{field_name} must include a time component (e.g., 2025-11-30T23:59:59Z)." + ) + try: + dt_value = datetime.datetime.fromisoformat(normalized) + except ValueError as exc: + raise ValueError( + f"{field_name} must be ISO-8601 formatted (e.g., 2025-11-30T23:59:59Z or 2025-11-30T23:59:59-05:00)." + ) from exc + else: + raise ValueError( + f"{field_name} must be a string, datetime, or date instance." + ) + + if dt_value.tzinfo is None: + dt_value = dt_value.replace(tzinfo=datetime.timezone.utc) + else: + dt_value = dt_value.astimezone(datetime.timezone.utc) + + return dt_value + + def _build_custom_time_period( + self, + start_datetime: Optional[Union[str, datetime.datetime, datetime.date]], + end_datetime: Optional[Union[str, datetime.datetime, datetime.date]], + ) -> Dict[str, str]: + """Return a Custom timeframe dictionary derived from start/end inputs or defaults.""" + + if start_datetime is None and end_datetime is None: + now = datetime.datetime.now(datetime.timezone.utc) + month_start = self._first_day_of_month(now) + return {"from": self._iso_utc(month_start), "to": self._iso_utc(now)} + + if (start_datetime is None) != (end_datetime is None): + raise ValueError("start_datetime and end_datetime must both be provided.") + + start_dt = self._parse_datetime_to_utc(start_datetime, "start_datetime") + end_dt = self._parse_datetime_to_utc(end_datetime, "end_datetime") + + if start_dt > end_dt: + raise ValueError("start_datetime must be earlier than end_datetime") + + return {"from": self._iso_utc(start_dt), "to": self._iso_utc(end_dt)} + + def _normalize_enum(self, value: Optional[str], choices: List[str]) -> Optional[str]: + """ + Normalize a string to one of the canonical choices in a case-insensitive way. + Returns the canonical choice if matched, otherwise None. + """ + if value is None: + return None + if not isinstance(value, str): + return None + v = value.strip() + # quick exact match + if v in choices: + return v + # case-insensitive match + lower_map = {c.lower(): c for c in choices} + return lower_map.get(v.lower()) + + @property + def display_name(self) -> str: + return "Azure Billing" + + @property + def metadata(self) -> Dict[str, Any]: + return { + "name": self.metadata_dict.get("name", "azure_billing_plugin"), + "type": "azure_billing", + "description": "Azure Billing plugin for cost, budgets, alerts, forecasting, CSV export, and PNG graphing.", + "methods": self._collect_kernel_methods_for_metadata() + } + + @kernel_function(description="Generate plotting hints for Cost Management data so callers can intentionally choose chart parameters.") + @plugin_function_logger("AzureBillingPlugin") + def suggest_plot_config(self, data, columns: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + if columns is not None and not isinstance(columns, list): + return {"status": "error", "error": "columns must be a list of column metadata entries"} + try: + rows = self._coerce_rows_for_plot(data) + except ValueError as exc: + return {"status": "error", "error": str(exc)} + except Exception as exc: # pragma: no cover - defensive path + logging.exception("Unexpected error while preparing data for plot hints") + return {"status": "error", "error": f"Failed to parse data for plot hints: {exc}"} + + if not rows: + return {"status": "error", "error": "No data provided for plotting"} + + hints = self._build_plot_hints(rows, columns) + return hints + + @kernel_function(description="Plot a chart/graph from provided data. Supports pie, column_stacked, column_grouped, line, and area.",) + @plugin_function_logger("AzureBillingPlugin") + def plot_chart(self, + conversation_id: str, + data, + x_keys: Optional[List[str]] = None, + y_keys: Optional[List[str]] = None, + graph_type: str = "line", + title: str = "", + xlabel: str = "", + ylabel: str = "", + filename: str = "chart.png", + figsize: Optional[List[float]] = [7.0, 5.0]) -> Dict[str, Any]: + return self.plot_custom_chart( + conversation_id=conversation_id, + data=data, + x_keys=x_keys, + y_keys=y_keys, + graph_type=graph_type, + title=title, + xlabel=xlabel, + ylabel=ylabel, + filename=filename, + figsize=figsize + ) + + def _estimate_legend_items( + self, + graph_type: str, + rows: List[Dict[str, Any]], + y_keys_list: List[str], + stack_col: Optional[str], + ) -> int: + """Return the number of legend entries expected for a plot.""" + if graph_type == "pie": + return len(rows) + if graph_type == "column_stacked": + if stack_col: + return len({r.get(stack_col) for r in rows if r.get(stack_col) is not None}) + return len(y_keys_list) + return len(y_keys_list) + + def _adjust_figsize(self, base_figsize: List[float], legend_items: int) -> List[float]: + """Scale the figsize heuristically based on legend size.""" + scaled = list(base_figsize) + if legend_items > 6: + extra_width = min(legend_items * 0.12, 5.0) + scaled[0] = base_figsize[0] + extra_width + elif legend_items > 3: + scaled[1] = base_figsize[1] + 0.8 + if legend_items > 10: + scaled[1] = max(scaled[1], base_figsize[1] + min((legend_items - 10) * 0.2, 3.0)) + return scaled + + def _wrap_title(self, title: str, width: int = 60) -> str: + """Return a wrapped title so long strings stay inside the figure.""" + + if not title: + return "" + try: + return textwrap.fill(title, width=max(20, width)) + except Exception: + return title + + def _pie_autopct_formatter(self, values: List[float]): + """Return an autopct formatter that prints absolute value and percentage for top slices only.""" + + total = sum(values) or 1.0 + # Show labels for the most meaningful slices to avoid visual clutter. + sorted_indices = sorted(range(len(values)), key=lambda i: values[i], reverse=True) + max_labels = 8 if len(values) >= 15 else 12 + pct_threshold = 2.0 if len(values) >= 12 else 0.5 + show_indices = set() + for idx in sorted_indices[:max_labels]: + pct = (values[idx] / total) * 100 + if pct >= pct_threshold: + show_indices.add(idx) + + call_count = {"idx": -1} + + def _format(pct: float) -> str: + call_count["idx"] += 1 + idx = call_count["idx"] + if idx not in show_indices: + return "" + value = values[idx] + value_str = f"{value:,.0f}" if abs(value) >= 1000 else f"{value:,.2f}" + return f"{value_str}\n({pct:.1f}%)" + + return _format + + def _annotate_column_totals(self, ax, positions: List[float], totals: List[float]) -> None: + """Annotate summed column totals above each bar cluster and extend axes if needed.""" + + if not totals or not positions: + return + safe_totals: List[float] = [] + for value in totals: + try: + safe_totals.append(float(value)) + except (TypeError, ValueError): + safe_totals.append(0.0) + if not safe_totals: + return + abs_max = max(max(safe_totals), abs(min(safe_totals)), 1.0) + offset = max(abs_max * 0.02, 0.5) + headroom = max(abs_max * 0.05, offset) + label_positions: List[float] = [] + for x, total in zip(positions, safe_totals): + y = total + offset if total >= 0 else total - offset + label_positions.append(y) + va = 'bottom' if total >= 0 else 'top' + ax.text( + x, + y, + f"{total:,.2f}", + ha='center', + va=va, + fontsize=8, + fontweight='bold' + ) + + if label_positions: + current_bottom, current_top = ax.get_ylim() + max_label = max(label_positions) + min_label = min(label_positions) + pad = headroom + top_needed = max_label + pad + bottom_needed = min_label - pad + new_bottom = current_bottom + new_top = current_top + if top_needed > current_top: + new_top = top_needed + if bottom_needed < current_bottom: + new_bottom = bottom_needed + if new_bottom != current_bottom or new_top != current_top: + ax.set_ylim(new_bottom, new_top) + + def _place_side_legend( + self, + ax, + handles: Optional[List[Any]] = None, + labels: Optional[List[Any]] = None, + title: Optional[str] = None, + ncol: int = 1, + ) -> bool: + """Place legend to the right of the axes and reserve horizontal space.""" + if handles is not None or labels is not None: + legend = ax.legend( + handles, + labels, + title=title, + loc="center left", + bbox_to_anchor=(1.02, 0.5), + borderaxespad=0.0, + ncol=ncol, + ) + else: + legend = ax.legend( + title=title, + loc="center left", + bbox_to_anchor=(1.02, 0.5), + borderaxespad=0.0, + ncol=ncol, + ) + if legend is not None: + ax.figure.subplots_adjust(right=0.78) + return True + return False + + def _plot_pie_chart( + self, + ax, + rows: List[Dict[str, Any]], + x_key: str, + y_key: str, + title: str, + xlabel: str, + ylabel: str, + ) -> bool: + labels = [r.get(x_key) for r in rows] + labels_display = ["Unknown" if label in (None, "") else str(label) for label in labels] + values = [float(r.get(y_key) or 0) for r in rows] + total_value = sum(values) + autopct = self._pie_autopct_formatter(values) + wedges, _, autotexts = ax.pie(values, autopct=autopct, startangle=90) + for autotext in autotexts: + autotext.set_fontsize(8) + ax.set_title(self._wrap_title(title or "Cost distribution")) + ax.text(0, 0, f"Total\n{total_value:,.2f}", ha='center', va='center', fontsize=10, fontweight='bold') + legend_labels = [] + for label, value in zip(labels_display, values): + value_str = f"{value:,.2f}" if abs(value) < 1000 else f"{value:,.0f}" + pct = (value / total_value * 100) if total_value else 0 + legend_labels.append(f"{label} — {value_str} ({pct:.1f}%)") + legend_title = f"{x_key} (Total: {total_value:,.2f})" + ncol = min(4, max(1, len(labels_display) // 10 + 1)) + return self._place_side_legend(ax, wedges, legend_labels, title=legend_title, ncol=ncol) + + def _plot_line_or_area_chart( + self, + ax, + rows: List[Dict[str, Any]], + x_vals: List[Any], + y_keys_list: List[str], + graph_type: str, + x_key: str, + xlabel: str, + ylabel: str, + title: str, + ) -> bool: + for yk in y_keys_list: + y_vals = [float(r.get(yk) or 0) for r in rows] + if graph_type == "line": + ax.plot(x_vals, y_vals, marker='o', label=yk) + else: + ax.fill_between(range(len(x_vals)), y_vals, alpha=0.5, label=yk) + ax.set_title(self._wrap_title(title or "Cost trend")) + ax.set_xlabel(xlabel or x_key) + ax.set_ylabel(ylabel or (y_keys_list[0] if y_keys_list else "Value")) + ax.grid(True, axis='y', alpha=0.3) + return self._place_side_legend(ax) + + def _plot_column_grouped_chart( + self, + ax, + rows: List[Dict[str, Any]], + x_vals: List[Any], + y_keys_list: List[str], + x_key: str, + xlabel: str, + ylabel: str, + title: str, + ) -> bool: + n_groups = len(rows) + n_bars = len(y_keys_list) + index = np.arange(n_groups) + bar_width = 0.8 / max(1, n_bars) + group_totals = [0.0 for _ in rows] + for i, yk in enumerate(y_keys_list): + y_vals = [float(r.get(yk) or 0) for r in rows] + # accumulate totals for the annotation step below + group_totals = [total + value for total, value in zip(group_totals, y_vals)] + ax.bar(index + i * bar_width, y_vals, bar_width, label=yk) + ax.set_xticks(index + bar_width * (n_bars - 1) / 2) + ax.set_xticklabels([str(x) for x in x_vals], rotation=45, ha='right') + ax.set_title(self._wrap_title(title or "Cost comparison")) + ax.set_xlabel(xlabel or x_key) + ax.set_ylabel(ylabel or ("Values" if len(y_keys_list) > 1 else y_keys_list[0])) + centers = (index + bar_width * (n_bars - 1) / 2).tolist() + self._annotate_column_totals(ax, centers, group_totals) + return self._place_side_legend(ax) + + def _plot_column_stacked_chart( + self, + ax, + rows: List[Dict[str, Any]], + x_key: str, + y_keys_list: List[str], + stack_col: Optional[str], + xlabel: str, + ylabel: str, + title: str, + ) -> bool: + x_vals_unique: List[Any] = [] + seen_x = set() + for r in rows: + xval = r.get(x_key) + if xval not in seen_x: + seen_x.add(xval) + x_vals_unique.append(xval) + + pivot = defaultdict(lambda: defaultdict(float)) + if stack_col: + for r in rows: + xval = r.get(x_key) + sval = r.get(stack_col) + yval = float(r.get(y_keys_list[0]) or 0) + pivot[xval][sval] += yval + y_keys_plot = sorted({key for row in pivot.values() for key in row.keys()}) + else: + for r in rows: + xval = r.get(x_key) + for yk in y_keys_list: + pivot[xval][yk] += float(r.get(yk) or 0) + y_keys_plot = y_keys_list + + data_matrix = [[pivot[x_val].get(yk, 0.0) for x_val in x_vals_unique] for yk in y_keys_plot] + index = np.arange(len(x_vals_unique)) + bottoms = np.zeros(len(x_vals_unique)) + for i, yk in enumerate(y_keys_plot): + ax.bar(index, data_matrix[i], bottom=bottoms, label=str(yk)) + bottoms += np.array(data_matrix[i]) + ax.set_xticks(index) + ax.set_xticklabels([str(x) for x in x_vals_unique], rotation=45, ha='right') + ax.set_title(self._wrap_title(title or "Cost breakdown")) + ax.set_xlabel(xlabel or x_key) + ax.set_ylabel(ylabel or (y_keys_list[0] if y_keys_list else "Values")) + legend_title = stack_col or "Segments" + self._annotate_column_totals(ax, index.tolist(), bottoms.tolist()) + return self._place_side_legend(ax, title=legend_title) + + def plot_custom_chart(self, + conversation_id: str, + data, + x_keys: Optional[List[str]] = None, + y_keys: Optional[List[str]] = None, + graph_type: str = "line", + title: str = "", + xlabel: str = "", + ylabel: str = "", + filename: str = "chart.png", + figsize: Optional[List[float]] = [7.0, 5.0]) -> Dict[str, Any]: + """ + General plotting function. + + - data: list of dict rows (e.g., [{'date': '2025-10-01', 'cost': 12.3, 'type': 'A'}, ...]) + - x_keys: list of keys to use for x axis (required for non-pie charts); first key is primary x-axis, additional keys are used for stacking/grouping + - y_keys: list of keys to plot on y axis (if None and graph_type is not pie, autodetect numeric columns) + - graph_type: one of ['pie', 'column_stacked', 'column_grouped', 'line', 'area'] + - returns structured dict with mime, filename, base64, image_url and metadata + """ + try: + #print(f"[AzureBillingPlugin] plot_custom_chart called with conversation_id={conversation_id}, graph_type={graph_type},\n x_keys={x_keys},\n y_keys={y_keys},\n title={title},\n xlabel={xlabel},\n ylabel={ylabel},\n figsize={figsize},\n data:{data}") + graph_type = graph_type.lower() if isinstance(graph_type, str) else str(graph_type) + # Validate figsize: must be a list/tuple of two numbers if provided + if figsize is None: + figsize = [7.0, 5.0] + elif isinstance(figsize, (list, tuple)): + if len(figsize) != 2: + return {"status": "error", "error": "figsize must be a list of two numbers: [width, height]"} + try: + figsize = [float(figsize[0]), float(figsize[1])] + except Exception: + return {"status": "error", "error": "figsize elements must be numeric"} + else: + return {"status": "error", "error": "figsize must be a list of two numbers or null"} + + except Exception as ex: + logging.exception("Unexpected error in plot_custom_chart parameter validation") + return {"status": "error", "error": str(ex)} + if graph_type not in SUPPORTED_GRAPH_TYPES: + raise ValueError(f"Unsupported graph_type '{graph_type}'. Supported: {SUPPORTED_GRAPH_TYPES}") + try: + rows = self._coerce_rows_for_plot(data) + except ValueError as exc: + return {"status": "error", "error": str(exc)} + except Exception as exc: + logging.exception("Failed to parse input data for plotting") + return {"status": "error", "error": f"Failed to parse data for plotting: {str(exc)}"} + + if not rows: + return {"status": "error", "error": "No data provided for plotting"} + + hints = self._build_plot_hints(rows, None) + recommended_defaults = hints.get("recommended", {}).get("default", {}) + recommended_pie = hints.get("recommended", {}).get("pie", {}) + + def ensure_list(value) -> List[Any]: + if value is None: + return [] + if isinstance(value, list): + return list(value) + if isinstance(value, tuple): + return list(value) + if isinstance(value, str): + return [value] + if hasattr(value, '__iter__'): + return list(value) + return [value] + + x_keys_list = ensure_list(x_keys) + y_keys_list = ensure_list(y_keys) + + if graph_type == "pie": + if not y_keys_list: + y_keys_list = ensure_list(recommended_pie.get("y_keys") or recommended_defaults.get("y_keys")) + if len(y_keys_list) > 1: + y_keys_list = y_keys_list[:1] + if not y_keys_list: + raise ValueError("Pie chart requires a numeric column for values") + + if not x_keys_list: + x_keys_list = ensure_list(recommended_pie.get("x_keys") or recommended_defaults.get("x_keys")) + if len(x_keys_list) > 1: + x_keys_list = x_keys_list[:1] + if not x_keys_list: + sample_row = rows[0] + for candidate in sample_row.keys(): + if candidate in hints.get("label_candidates", []): + x_keys_list = [candidate] + break + if not x_keys_list: + raise ValueError("Pie chart requires a label column (x_keys)") + + x_key = x_keys_list[0] + stack_col = None + x_vals = None + else: + if not y_keys_list: + y_keys_list = ensure_list(recommended_defaults.get("y_keys")) + if not y_keys_list: + sample_row = rows[0] + y_keys_list = [k for k, v in sample_row.items() if isinstance(v, (int, float))] + if not y_keys_list: + raise ValueError("Could not autodetect numeric columns for y axis. Provide y_keys explicitly.") + + if not x_keys_list: + x_keys_list = ensure_list(recommended_defaults.get("x_keys")) + if not x_keys_list: + sample_row = rows[0] + for key, value in sample_row.items(): + if not isinstance(value, (int, float)): + x_keys_list = [key] + break + if not x_keys_list: + raise ValueError("x_keys is required for this chart type") + if len(x_keys_list) > 2: + x_keys_list = x_keys_list[:2] + + x_key = x_keys_list[0] + stack_col = x_keys_list[1] if len(x_keys_list) > 1 else None + x_vals = [r.get(x_key) for r in rows] + + fig = None + try: + legend_items = self._estimate_legend_items(graph_type, rows, y_keys_list, stack_col) + scaled_figsize = self._adjust_figsize(figsize, legend_items) + + fig, ax = plt.subplots(figsize=tuple(scaled_figsize)) + + legend_outside = False + if graph_type == "pie": + legend_outside = self._plot_pie_chart( + ax, + rows, + x_keys_list[0], + y_keys_list[0], + title, + xlabel, + ylabel, + ) + elif graph_type in ("line", "area"): + legend_outside = self._plot_line_or_area_chart( + ax, + rows, + x_vals, + y_keys_list, + graph_type, + x_key, + xlabel, + ylabel, + title, + ) + elif graph_type == "column_grouped": + legend_outside = self._plot_column_grouped_chart( + ax, + rows, + x_vals, + y_keys_list, + x_key, + xlabel, + ylabel, + title, + ) + elif graph_type == "column_stacked": + legend_outside = self._plot_column_stacked_chart( + ax, + rows, + x_key, + y_keys_list, + stack_col, + xlabel, + ylabel, + title, + ) + + if legend_outside: + plt.tight_layout(rect=[0, 0, 0.78, 1]) + else: + plt.tight_layout() + img_b64 = self._fig_to_base64_dict(fig, filename=filename) + payload = { + "status": "ok", + "type": "image_url", + "image_url": {"url": str(img_b64.get("image_url", ""))}, + "metadata": { + "graph_type": graph_type, + "x_keys": x_keys_list, + "y_keys": y_keys_list, + "stack_key": stack_col, + "figure_size": scaled_figsize, + "recommendations": hints.get("recommended", {}) + } + } + + if conversation_id: + try: + self.upload_cosmos_message(conversation_id, str(img_b64.get("image_url", ""))) + payload["image_url"] = f"Stored chart image for conversation {conversation_id}" + payload["requires_message_reload"] = True + except Exception: + logging.exception("Failed to upload chart image to Cosmos DB") + payload.setdefault("warnings", []).append("Chart rendered but storing to conversation failed.") + else: + payload.setdefault("warnings", []).append("Chart rendered but conversation_id was not provided; image not persisted.") + + #time.sleep(5) # give time for image to upload before returning + return payload + except Exception as ex: + logging.exception("Error while generating chart") + return {"status": "error", "error": f"Error while generating chart: {str(ex)}"} + finally: + if fig is not None: + plt.close(fig) + + + @plugin_function_logger("AzureBillingPlugin") + @kernel_function(description="List all subscriptions and resource groups accessible to the user/service principal.") + def list_subscriptions_and_resourcegroups(self) -> str: + url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" + subs = self._get(url).get('value', []) + if isinstance(subs, dict) and ("error" in subs or "consent_url" in subs): + return subs + result = [] + for sub in subs: + sub_id = sub.get('subscriptionId') + sub_name = sub.get('displayName') + rg_url = f"{self.endpoint}/subscriptions/{sub_id}/resourcegroups?api-version=2021-04-01" + rgs = self._get(rg_url).get('value', []) + result.append({ + "subscriptionId": sub_id, + "subscriptionName": sub_name, + "resourceGroups": [rg.get('name') for rg in rgs] + }) + return self._csv_from_table(result) + + @plugin_function_logger("AzureBillingPlugin") + @kernel_function(description="List all subscriptions accessible to the user/service principal.") + def list_subscriptions(self) -> str: + url = f"{self.endpoint}/subscriptions?api-version=2020-01-01" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + subs = data.get('value', []) + return self._csv_from_table(subs) + + @plugin_function_logger("AzureBillingPlugin") + @kernel_function(description="List all resource groups in a subscription.") + def list_resource_groups(self, subscription_id: str) -> str: + url = f"{self.endpoint}/subscriptions/{subscription_id}/resourcegroups?api-version=2020-01-01" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + rgs = data.get('value', []) + return self._csv_from_table(rgs) + + @kernel_function(description="Get cost forecast with custom duration and granularity.") + @plugin_function_logger("AzureBillingPlugin") + def get_forecast(self, resourceId: str, forecast_period_months: int = 12, granularity: str = "Monthly", lookback_months: Optional[int] = None) -> str: + """ + #Get cost forecast for a given period and granularity. + #scope: /subscriptions/{id} or /subscriptions/{id}/resourceGroups/{rg} + #forecast_period_months: Number of months to forecast (default 12) + #granularity: "Daily", "Monthly", "Weekly" + #lookback_months: If provided, use last N months as historical data for forecasting + """ + url = f"{self.endpoint.rstrip('/')}/{resourceId.lstrip('/').rstrip('/')}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + timeframe = "Custom" + # Calculate start/end dates for forecast + today = datetime.datetime.utcnow().date() + start_date = today + end_date = today + datetime.timedelta(days=forecast_period_months * 30) + # If lookback_months is set, use that for historical data + if lookback_months: + hist_start = today - datetime.timedelta(days=lookback_months * 30) + hist_end = today + else: + hist_start = None + hist_end = None + query = { + "type": "Forecast", + "timeframe": timeframe, + "timePeriod": { + "from": start_date.isoformat(), + "to": end_date.isoformat() + }, + "dataset": {"granularity": granularity} + } + # Optionally add historical data window + if hist_start and hist_end: + query["historicalTimePeriod"] = { + "from": hist_start.isoformat(), + "to": hist_end.isoformat() + } + data = self._post(url, query) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + rows = data.get('properties', {}).get('rows', []) + columns = [c['name'] for c in data.get('properties', {}).get('columns', [])] + result = [dict(zip(columns, row)) for row in rows] + return self._csv_from_table(result) + + @kernel_function(description="Get budgets for a subscription or resource group.") + @plugin_function_logger("AzureBillingPlugin") + def get_budgets(self, subscription_id: str, resource_group_name: Optional[str] = None) -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/budgets?api-version={self.api_version}" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + budgets = data.get('value', []) + return self._csv_from_table(budgets) + + @kernel_function(description="Get cost alerts.") + @plugin_function_logger("AzureBillingPlugin") + def get_alerts(self, subscription_id: str, resource_group_name: Optional[str] = None) -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/alerts?api-version={self.api_version}" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + alerts = data.get('value', []) + return self._csv_from_table(alerts) + + @kernel_function(description="Get specific cost alert by ID.") + @plugin_function_logger("AzureBillingPlugin") + def get_specific_alert(self, subscription_id: str, alertId: str , resource_group_name: Optional[str] = None) -> str: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/alerts/{alertId}?api-version={self.api_version}" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + # Flatten nested properties for CSV friendliness + if isinstance(data, dict): + flat = self._flatten_dict(data) + # Convert lists to JSON strings for CSV + for k, v in list(flat.items()): + if isinstance(v, (list, dict)): + try: + flat[k] = json.dumps(v) + except Exception: + flat[k] = str(v) + return self._csv_from_table([flat]) + else: + # Fallback: return raw JSON string in a single column + return self._csv_from_table([{"raw": json.dumps(data)}]) + + @kernel_function(description="Run an Azure Cost Management query and return rows, column metadata, and plotting hints for manual chart selection. Requires explicit start/end datetimes and always uses a Custom timeframe.") + @plugin_function_logger("AzureBillingPlugin") + def run_data_query(self, + conversation_id: str, + subscription_id: str, + aggregations: List[Dict[str, Any]], + groupings: List[Dict[str, Any]], + start_datetime: Union[str, datetime.datetime, datetime.date], + end_datetime: Union[str, datetime.datetime, datetime.date], + query_type: str = "Usage", + granularity: str = "Daily", + resource_group_name: Optional[str] = None, + query_filter: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Execute an Azure Cost Management query and return structured results. + + Callers must supply start_datetime and end_datetime (ISO-8601 strings or + datetime objects). The outgoing payload always uses a Custom timeframe with a + fully populated timePeriod object. + + Returns a dict containing: + - rows: list of result dictionaries + - columns: metadata about returned columns + - csv: CSV-formatted string of the results + - plot_hints: heuristic suggestions for plotting the data + - query: the query payload that was submitted + - scope/api_version: request context details + """ + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + if not self._normalize_enum(query_type, QUERY_TYPE): + raise ValueError(f"Invalid query_type: {query_type}. Must be one of {QUERY_TYPE}.") + if not self._normalize_enum(granularity, GRANULARITY_TYPE): + raise ValueError(f"Invalid granularity: {granularity}. Must be one of {GRANULARITY_TYPE}.") + + if start_datetime is None or end_datetime is None: + return { + "status": "error", + "error": "start_datetime and end_datetime are required for run_data_query.", + "expected_format": "ISO-8601 timestamp with time component (e.g., 2025-11-01T00:00:00Z).", + "example": { + "start_datetime": "2025-11-01T00:00:00Z", + "end_datetime": "2025-11-30T23:59:59Z" + } + } + try: + time_period = self._build_custom_time_period(start_datetime, end_datetime) + except ValueError as exc: + return { + "status": "error", + "error": str(exc), + "expected_format": "ISO-8601 timestamp with time component (e.g., 2025-11-01T00:00:00Z).", + "example": { + "start_datetime": "2025-11-01T00:00:00Z", + "end_datetime": "2025-11-30T23:59:59Z" + } + } + + query = { + "type": query_type, + "timeframe": "Custom", + "dataset": { + "granularity": granularity + }, + "timePeriod": time_period, + } + if not aggregations: + return { + "status": "error", + "error": "Aggregations list cannot be empty; supply at least one aggregation entry.", + "example": [ + {"name": "totalCost", "function": "Sum", "column": "PreTaxCost"} + ] + } + if not groupings: + return { + "status": "error", + "error": "Groupings list cannot be empty; include at least one Dimension/Tag grouping.", + "example": [ + {"type": "Dimension", "name": "ResourceType"} + ] + } + # Validate and normalize aggregations (if provided) + if aggregations: + if not isinstance(aggregations, list): + return {"status": "error", "error": "aggregations must be a list of aggregation definitions", "example": [{"name": "totalCost", "function": "Sum", "column": "PreTaxCost"}]} + if len(aggregations) > 2: + logging.warning("More than 2 aggregations provided; only the first 2 will be used") + agg_map: Dict[str, Any] = {} + for agg in aggregations[:2]: + if not isinstance(agg, dict): + return {"status": "error", "error": "Each aggregation must be a dict", "example": [{"name": "totalCost", "function": "Sum", "column": "PreTaxCost"}]} + + # Determine aggregation alias (outer key) and underlying column + function + # Support these shapes: + # 1) flat: {"name": "totalCost", "function": "Sum", "column": "PreTaxCost"} + # 2) nested: {"name": "totalCost", "aggregation": {"name": "PreTaxCost", "function": "Sum"}} + # We will produce agg_map[alias] = {"name": , "function": } + + alias = agg.get('name') + column_name = None + function = None + + if 'aggregation' in agg and isinstance(agg['aggregation'], dict): + sub = agg['aggregation'] + # sub.get('name') is the column name in nested form + column_name = sub.get('name') or sub.get('column') or agg.get('column') + function = sub.get('function') or agg.get('function') + # allow sub to specify other properties but we'll only keep name and function for compatibility + else: + # flat form + column_name = agg.get('column') or agg.get('name_of_column') or agg.get('columnName') + function = agg.get('function') + + if not alias: + return {"status": "error", "error": "Aggregation entry missing aggregation alias in 'name' field", "example": [{"name": "totalCost", "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]} + if not function: + return {"status": "error", "error": f"Aggregation '{alias}' missing 'function'", "example": [{"name": alias, "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]} + if not self._normalize_enum(function, AGGREGATION_FUNCTIONS): + return {"status": "error", "error": f"Aggregation function '{function}' is invalid. Must be one of: {AGGREGATION_FUNCTIONS}", "example": [{"name": alias, "aggregation": {"name": "PreTaxCost", "function": "Sum"}}]} + + details: Dict[str, Any] = {} + # per your requested shape, the inner object should include the column as 'name' + if column_name: + details['name'] = column_name + details['function'] = function + + agg_map[alias] = details + query["dataset"]["aggregation"] = agg_map + + # Validate and normalize groupings (if provided) + if groupings: + if not isinstance(groupings, list): + return {"status": "error", "error": "groupings must be a list of grouping definitions", "example": [{"type": "Dimension", "name": "ResourceLocation"}]} + if len(groupings) > 2: + logging.warning("More than 2 groupings provided; only the first 2 will be used") + normalized_groupings: List[Dict[str, str]] = [] + for grp in groupings[:2]: + if not isinstance(grp, dict): + return {"status": "error", "error": "Each grouping must be a dict with 'type' and 'name'", "example": [{"type": "Dimension", "name": "ResourceType"}]} + gtype = grp.get('type') + gname = grp.get('name') + if not gtype or not self._normalize_enum(gtype, GROUPING_TYPE): + return {"status": "error", "error": f"Grouping type '{gtype}' is invalid. Must be one of: {GROUPING_TYPE}", "example": [{"type": "Dimension", "name": "ResourceType"}]} + if not gname or not self._normalize_enum(gname, self.grouping_dimensions): + return {"status": "error", "error": f"Grouping name '{gname}' is invalid. Must be one of: {self.grouping_dimensions}", "example": [{"type": "Dimension", "name": "ResourceType"}]} + normalized_groupings.append({'type': gtype, 'name': gname}) + query["dataset"]["grouping"] = normalized_groupings + if query_filter: + query["dataset"]["filter"] = query_filter + # No additional validation required; _build_custom_time_period enforces shape + logging.debug("Running Cost Management query with payload: %s", json.dumps(query, indent=2)) + data = self._post(url, query) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + rows = data.get('properties', {}).get('rows', []) + column_objects = data.get('properties', {}).get('columns', []) + column_names = [c.get('name') for c in column_objects] + result_rows = [dict(zip(column_names, row)) for row in rows] + csv_output = self._csv_from_table(result_rows) + + columns_meta: List[Dict[str, Any]] = [] + for col in column_objects: + if not isinstance(col, dict): + continue + columns_meta.append({ + "name": col.get('name') or col.get('displayName'), + "type": col.get('type') or col.get('dataType'), + "dataType": col.get('dataType'), + "unit": col.get('unit') + }) + + plot_hints = self._build_plot_hints(result_rows, column_objects) + + return { + "status": 200, + "conversation_id": conversation_id, + "scope": scope, + "api_version": self.api_version, + "query": query, + "row_count": len(result_rows), + "columns": columns_meta, + "csv": csv_output, + "plot_hints": plot_hints, + } + + @kernel_function(description="Return available configuration options for Azure Billing report queries.") + @plugin_function_logger("AzureBillingPlugin") + def get_query_configuration_options(self, subscription_id: str, resource_group_name: Optional[str] = None) -> Dict[str, Any]: + get_dimension_results = self.get_grouping_dimensions(subscription_id, resource_group_name) + if isinstance(get_dimension_results, dict) and ("error" in get_dimension_results or "consent_url" in get_dimension_results): + return get_dimension_results + if isinstance(get_dimension_results, list): + # Store a per-instance copy to prevent cross-request state bleed. + self.grouping_dimensions = list(get_dimension_results) or list(DEFAULT_GROUPING_DIMENSIONS) + return { + "TIME_FRAME_TYPE": TIME_FRAME_TYPE, + "QUERY_TYPE": QUERY_TYPE, + "GRANULARITY_TYPE": GRANULARITY_TYPE, + "GROUPING_TYPE": GROUPING_TYPE, + "GROUPING_DIMENSIONS": self.grouping_dimensions, + "AGGREGATION_FUNCTIONS": AGGREGATION_FUNCTIONS, + "AGGREGATION_COLUMNS": AGGREGATION_COLUMNS, + "NOTE": "Not all combinations are available for all queries." + } + + @kernel_function(description="Get available cost dimensions for Azure Billing.") + @plugin_function_logger("AzureBillingPlugin") + def get_grouping_dimensions(self, subscription_id: str, resource_group_name: Optional[str] = None) -> List[str]: + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + # Use the Cost Management query endpoint to retrieve available dimensions/categories + # Note: some Cost Management responses return a 'value' array where each item has a + # 'properties' object containing a 'category' property. We handle that shape and + # fall back to other common fields. + url = f"{self.endpoint.rstrip('/')}{scope}/providers/Microsoft.CostManagement/dimensions?api-version={self.api_version}&$expand=properties/data" + data = self._get(url) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + + values = data.get('value', []) if isinstance(data, dict) else [] + dims = [] + for item in values: + if not isinstance(item, dict): + continue + # Preferred location: item['properties']['category'] + props = item.get('properties') if isinstance(item.get('properties'), dict) else {} + cat = props.get('category') or props.get('Category') + if not cat: + # fallback to name/displayName + cat = item.get('name') or props.get('name') or props.get('displayName') + if cat: + dims.append(cat) + + # dedupe while preserving order + seen = set() + deduped = [] + for d in dims: + if d not in seen: + seen.add(d) + deduped.append(d) + return deduped + + @kernel_function(description="Run a sample or provided Cost Management query and return the columns metadata (name + type). Useful for discovering which columns can be used for aggregation and grouping.") + @plugin_function_logger("AzureBillingPlugin") + def get_query_columns(self, subscription_id: str, resource_group_name: Optional[str] = None, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """ + Discover columns for a Cost Management query. + + - subscription_id: required + - resource_group_name: optional + - query: optional Cost Management query dict; if omitted a minimal Usage MonthToDate query is used + + Returns a list of {"name": , "type": }. + """ + if resource_group_name: + scope = f"/subscriptions/{subscription_id}/resourceGroups/{resource_group_name}" + else: + scope = f"/subscriptions/{subscription_id}" + url = f"{self.endpoint.rstrip('/')}" + f"{scope}/providers/Microsoft.CostManagement/query?api-version={self.api_version}" + + if not query: + query = { + "type": "Usage", + "timeframe": "MonthToDate", + "dataset": {"granularity": "None"} + } + + data = self._post(url, query) + if isinstance(data, dict) and ("error" in data or "consent_url" in data): + return data + + # Two possible shapes: properties.columns or value[].properties.columns + cols = [] + props = data.get('properties') if isinstance(data, dict) else None + if props and isinstance(props, dict) and props.get('columns'): + cols = props.get('columns', []) + else: + # Inspect value[] items for properties.columns + values = data.get('value', []) if isinstance(data, dict) else [] + for item in values: + if not isinstance(item, dict): + continue + p = item.get('properties') if isinstance(item.get('properties'), dict) else {} + if p.get('columns'): + cols = p.get('columns') + break + + result = [] + for c in cols or []: + if not isinstance(c, dict): + continue + name = c.get('name') or c.get('displayName') + typ = c.get('type') or c.get('dataType') or c.get('data', {}).get('type') if isinstance(c.get('data'), dict) else c.get('type') + result.append({"name": name, "type": typ}) + + return result + + @kernel_function(description="Return only aggregatable (numeric) columns from a sample or provided query.") + @plugin_function_logger("AzureBillingPlugin") + def get_aggregatable_columns(self, subscription_id: str, resource_group_name: Optional[str] = None, query: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """ + Returns columns suitable for aggregation (numeric types). Uses `get_query_columns` internally. + """ + cols = self.get_query_columns(subscription_id, resource_group_name, query) + if isinstance(cols, dict) and ("error" in cols or "consent_url" in cols): + return cols + numeric_types = {"Number", "Double", "Integer", "Decimal", "Long", "Float"} + agg = [c for c in (cols or []) if (c.get('type') in numeric_types or (isinstance(c.get('type'), str) and c.get('type').lower() == 'number'))] + return agg + + + @kernel_function(description="Get the expected formatting, in JSON, for run_data_query parameters.") + @plugin_function_logger("AzureBillingPlugin") + def get_run_data_query_format(self) -> Dict[str, Any]: + """ + Returns an example JSON object describing the expected parameters for run_data_query. + Includes required/optional fields, types, valid values, and reflects the latest method signature. + """ + return { + "conversation_id": "", + "subscription_id": "", + "resource_group_name": "", + "query_type": f"", + "start_datetime": "", + "end_datetime": "", + "granularity": f"", + "aggregations": [ + { + "name": "totalCost", + "function": f"", + "column": f"" + } + ], + "groupings": [ + { + "type": f"", + "name": f"" + } + ], + "query_filter": "", + "example_request": { + "conversation_id": "abc123", + "subscription_id": "00000000-0000-0000-0000-000000000000", + "query_type": "Usage", + "start_datetime": "2025-04-01T00:00:00-04:00", + "end_datetime": "2025-09-30T23:59:59-04:00", + "granularity": "Daily", + "aggregations": [ + {"name": "totalCost", "function": "Sum", "column": "PreTaxCost"} + ], + "groupings": [ + {"type": "Dimension", "name": "ResourceType"} + ] + }, + "example_response": { + "status": "ok", + "row_count": 3, + "rows": [ + {"ResourceType": "microsoft.compute/virtualmachines", "PreTaxCost": 12694.43}, + {"ResourceType": "microsoft.compute/disks", "PreTaxCost": 4715.20}, + {"ResourceType": "microsoft.keyvault/vaults", "PreTaxCost": 201.11} + ], + "columns": [ + {"name": "ResourceType", "type": "String"}, + {"name": "PreTaxCost", "type": "Number"} + ], + "plot_hints": { + "recommended": { + "default": { + "graph_type": "column_stacked", + "x_keys": ["ResourceType"], + "y_keys": ["PreTaxCost"] + }, + "pie": { + "graph_type": "pie", + "x_keys": ["ResourceType"], + "y_keys": ["PreTaxCost"] + } + } + } + }, + "workflow": [ + "Call run_data_query to retrieve rows, columns, csv, and plot_hints.", + "Always provide start_datetime and end_datetime using ISO-8601 strings (e.g., 2025-11-01T00:00:00Z).", + "Always supply at least one aggregation entry; the plugin no longer infers defaults when none are provided.", + "Include at least one grouping (Dimension + name) so the query can bucket the data.", + "Inspect plot_hints['recommended'] for suggested x_keys, y_keys, and chart types.", + "Pass rows (or the csv string) plus the chosen keys into plot_chart to render and persist a graph." + ] + } + + # Returns the expected input data format for plot_custom_chart + @kernel_function(description="Get the expected input data format for plot_custom_chart (graphing) as JSON.") + @plugin_function_logger("AzureBillingPlugin") + def get_plot_chart_format(self) -> Dict[str, Any]: + """ + Returns an example object describing the expected 'data' parameter for plot_custom_chart. + The 'data' field should be a CSV string (with headers and rows), matching the output format of run_data_query. + """ + return { + "conversationId": "", + "data": "", + "x_keys": ["ResourceType"], + "y_keys": ["PreTaxCost"], + "graph_type": "pie", + "title": "Cost share by resource type", + "xlabel": "Resource Type", + "ylabel": "Cost (USD)", + "filename": "chart.png", + "figsize": [7.0, 5.0], + "notes": [ + "Feed the list returned in run_data_query['rows'] directly, or supply the CSV from run_data_query['csv'].", + "Pick x_keys/y_keys from run_data_query['plot_hints']['recommended'] to ensure compatible chart input.", + "Pie charts require exactly one numeric y_key; stacked/grouped charts accept multiple." + ] + } + + def upload_cosmos_message(self, + conversation_id: str, + content: str) -> Dict[str, Any]: + """ + Upload a message to Azure Cosmos DB. + """ + try: + image_message_id = f"{conversation_id}_image_{int(time.time())}_{random.randint(1000,9999)}" + # Check if image data is too large for a single Cosmos document (2MB limit) + # Account for JSON overhead by using 1.5MB as the safe limit for base64 content + max_content_size = 1500000 # 1.5MB in bytes + + if len(content) > max_content_size: + debug_print(f"Large image detected ({len(content)} bytes), splitting across multiple documents") + + # Split the data URL into manageable chunks + if content.startswith('data:image/png;base64,'): + # Extract just the base64 part for splitting + data_url_prefix = 'data:image/png;base64,' + base64_content = content[len(data_url_prefix):] + debug_print(f"Extracted base64 content length: {len(base64_content)} bytes") + else: + # For regular URLs, store as-is (shouldn't happen with large content) + data_url_prefix = '' + base64_content = content + + # Calculate chunk size and number of chunks + chunk_size = max_content_size - len(data_url_prefix) - 200 # More room for JSON overhead + chunks = [base64_content[i:i+chunk_size] for i in range(0, len(base64_content), chunk_size)] + total_chunks = len(chunks) + + debug_print(f"Splitting into {total_chunks} chunks of max {chunk_size} bytes each") + for i, chunk in enumerate(chunks): + debug_print(f"Chunk {i} length: {len(chunk)} bytes") + + # Verify we can reassemble before storing + reassembled_test = data_url_prefix + ''.join(chunks) + if len(reassembled_test) == len(content): + debug_print(f"✅ Chunking verification passed - can reassemble to original size") + else: + debug_print(f"❌ Chunking verification failed - {len(reassembled_test)} vs {len(content)}") + + + # Create main image document with metadata + main_image_doc = { + 'id': image_message_id, + 'conversation_id': conversation_id, + 'role': 'image', + 'content': f"{data_url_prefix}{chunks[0]}", # First chunk with data URL prefix + 'prompt': '', + 'created_at': datetime.datetime.utcnow().isoformat(), + 'timestamp': datetime.datetime.utcnow().isoformat(), + 'model_deployment_name': 'azurebillingplugin', + 'metadata': { + 'is_chunked': True, + 'total_chunks': total_chunks, + 'chunk_index': 0, + 'original_size': len(content) + } + } + + # Create additional chunk documents + chunk_docs = [] + for i in range(1, total_chunks): + chunk_doc = { + 'id': f"{image_message_id}_chunk_{i}", + 'conversation_id': conversation_id, + 'role': 'image_chunk', + 'content': chunks[i], + 'parent_message_id': image_message_id, + 'created_at': datetime.datetime.utcnow().isoformat(), + 'timestamp': datetime.datetime.utcnow().isoformat(), + 'metadata': { + 'is_chunk': True, + 'chunk_index': i, + 'total_chunks': total_chunks, + 'parent_message_id': image_message_id + } + } + chunk_docs.append(chunk_doc) + + # Store all documents + debug_print(f"Storing main document with content length: {len(main_image_doc['content'])} bytes") + cosmos_messages_container.upsert_item(main_image_doc) + + for i, chunk_doc in enumerate(chunk_docs): + debug_print(f"Storing chunk {i+1} with content length: {len(chunk_doc['content'])} bytes") + cosmos_messages_container.upsert_item(chunk_doc) + + debug_print(f"Successfully stored image in {total_chunks} documents") + debug_print(f"Main doc content starts with: {main_image_doc['content'][:50]}...") + debug_print(f"Main doc content ends with: ...{main_image_doc['content'][-50:]}") + else: + # Small image - store normally in single document + debug_print(f"Small image ({len(content)} bytes), storing in single document") + + image_doc = { + 'id': image_message_id, + 'conversation_id': conversation_id, + 'role': 'image', + 'content': content, + 'prompt': "", + 'created_at': datetime.datetime.utcnow().isoformat(), + 'timestamp': datetime.datetime.utcnow().isoformat(), + 'model_deployment_name': "azurebillingplugin", + 'metadata': { + 'is_chunked': False, + 'original_size': len(content) + } + } + cosmos_messages_container.upsert_item(image_doc) + conversation_item = cosmos_conversations_container.read_item(item=conversation_id, partition_key=conversation_id) + conversation_item['last_updated'] = datetime.datetime.utcnow().isoformat() + cosmos_conversations_container.upsert_item(conversation_item) + #time.sleep(5) # sleep to allow the message to propogate and the front end to pick it up when receiving the agent response + except Exception as e: + print(f"[ABP] Error uploading image message to Cosmos DB: {str(e)}") + logging.error(f"[ABP] Error uploading image message to Cosmos DB: {str(e)}") diff --git a/application/community_customizations/actions/azure_billing_retriever/readme.md b/application/community_customizations/actions/azure_billing_retriever/readme.md new file mode 100644 index 00000000..0c9b365e --- /dev/null +++ b/application/community_customizations/actions/azure_billing_retriever/readme.md @@ -0,0 +1,53 @@ +**⚠️ NOT PRODUCTION READY — This action is a proof of concept.** + +# Azure Billing Action Instructions + +## Overview +The Azure Billing action is an experimental Semantic Kernel plugin that helps agents explore Azure Cost Management data, generate CSV outputs, and render server-side charts for conversational reporting. It stitches together Azure REST APIs, matplotlib rendering, and Cosmos DB persistence so prototype agents can investigate subscriptions, budgets, alerts, and forecasts without touching the production portal. It leverages message injection (direct cosmos_messages_container access) to store chart images as conversation artifacts in lieu of embedding binary data in chat responses. + +## Core capabilities +- Enumerate subscriptions and resource groups via `list_subscriptions*` helpers for quick scope discovery. +- Query budgets, alerts, and forecast data with Cost Management APIs, returning flattened CSV for low-token conversations. +- Execute fully custom `run_data_query(...)` calls that enforce ISO-8601 time windows, aggregations, and groupings while emitting plot hints. +- Generate Matplotlib charts (`pie`, `column_stacked`, `column_grouped`, `line`, `area`) through `plot_chart` / `plot_custom_chart`, storing PNGs in Cosmos DB per conversation. +- Offer helper endpoints (`get_query_configuration_options`, `get_query_columns`, `get_aggregatable_columns`, `get_run_data_query_format`, `get_plot_chart_format`) so agents can self-discover valid parameters. + +## Architecture highlights +- **Plugin class**: `AzureBillingPlugin` (see `azure_billing_plugin.py`) inherits from `BasePlugin`, exposing annotated `@kernel_function`s for the agent runtime. +- **Authentication**: supports user impersonation (via `get_valid_access_token_for_plugins`) and service principals defined in the plugin manifest; automatically selects the right AAD authority per cloud. +- **Data rendering**: CSV assembly uses in-memory writers, while charts are produced with matplotlib, encoded as base64 data URLs, and persisted to Cosmos DB for later retrieval. +- **Sample assets**: `sample_pie.csv`, `sample_stacked_column.csv`, and `my_chart.png` demonstrate expected data formats and outputs for local experimentation. + +## Authentication & configuration +1. Provide a plugin manifest with `endpoint`, `auth` (user or service principal), and optional `metadata/additionalFields` such as `apiVersion` (defaults to `2023-03-01`). +2. Grant `user_impersonation` permission on the **Azure Service Management** resource (`40a69793-8fe6-4db1-9591-dbc5c57b17d8`) when testing user authentication. +3. For sovereign clouds, set the management endpoint (e.g., `https://management.usgovcloudapi.net`) so the plugin can resolve the matching AAD authority. + +## Typical workflow +1. **Discover scope**: call `list_subscriptions_and_resourcegroups()` or `list_subscriptions()` followed by `list_resource_groups(subscription_id)`. +2. **Inspect available dimensions**: use `get_query_configuration_options()` plus `get_grouping_dimensions()` to learn valid aggregations and groupings. +3. **Fetch data**: invoke `run_data_query(...)` with explicit `start_datetime`, `end_datetime`, at least one aggregation, and one grouping. The response includes `csv`, column metadata, and `plot_hints`. +4. **Visualize**: immediately pass the returned rows or CSV into `plot_chart(...)`, selecting `x_keys`, `y_keys`, and `graph_type` from `plot_hints`. Include the same `conversation_id` so the base64 PNG is attached to the chat transcript in Cosmos DB. +5. **Iterate**: explore budgets with `get_budgets`, monitor alerts via `get_alerts` / `get_specific_alert`, or generate multi-month forecasts through `get_forecast`. + +## Charting guidance +- Supported graph types: `pie`, `column_stacked`, `column_grouped`, `line`, `area`. +- `plot_chart` is a convenience wrapper that forwards to `plot_custom_chart`; both sanitize figure sizes, wrap long titles, and annotate stacked totals. +- `suggest_plot_config` can analyze arbitrary CSV/rows to recommend labels and numeric fields when the Cost Management query did not originate from this plugin. + +## Outputs & persistence +- Tabular results are returned as CSV strings to minimize token usage while keeping schemas explicit. +- Chart payloads include metadata (axes, graph type, figure size) plus a `data:image/png;base64` URL; when `conversation_id` is supplied the image is chunked/stored inside `cosmos_messages_container` with retry-friendly metadata. +- The agent should describe generated charts textually to users; binary content is delivered through the persisted conversation artifacts. + +## Limitations & cautions +- No throttling, retry, or quota management has been hardened—expect occasional failures from Cost Management when running multiple heavy queries. +- Error handling is best-effort: the plugin attempts to normalize enums, dates, and aggregations but may still raise when inputs are malformed. +- Cosmos DB storage assumes the surrounding SimpleChat environment; using the plugin outside that context requires replacing the persistence hooks. +- Security hardening (secret rotation, granular RBAC validation, zero-trust networking) has **not** been completed; do not expose this plugin to production tenants or sensitive billing data without additional review. + +## Additional resources +- Review `instructions.md` in the same directory for the autonomous agent persona tailored to this action. +- Inspect `abd_proto.py` for prompt experimentation tied to Azure Billing dialogues. +- Leverage the sample CSV files to validate plotting offline before wiring the plugin into a notebook or agent loop. + diff --git a/application/single_app/.gitignore b/application/single_app/.gitignore index cb44490a..803cb917 100644 --- a/application/single_app/.gitignore +++ b/application/single_app/.gitignore @@ -194,4 +194,4 @@ cython_debug/ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # refer to https://docs.cursor.com/context/ignore-files .cursorignore -.cursorindexingignore \ No newline at end of file +.cursorindexingignore diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 56c8c145..3b76aa57 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -1,49 +1,97 @@ -# Builder stage: install dependencies in a virtualenv -# FROM cgr.dev/chainguard/python:latest-dev AS builder -FROM python:3.13-slim AS builder +# Stage 1: System dependencies and ODBC driver install +ARG PYTHON_MAJOR_VERSION_ARG="3" +ARG PYTHON_MINOR_VERSION_ARG="13" +ARG PYTHON_PATCH_VERSION_ARG="11" +FROM debian:12-slim AS builder + +ARG PYTHON_MAJOR_VERSION_ARG +ARG PYTHON_MINOR_VERSION_ARG +ARG PYTHON_PATCH_VERSION_ARG + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONIOENCODING=utf-8 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +# Build deps for CPython and pip stdlib modules +WORKDIR /deps +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + wget ca-certificates \ + libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ + libncursesw5-dev libffi-dev liblzma-dev uuid-dev tk-dev && \ + rm -rf /var/lib/apt/lists/* + +# Build and install Python from source +# Example: https://www.python.org/ftp/python/3.13.11/Python-3.13.11.tgz +WORKDIR /tmp +RUN wget https://www.python.org/ftp/python/${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}/Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}.tgz && \ + tar -xzf Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG}.tgz && \ + cd Python-${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG}.${PYTHON_PATCH_VERSION_ARG} && \ + LDFLAGS="-Wl,-rpath,/usr/local/lib" ./configure --enable-optimizations --enable-shared --with-ensurepip=install --prefix=/usr/local && \ + make -j"$(nproc)" && \ + make altinstall USER root - -# Ensure /app directory exists and has proper permissions -RUN mkdir -p /app && chown root:root /app - WORKDIR /app +RUN groupadd -g 65532 nonroot && useradd -m -u 65532 -g nonroot nonroot -# Create a Python virtual environment -RUN python -m venv /app/venv - -# Create and permission the flask_session directory -#RUN mkdir -p /app/flask_session && chown -R nonroot:nonroot /app/flask_session -RUN mkdir -p /app/flask_session +RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m venv /app/venv +RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m pip install wheel # Copy requirements and install them into the virtualenv -COPY application/single_app/requirements.txt . ENV PATH="/app/venv/bin:$PATH" -RUN pip install --no-cache-dir -r requirements.txt +COPY application/single_app/requirements.txt /app/requirements.txt +RUN python${PYTHON_MAJOR_VERSION_ARG}.${PYTHON_MINOR_VERSION_ARG} -m pip install --no-cache-dir -r /app/requirements.txt + +# Fix permissions so nonroot can use everything +RUN chown -R 65532:65532 /app + +RUN mkdir -p /app/flask_session && chown -R 65532:65532 /app/flask_session +RUN mkdir /sc-temp-files && chown -R 65532:65532 /sc-temp-files +USER 65532:65532 -#FROM cgr.dev/chainguard/python:latest -FROM python:3.13-slim +#Stage 2: Final containter +FROM gcr.io/distroless/base-debian12:latest +ARG PYTHON_MAJOR_VERSION_ARG +ARG PYTHON_MINOR_VERSION_ARG +ARG PYTHON_PATCH_VERSION_ARG -# Create nonroot user -RUN useradd -m -u 1000 nonroot +ENV PYTHONIOENCODING=utf-8 \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 \ + PYTHONUNBUFFERED=1 \ + PATH="/app/venv/bin:/usr/local/bin:$PATH" \ + LD_LIBRARY_PATH="/usr/local/lib:${LD_LIBRARY_PATH}" WORKDIR /app -ENV PYTHONUNBUFFERED=1 -ENV PATH="/app/venv/bin:$PATH" +USER root + +# Copy only the built Python interpreter (venv entrypoint handles python/python3) +# Copy the full CPython installation so stdlib modules (e.g., encodings) are available +COPY --from=builder /usr/local/ /usr/local/ + +# Copy system libraries for x86_64 +COPY --from=builder /lib/x86_64-linux-gnu/ \ + /lib64/ld-linux-x86-64.so.2 \ + /usr/lib/x86_64-linux-gnu/ + #/usr/share/ca-certificates \ + #/etc/ssl/certs \ + #/usr/bin/ffmpeg \ + #/usr/share/zoneinfo /usr/share/ # Copy application code and set ownership -COPY --chown=nonroot:nonroot application/single_app ./ +COPY --chown=65532:65532 application/single_app/ /app/ # Copy the virtualenv from the builder stage -COPY --from=builder --chown=nonroot:nonroot /app/venv /app/venv - -# Copy the flask_session directory from the builder stage -COPY --from=builder --chown=nonroot:nonroot /app/flask_session /app/flask_session +COPY --from=builder --chown=65532:65532 /app/venv /app/venv +COPY --from=builder --chown=65532:65532 /app/flask_session /app/flask_session +COPY --from=builder --chown=65532:65532 /sc-temp-files /sc-temp-files # Expose port EXPOSE 5000 -USER nonroot:nonroot +USER 65532:65532 -ENTRYPOINT [ "python", "/app/app.py" ] \ No newline at end of file +ENTRYPOINT ["/app/venv/bin/python", "-c", "import runpy; runpy.run_path('/app/app.py', run_name='__main__')"] diff --git a/application/single_app/app.py b/application/single_app/app.py index 77b93447..3f023956 100644 --- a/application/single_app/app.py +++ b/application/single_app/app.py @@ -479,7 +479,6 @@ def list_semantic_kernel_plugins(): werkzeug_logger = logging.getLogger('werkzeug') werkzeug_logger.setLevel(logging.ERROR) app.run(host="0.0.0.0", port=5000, debug=True, ssl_context='adhoc', threaded=True, use_reloader=False) - else: # Production port = int(os.environ.get("PORT", 5000)) diff --git a/application/single_app/functions_plugins.py b/application/single_app/functions_plugins.py index d540352c..0bdbedad 100644 --- a/application/single_app/functions_plugins.py +++ b/application/single_app/functions_plugins.py @@ -3,6 +3,7 @@ import os import json import jsonschema +from functions_security import is_safe_slug def load_plugin_schema(plugin_type, schema_dir): """ @@ -92,6 +93,9 @@ def get_merged_plugin_settings(plugin_type, current_settings, schema_dir): """ Loads the schema for the plugin_type, merges with current_settings, and returns the merged dict. """ + if not is_safe_slug(plugin_type): + # Reject unsafe plugin types to avoid path traversal or unexpected filenames + return {} result = {} # Use plugin_type as base for schema loading (matches actual schema filenames) for nested_key, schema_filename in [ diff --git a/application/single_app/functions_security.py b/application/single_app/functions_security.py new file mode 100644 index 00000000..a6e71c22 --- /dev/null +++ b/application/single_app/functions_security.py @@ -0,0 +1,24 @@ +# functions_security.py +"""Security-related helper functions.""" + +import re + + +SAFE_STORAGE_NAME_PATTERN = re.compile(r"^(?!.*\.\.)[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$") +SAFE_SLUG_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +def is_valid_storage_name(name: str) -> bool: + """Validate storage file names to prevent traversal and unsafe patterns.""" + if not name: + return False + if '/' in name or '\\' in name: + return False + return bool(SAFE_STORAGE_NAME_PATTERN.fullmatch(name)) + + +def is_safe_slug(value: str) -> bool: + """Allowlist check for simple slug values (alnum, underscore, hyphen).""" + if not value: + return False + return bool(SAFE_SLUG_PATTERN.fullmatch(value)) diff --git a/application/single_app/route_backend_chats.py b/application/single_app/route_backend_chats.py index f9dae599..ee5e144b 100644 --- a/application/single_app/route_backend_chats.py +++ b/application/single_app/route_backend_chats.py @@ -11,6 +11,7 @@ import builtins import asyncio, types import json +from typing import Any, Dict, List from config import * from flask import g from functions_authentication import * @@ -59,6 +60,66 @@ def chat_api(): selected_document_id = data.get('selected_document_id') image_gen_enabled = data.get('image_generation') document_scope = data.get('doc_scope') + reload_messages_required = False + + def parse_json_string(candidate: str) -> Any: + """Parse JSON content when strings look like serialized structures.""" + trimmed = candidate.strip() + if not trimmed or trimmed[0] not in ('{', '['): + return None + try: + return json.loads(trimmed) + except Exception as exc: + log_event( + f"[result_requires_message_reload] Failed to parse JSON: {str(exc)} | candidate: {trimmed[:200]}", + level=logging.DEBUG + ) + return None + + def dict_requires_reload(payload: Dict[str, Any]) -> bool: + """Inspect dictionary payloads for any signal that messages were persisted.""" + if payload.get('reload_messages') or payload.get('requires_message_reload'): + return True + + metadata = payload.get('metadata') + if isinstance(metadata, dict) and metadata.get('requires_message_reload'): + return True + + image_url = payload.get('image_url') + if isinstance(image_url, dict) and image_url.get('url'): + return True + if isinstance(image_url, str) and image_url.strip(): + return True + + result_type = payload.get('type') + if isinstance(result_type, str) and result_type.lower() == 'image_url': + return True + + mime = payload.get('mime') + if isinstance(mime, str) and mime.startswith('image/'): + return True + + for value in payload.values(): + if result_requires_message_reload(value): + return True + return False + + def list_requires_reload(items: List[Any]) -> bool: + """Evaluate list items for reload requirements.""" + return any(result_requires_message_reload(item) for item in items) + + def result_requires_message_reload(result: Any) -> bool: + """Heuristically detect plugin outputs that inject new Cosmos messages (e.g., chart images).""" + if result is None: + return False + if isinstance(result, str): + parsed = parse_json_string(result) + return result_requires_message_reload(parsed) if parsed is not None else False + if isinstance(result, list): + return list_requires_reload(result) + if isinstance(result, dict): + return dict_requires_reload(result) + return False active_group_id = data.get('active_group_id') active_public_workspace_id = data.get('active_public_workspace_id') # Extract active public workspace ID frontend_gpt_model = data.get('model_deployment') @@ -1954,6 +2015,7 @@ def invoke_selected_agent(): agent_message_history, )) def agent_success(result): + nonlocal reload_messages_required msg = str(result) notice = None agent_used = getattr(selected_agent, 'name', 'All Plugins') @@ -2025,6 +2087,12 @@ def make_json_serializable(obj): # Store detailed citations globally to be accessed by the calling function agent_citations_list.extend(detailed_citations) + + if not reload_messages_required: + for citation in detailed_citations: + if result_requires_message_reload(citation.get('function_result')): + reload_messages_required = True + break if enable_multi_agent_orchestration and not per_user_semantic_kernel: # If the agent response indicates fallback mode @@ -2411,6 +2479,7 @@ def gpt_error(e): 'augmented': bool(system_messages_for_augmentation), 'hybrid_citations': hybrid_citations_list, 'agent_citations': agent_citations_list, + 'reload_messages': reload_messages_required, 'kernel_fallback_notice': kernel_fallback_notice }), 200 diff --git a/application/single_app/route_openapi.py b/application/single_app/route_openapi.py index 684e3c2c..08a86f41 100644 --- a/application/single_app/route_openapi.py +++ b/application/single_app/route_openapi.py @@ -1,3 +1,4 @@ +# route_openapi.py """ OpenAPI Plugin Routes @@ -13,6 +14,8 @@ from openapi_security import openapi_validator from openapi_auth_analyzer import analyze_openapi_authentication, get_authentication_help_text from swagger_wrapper import swagger_route, get_auth_security +from functions_security import is_valid_storage_name + def register_openapi_routes(app): """Register OpenAPI-related routes.""" @@ -193,6 +196,11 @@ def validate_openapi_url(): unique_id = str(uuid.uuid4())[:8] base_name, ext = os.path.splitext(safe_filename) stored_filename = f"{base_name}_{unique_id}{ext}" + if not is_valid_storage_name(stored_filename): + return jsonify({ + 'success': False, + 'error': 'Invalid storage filename' + }), 400 storage_path = os.path.join(upload_dir, stored_filename) # Save spec to file @@ -303,6 +311,11 @@ def download_openapi_from_url(): unique_id = str(uuid.uuid4())[:8] base_name, ext = os.path.splitext(safe_filename) stored_filename = f"{base_name}_{unique_id}{ext}" + if not is_valid_storage_name(stored_filename): + return jsonify({ + 'success': False, + 'error': 'Invalid storage filename' + }), 400 storage_path = os.path.join(upload_dir, stored_filename) # Save spec to file diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 70ed5efa..0874fa20 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -838,6 +838,7 @@ def load_single_agent_for_kernel(kernel, agent_cfg, settings, context_obj, redis "deployment_name": agent_config["deployment"], "azure_endpoint": agent_config["endpoint"], "api_version": agent_config["api_version"], + "function_choice_behavior": FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=10) } # Don't pass plugins to agent since they're already loaded in kernel agent_obj = LoggingChatCompletionAgent(**kwargs) @@ -1515,7 +1516,8 @@ def load_semantic_kernel(kernel: Kernel, settings): "default_agent": agent_config.get("default_agent", False), "deployment_name": agent_config["deployment"], "azure_endpoint": agent_config["endpoint"], - "api_version": agent_config["api_version"] + "api_version": agent_config["api_version"], + "function_choice_behavior": FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=10) } if agent_config.get("actions_to_load"): kwargs["plugins"] = agent_config["actions_to_load"] @@ -1854,7 +1856,7 @@ def pick(key): if hasattr(prompt_exec_settings, 'function_choice_behavior'): if getattr(prompt_exec_settings, 'function_choice_behavior', None) is None: try: - prompt_exec_settings.function_choice_behavior = FunctionChoiceBehavior.from_string('auto') + prompt_exec_settings.function_choice_behavior = FunctionChoiceBehavior.Auto(maximum_auto_invoke_attempts=10) except Exception: # pass this to prevent additional future agent types from potentially failing pass diff --git a/application/single_app/semantic_kernel_plugins/azure_function_plugin.py b/application/single_app/semantic_kernel_plugins/azure_function_plugin.py deleted file mode 100644 index f388404e..00000000 --- a/application/single_app/semantic_kernel_plugins/azure_function_plugin.py +++ /dev/null @@ -1,91 +0,0 @@ -from typing import Dict, Any, List -from semantic_kernel_plugins.base_plugin import BasePlugin -from semantic_kernel.functions import kernel_function -from semantic_kernel_plugins.plugin_invocation_logger import plugin_function_logger -import requests -from azure.identity import DefaultAzureCredential - -class AzureFunctionPlugin(BasePlugin): - def __init__(self, manifest: Dict[str, Any]): - super().__init__(manifest) - self.endpoint = manifest.get('endpoint') - self.key = manifest.get('auth', {}).get('key') - self.auth_type = manifest.get('auth', {}).get('type', 'key') - self._metadata = manifest.get('metadata', {}) - if not self.endpoint or not self.auth_type: - raise ValueError("AzureFunctionPlugin requires 'endpoint' and 'auth.type' in the manifest.") - if self.auth_type == 'identity': - self.credential = DefaultAzureCredential() - elif self.auth_type == 'key': - if not self.key: - raise ValueError("AzureFunctionPlugin requires 'auth.key' when using key authentication.") - self.credential = None - else: - raise ValueError(f"Unsupported auth.type: {self.auth_type}") - - @property - def display_name(self) -> str: - return "Azure Function" - - @property - def metadata(self) -> Dict[str, Any]: - return { - "name": self.manifest.get("name", "azure_function_plugin"), - "type": "azure_function", - "description": "Plugin for calling an Azure Function via HTTP POST or GET using function key or managed identity authentication. Use this to trigger serverless logic or workflows in Azure Functions.", - "methods": [ - { - "name": "call_function_post", - "description": "Call the Azure Function using HTTP POST.", - "parameters": [ - {"name": "payload", "type": "dict", "description": "JSON payload to send in the POST request.", "required": True} - ], - "returns": {"type": "dict", "description": "Response from the Azure Function as a JSON object."} - }, - { - "name": "call_function_get", - "description": "Call the Azure Function using HTTP GET.", - "parameters": [ - {"name": "params", "type": "dict", "description": "Query parameters for the GET request.", "required": False} - ], - "returns": {"type": "dict", "description": "Response from the Azure Function as a JSON object."} - } - ] - } - - def get_functions(self) -> List[str]: - return ["call_function_post", "call_function_get"] - - @plugin_function_logger("AzureFunctionPlugin") - @kernel_function(description="Call the Azure Function using HTTP POST.") - def call_function_post(self, payload: dict) -> dict: - url = self.endpoint - headers = {} - if self.auth_type == 'identity': - token = self.credential.get_token("{resource_manager}/.default").token - headers["Authorization"] = f"Bearer {token}" - elif self.auth_type == 'key': - if '?' in url: - url += f"&code={self.key}" - else: - url += f"?code={self.key}" - response = requests.post(url, json=payload, headers=headers) - response.raise_for_status() - return response.json() - - @plugin_function_logger("AzureFunctionPlugin") - @kernel_function(description="Call the Azure Function using HTTP GET.") - def call_function_get(self, params: dict = None) -> dict: - url = self.endpoint - headers = {} - if self.auth_type == 'identity': - token = self.credential.get_token("{resource_manager}/.default").token - headers["Authorization"] = f"Bearer {token}" - elif self.auth_type == 'key': - if '?' in url: - url += f"&code={self.key}" - else: - url += f"?code={self.key}" - response = requests.get(url, params=params or {}, headers=headers) - response.raise_for_status() - return response.json() diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d1d9c6dd..3ff0f070 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -1556,6 +1556,11 @@ export function actuallySendMessage(finalMessageToSend) { ); } + if (data.reload_messages && currentConversationId) { + console.log("Reload flag received from backend - refreshing messages."); + loadMessages(currentConversationId); + } + // Update conversation list item and header if needed if (data.conversation_id) { currentConversationId = data.conversation_id; // Update current ID From ef2a2a7debaced2347835f71e6c68361264620ef Mon Sep 17 00:00:00 2001 From: Bionic711 <13358952+Bionic711@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:56:07 -0600 Subject: [PATCH 6/7] Security/container build (#549) * upd dockerfile to google distroless * add pipelines * add modifications to container * upd to build * add missing arg * add arg for major/minor/patch python version * upd python paths and pip install * add perms to /app for user * chg back to root * rmv python3 * rmv not built python * add shared * add path and home * upd for stdlib paths * fix user input filesystem path vulns * fix to consecutive dots --------- Co-authored-by: Bionic711 --- application/single_app/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/single_app/Dockerfile b/application/single_app/Dockerfile index 3b76aa57..c6209334 100644 --- a/application/single_app/Dockerfile +++ b/application/single_app/Dockerfile @@ -94,4 +94,5 @@ EXPOSE 5000 USER 65532:65532 -ENTRYPOINT ["/app/venv/bin/python", "-c", "import runpy; runpy.run_path('/app/app.py', run_name='__main__')"] + +ENTRYPOINT ["/app/venv/bin/python", "-c", "import runpy; runpy.run_path('/app/app.py', run_name='__main__')"] \ No newline at end of file From 04b5c1233c23e4905bf904a1a8fc6d755fc593c0 Mon Sep 17 00:00:00 2001 From: Xeelee33 Date: Fri, 19 Dec 2025 12:26:33 -0800 Subject: [PATCH 7/7] Feature/speech managed identity (#543) * Bugfix - deleted duplicate enable_external_healthcheck entry * Feature - updated Speech Service to use Managed Identity in addition to the key, added MAG functionality via Azure Speech SDK since the Fast Transcription API is not available in MAG, updated Admin Setup Walkthrough so it goes to the right place in the settings when Next is clicked, updated Speech requirements in Walkthrough, rewrote Admin Configuration docs, updated/corrected Managed Identity roles in Setup Instructions Special docs. * Update application/single_app/templates/admin_settings.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update application/single_app/functions_settings.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update application/single_app/functions_documents.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update application/single_app/functions_documents.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Paul Lizer Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- application/single_app/config.py | 1 + application/single_app/functions_documents.py | 95 ++++++-- application/single_app/requirements.txt | 3 +- .../route_frontend_admin_settings.py | 1 + .../static/js/admin/admin_settings.js | 90 ++++++-- .../single_app/templates/admin_settings.html | 52 ++++- docs/admin_configuration.md | 213 +++++++++++++++--- docs/setup_instructions_special.md | 8 +- 8 files changed, 379 insertions(+), 84 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index dbea89df..f139bbf3 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -64,6 +64,7 @@ from io import BytesIO from typing import List +import azure.cognitiveservices.speech as speechsdk from azure.cosmos import CosmosClient, PartitionKey, exceptions from azure.cosmos.exceptions import CosmosResourceNotFoundError from azure.core.credentials import AzureKeyCredential diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index d7c43110..fd21bc81 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -4765,6 +4765,24 @@ def _split_audio_file(input_path: str, chunk_seconds: int = 540) -> List[str]: print(f"Produced {len(chunks)} WAV chunks: {chunks}") return chunks +# Azure Speech SDK helper to get speech config with fresh token +def _get_speech_config(settings, endpoint: str, locale: str): + """Get speech config with fresh token""" + if settings.get("speech_service_authentication_type") == "managed_identity": + credential = DefaultAzureCredential() + token = credential.get_token(cognitive_services_scope) + speech_config = speechsdk.SpeechConfig(endpoint=endpoint) + + # Set the authorization token AFTER creating the config + speech_config.authorization_token = token.token + else: + key = settings.get("speech_service_key", "") + speech_config = speechsdk.SpeechConfig(endpoint=endpoint, subscription=key) + + speech_config.speech_recognition_language = locale + print(f"[Debug] Speech config obtained successfully", flush=True) + return speech_config + def process_audio_document( document_id: str, user_id: str, @@ -4804,32 +4822,65 @@ def process_audio_document( # 3) transcribe each WAV chunk settings = get_settings() endpoint = settings.get("speech_service_endpoint", "").rstrip('/') - key = settings.get("speech_service_key", "") locale = settings.get("speech_service_locale", "en-US") - url = f"{endpoint}/speechtotext/transcriptions:transcribe?api-version=2024-11-15" all_phrases: List[str] = [] - for idx, chunk_path in enumerate(chunk_paths, start=1): - update_callback(current_file_chunk=idx, status=f"Transcribing chunk {idx}/{len(chunk_paths)}…") - print(f"Transcribing WAV chunk: {chunk_path}") - - with open(chunk_path, 'rb') as audio_f: - files = { - 'audio': (os.path.basename(chunk_path), audio_f, 'audio/wav'), - 'definition': (None, json.dumps({'locales':[locale]}), 'application/json') - } - headers = {'Ocp-Apim-Subscription-Key': key} - resp = requests.post(url, headers=headers, files=files) - try: - resp.raise_for_status() - except Exception as e: - print(f"[Error] HTTP error for {chunk_path}: {e}") - raise - result = resp.json() - phrases = result.get('combinedPhrases', []) - print(f"Received {len(phrases)} phrases") - all_phrases += [p.get('text','').strip() for p in phrases if p.get('text')] + # Fast Transcription API not yet available in sovereign clouds, so use SDK + if AZURE_ENVIRONMENT in ("usgovernment", "custom"): + for idx, chunk_path in enumerate(chunk_paths, start=1): + print(f"[Debug] Transcribing chunk {idx}: {chunk_path}") + + # Get fresh config (tokens expire after ~1 hour) + speech_config = _get_speech_config(settings, endpoint, locale) + + audio_config = speechsdk.AudioConfig(filename=chunk_path) + speech_recognizer = speechsdk.SpeechRecognizer( + speech_config=speech_config, + audio_config=audio_config + ) + + result = speech_recognizer.recognize_once() + if result.reason == speechsdk.ResultReason.RecognizedSpeech: + print(f"[Debug] Recognized: {result.text}") + all_phrases.append(result.text) + elif result.reason == speechsdk.ResultReason.NoMatch: + print(f"[Warning] No speech in {chunk_path}") + elif result.reason == speechsdk.ResultReason.Canceled: + print(f"[Error] {result.cancellation_details.reason}: {result.cancellation_details.error_details}") + raise RuntimeError(f"Transcription canceled for {chunk_path}: {result.cancellation_details.error_details}") + + else: + # Use the fast-transcription API if not in sovereign or custom cloud + url = f"{endpoint}/speechtotext/transcriptions:transcribe?api-version=2024-11-15" + for idx, chunk_path in enumerate(chunk_paths, start=1): + update_callback(current_file_chunk=idx, status=f"Transcribing chunk {idx}/{len(chunk_paths)}…") + print(f"[Debug] Transcribing WAV chunk: {chunk_path}") + + with open(chunk_path, 'rb') as audio_f: + files = { + 'audio': (os.path.basename(chunk_path), audio_f, 'audio/wav'), + 'definition': (None, json.dumps({'locales':[locale]}), 'application/json') + } + if settings.get("speech_service_authentication_type") == "managed_identity": + credential = DefaultAzureCredential() + token = credential.get_token(cognitive_services_scope) + headers = {'Authorization': f'Bearer {token.token}'} + else: + key = settings.get("speech_service_key", "") + headers = {'Ocp-Apim-Subscription-Key': key} + + resp = requests.post(url, headers=headers, files=files) + try: + resp.raise_for_status() + except Exception as e: + print(f"[Error] HTTP error for {chunk_path}: {e}") + raise + + result = resp.json() + phrases = result.get('combinedPhrases', []) + print(f"[Debug] Received {len(phrases)} phrases") + all_phrases += [p.get('text','').strip() for p in phrases if p.get('text')] # 4) cleanup WAV chunks for p in chunk_paths: diff --git a/application/single_app/requirements.txt b/application/single_app/requirements.txt index a85ce7b2..a5467d9a 100644 --- a/application/single_app/requirements.txt +++ b/application/single_app/requirements.txt @@ -52,4 +52,5 @@ cython pyyaml==6.0.2 aiohttp==3.12.15 html2text==2025.4.15 -matplotlib==3.10.7 \ No newline at end of file +matplotlib==3.10.7 +azure-cognitiveservices-speech==1.47.0 \ No newline at end of file diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 5580f7b6..32498432 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -668,6 +668,7 @@ def is_valid_url(url): 'speech_service_endpoint': form_data.get('speech_service_endpoint', '').strip(), 'speech_service_location': form_data.get('speech_service_location', '').strip(), 'speech_service_locale': form_data.get('speech_service_locale', '').strip(), + 'speech_service_authentication_type': form_data.get('speech_service_authentication_type', 'key'), 'speech_service_key': form_data.get('speech_service_key', '').strip(), 'metadata_extraction_model': form_data.get('metadata_extraction_model', '').strip(), diff --git a/application/single_app/static/js/admin/admin_settings.js b/application/single_app/static/js/admin/admin_settings.js index 30817987..2864bd3d 100644 --- a/application/single_app/static/js/admin/admin_settings.js +++ b/application/single_app/static/js/admin/admin_settings.js @@ -1654,6 +1654,15 @@ function setupToggles() { }); } + const speechAuthType = document.getElementById('speech_service_authentication_type'); + if (speechAuthType) { + speechAuthType.addEventListener('change', function () { + document.getElementById('speech_service_key_container').style.display = + (this.value === 'key') ? 'block' : 'none'; + markFormAsModified(); + }); + } + const officeAuthType = document.getElementById('office_docs_authentication_type'); const connStrGroup = document.getElementById('office_docs_storage_conn_str_group'); const urlGroup = document.getElementById('office_docs_storage_url_group'); @@ -3113,28 +3122,41 @@ function handleTabNavigation(stepNumber) { 5: 'ai-models-tab', // Embedding settings (now in AI Models tab) 6: 'search-extract-tab', // AI Search settings 7: 'search-extract-tab', // Document Intelligence settings - 8: 'workspaces-tab', // Video support - 9: 'workspaces-tab', // Audio support + 8: 'search-extract-tab', // Video support + 9: 'search-extract-tab', // Audio support 10: 'safety-tab', // Content safety - 11: 'system-tab', // User feedback and archiving (renamed from other-tab) + 11: 'safety-tab', // User feedback and archiving (changed from system-tab) 12: 'citation-tab' // Enhanced Citations and Image Generation }; // Activate the appropriate tab const tabId = stepToTab[stepNumber]; if (tabId) { - const tab = document.getElementById(tabId); - if (tab) { - // Use bootstrap Tab to show the tab - const bootstrapTab = new bootstrap.Tab(tab); - bootstrapTab.show(); - - // Scroll to the relevant section after a small delay to allow tab to switch - setTimeout(() => { - // For tabs that need to jump to specific sections - scrollToRelevantSection(stepNumber, tabId); - }, 300); + // Check if we're using sidebar navigation or tab navigation + const sidebarToggle = document.getElementById('admin-settings-toggle'); + + if (sidebarToggle) { + // Using sidebar navigation - call showAdminTab function + const tabName = tabId.replace('-tab', ''); // Remove '-tab' suffix + if (typeof showAdminTab === 'function') { + showAdminTab(tabName); + } else if (typeof window.showAdminTab === 'function') { + window.showAdminTab(tabName); + } + } else { + // Using Bootstrap tabs + const tab = document.getElementById(tabId); + if (tab) { + // Use bootstrap Tab to show the tab + const bootstrapTab = new bootstrap.Tab(tab); + bootstrapTab.show(); + } } + + // Scroll to the relevant section after a small delay to allow tab to switch + setTimeout(() => { + scrollToRelevantSection(stepNumber, tabId); + }, 300); } } @@ -3148,8 +3170,26 @@ function scrollToRelevantSection(stepNumber, tabId) { let targetElement = null; switch (stepNumber) { + case 1: // App title and logo + targetElement = document.getElementById('branding-section'); + break; + case 2: // GPT settings + targetElement = document.getElementById('gpt-configuration'); + break; + case 3: // GPT model selection + targetElement = document.getElementById('gpt_models_list')?.closest('.mb-3'); + break; case 4: // Workspaces toggle section - targetElement = document.getElementById('enable_user_workspace')?.closest('.card'); + targetElement = document.getElementById('personal-workspaces-section'); + break; + case 5: // Embedding settings + targetElement = document.getElementById('embeddings-configuration'); + break; + case 6: // AI Search settings + targetElement = document.getElementById('azure-ai-search-section'); + break; + case 7: // Document Intelligence settings + targetElement = document.getElementById('document-intelligence-section'); break; case 8: // Video file support targetElement = document.getElementById('enable_video_file_support')?.closest('.form-group'); @@ -3157,6 +3197,15 @@ function scrollToRelevantSection(stepNumber, tabId) { case 9: // Audio file support targetElement = document.getElementById('enable_audio_file_support')?.closest('.form-group'); break; + case 10: // Content safety + targetElement = document.getElementById('content-safety-section'); + break; + case 11: // User feedback and archiving + targetElement = document.getElementById('user-feedback-section'); + break; + case 12: // Enhanced citations and image generation + targetElement = document.getElementById('enhanced-citations-section'); + break; default: // For other steps, no specific scrolling break; @@ -3164,7 +3213,7 @@ function scrollToRelevantSection(stepNumber, tabId) { // If we found a target element, scroll to it if (targetElement) { - targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } @@ -3302,9 +3351,14 @@ function isStepComplete(stepNumber) { // Otherwise check settings const speechEndpoint = document.getElementById('speech_service_endpoint')?.value; - const speechKey = document.getElementById('speech_service_key')?.value; + const authType = document.getElementById('speech_service_authentication_type').value; + const key = document.getElementById('speech_service_key').value; - return speechEndpoint && speechKey; + if (!speechEndpoint || (authType === 'key' && !key)) { + return false; + } else { + return true; + } case 10: // Content safety - always complete (optional) case 11: // User feedback and archiving - always complete (optional) diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index e8dc1fe2..90a2c541 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -369,7 +369,7 @@

    9. Enable Audio File Support

    Required: Audio support configuration is required if workspaces are enabled. -

    Use the Audio Support settings in the Workspaces tab to enable and configure audio file processing.

    +

    Use the Audio Support settings on the Search & Extract tab to enable and configure audio file processing.

    • @@ -377,10 +377,16 @@

      9. Enable Audio File Support

      Required
    • - Speech Service Key - Required + Authentication + Required
    + +
    + Note: If using Managed Identity authentication ensure the Service Principal has been assigned the Cognitive Services Speech Contributor role on the Azure Speech Service and that the Speech endpoint is configured with a custom domain name. + + +
    @@ -2831,7 +2837,7 @@
    Speech Service Settings
    + placeholder="https://.cognitiveservices.azure./">
    @@ -2849,17 +2855,41 @@
    Speech Service Settings
    - + + +
    +
    +
    - - + +
    - +

    diff --git a/docs/admin_configuration.md b/docs/admin_configuration.md index 9d3b8bf6..af8a9df1 100644 --- a/docs/admin_configuration.md +++ b/docs/admin_configuration.md @@ -2,37 +2,194 @@ [Return to Main](../README.md) - Once the application is running and you log in as a user assigned the Admin role, you can access the **Admin Settings** page. This UI provides a centralized location to configure most application features and service connections. ![alt text](./images/admin_settings_page.png) +## Setup Walkthrough + +The Admin Settings page includes an interactive **Setup Walkthrough** feature that guides you through the initial configuration process. This is particularly helpful for first-time setup. + +### Starting the Walkthrough + +- The walkthrough automatically appears on first-time setup when critical settings are missing +- You can manually launch it anytime by clicking the **"Start Setup Walkthrough"** button at the top of the Admin Settings page +- The walkthrough will automatically navigate to the relevant configuration tabs as you progress through each step + +### Walkthrough Features + +- **Automatic Tab Navigation**: As you move through steps, the walkthrough automatically switches to the relevant admin settings tab and scrolls to the appropriate section +- **Smart Step Skipping**: Steps that aren't applicable based on your configuration choices (e.g., workspace-dependent features) are automatically skipped +- **Real-time Validation**: The "Next" button becomes available only when required fields for the current step are completed +- **Progress Tracking**: Visual progress bar shows your completion status through the setup process +- **Flexible Navigation**: Use "Previous" and "Next" buttons to move between steps, or close the walkthrough at any time to configure settings manually + +### Walkthrough Steps Overview + +The walkthrough covers these key configuration areas in order: + +1. **Application Basics** (Optional) - App title and logo +2. **GPT API Settings** (Required) - Azure OpenAI GPT endpoint and authentication +3. **GPT Model Selection** (Required) - Select available GPT models for users +4. **Workspaces** (Optional) - Enable personal and/or group workspaces +5. **Embedding API** (Required if workspaces enabled) - Configure embedding service +6. **Azure AI Search** (Required if workspaces enabled) - Configure search indexing +7. **Document Intelligence** (Required if workspaces enabled) - Configure document processing +8. **Video Support** (Optional, workspace-dependent) - Configure video file processing +9. **Audio Support** (Optional, workspace-dependent) - Configure audio file processing +10. **Content Safety** (Optional) - Configure content filtering +11. **User Feedback & Archiving** (Optional) - Enable feedback and conversation archiving +12. **Enhanced Features** (Optional) - Enhanced citations and image generation + +The walkthrough automatically adjusts which steps are required based on your selections. For example, if you don't enable workspaces, embedding and search configuration steps become optional. + +## Configuration Sections + Key configuration sections include: -1. **General**: Application title, custom logo upload, landing page markdown text. -2. **GPT**: Configure Azure OpenAI endpoint(s) for chat models. Supports Direct endpoint or APIM. Allows Key or Managed Identity authentication. Test connection button. Select active deployment(s). - 1. Setting up Multi-model selection for users -3. **Embeddings**: Configure Azure OpenAI endpoint(s) for embedding models. Supports Direct/APIM, Key/Managed Identity. Test connection. Select active deployment. -4. **Image Generation** *(Optional)*: Enable/disable feature. Configure Azure OpenAI DALL-E endpoint. Supports Direct/APIM, Key/Managed Identity. Test connection. Select active deployment. -5. **Workspaces**: - - Enable/disable **Your Workspace** (personal docs). - - Enable/disable **My Groups** (group docs). Option to enforce `CreateGroups` RBAC role for creating new groups. - - Enable/disable **Multimedia Support** (Video/Audio uploads). Configure **Video Indexer** (Account ID, Location, Key, API Endpoint, Timeout) and **Speech Service** (Endpoint, Region, Key). - - Enable/disable **Metadata Extraction**. Select the GPT model used for extraction. - - Enable/disable **Document Classification**. Define classification labels and colors. -6. **Citations**: - - Standard Citations (basic text references) are always on. - - Enable/disable **Enhanced Citations**. Configure **Azure Storage Account Connection String** (or indicate Managed Identity use if applicable). -7. **Safety**: - - Enable/disable **Content Safety**. Configure endpoint (Direct/APIM), Key/Managed Identity. Test connection. - - Enable/disable **User Feedback**. - - Configure **Admin Access RBAC**: Option to require `SafetyViolationAdmin` or `FeedbackAdmin` roles for respective admin views. - - Enable/disable **Conversation Archiving**. -8. **Search & Extract**: - - Configure **Azure AI Search** connection (Endpoint, Key/Managed Identity). Test connection. (Primarily for testing, main indexing uses backend logic). - - Configure **Document Intelligence** connection (Endpoint, Key/Managed Identity). Test connection. -9. **Other**: - - Set **Maximum File Size** for uploads (in MB). - - Set **Conversation History Limit** (max number of past conversations displayed). - - Define the **Default System Prompt** used for the AI model. - - Enable/disable **File Processing Logs** (verbose logging for ingestion pipelines). +### 1. General +- **Branding**: Application title, custom logo upload (light and dark mode), favicon +- **Home Page Text**: Landing page markdown content with alignment options and optional editor +- **Appearance**: Default theme (light/dark mode) and navigation layout (top nav or left sidebar) +- **Health Check**: External health check endpoint configuration for monitoring systems +- **API Documentation**: Enable/disable Swagger/OpenAPI documentation endpoint +- **Classification Banner**: Security classification banner for data sensitivity indication +- **External Links**: Custom navigation links to external resources with configurable menu behavior +- **System Settings**: Maximum file size, conversation history limit, default system prompt + +### 2. AI Models +- **GPT Configuration**: + - Configure Azure OpenAI endpoint(s) for chat models + - Supports Direct endpoint or APIM (API Management) + - Allows Key or Managed Identity authentication + - Test connection button + - Select multiple active deployment(s) - users can choose from available models + - Multi-model selection for users + +- **Embeddings Configuration**: + - Configure Azure OpenAI endpoint(s) for embedding models + - Supports Direct/APIM, Key/Managed Identity + - Test connection + - Select active deployment + +- **Image Generation** *(Optional)*: + - Enable/disable feature + - Configure Azure OpenAI DALL-E endpoint + - Supports Direct/APIM, Key/Managed Identity + - Test connection + - Select active deployment + +### 3. Workspaces +- **Personal Workspaces**: Enable/disable "Your Workspace" (personal docs) +- **Group Workspaces**: + - Enable/disable "Groups" (group docs) + - Option to enforce `CreateGroups` RBAC role for creating new groups +- **Public Workspaces**: + - Enable/disable "Public" (public docs) + - Option to enforce `CreatePublicWorkspaces` RBAC role for creating new public workspaces +- **File Sharing**: + - Enable/disable file sharing capabilities between users and workspaces. +- **Metadata Extraction**: + - Enable/disable metadata extraction from documents + - Select the GPT model used for extraction +- **Multi-Modal Vision Analysis**: + - Enable vision-capable models for image analysis in addition to document OCR + - Automatic filtering of compatible GPT models (GPT-4o, GPT-4 Vision, etc.) +- **Document Classification**: + - Enable/disable classification features + - Define custom classification labels and colors + - Dynamic category management with inline editing + +### 4. Citations +- **Standard Citations**: Basic text references (always enabled) +- **Enhanced Citations**: + - Enable/disable enhanced citation features + - Configure Azure Storage Account Connection String or Service Endpoint with Managed Identity + - Store original files for direct reference and preview + +### 5. Safety +- **Content Safety**: + - Enable/disable content filtering + - Configure endpoint (Direct/APIM) + - Key/Managed Identity authentication + - Test connection +- **User Feedback**: + - Enable/disable thumbs up/down feedback on AI responses +- **Admin Access RBAC**: + - Option to require `SafetyViolationAdmin` role for safety violation admin views + - Option to require `FeedbackAdmin` role for feedback admin views +- **Conversation Archiving**: + - Enable/disable conversation archiving instead of permanent deletion + +### 6. Search & Extract +- **Azure AI Search**: + - Configure connection (Endpoint, Key/Managed Identity) + - Support for Direct or APIM routing + - Test connection +- **Document Intelligence**: + - Configure connection (Endpoint, Key/Managed Identity) + - Support for Direct or APIM routing + - Test connection +- **Multimedia Support** (Video/Audio uploads): + - **Video Files**: Configure Azure Video Indexer using Managed Identity authentication + - Resource Group, Subscription ID, Account Name, Location, Account ID + - API Endpoint, ARM API Version, Timeout + - **Audio Files**: Configure Speech Service + - Endpoint, Location/Region, Locale + - Key/Managed Identity authentication + +### 7. Agents +- **Agents Configuration**: + - Enable/disable Semantic Kernel-powered agents + - Configure workspace mode (per-user vs global agents) + - Agent orchestration settings (single agent vs multi-agent group chat) + - Manage global agents and select default/orchestrator agent +- **Actions Configuration**: + - Enable/disable core plugins (Time, HTTP, Wait, Math, Text, Fact Memory, Embedding) + - Configure user and group plugin permissions + - Manage custom OpenAPI plugins + +### 8. Scale +- **Redis Cache**: + - Enable distributed session storage for horizontal scaling + - Configure Redis endpoint and authentication (Key or Managed Identity) + - Test connection +- **Front Door**: + - Enable Azure Front Door integration + - Configure Front Door URL for authentication flows + - Supports global load balancing and custom domains + +### 9. Logging +- **Application Insights Logging**: + - Enable global logging for agents and orchestration + - Requires application restart to take effect +- **Debug Logging**: + - Enable/disable debug print statements + - Optional time-based auto-disable feature + - Warning: Collects tokens and keys during debug +- **File Processing Logs**: + - Enable logging of file processing events + - Logs stored in Cosmos DB file_processing container + - Optional time-based auto-disable feature + +## Navigation Options + +The Admin Settings page supports two navigation layouts: + +1. **Tab Navigation** (Default): Horizontal tabs at the top for switching between configuration sections +2. **Left Sidebar Navigation**: Collapsible left sidebar with grouped navigation items + - Can be set as the default for all users in General → Appearance settings + - Users can toggle between layouts individually + - The Setup Walkthrough works seamlessly with both navigation styles + +## Tips for Configuration + +- **Save Changes**: The floating "Save Settings" button in the bottom-right becomes active (blue) when you make changes +- **Test Connections**: Use the "Test Connection" buttons to verify your service configurations before saving +- **APIM vs Direct**: When using Azure API Management (APIM), you'll need to manually specify model names as automatic model fetching is not available +- **Managed Identity**: When using Managed Identity authentication, ensure your Service Principal has the appropriate roles assigned: + - **Azure OpenAI**: Cognitive Services OpenAI User role + - **Speech Service**: Cognitive Services Speech Contributor role (requires custom domain name on endpoint) + - **Video Indexer**: Appropriate Video Indexer roles for your account +- **Dependencies**: The walkthrough will alert you if required services aren't configured when you enable dependent features (e.g., workspaces require embeddings, AI Search, and Document Intelligence) +- **Required vs Optional**: The walkthrough clearly indicates which settings are required vs optional based on your configuration choices \ No newline at end of file diff --git a/docs/setup_instructions_special.md b/docs/setup_instructions_special.md index b46213f5..81b4d4b0 100644 --- a/docs/setup_instructions_special.md +++ b/docs/setup_instructions_special.md @@ -25,7 +25,7 @@ To run the application in Azure Government cloud: - This ensures the application uses the correct Azure Government endpoints for authentication (MSAL) and potentially for fetching management plane details when using Managed Identity with direct endpoints. -3. **Endpoint URLs**: Ensure all endpoint URLs configured (in App Settings or via the Admin UI) point to the correct .usgovernment.azure.com (or specific service) domains. Azure OpenAI endpoints in Gov are different from Commercial. +3. **Endpoint URLs**: Ensure all endpoint URLs configured (in App Settings or via the Admin UI) point to the correct .azure.us (or specific service) domains. Azure OpenAI endpoints in Gov are different from Commercial. 4. **App Registration**: Ensure the App Registration is done within your Azure Government Azure AD tenant. The Redirect URI for the App Service will use the .azurewebsites.us domain. @@ -75,12 +75,12 @@ Using Managed Identity allows the App Service to authenticate to other Azure res | Target Service | Required Role | Notes | | --------------------- | ----------------------------------- | ------------------------------------------------------------ | | Azure OpenAI | Cognitive Services OpenAI User | Allows data plane access (generating completions, embeddings, images). | - | Azure AI Search | Search Index Data Contributor | Allows reading/writing data to search indexes. | + | Azure AI Search | Contributor & Search Index Data Contributor | Allows acquiring authentication token from Search resource manager and reading/writing data to search indexes. | | Azure Cosmos DB | Cosmos DB Built-in Data Contributor | Allows reading/writing data. Least privilege possible via custom roles. Key auth might be simpler. | | Document Intelligence | Cognitive Services User | Allows using the DI service for analysis. | - | Content Safety | Cognitive Services Contributor | Allows using the CS service for analysis. (Role name might vary slightly, check portal) | + | Content Safety | Azure AI Developer | Allows using the CS service for analysis. (Role name might vary slightly, check portal) | | Azure Storage Account | Storage Blob Data Contributor | Required for Enhanced Citations if using Managed Identity. Allows reading/writing blobs. | - | Azure Speech Service | Cognitive Services User | Allows using the Speech service for transcription. | + | Azure Speech Service | Cognitive Services Speech Contributor | Allows using the Speech service for transcription. | | Video Indexer | (Handled via VI resource settings) | VI typically uses its own Managed Identity to access associated Storage/Media Services. Check VI docs. | 3. **Configure Application to Use Managed Identity**: