integration test automated workflow execution with ui completed

This commit is contained in:
ValueOn AG 2025-11-04 19:22:25 +01:00
parent 4e98ae4e6e
commit 78157324ac
7 changed files with 164 additions and 133 deletions

View file

@ -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:

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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 = {

View file

@ -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"
}
}

View file

@ -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