From 78157324ac7b4645f89e9b5a4c514fb300569a2a Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 4 Nov 2025 19:22:25 +0100
Subject: [PATCH] integration test automated workflow execution with ui
completed
---
modules/interfaces/interfaceDbChatObjects.py | 8 +-
.../services/serviceChat/mainServiceChat.py | 77 +++++++--------
modules/workflows/methods/methodSharepoint.py | 88 +++++++++++++----
.../processing/core/actionExecutor.py | 2 +-
.../processing/core/messageCreator.py | 12 ++-
.../shared/automationTemplateInitial.json | 96 ++++++++-----------
modules/workflows/workflowManager.py | 14 +--
7 files changed, 164 insertions(+), 133 deletions(-)
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