diff --git a/modules/interfaces/interfaceDbChatObjects.py b/modules/interfaces/interfaceDbChatObjects.py index 46fd4ba0..1fdbe5d6 100644 --- a/modules/interfaces/interfaceDbChatObjects.py +++ b/modules/interfaces/interfaceDbChatObjects.py @@ -1057,13 +1057,13 @@ class ChatObjects: # Add progress information if not present if "progress" not in logData: - # Default progress values based on log type + # Default progress values based on log type (0.0 to 1.0 format) if logData.get("type") == "info": - logData["progress"] = 50 # Default middle progress + logData["progress"] = 0.5 # Default middle progress elif logData.get("type") == "error": - logData["progress"] = -1 # Error state + logData["progress"] = 1.0 # Error state - completed (failed) elif logData.get("type") == "warning": - logData["progress"] = 50 # Default middle progress + logData["progress"] = 0.5 # Default middle progress # Validate log data against ChatLog model try: diff --git a/modules/services/serviceChat/mainServiceChat.py b/modules/services/serviceChat/mainServiceChat.py index bad3b605..9b1b684a 100644 --- a/modules/services/serviceChat/mainServiceChat.py +++ b/modules/services/serviceChat/mainServiceChat.py @@ -145,52 +145,41 @@ class ChatService: if messageFound and messageFound.documents: allDocuments.extend(messageFound.documents) else: - # Direct label reference (round1_task2_action3_contextinfo) + # Direct label reference - can be round1_task2_action3_contextinfo format or simple label # Search for messages with matching documentsLabel to find the actual documents - if docRef.startswith("round"): - # Parse round/task/action to find the corresponding document list - labelParts = docRef.split('_', 3) - if len(labelParts) >= 4: - roundNum = int(labelParts[0].replace('round', '')) - taskNum = int(labelParts[1].replace('task', '')) - actionNum = int(labelParts[2].replace('action', '')) - contextInfo = labelParts[3] - - # Find messages with matching documentsLabel (this is the correct way!) - # In case of retries, we want the NEWEST message (most recent publishedAt) - matchingMessages = [] - for message in workflow.messages: - # Validate message belongs to this workflow - msgWorkflowId = getattr(message, 'workflowId', None) - if not msgWorkflowId or msgWorkflowId != workflowId: - if msgWorkflowId: - logger.debug(f"Skipping message {message.id} with workflowId {msgWorkflowId} (expected {workflowId})") - else: - logger.debug(f"Skipping message {message.id} with no workflowId (expected {workflowId})") - continue - - msgDocumentsLabel = getattr(message, 'documentsLabel', '') - - # Check if this message's documentsLabel matches our reference - if msgDocumentsLabel == docRef: - # Found a matching message, collect it for comparison - matchingMessages.append(message) - - # If we found matching messages, take the newest one (highest publishedAt) - if matchingMessages: - # Sort by publishedAt descending (newest first) - matchingMessages.sort(key=lambda msg: getattr(msg, 'publishedAt', 0), reverse=True) - newestMessage = matchingMessages[0] - - if newestMessage.documents: - docNames = [doc.fileName for doc in newestMessage.documents if hasattr(doc, 'fileName')] - logger.debug(f"Added {len(newestMessage.documents)} documents from newest message {newestMessage.id}: {docNames}") - allDocuments.extend(newestMessage.documents) - else: - logger.debug(f"No documents found in newest message {newestMessage.id}") + matchingMessages = [] + for message in workflow.messages: + # Validate message belongs to this workflow + msgWorkflowId = getattr(message, 'workflowId', None) + if not msgWorkflowId or msgWorkflowId != workflowId: + if msgWorkflowId: + logger.debug(f"Skipping message {message.id} with workflowId {msgWorkflowId} (expected {workflowId})") else: - logger.error(f"No messages found with documentsLabel: {docRef}") - raise ValueError(f"Document reference not found: {docRef}") + logger.debug(f"Skipping message {message.id} with no workflowId (expected {workflowId})") + continue + + msgDocumentsLabel = getattr(message, 'documentsLabel', '') + + # Check if this message's documentsLabel matches our reference + if msgDocumentsLabel == docRef: + # Found a matching message, collect it for comparison + matchingMessages.append(message) + + # If we found matching messages, take the newest one (highest publishedAt) + if matchingMessages: + # Sort by publishedAt descending (newest first) + matchingMessages.sort(key=lambda msg: getattr(msg, 'publishedAt', 0), reverse=True) + newestMessage = matchingMessages[0] + + if newestMessage.documents: + docNames = [doc.fileName for doc in newestMessage.documents if hasattr(doc, 'fileName')] + logger.debug(f"Added {len(newestMessage.documents)} documents from newest message {newestMessage.id}: {docNames}") + allDocuments.extend(newestMessage.documents) + else: + logger.debug(f"No documents found in newest message {newestMessage.id}") + else: + logger.error(f"No messages found with documentsLabel: {docRef}") + raise ValueError(f"Document reference not found: {docRef}") logger.debug(f"Resolved {len(allDocuments)} documents from document list: {documentList}") return allDocuments diff --git a/modules/workflows/methods/methodSharepoint.py b/modules/workflows/methods/methodSharepoint.py index dea73974..21293e04 100644 --- a/modules/workflows/methods/methodSharepoint.py +++ b/modules/workflows/methods/methodSharepoint.py @@ -5,9 +5,10 @@ Handles SharePoint document operations using the SharePoint service. import logging import re +import json from typing import Dict, Any, List, Optional from datetime import datetime, UTC -from urllib.parse import urlparse +import urllib import aiohttp import asyncio @@ -346,7 +347,7 @@ class MethodSharepoint(MethodBase): def _parseSiteUrl(self, siteUrl: str) -> Dict[str, str]: """Parse SharePoint site URL to extract hostname and site path""" try: - parsed = urlparse(siteUrl) + parsed = urllib.parse.urlparse(siteUrl) hostname = parsed.hostname path = parsed.path.strip('/') @@ -497,7 +498,6 @@ class MethodSharepoint(MethodBase): if searchType == "folders" and fileQuery and fileQuery.strip() != "" and fileQuery.strip() != "*": # Use unified search for folders - this is global and searches all sites try: - import json # Use Microsoft Graph Search API syntax (simple term search only) terms = [t for t in fileQuery.split() if t.strip()] @@ -644,7 +644,6 @@ class MethodSharepoint(MethodBase): if '/sites/' in web_url: path_part = web_url.split('/sites/')[1] # Decode URL encoding and convert to backslash format - import urllib.parse decoded_path = urllib.parse.unquote(path_part) full_path = "\\" + decoded_path.replace('/', '\\') elif parent_path: @@ -745,7 +744,6 @@ class MethodSharepoint(MethodBase): if '/sites/' in web_url: path_part = web_url.split('/sites/')[1] # Decode URL encoding and convert to backslash format - import urllib.parse decoded_path = urllib.parse.unquote(path_part) full_path = "\\" + decoded_path.replace('/', '\\') elif parent_path: @@ -845,7 +843,6 @@ class MethodSharepoint(MethodBase): if pathQuery and pathQuery != "*": logger.debug(f"Both pathObject and pathQuery provided - using pathObject (pathQuery '{pathQuery}' will be ignored)") try: - import json # Resolve the reference label to get the actual document list document_list = self.services.chat.getChatDocumentsFromDocumentList([pathObject]) if not document_list or len(document_list) == 0: @@ -1125,7 +1122,6 @@ class MethodSharepoint(MethodBase): # If pathObject is provided, extract folder IDs from it if pathObject: try: - import json # Resolve the reference label to get the actual document list document_list = self.services.chat.getChatDocumentsFromDocumentList([pathObject]) if not document_list or len(document_list) == 0: @@ -1479,7 +1475,6 @@ class MethodSharepoint(MethodBase): if pathQuery and pathQuery != "*": logger.debug(f"Both pathObject and pathQuery provided - using pathObject (pathQuery '{pathQuery}' will be ignored)") try: - import json # Resolve the reference label to get the actual document list document_list = self.services.chat.getChatDocumentsFromDocumentList([pathObject]) if not document_list or len(document_list) == 0: @@ -1528,7 +1523,7 @@ class MethodSharepoint(MethodBase): } found_documents = [doc] logger.info(f"Extracted 1 document from validation report") - except json.JSONDecodeError as e: + except ValueError as e: logger.error(f"Failed to parse nested JSON in result field: {e}") return ActionResult.isFailure(error=f"Invalid nested JSON in pathObject: {str(e)}") @@ -1571,7 +1566,7 @@ class MethodSharepoint(MethodBase): else: return ActionResult.isFailure(error="No folders found in pathObject") - except json.JSONDecodeError as e: + except ValueError as e: return ActionResult.isFailure(error=f"Invalid JSON in pathObject: {str(e)}") except Exception as e: return ActionResult.isFailure(error=f"Error resolving pathObject reference: {str(e)}") @@ -1584,8 +1579,16 @@ class MethodSharepoint(MethodBase): logger.info(f"Starting SharePoint listDocuments for list_query: {list_query}") logger.debug(f"Connection ID: {connection['id']}") - # Parse list_query to extract path, search terms, search type, and options - pathQuery, fileQuery, searchType, searchOptions = self._parseSearchQuery(list_query) + # For listDocuments, if pathQuery starts with /site:, use it directly without parsing + # (parsing would split on the colon and break the site name) + if list_query and list_query.strip().startswith('/site:'): + pathQuery = list_query.strip() + fileQuery = "*" + searchType = "all" + searchOptions = {} + else: + # Parse list_query to extract path, search terms, search type, and options + pathQuery, fileQuery, searchType, searchOptions = self._parseSearchQuery(list_query) # Determine sites to use - strict validation: pathObject → pathQuery → ERROR sites = None @@ -1631,9 +1634,26 @@ class MethodSharepoint(MethodBase): return ActionResult.isFailure(error=f"Invalid pathQuery '{pathQuery}'. This appears to be search terms, not a valid SharePoint path. Use findDocumentPath action first to search for folders, then use the returned folder path as pathQuery.") # For pathQuery, we need to discover sites to find the specific one - sites = await self._discoverSharePointSites() - if not sites: + all_sites = await self._discoverSharePointSites() + if not all_sites: return ActionResult.isFailure(error="No SharePoint sites found or accessible") + + # If pathQuery starts with /site:, extract site name and filter + if pathQuery.startswith('/site:'): + # Extract site name from /site:Company Share/... format + site_path_part = pathQuery[6:] # Remove '/site:' + if '/' in site_path_part: + site_name = site_path_part.split('/', 1)[0] + else: + site_name = site_path_part + + # Filter sites by name (case-insensitive substring match) + sites = self._filter_sites_by_hint(all_sites, site_name) + if not sites: + return ActionResult.isFailure(error=f"No SharePoint site found matching '{site_name}'") + logger.info(f"Filtered to site(s) matching '{site_name}': {[s['displayName'] for s in sites]}") + else: + sites = all_sites else: # Step 3: Both pathObject and pathQuery failed - ERROR, NO FALLBACK return ActionResult.isFailure(error="No valid list path provided. Either provide pathObject (from findDocumentPath) or a valid pathQuery with specific site information.") @@ -1647,8 +1667,40 @@ class MethodSharepoint(MethodBase): folder_paths = [list_query] logger.info(f"Using direct folder ID: {list_query}") else: + # Remove /site:SiteName prefix from pathQuery before resolving (it's only for site filtering) + pathQueryForResolve = pathQuery + if pathQuery.startswith('/site:'): + # Remove /site:SiteName/ and keep the rest + site_path_part = pathQuery[6:] # Remove '/site:' + if '/' in site_path_part: + # Remove the site name part, keep the folder path + pathQueryForResolve = '/' + site_path_part.split('/', 1)[1] + else: + # Only site name, no path - use root + pathQueryForResolve = '/' + + # Remove first path segment if it looks like a document library name + # In SharePoint Graph API, /drive/root already points to the default document library, + # so library names in paths should be removed + # Generic approach: if path has multiple segments, store original for fallback + path_segments = [s for s in pathQueryForResolve.split('/') if s.strip()] + if len(path_segments) > 1: + # Path has multiple segments - first might be a library name + # Store original for potential fallback + original_path = pathQueryForResolve + # Try without first segment (assuming it's a library name) + pathQueryForResolve = '/' + '/'.join(path_segments[1:]) + logger.info(f"Removed first path segment (potential library name), path changed from '{original_path}' to '{pathQueryForResolve}'") + elif len(path_segments) == 1: + # Only one segment - if it's a common library-like name, use root + first_segment_lower = path_segments[0].lower() + library_indicators = ['document', 'dokument', 'shared', 'freigegeben', 'library', 'bibliothek'] + if any(indicator in first_segment_lower for indicator in library_indicators): + pathQueryForResolve = '/' + logger.info(f"First segment '{path_segments[0]}' appears to be a library name, using root") + # Resolve path query into folder paths - folder_paths = self._resolvePathQuery(pathQuery) + folder_paths = self._resolvePathQuery(pathQueryForResolve) logger.info(f"Resolved folder paths: {folder_paths}") # Process each folder path across all sites @@ -1673,9 +1725,11 @@ class MethodSharepoint(MethodBase): # Direct folder ID endpoint = f"sites/{site_id}/drive/items/{folderPath}/children" else: - # Specific folder path - remove leading slash if present + # Specific folder path - remove leading slash if present and URL encode folder_path_clean = folderPath.lstrip('/') - endpoint = f"sites/{site_id}/drive/root:/{folder_path_clean}:/children" + # URL encode the path for Graph API (spaces and special characters need encoding) + folder_path_encoded = urllib.parse.quote(folder_path_clean, safe='/') + endpoint = f"sites/{site_id}/drive/root:/{folder_path_encoded}:/children" # Make the API call to list folder contents api_result = await self._makeGraphApiCall(endpoint) diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py index d1b523ea..b3e740df 100644 --- a/modules/workflows/processing/core/actionExecutor.py +++ b/modules/workflows/processing/core/actionExecutor.py @@ -146,7 +146,7 @@ class ActionExecutor: self.services.chat.storeLog(workflow, { "message": f"āŒ **Task {taskNum}**āŒ **Action {actionNum}/{totalActions}** failed: {result.error}", "type": "error", - "progress": 100 + "progress": 1.0 }) # Log action summary diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py index ea0699ed..ddf32170 100644 --- a/modules/workflows/processing/core/messageCreator.py +++ b/modules/workflows/processing/core/messageCreator.py @@ -183,8 +183,7 @@ class MessageCreator: except Exception as e: logger.error(f"Error creating action message: {str(e)}") - async def createTaskCompletionMessage(self, taskStep: TaskStep, workflow: ChatWorkflow, taskIndex: int, - totalTasks: int, reviewResult: ReviewResult): + async def createTaskCompletionMessage(self, taskStep: TaskStep, workflow: ChatWorkflow, taskIndex: int, totalTasks: int, reviewResult: ReviewResult = None): """Create a task completion message for the user""" try: # Check workflow status before creating message @@ -194,14 +193,17 @@ class MessageCreator: taskProgress = str(taskIndex) # Enhanced completion message with criteria details - completionMessage = f"šŸŽÆ **Task {taskProgress}**\n\nāœ… {reviewResult.reason or 'Task completed successfully'}" + if reviewResult and hasattr(reviewResult, 'reason'): + completionMessage = f"šŸŽÆ **Task {taskProgress}**\n\nāœ… {reviewResult.reason or 'Task completed successfully'}" + else: + completionMessage = f"šŸŽÆ **Task {taskProgress}**\n\nāœ… Task completed successfully" # Add criteria status if available - if hasattr(reviewResult, 'metCriteria') and reviewResult.metCriteria: + if reviewResult and hasattr(reviewResult, 'metCriteria') and reviewResult.metCriteria: for criterion in reviewResult.metCriteria: completionMessage += f"\n• {criterion}" - if hasattr(reviewResult, 'qualityScore'): + if reviewResult and hasattr(reviewResult, 'qualityScore'): completionMessage += f"\nšŸ“Š Score {reviewResult.qualityScore}/10" taskCompletionMessage = { diff --git a/modules/workflows/processing/shared/automationTemplateInitial.json b/modules/workflows/processing/shared/automationTemplateInitial.json index 7a7fec8b..cecedf56 100644 --- a/modules/workflows/processing/shared/automationTemplateInitial.json +++ b/modules/workflows/processing/shared/automationTemplateInitial.json @@ -1,17 +1,13 @@ { - "overview": "Automated workflow: Web research, SharePoint data extraction, and document generation", - "userMessage": "Execute automated workflow: research web, extract SharePoint data, and generate document", + "template": +{ + "overview": "Automated workflow task", "tasks": [ { - "id": "task_1", - "objective": "Perform web research using provided URL and prompt to gather information", - "dependencies": [], - "successCriteria": [ - "Web research completed successfully", - "Research results saved as web_research_response" - ], - "estimatedComplexity": "medium", - "userMessage": "Researching web for information", + "id": "Task01", + "title": "Main Task", + "description": "Execute automated workflow", + "objective": "Execute automated workflow", "actionList": [ { "execMethod": "ai", @@ -20,61 +16,51 @@ "prompt": "{{KEY:webResearchPrompt}}", "list(url)": ["{{KEY:webResearchUrl}}"] }, - "execResultLabel": "web_research_response" - } - ] - }, - { - "id": "task_2", - "objective": "Extract data from files in SharePoint directory using provided folder path and prompt", - "dependencies": ["task_1"], - "successCriteria": [ - "SharePoint files read successfully", - "Data extracted and saved as sharepoint_data" - ], - "estimatedComplexity": "medium", - "userMessage": "Extracting data from SharePoint files", - "actionList": [ + "execResultLabel": "web_research_results" + }, + { + "execMethod": "sharepoint", + "execAction": "listDocuments", + "execParameters": { + "connectionReference": "{{KEY:connectionName}}", + "pathQuery": "{{KEY:sharepointFolderNameSource}}" + }, + "execResultLabel": "sharepoint_source_path" + }, { "execMethod": "sharepoint", "execAction": "readDocuments", "execParameters": { "connectionReference": "{{KEY:connectionName}}", - "pathQuery": "{{KEY:sharepointFolderNameSource}}", - "documentList": [], - "includeMetadata": true + "documentList": ["sharepoint_source_path"], + "pathQuery": "{{KEY:sharepointFolderNameSource}}" }, - "execResultLabel": "sharepoint_data" - } - ] - }, - { - "id": "task_3", - "objective": "Generate document using web research results and SharePoint data with provided prompt", - "dependencies": ["task_1", "task_2"], - "successCriteria": [ - "Document generated successfully", - "Document combines web research and SharePoint data", - "Document saved as result_data" - ], - "estimatedComplexity": "high", - "userMessage": "Generating final document", - "actionList": [ + "execResultLabel": "sharepoint_source_documents" + }, { - "execMethod": "ai", - "execAction": "process", + "execMethod": "sharepoint", + "execAction": "uploadDocument", "execParameters": { - "prompt": "{{KEY:documentPrompt}}", - "documentList": [ - "web_research_response", - "sharepoint_data" - ], - "outputExtension": ".docx" + "connectionReference": "{{KEY:connectionName}}", + "documentList": ["sharepoint_source_documents","web_research_results"], + "pathQuery": "{{KEY:sharepointFolderNameDestination}}", + "fileNames": ["report.docx"] }, - "execResultLabel": "result_data" + "execResultLabel": "sharepoint_upload_documents" } ] } ] } - +, +"parameters": +{ + "connectionName": "connection:msft:p.motsch@valueon.ch", + "webResearchUrl": "https://www.valueon.ch", + "webResearchPrompt": "Wer arbeitet bei ValueOn AG in der Schweiz und was machen die?", + "PromptSharepointSource": "Fasse die Dokumente in einer Liste zusammen", + "sharepointFolderNameSource": "/site:Company Share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/input", + "sharepointFolderNameDestination": "/site:Company Share/Freigegebene Dokumente/15. Persoenliche Ordner/Patrick Motsch/output", + "PromptDeliverable": "Erstelle mir einen Word Bericht der Webanalyse und der Dokumente im Sharepoint" +} +} diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index d2f29ddc..363a42e6 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -59,7 +59,7 @@ class WorkflowManager: "message": "Workflow stopped for new prompt", "type": "info", "status": "stopped", - "progress": 100 + "progress": 1.0 }) newRound = workflow.currentRound + 1 @@ -141,7 +141,7 @@ class WorkflowManager: "message": "Workflow stopped", "type": "warning", "status": "stopped", - "progress": 100 + "progress": 1.0 }) return workflow except Exception as e: @@ -470,7 +470,7 @@ class WorkflowManager: "message": "Workflow stopped by user", "type": "warning", "status": "stopped", - "progress": 100 + "progress": 1.0 }) return elif workflow.status == 'failed': @@ -509,7 +509,7 @@ class WorkflowManager: "message": "Workflow failed: Unknown error", "type": "error", "status": "failed", - "progress": 100 + "progress": 1.0 }) return @@ -597,7 +597,7 @@ class WorkflowManager: "message": "Workflow completed", "type": "success", "status": "completed", - "progress": 100 + "progress": 1.0 }) except Exception as e: @@ -672,7 +672,7 @@ class WorkflowManager: "message": "Workflow stopped by user", "type": "warning", "status": "stopped", - "progress": 100 + "progress": 1.0 }) def _handleWorkflowError(self, error: Exception) -> None: @@ -715,7 +715,7 @@ class WorkflowManager: "message": f"Workflow failed: {str(error)}", "type": "error", "status": "failed", - "progress": 100 + "progress": 1.0 }) raise