fixed critical code issues

This commit is contained in:
patrick-motsch 2026-03-03 18:57:20 +01:00
parent d3f891453a
commit 16db2d91c6
36 changed files with 494 additions and 878 deletions

View file

@ -56,12 +56,12 @@ async def chatStart(currentUser: User, userInput: UserInputRequest, workflowMode
logger.error(f"Error starting chat: {str(e)}") logger.error(f"Error starting chat: {str(e)}")
raise raise
async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> ChatWorkflow: async def chatStop(currentUser: User, workflowId: str, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, featureCode: Optional[str] = None) -> ChatWorkflow:
"""Stops a running chat.""" """Stops a running chat."""
try: try:
services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) services = getServices(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
if featureInstanceId: if featureCode:
services.featureCode = 'chatplayground' services.featureCode = featureCode
workflowManager = WorkflowManager(services) workflowManager = WorkflowManager(services)
return await workflowManager.workflowStop(workflowId) return await workflowManager.workflowStop(workflowId)
except Exception as e: except Exception as e:
@ -101,8 +101,11 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
logger.debug(f"Automation {automationId} restricted to providers: {automation.allowedProviders}") logger.debug(f"Automation {automationId} restricted to providers: {automation.allowedProviders}")
# Context comes EXCLUSIVELY from the automation definition # Context comes EXCLUSIVELY from the automation definition
automationMandateId = str(automation.mandateId) automationMandateId = str(automation.mandateId) if automation.mandateId is not None else None
automationFeatureInstanceId = str(automation.featureInstanceId) automationFeatureInstanceId = str(automation.featureInstanceId) if automation.featureInstanceId is not None else None
if not automationMandateId or not automationFeatureInstanceId:
raise ValueError(f"Automation {automationId} missing mandateId or featureInstanceId")
logger.info(f"Executing automation {automationId} as user {creatorUser.id} with mandateId={automationMandateId}, featureInstanceId={automationFeatureInstanceId}") logger.info(f"Executing automation {automationId} as user {creatorUser.id} with mandateId={automationMandateId}, featureInstanceId={automationFeatureInstanceId}")
@ -118,7 +121,7 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
logger.error(f"Placeholders: {placeholders}") logger.error(f"Placeholders: {placeholders}")
logger.error(f"Generated planJson (first 1000 chars): {planJson[:1000]}") logger.error(f"Generated planJson (first 1000 chars): {planJson[:1000]}")
logger.error(f"Error position: line {e.lineno}, column {e.colno}, char {e.pos}") logger.error(f"Error position: line {e.lineno}, column {e.colno}, char {e.pos}")
if e.pos: if e.pos is not None:
start = max(0, e.pos - 100) start = max(0, e.pos - 100)
end = min(len(planJson), e.pos + 100) end = min(len(planJson), e.pos + 100)
logger.error(f"Context around error: ...{planJson[start:end]}...") logger.error(f"Context around error: ...{planJson[start:end]}...")
@ -233,20 +236,10 @@ def syncAutomationEvents(services, eventUser) -> Dict[str, Any]:
cronKwargs = parseScheduleToCron(schedule) cronKwargs = parseScheduleToCron(schedule)
if isActive: if isActive:
# Remove existing event if present (handles schedule changes)
if currentEventId:
try:
eventManager.remove(currentEventId)
except Exception as e:
logger.warning(f"Error removing old event {currentEventId}: {str(e)}")
# Register new event
newEventId = f"automation.{automationId}" newEventId = f"automation.{automationId}"
# Create event handler function
handler = createAutomationEventHandler(automationId, eventUser) handler = createAutomationEventHandler(automationId, eventUser)
# Register cron job # Register with replaceExisting=True (atomically replaces old event)
eventManager.registerCron( eventManager.registerCron(
jobId=newEventId, jobId=newEventId,
func=handler, func=handler,

View file

@ -48,7 +48,7 @@ def start(eventUser) -> bool:
except Exception as e: except Exception as e:
logger.error(f"Automation: Error setting up events on startup: {str(e)}") logger.error(f"Automation: Error setting up events on startup: {str(e)}")
# Don't fail startup if automation sync fails return False
return True return True

View file

@ -6,7 +6,7 @@ Automation templates for workflow definitions.
Contains predefined workflow templates that can be used to create automation definitions. Contains predefined workflow templates that can be used to create automation definitions.
""" """
from typing import Dict, Any, List from typing import Dict, Any
# Automation templates structure # Automation templates structure
AUTOMATION_TEMPLATES: Dict[str, Any] = { AUTOMATION_TEMPLATES: Dict[str, Any] = {

View file

@ -69,50 +69,42 @@ def replacePlaceholders(template: str, placeholders: Dict[str, str]) -> str:
result = result.replace(arrayPattern, arrayValue) result = result.replace(arrayPattern, arrayValue)
continue # Skip the regular replacement below continue # Skip the regular replacement below
# Regular replacement - check if in quoted context # Replace occurrences one-by-one to handle mixed contexts
patternStart = result.find(pattern) while pattern in result:
isQuoted = False patternStart = result.find(pattern)
if patternStart > 0: isQuoted = False
charBefore = result[patternStart - 1] if patternStart > 0 else None if patternStart > 0:
patternEnd = patternStart + len(pattern) charBefore = result[patternStart - 1]
charAfter = result[patternEnd] if patternEnd < len(result) else None patternEnd = patternStart + len(pattern)
if charBefore == '"' and charAfter == '"': charAfter = result[patternEnd] if patternEnd < len(result) else None
isQuoted = True if charBefore == '"' and charAfter == '"':
isQuoted = True
# Handle different value types if isinstance(value, (list, dict)):
if isinstance(value, (list, dict)): replacement = json.dumps(value)
# Python list/dict - convert to JSON elif isinstance(value, str):
replacement = json.dumps(value) try:
elif isinstance(value, str): parsed = json.loads(value)
# String value - check if it's a JSON string representing list/dict if isinstance(parsed, (list, dict)):
try: if isQuoted:
parsed = json.loads(value) escaped = json.dumps(value)
if isinstance(parsed, (list, dict)): replacement = escaped[1:-1]
# It's a JSON string of a list/dict else:
if isQuoted: replacement = value
# In quoted context, escape the JSON string
escaped = json.dumps(value)
replacement = escaped[1:-1] # Remove outer quotes
else: else:
# In unquoted context, use JSON directly if isQuoted:
replacement = value escaped = json.dumps(value)
else: replacement = escaped[1:-1]
# It's a JSON string of a primitive else:
replacement = value
except (json.JSONDecodeError, ValueError):
if isQuoted: if isQuoted:
escaped = json.dumps(value) escaped = json.dumps(value)
replacement = escaped[1:-1] replacement = escaped[1:-1]
else: else:
replacement = value replacement = value
except (json.JSONDecodeError, ValueError): else:
# Not valid JSON - treat as plain string replacement = str(value)
if isQuoted: result = result[:patternStart] + replacement + result[patternStart + len(pattern):]
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
else:
# Numbers, booleans, None - convert to string
replacement = str(value)
result = result.replace(pattern, replacement)
return result return result

View file

@ -74,7 +74,11 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult:
documentName=docData.documentName, documentName=docData.documentName,
documentData=docData.documentData, documentData=docData.documentData,
mimeType=docData.mimeType, mimeType=docData.mimeType,
sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None,
validationMetadata={
"actionType": "ai.generateCode",
"resultType": resultType,
}
)) ))
# If no documents but content exists, create a document from content # If no documents but content exists, create a document from content
@ -112,7 +116,11 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult:
documents.append(ActionDocument( documents.append(ActionDocument(
documentName=docName, documentName=docName,
documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content, documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content,
mimeType=mimeType mimeType=mimeType,
validationMetadata={
"actionType": "ai.generateCode",
"resultType": resultType,
}
)) ))
return ActionResult.isSuccess(documents=documents) return ActionResult.isSuccess(documents=documents)

View file

@ -78,7 +78,12 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult:
documentName=docData.documentName, documentName=docData.documentName,
documentData=docData.documentData, documentData=docData.documentData,
mimeType=docData.mimeType, mimeType=docData.mimeType,
sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None,
validationMetadata={
"actionType": "ai.generateDocument",
"documentType": documentType,
"resultType": resultType,
}
)) ))
# If no documents but content exists, create a document from content # If no documents but content exists, create a document from content
@ -112,7 +117,12 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult:
documents.append(ActionDocument( documents.append(ActionDocument(
documentName=docName, documentName=docName,
documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content, documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content,
mimeType=mimeType mimeType=mimeType,
validationMetadata={
"actionType": "ai.generateDocument",
"documentType": documentType,
"resultType": resultType,
}
)) ))
return ActionResult.isSuccess(documents=documents) return ActionResult.isSuccess(documents=documents)

View file

@ -12,8 +12,8 @@ from modules.datamodels.datamodelExtraction import ContentPart
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def process(self, parameters: Dict[str, Any]) -> ActionResult: async def process(self, parameters: Dict[str, Any]) -> ActionResult:
operationId = None
try: try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"ai_process_{workflowId}_{int(time.time())}" operationId = f"ai_process_{workflowId}_{int(time.time())}"
@ -83,7 +83,8 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
output_format = None output_format = None
logger.debug("resultType not provided - formats will be determined from prompt by AI") logger.debug("resultType not provided - formats will be determined from prompt by AI")
output_mime_type = "application/octet-stream" # Prefer service-provided mimeType when available mimeMap = {"txt": "text/plain", "json": "application/json", "html": "text/html", "md": "text/markdown", "csv": "text/csv", "xml": "application/xml"}
output_mime_type = mimeMap.get(normalized_result_type, "text/plain") if normalized_result_type else "text/plain"
# Phase 7.3: Pass both documentList and contentParts to AI service # Phase 7.3: Pass both documentList and contentParts to AI service
# (Extraction logic removed - handled by AI service) # (Extraction logic removed - handled by AI service)
@ -264,11 +265,11 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult:
except Exception as e: except Exception as e:
logger.error(f"Error in AI processing: {str(e)}") logger.error(f"Error in AI processing: {str(e)}")
# Complete progress tracking with failure
try: try:
self.services.chat.progressLogFinish(operationId, False) if operationId:
except: self.services.chat.progressLogFinish(operationId, False)
pass # Don't fail on progress logging errors except Exception:
pass
return ActionResult.isFailure( return ActionResult.isFailure(
error=str(e) error=str(e)

View file

@ -4,18 +4,19 @@
import logging import logging
import time import time
import re import re
import json
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelChat import ActionResult, ActionDocument
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult: async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
operationId = None
try: try:
prompt = parameters.get("prompt") prompt = parameters.get("prompt")
if not prompt: if not prompt:
return ActionResult.isFailure(error="Research prompt is required") return ActionResult.isFailure(error="Research prompt is required")
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"web_research_{workflowId}_{int(time.time())}" operationId = f"web_research_{workflowId}_{int(time.time())}"
@ -78,9 +79,10 @@ async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
"researchDepth": parameters.get("researchDepth", "general"), "researchDepth": parameters.get("researchDepth", "general"),
"resultFormat": "json" "resultFormat": "json"
} }
documentData = json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else result
actionDocument = ActionDocument( actionDocument = ActionDocument(
documentName=meaningfulName, documentName=meaningfulName,
documentData=result, documentData=documentData,
mimeType="application/json", mimeType="application/json",
validationMetadata=validationMetadata validationMetadata=validationMetadata
) )
@ -90,8 +92,9 @@ async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
except Exception as e: except Exception as e:
logger.error(f"Error in web research: {str(e)}") logger.error(f"Error in web research: {str(e)}")
try: try:
self.services.chat.progressLogFinish(operationId, False) if operationId:
except: self.services.chat.progressLogFinish(operationId, False)
except Exception:
pass pass
return ActionResult.isFailure(error=str(e)) return ActionResult.isFailure(error=str(e))

View file

@ -1,11 +1,10 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
from typing import Dict, List, Optional, Any, Literal from typing import Dict, List, Optional, Any
from datetime import datetime, UTC from datetime import datetime, UTC
import logging import logging
from functools import wraps from functools import wraps
import inspect
from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter from modules.datamodels.datamodelWorkflowActions import WorkflowActionDefinition, WorkflowActionParameter
from modules.datamodels.datamodelRbac import AccessRuleContext from modules.datamodels.datamodelRbac import AccessRuleContext
@ -258,9 +257,13 @@ class MethodBase:
raise ValueError(f"Expected dict for type '{expectedType}', got {type(value).__name__}") raise ValueError(f"Expected dict for type '{expectedType}', got {type(value).__name__}")
return value return value
# Handle simple types # Handle simple types (bool must be checked before int since bool is subclass of int)
if expectedType in typeMap: if expectedType in typeMap:
expectedTypeClass = typeMap[expectedType] expectedTypeClass = typeMap[expectedType]
if expectedType == 'int' and isinstance(value, bool):
raise ValueError(f"Expected int, got bool: {value}")
if expectedType == 'bool' and isinstance(value, int) and not isinstance(value, bool):
return bool(value)
if not isinstance(value, expectedTypeClass): if not isinstance(value, expectedTypeClass):
try: try:
return expectedTypeClass(value) return expectedTypeClass(value)
@ -290,10 +293,11 @@ class MethodBase:
def getActionSignature(self, actionName: str) -> str: def getActionSignature(self, actionName: str) -> str:
"""Get formatted action signature for AI prompt generation (detailed version)""" """Get formatted action signature for AI prompt generation (detailed version)"""
if actionName not in self.actions: allActions = self.actions
if actionName not in allActions:
return "" return ""
action = self.actions[actionName] action = allActions[actionName]
paramList = [] paramList = []
# Extract detailed parameter information from docstring # Extract detailed parameter information from docstring

View file

@ -89,14 +89,26 @@ async def queryDatabase(self, parameters: Dict[str, Any]) -> ActionResult:
# Update progress # Update progress
self.services.chat.progressLogUpdate(operationId, 0.3, "Validating query") self.services.chat.progressLogUpdate(operationId, 0.3, "Validating query")
# Validate: only SELECT queries allowed
sqlNormalized = sqlQuery.strip().upper()
if not sqlNormalized.startswith("SELECT"):
return ActionResult.isFailure(error="Only SELECT queries are allowed")
forbiddenKeywords = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "EXEC", "EXECUTE"]
for kw in forbiddenKeywords:
if f" {kw} " in f" {sqlNormalized} " or sqlNormalized.startswith(f"{kw} "):
return ActionResult.isFailure(error=f"Forbidden SQL keyword detected: {kw}")
# Initialize connector # Initialize connector
connector = PreprocessorConnector() connector = PreprocessorConnector()
# Update progress # Update progress
self.services.chat.progressLogUpdate(operationId, 0.5, "Executing query") self.services.chat.progressLogUpdate(operationId, 0.5, "Executing query")
# Execute query try:
result = await connector.executeQuery(sqlQuery) result = await connector.executeQuery(sqlQuery)
except Exception:
await connector.close()
raise
# Update progress # Update progress
self.services.chat.progressLogUpdate(operationId, 0.8, "Formatting results") self.services.chat.progressLogUpdate(operationId, 0.8, "Formatting results")
@ -134,10 +146,9 @@ async def queryDatabase(self, parameters: Dict[str, Any]) -> ActionResult:
except Exception as e: except Exception as e:
logger.error(f"Error executing database query: {str(e)}") logger.error(f"Error executing database query: {str(e)}")
# Complete progress tracking with failure
try: try:
self.services.chat.progressLogFinish(operationId, False) self.services.chat.progressLogFinish(operationId, False)
except: except Exception:
pass pass
return ActionResult.isFailure( return ActionResult.isFailure(

View file

@ -11,8 +11,8 @@ from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrat
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult: async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
operationId = None
try: try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"context_extract_{workflowId}_{int(time.time())}" operationId = f"context_extract_{workflowId}_{int(time.time())}"
@ -208,11 +208,11 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
except Exception as e: except Exception as e:
logger.error(f"Error in content extraction: {str(e)}") logger.error(f"Error in content extraction: {str(e)}")
# Complete progress tracking with failure
try: try:
self.services.chat.progressLogFinish(operationId, False) if operationId:
except: self.services.chat.progressLogFinish(operationId, False)
pass # Don't fail on progress logging errors except Exception:
pass
return ActionResult.isFailure(error=str(e)) return ActionResult.isFailure(error=str(e))

View file

@ -22,14 +22,13 @@ async def getDocumentIndex(self, parameters: Dict[str, Any]) -> ActionResult:
documentsIndex = self.services.chat.getAvailableDocuments(workflow) documentsIndex = self.services.chat.getAvailableDocuments(workflow)
if not documentsIndex or documentsIndex == "No documents available" or documentsIndex == "NO DOCUMENTS AVAILABLE - This workflow has no documents to process.": if not documentsIndex or documentsIndex == "No documents available" or documentsIndex == "NO DOCUMENTS AVAILABLE - This workflow has no documents to process.":
# Return empty index structure indexData = {
"workflowId": getattr(workflow, 'id', 'unknown'),
"totalDocuments": 0,
"rounds": [],
"documentReferences": []
}
if resultType == "json": if resultType == "json":
indexData = {
"workflowId": getattr(workflow, 'id', 'unknown'),
"totalDocuments": 0,
"rounds": [],
"documentReferences": []
}
indexContent = json.dumps(indexData, indent=2, ensure_ascii=False) indexContent = json.dumps(indexData, indent=2, ensure_ascii=False)
else: else:
indexContent = "Document Index\n==============\n\nNo documents available in this workflow.\n" indexContent = "Document Index\n==============\n\nNo documents available in this workflow.\n"
@ -64,7 +63,7 @@ async def getDocumentIndex(self, parameters: Dict[str, Any]) -> ActionResult:
document = ActionDocument( document = ActionDocument(
documentName=filename, documentName=filename,
documentData=indexContent, documentData=indexContent,
mimeType="application/json" if resultType == "json" else "text/plain", mimeType="application/json" if resultType == "json" else ("text/markdown" if resultType == "md" else "text/plain"),
validationMetadata=validationMetadata validationMetadata=validationMetadata
) )

View file

@ -11,8 +11,8 @@ from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult: async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
operationId = None
try: try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"context_neutralize_{workflowId}_{int(time.time())}" operationId = f"context_neutralize_{workflowId}_{int(time.time())}"
@ -228,10 +228,10 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult:
except Exception as e: except Exception as e:
logger.error(f"Error in data neutralization: {str(e)}") logger.error(f"Error in data neutralization: {str(e)}")
# Complete progress tracking with failure
try: try:
self.services.chat.progressLogFinish(operationId, False) if operationId:
except: self.services.chat.progressLogFinish(operationId, False)
pass # Don't fail on progress logging errors except Exception:
pass
return ActionResult.isFailure(error=str(e)) return ActionResult.isFailure(error=str(e))

View file

@ -29,7 +29,7 @@ async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
connectionReference = parameters.get("connectionReference") connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Inbox") folder = parameters.get("folder", "Inbox")
limit = parameters.get("limit", 10) limit = parameters.get("limit", 1000)
filter = parameters.get("filter") filter = parameters.get("filter")
outputMimeType = parameters.get("outputMimeType", "application/json") outputMimeType = parameters.get("outputMimeType", "application/json")
@ -110,7 +110,6 @@ async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
if response.status_code != 200: if response.status_code != 200:
logger.error(f"Graph API error: {response.status_code} - {response.text}") logger.error(f"Graph API error: {response.status_code} - {response.text}")
logger.error(f"Request URL: {response.url}") logger.error(f"Request URL: {response.url}")
logger.error(f"Request headers: {headers}")
logger.error(f"Request params: {params}") logger.error(f"Request params: {params}")
response.raise_for_status() response.raise_for_status()
@ -217,8 +216,8 @@ async def readEmails(self, parameters: Dict[str, Any]) -> ActionResult:
if operationId: if operationId:
try: try:
self.services.chat.progressLogFinish(operationId, False) self.services.chat.progressLogFinish(operationId, False)
except: except Exception:
pass # Don't fail on progress logging errors pass
return ActionResult.isFailure( return ActionResult.isFailure(
error=str(e) error=str(e)
) )

View file

@ -93,7 +93,7 @@ async def searchEmails(self, parameters: Dict[str, Any]) -> ActionResult:
try: try:
error_data = response.json() error_data = response.json()
logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}") logger.error(f"Microsoft Graph API error: {response.status_code} - {error_data}")
except: except Exception:
logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}") logger.error(f"Microsoft Graph API error: {response.status_code} - {response.text}")
# Check for specific error types and provide helpful messages # Check for specific error types and provide helpful messages
@ -111,8 +111,6 @@ async def searchEmails(self, parameters: Dict[str, Any]) -> ActionResult:
raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}") raise Exception(f"Microsoft Graph API returned {response.status_code}: {response.text}")
response.raise_for_status()
search_data = response.json() search_data = response.json()
emails = search_data.get("value", []) emails = search_data.get("value", [])

View file

@ -293,8 +293,18 @@ async def sendDraftEmail(self, parameters: Dict[str, Any]) -> ActionResult:
except ImportError: except ImportError:
logger.error("requests module not available") logger.error("requests module not available")
if operationId:
try:
self.services.chat.progressLogFinish(operationId, False)
except Exception:
pass
return ActionResult.isFailure(error="requests module not available") return ActionResult.isFailure(error="requests module not available")
except Exception as e: except Exception as e:
logger.error(f"Error in sendDraftEmail: {str(e)}") logger.error(f"Error in sendDraftEmail: {str(e)}")
if operationId:
try:
self.services.chat.progressLogFinish(operationId, False)
except Exception:
pass
return ActionResult.isFailure(error=str(e)) return ActionResult.isFailure(error=str(e))

View file

@ -40,25 +40,21 @@ class ConnectionHelper:
logger.debug(f"Found connection: {userConnection.id}, status: {userConnection.status.value}, authority: {userConnection.authority.value}") logger.debug(f"Found connection: {userConnection.id}, status: {userConnection.status.value}, authority: {userConnection.authority.value}")
# Get a fresh token for this connection # Check status BEFORE fetching token (avoids unnecessary network call)
token = self.services.chat.getFreshConnectionToken(userConnection.id)
if not token:
logger.error(f"Fresh token not found for connection: {userConnection.id}")
logger.debug(f"Connection details: {userConnection}")
return None
logger.debug(f"Fresh token retrieved for connection {userConnection.id}")
# Check if connection is active
if userConnection.status.value != "active": if userConnection.status.value != "active":
logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}") logger.error(f"Connection is not active: {userConnection.id}, status: {userConnection.status.value}")
return None return None
token = self.services.chat.getFreshConnectionToken(userConnection.id)
if not token:
logger.error(f"Fresh token not found for connection: {userConnection.id}")
return None
logger.debug(f"Fresh token retrieved for connection {userConnection.id}")
return { return {
"id": userConnection.id, "id": userConnection.id,
"accessToken": token.tokenAccess, "accessToken": token.tokenAccess,
"refreshToken": token.tokenRefresh,
"scopes": ["Mail.ReadWrite", "Mail.Send", "Mail.ReadWrite.Shared", "User.Read"] # Valid Microsoft Graph API scopes
} }
except Exception as e: except Exception as e:
logger.error(f"Error getting Microsoft connection: {str(e)}") logger.error(f"Error getting Microsoft connection: {str(e)}")

View file

@ -57,10 +57,10 @@ class EmailProcessingHelper:
# This is an advanced search query, return as-is # This is an advanced search query, return as-is
return clean_query return clean_query
# For basic text search, ensure it's safe for contains() filter # Escape single quotes for OData safety (double them)
# Remove any characters that might break the OData filter syntax safe_query = clean_query.replace("'", "''")
# Remove or escape characters that could break OData filter syntax # Remove backslashes and double quotes
safe_query = re.sub(r'[\\\'"]', '', clean_query) safe_query = re.sub(r'[\\"]', '', safe_query)
return safe_query return safe_query
@ -173,12 +173,14 @@ class EmailProcessingHelper:
# Handle email address filters (only if it's NOT a search query) # Handle email address filters (only if it's NOT a search query)
if '@' in filter_text and '.' in filter_text and ' ' not in filter_text and not filter_text.startswith('from:'): if '@' in filter_text and '.' in filter_text and ' ' not in filter_text and not filter_text.startswith('from:'):
return {"$filter": f"from/fromAddress/address eq '{filter_text}'"} safeEmail = filter_text.replace("'", "''")
return {"$filter": f"from/fromAddress/address eq '{safeEmail}'"}
# Handle OData filter conditions (contains 'eq', 'ne', 'gt', 'lt', etc.) # Handle OData filter conditions (contains 'eq', 'ne', 'gt', 'lt', etc.)
if any(op in filter_text.lower() for op in [' eq ', ' ne ', ' gt ', ' lt ', ' ge ', ' le ', ' and ', ' or ']): if any(op in filter_text.lower() for op in [' eq ', ' ne ', ' gt ', ' lt ', ' ge ', ' le ', ' and ', ' or ']):
return {"$filter": filter_text} return {"$filter": filter_text}
# Handle text content - search in subject # Handle text content - search in subject (escape single quotes)
return {"$filter": f"contains(subject,'{filter_text}')"} safeText = filter_text.replace("'", "''")
return {"$filter": f"contains(subject,'{safeText}')"}

View file

@ -240,11 +240,12 @@ async def uploadDocument(self, parameters: Dict[str, Any]) -> ActionResult:
} }
successfulUploads = len([r for r in uploadResults if r.get("uploadStatus") == "success"]) successfulUploads = len([r for r in uploadResults if r.get("uploadStatus") == "success"])
overallSuccess = successfulUploads > 0
self.services.chat.progressLogUpdate(operationId, 0.9, f"Uploaded {successfulUploads}/{len(uploadResults)} file(s)") self.services.chat.progressLogUpdate(operationId, 0.9, f"Uploaded {successfulUploads}/{len(uploadResults)} file(s)")
self.services.chat.progressLogFinish(operationId, successfulUploads > 0) self.services.chat.progressLogFinish(operationId, overallSuccess)
return ActionResult( return ActionResult(
success=True, success=overallSuccess,
documents=[ documents=[
ActionDocument( ActionDocument(
documentName=self._generateMeaningfulFileName("sharepoint_upload", "json", None, "uploadDocument"), documentName=self._generateMeaningfulFileName("sharepoint_upload", "json", None, "uploadDocument"),
@ -260,7 +261,7 @@ async def uploadDocument(self, parameters: Dict[str, Any]) -> ActionResult:
if operationId: if operationId:
try: try:
self.services.chat.progressLogFinish(operationId, False) self.services.chat.progressLogFinish(operationId, False)
except: except Exception:
pass pass
return ActionResult( return ActionResult(
success=False, success=False,

View file

@ -17,14 +17,20 @@ class ApiClientHelper:
"""Helper for Microsoft Graph API calls""" """Helper for Microsoft Graph API calls"""
def __init__(self, methodInstance): def __init__(self, methodInstance):
"""
Initialize API client helper.
Args:
methodInstance: Instance of MethodSharepoint (for access to services)
"""
self.method = methodInstance self.method = methodInstance
self.services = methodInstance.services self.services = methodInstance.services
self._session: aiohttp.ClientSession = None
async def _getSession(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=30)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def close(self):
if self._session and not self._session.closed:
await self._session.close()
self._session = None
async def makeGraphApiCall(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]: async def makeGraphApiCall(self, endpoint: str, method: str = "GET", data: bytes = None) -> Dict[str, Any]:
""" """
@ -50,60 +56,28 @@ class ApiClientHelper:
url = f"https://graph.microsoft.com/v1.0/{endpoint}" url = f"https://graph.microsoft.com/v1.0/{endpoint}"
logger.info(f"Making Graph API call: {method} {url}") logger.info(f"Making Graph API call: {method} {url}")
# Set timeout to 30 seconds session = await self._getSession()
timeout = aiohttp.ClientTimeout(total=30)
async with aiohttp.ClientSession(timeout=timeout) as session: successCodes = {"GET": [200], "PUT": [200, 201], "POST": [200, 201], "DELETE": [200, 204]}
if method == "GET": httpMethod = getattr(session, method.lower(), None)
logger.debug(f"Starting GET request to {url}") if not httpMethod:
async with session.get(url, headers=headers) as response: return {"error": f"Unsupported HTTP method: {method}"}
logger.info(f"Graph API response: {response.status}")
if response.status == 200:
result = await response.json()
logger.debug(f"Graph API success: {len(str(result))} characters response")
return result
else:
errorText = await response.text()
logger.error(f"Graph API call failed: {response.status} - {errorText}")
return {"error": f"API call failed: {response.status} - {errorText}"}
elif method == "PUT": kwargs = {"headers": headers}
logger.debug(f"Starting PUT request to {url}") if data is not None:
async with session.put(url, headers=headers, data=data) as response: kwargs["data"] = data
logger.info(f"Graph API response: {response.status}")
if response.status in [200, 201]:
result = await response.json()
logger.debug(f"Graph API success: {len(str(result))} characters response")
return result
else:
errorText = await response.text()
logger.error(f"Graph API call failed: {response.status} - {errorText}")
return {"error": f"API call failed: {response.status} - {errorText}"}
elif method == "POST": async with httpMethod(url, **kwargs) as response:
logger.debug(f"Starting POST request to {url}") logger.info(f"Graph API response: {response.status}")
async with session.post(url, headers=headers, data=data) as response: if response.status in successCodes.get(method, [200]):
logger.info(f"Graph API response: {response.status}") if method == "DELETE":
if response.status in [200, 201]: return {"success": True}
result = await response.json() result = await response.json()
logger.debug(f"Graph API success: {len(str(result))} characters response") return result
return result else:
else: errorText = await response.text()
errorText = await response.text() logger.error(f"Graph API call failed: {response.status} - {errorText}")
logger.error(f"Graph API call failed: {response.status} - {errorText}") return {"error": f"API call failed: {response.status} - {errorText}"}
return {"error": f"API call failed: {response.status} - {errorText}"}
elif method == "DELETE":
logger.debug(f"Starting DELETE request to {url}")
async with session.delete(url, headers=headers) as response:
logger.info(f"Graph API response: {response.status}")
if response.status in [200, 204]:
logger.debug(f"Graph API DELETE success")
return {"success": True}
else:
errorText = await response.text()
logger.error(f"Graph API call failed: {response.status} - {errorText}")
return {"error": f"API call failed: {response.status} - {errorText}"}
except asyncio.TimeoutError: except asyncio.TimeoutError:
logger.error(f"Graph API call timed out after 30 seconds: {endpoint}") logger.error(f"Graph API call timed out after 30 seconds: {endpoint}")

View file

@ -14,11 +14,19 @@ class AdaptiveLearningEngine:
"""Enhanced learning engine that tracks validation patterns and adapts prompts""" """Enhanced learning engine that tracks validation patterns and adapts prompts"""
def __init__(self): def __init__(self):
self.validationHistory = [] # Store validation results with context self.validationHistory = []
self.failurePatterns = defaultdict(list) # Track failure patterns by action type self.failurePatterns = defaultdict(list)
self.successPatterns = defaultdict(list) # Track success patterns self.successPatterns = defaultdict(list)
self.actionAttempts = defaultdict(int) # Track attempt counts per action self.actionAttempts = defaultdict(int)
self.learningInsights = {} # Store learned insights per workflow self.learningInsights = {}
def reset(self):
"""Reset all learned state for a new workflow session."""
self.validationHistory.clear()
self.failurePatterns.clear()
self.successPatterns.clear()
self.actionAttempts.clear()
self.learningInsights.clear()
def recordValidationResult(self, validationResult: Dict[str, Any], actionContext: Dict[str, Any], def recordValidationResult(self, validationResult: Dict[str, Any], actionContext: Dict[str, Any],
workflowId: str, attemptNumber: int): workflowId: str, attemptNumber: int):
@ -195,15 +203,6 @@ class AdaptiveLearningEngine:
for issue, count in list(commonIssues.items())[:3]: # Top 3 issues for issue, count in list(commonIssues.items())[:3]: # Top 3 issues
guidance_parts.append(f"- {issue} (occurred {count} times)") guidance_parts.append(f"- {issue} (occurred {count} times)")
# Add specific action guidance based on user prompt
if "email" in userPrompt.lower() and "outlook" in userPrompt.lower():
if any("account" in str(issue).lower() for issue in commonIssues.keys()):
guidance_parts.append("SPECIFIC GUIDANCE: Ensure email is sent from the correct account (valueon).")
if any("attachment" in str(issue).lower() for issue in commonIssues.keys()):
guidance_parts.append("SPECIFIC GUIDANCE: Verify PDF attachment is properly included.")
if any("summary" in str(issue).lower() for issue in commonIssues.keys()):
guidance_parts.append("SPECIFIC GUIDANCE: Include German summary in email body.")
return "\n".join(guidance_parts) if guidance_parts else "No specific guidance available." return "\n".join(guidance_parts) if guidance_parts else "No specific guidance available."
def _generateParameterGuidance(self, actionName: str, parametersContext: str, def _generateParameterGuidance(self, actionName: str, parametersContext: str,
@ -219,12 +218,11 @@ class AdaptiveLearningEngine:
if attemptNumber and attemptNumber >= 3: if attemptNumber and attemptNumber >= 3:
guidanceParts.append(f"Attempt #{attemptNumber}: Adjust parameters based on validation feedback.") guidanceParts.append(f"Attempt #{attemptNumber}: Adjust parameters based on validation feedback.")
# Generic issues summary
commonIssues = failureAnalysis.get('commonIssues', {}) or {} commonIssues = failureAnalysis.get('commonIssues', {}) or {}
if commonIssues: if commonIssues:
guidanceParts.append("Address the following parameter issues:") guidanceParts.append("Address the following parameter issues:")
for issueKey, issueDesc in commonIssues.items(): for issueText, count in commonIssues.items():
guidanceParts.append(f"- {issueKey}: {issueDesc}") guidanceParts.append(f"- {issueText} (occurred {count} time{'s' if count != 1 else ''})")
# Keep guidance format stable # Keep guidance format stable
return "\n".join(guidanceParts) if guidanceParts else "Use standard parameter values." return "\n".join(guidanceParts) if guidanceParts else "Use standard parameter values."

View file

@ -273,16 +273,15 @@ class ContentValidator:
elif section.get("content_type") in ["paragraph", "heading"]: elif section.get("content_type") in ["paragraph", "heading"]:
if elements and isinstance(elements, list) and len(elements) > 0: if elements and isinstance(elements, list) and len(elements) > 0:
textElement = elements[0] textElement = elements[0]
# Ensure textElement is a dictionary before accessing
if isinstance(textElement, dict): if isinstance(textElement, dict):
content = textElement.get("content", {}) content = textElement.get("content", {})
if isinstance(content, dict): if isinstance(content, dict):
text = content.get("text", "") text = content.get("text", "")
else: else:
text = textElement.get("text", "") text = textElement.get("text", "")
if text: if text:
sectionSummary["textLength"] = len(text) sectionSummary["textLength"] = len(text)
sectionSummary["wordCount"] = len(text.split()) sectionSummary["wordCount"] = len(text.split())
if section.get("textLength"): if section.get("textLength"):
sectionSummary["textLength"] = section.get("textLength") sectionSummary["textLength"] = section.get("textLength")
@ -290,59 +289,47 @@ class ContentValidator:
elif section.get("content_type") == "code_block": elif section.get("content_type") == "code_block":
if elements and isinstance(elements, list) and len(elements) > 0: if elements and isinstance(elements, list) and len(elements) > 0:
codeElement = elements[0] codeElement = elements[0]
content = codeElement.get("content", {}) if isinstance(codeElement, dict):
if isinstance(content, dict): content = codeElement.get("content", {})
code = content.get("code", "") if isinstance(content, dict):
language = content.get("language", "") code = content.get("code", "")
if code: language = content.get("language", "")
sectionSummary["codeLength"] = len(code) if code:
sectionSummary["codeLineCount"] = code.count('\n') + 1 sectionSummary["codeLength"] = len(code)
if language: sectionSummary["codeLineCount"] = code.count('\n') + 1
sectionSummary["language"] = language if language:
sectionSummary["language"] = language
# Wenn contentPartIds vorhanden sind, aber keine elements: Füge ContentParts-Metadaten hinzu
contentPartIds = section.get("contentPartIds", []) contentPartIds = section.get("contentPartIds", [])
if contentPartIds and not elements: if contentPartIds and not elements:
# Prüfe ob contentPartsMetadata vorhanden ist
contentPartsMetadata = section.get("contentPartsMetadata", []) contentPartsMetadata = section.get("contentPartsMetadata", [])
if contentPartsMetadata: if contentPartsMetadata:
sectionSummary["contentPartsMetadata"] = contentPartsMetadata sectionSummary["contentPartsMetadata"] = contentPartsMetadata
else: else:
# Fallback: Zeige nur IDs wenn Metadaten nicht verfügbar
sectionSummary["contentPartIds"] = contentPartIds sectionSummary["contentPartIds"] = contentPartIds
sectionSummary["note"] = "ContentParts referenced but metadata not available" sectionSummary["note"] = "ContentParts referenced but metadata not available"
# Include any additional fields from section (generic approach)
# BUT exclude type-specific KPIs that don't belong to this content_type
# AND exclude internal planning fields that confuse validation
contentType = section.get("content_type", "") contentType = section.get("content_type", "")
# Define KPIs that are ONLY valid for specific types
typeExclusiveKpis = { typeExclusiveKpis = {
"table": ["columnCount", "rowCount", "headers"], # Only for tables "table": ["columnCount", "rowCount", "headers"],
"bullet_list": ["itemCount"], # Only for bullet_list "bullet_list": ["itemCount"],
"list": ["itemCount"] # Only for list "list": ["itemCount"]
} }
excludedKpis = [] excludedKpis = []
for kpiType, kpiFields in typeExclusiveKpis.items(): for kpiType, kpiFields in typeExclusiveKpis.items():
if kpiType != contentType: if kpiType != contentType:
excludedKpis.extend(kpiFields) excludedKpis.extend(kpiFields)
# Internal planning fields that should NOT be shown to validation AI
# These are implementation details, not content indicators
internalFields = ["generationHint", "useAiCall", "elements"] internalFields = ["generationHint", "useAiCall", "elements"]
for key, value in section.items(): for key, value in section.items():
if key not in sectionSummary and key not in internalFields and key not in excludedKpis: if key not in sectionSummary and key not in internalFields and key not in excludedKpis:
# Don't copy type-specific KPIs if they're 0/empty and we didn't extract them ourselves
# This prevents copying columnCount: 0, rowCount: 0, headers: [] from structure generation phase
if key in ["columnCount", "rowCount", "headers", "itemCount"]: if key in ["columnCount", "rowCount", "headers", "itemCount"]:
# Skip if it's 0/empty - we'll only include KPIs we extracted from elements
if isinstance(value, int) and value == 0: if isinstance(value, int) and value == 0:
continue continue
if isinstance(value, list) and len(value) == 0: if isinstance(value, list) and len(value) == 0:
continue continue
# Include simple types (str, int, float, bool, list of primitives)
if isinstance(value, (str, int, float, bool)) or (isinstance(value, list) and len(value) <= 10): if isinstance(value, (str, int, float, bool)) or (isinstance(value, list) and len(value) <= 10):
sectionSummary[key] = value sectionSummary[key] = value
@ -486,7 +473,7 @@ class ContentValidator:
try: try:
json_str = json.dumps(data) json_str = json.dumps(data)
size_bytes = len(json_str.encode('utf-8')) size_bytes = len(json_str.encode('utf-8'))
except: except (TypeError, ValueError):
size_bytes = len(str(data).encode('utf-8')) size_bytes = len(str(data).encode('utf-8'))
else: else:
size_bytes = len(str(data).encode('utf-8')) size_bytes = len(str(data).encode('utf-8'))

View file

@ -16,6 +16,11 @@ class LearningEngine:
self.strategies = {} self.strategies = {}
self.feedbackHistory = [] self.feedbackHistory = []
def reset(self):
"""Reset all learned state for a new workflow session."""
self.strategies.clear()
self.feedbackHistory.clear()
def learnFromFeedback(self, feedback: Dict[str, Any], context: Any, taskIntent: Dict[str, Any]): def learnFromFeedback(self, feedback: Dict[str, Any], context: Any, taskIntent: Dict[str, Any]):
"""Learns from feedback and updates strategies - works on TASK level, not workflow level""" """Learns from feedback and updates strategies - works on TASK level, not workflow level"""
try: try:

View file

@ -136,6 +136,7 @@ class ActionExecutor:
# Execute action and track success for progress log # Execute action and track success for progress log
result = None result = None
actionSuccess = False actionSuccess = False
actionError = None
try: try:
result = await self.executeAction( result = await self.executeAction(
methodName=action.execMethod, methodName=action.execMethod,
@ -144,23 +145,23 @@ class ActionExecutor:
) )
actionSuccess = result.success if result else False actionSuccess = result.success if result else False
except Exception as e: except Exception as e:
logger.error(f"Error executing action: {str(e)}") logger.error(f"Error executing action {action.execMethod}.{action.execAction}: {str(e)}")
actionSuccess = False actionSuccess = False
actionError = str(e)
finally: finally:
# Finish action progress tracking
try: try:
self.services.chat.progressLogFinish(actionOperationId, actionSuccess) self.services.chat.progressLogFinish(actionOperationId, actionSuccess)
except Exception as e: except Exception as e:
logger.error(f"Error finishing action progress log: {str(e)}") logger.error(f"Error finishing action progress log: {str(e)}")
# If action execution failed, return error result
if result is None: if result is None:
action.setError("Action execution failed") errorMsg = actionError or "Action execution failed"
action.setError(errorMsg)
return ActionResult( return ActionResult(
success=False, success=False,
documents=[], documents=[],
resultLabel=action.execResultLabel, resultLabel=action.execResultLabel,
error="Action execution failed" error=errorMsg
) )
resultLabel = action.execResultLabel resultLabel = action.execResultLabel

View file

@ -319,56 +319,27 @@ class MessageCreator:
except Exception as e: except Exception as e:
logger.error(f"Error creating error message: {str(e)}") logger.error(f"Error creating error message: {str(e)}")
def _extractRoundNumberFromLabel(self, label: str) -> int: def _extractNumberFromLabelPart(self, label: str, prefix: str) -> int:
"""Extract round number from a document label like 'round1_task1_action1_diagram_analysis'""" """Extract number following a prefix in a label like 'round1_task1_action1_context'.
Works for prefix='round', 'task', 'action'. Returns 0 on failure.
"""
try: try:
if not label or not isinstance(label, str): if not label or not isinstance(label, str):
return 0 return 0
# Parse label format: round{round}_task{task}_action{action}_{context} import re
if label.startswith('round'): pattern = rf'{prefix}(\d+)'
roundPart = label.split('_')[0] # Get 'round1' part match = re.search(pattern, label)
if roundPart.startswith('round'): return int(match.group(1)) if match else 0
roundNumber = roundPart[5:] # Remove 'round' prefix
return int(roundNumber)
return 0
except Exception as e: except Exception as e:
logger.warning(f"Could not extract round number from label '{label}': {str(e)}") logger.warning(f"Could not extract {prefix} number from label '{label}': {str(e)}")
return 0 return 0
def _extractRoundNumberFromLabel(self, label: str) -> int:
return self._extractNumberFromLabelPart(label, 'round')
def _extractTaskNumberFromLabel(self, label: str) -> int: def _extractTaskNumberFromLabel(self, label: str) -> int:
"""Extract task number from a document label like 'round1_task1_action1_diagram_analysis'""" return self._extractNumberFromLabelPart(label, 'task')
try:
if not label or not isinstance(label, str):
return 0
# Parse label format: round{round}_task{task}_action{action}_{context}
if '_task' in label:
taskPart = label.split('_task')[1]
if taskPart and '_' in taskPart:
taskNumber = taskPart.split('_')[0]
return int(taskNumber)
return 0
except Exception as e:
logger.warning(f"Could not extract task number from label '{label}': {str(e)}")
return 0
def _extractActionNumberFromLabel(self, label: str) -> int: def _extractActionNumberFromLabel(self, label: str) -> int:
"""Extract action number from a document label like 'round1_task1_action1_diagram_analysis'""" return self._extractNumberFromLabelPart(label, 'action')
try:
if not label or not isinstance(label, str):
return 0
# Parse label format: round{round}_task{task}_action{action}_{context}
if '_action' in label:
actionPart = label.split('_action')[1]
if actionPart and '_' in actionPart:
actionNumber = actionPart.split('_')[0]
return int(actionNumber)
return 0
except Exception as e:
logger.warning(f"Could not extract action number from label '{label}': {str(e)}")
return 0

View file

@ -7,7 +7,6 @@ import json
import logging import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, WorkflowModeEnum from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, WorkflowModeEnum
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, ProcessingModeEnum, PriorityEnum
from modules.workflows.processing.shared.promptGenerationTaskplan import ( from modules.workflows.processing.shared.promptGenerationTaskplan import (
generateTaskPlanningPrompt generateTaskPlanningPrompt
) )
@ -107,17 +106,6 @@ class TaskPlanner:
taskPlanningPromptTemplate = bundle.prompt taskPlanningPromptTemplate = bundle.prompt
placeholders = bundle.placeholders placeholders = bundle.placeholders
# Centralized AI call: Task planning (quality, detailed) with placeholders
options = AiCallOptions(
operationType=OperationTypeEnum.PLAN,
priority=PriorityEnum.QUALITY,
compressPrompt=False,
compressContext=False,
processingMode=ProcessingModeEnum.DETAILED,
maxCost=0.10,
maxProcessingTime=30
)
prompt = await self.services.ai.callAiPlanning( prompt = await self.services.ai.callAiPlanning(
prompt=taskPlanningPromptTemplate, prompt=taskPlanningPromptTemplate,
placeholders=placeholders, placeholders=placeholders,
@ -141,9 +129,11 @@ class TaskPlanner:
raise ValueError("Task plan missing 'tasks' field") raise ValueError("Task plan missing 'tasks' field")
except Exception as e: except Exception as e:
logger.error(f"Error parsing task plan response: {str(e)}") logger.error(f"Error parsing task plan response: {str(e)}")
taskPlanDict = {'tasks': []} raise ValueError(f"Failed to parse AI task plan response: {str(e)}") from e
if not self._validateTaskPlan(taskPlanDict): from modules.workflows.processing.core.validator import WorkflowValidator
validator = WorkflowValidator(self.services)
if not validator.validateTask(taskPlanDict):
logger.error("Generated task plan failed validation") logger.error("Generated task plan failed validation")
logger.error(f"AI Response: {prompt}") logger.error(f"AI Response: {prompt}")
logger.error(f"Parsed Task Plan: {json.dumps(taskPlanDict, indent=2)}") logger.error(f"Parsed Task Plan: {json.dumps(taskPlanDict, indent=2)}")
@ -208,60 +198,3 @@ class TaskPlanner:
raise raise
def _validateTaskPlan(self, taskPlan: Dict[str, Any]) -> bool:
"""Validate task plan structure"""
try:
if not isinstance(taskPlan, dict):
logger.error("Task plan is not a dictionary")
return False
if 'tasks' not in taskPlan or not isinstance(taskPlan['tasks'], list):
logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}")
return False
# First pass: collect all task IDs to validate dependencies
taskIds = set()
for task in taskPlan['tasks']:
if not isinstance(task, dict):
logger.error(f"Task is not a dictionary: {type(task)}")
return False
if 'id' not in task:
logger.error(f"Task missing 'id' field: {task}")
return False
taskIds.add(task['id'])
# Second pass: validate each task
for i, task in enumerate(taskPlan['tasks']):
if not isinstance(task, dict):
logger.error(f"Task {i} is not a dictionary: {type(task)}")
return False
requiredFields = ['id', 'objective', 'successCriteria']
missingFields = [field for field in requiredFields if field not in task]
if missingFields:
logger.error(f"Task {i} missing required fields: {missingFields}")
return False
# Check for duplicate IDs (shouldn't happen after first pass, but safety check)
if task['id'] in taskIds and list(taskPlan['tasks']).count(task['id']) > 1:
logger.error(f"Task {i} has duplicate ID: {task['id']}")
return False
dependencies = task.get('dependencies', [])
if not isinstance(dependencies, list):
logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}")
return False
for dep in dependencies:
if dep not in taskIds and dep != 'task_0':
logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})")
return False
logger.info(f"Task plan validation successful with {len(taskIds)} tasks")
return True
except Exception as e:
logger.error(f"Error validating task plan: {str(e)}")
return False

View file

@ -25,22 +25,20 @@ class WorkflowValidator:
logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}") logger.error(f"Task plan missing 'tasks' field or not a list. Found: {type(taskPlan.get('tasks', 'MISSING'))}")
return False return False
# First pass: collect all task IDs to validate dependencies # Single pass: collect IDs (detect duplicates) and validate each task
taskIds = set() taskIds = set()
for task in taskPlan['tasks']:
if not isinstance(task, dict):
logger.error(f"Task is not a dictionary: {type(task)}")
return False
if 'id' not in task:
logger.error(f"Task missing 'id' field: {task}")
return False
taskIds.add(task['id'])
# Second pass: validate each task
for i, task in enumerate(taskPlan['tasks']): for i, task in enumerate(taskPlan['tasks']):
if not isinstance(task, dict): if not isinstance(task, dict):
logger.error(f"Task {i} is not a dictionary: {type(task)}") logger.error(f"Task {i} is not a dictionary: {type(task)}")
return False return False
if 'id' not in task:
logger.error(f"Task {i} missing 'id' field: {task}")
return False
if task['id'] in taskIds:
logger.error(f"Task {i} has duplicate ID: {task['id']}")
return False
taskIds.add(task['id'])
requiredFields = ['id', 'objective', 'successCriteria'] requiredFields = ['id', 'objective', 'successCriteria']
missingFields = [field for field in requiredFields if field not in task] missingFields = [field for field in requiredFields if field not in task]
@ -48,17 +46,14 @@ class WorkflowValidator:
logger.error(f"Task {i} missing required fields: {missingFields}") logger.error(f"Task {i} missing required fields: {missingFields}")
return False return False
# Check for duplicate IDs (shouldn't happen after first pass, but safety check)
if task['id'] in taskIds and list(taskPlan['tasks']).count(task['id']) > 1:
logger.error(f"Task {i} has duplicate ID: {task['id']}")
return False
dependencies = task.get('dependencies', []) dependencies = task.get('dependencies', [])
if not isinstance(dependencies, list): if not isinstance(dependencies, list):
logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}") logger.error(f"Task {i} dependencies is not a list: {type(dependencies)}")
return False return False
for dep in dependencies: # Second pass: validate dependencies (all IDs now known)
for i, task in enumerate(taskPlan['tasks']):
for dep in task.get('dependencies', []):
if dep not in taskIds and dep != 'task_0': if dep not in taskIds and dep != 'task_0':
logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})") logger.error(f"Task {i} has invalid dependency: {dep} (available: {list(taskIds) + ['task_0']})")
return False return False
@ -93,7 +88,7 @@ class WorkflowValidator:
missingFields = [] missingFields = []
for field in requiredFields: for field in requiredFields:
if field not in action or not action[field]: if field not in action or action[field] is None:
missingFields.append(field) missingFields.append(field)
if missingFields: if missingFields:
logger.error(f"Action {i} missing required fields: {missingFields}") logger.error(f"Action {i} missing required fields: {missingFields}")

View file

@ -36,6 +36,9 @@ class AutomationMode(BaseMode):
- Or as direct JSON in userInput - Or as direct JSON in userInput
""" """
try: try:
# Reset action map to prevent state leaks from previous runs
self.taskActionMap = {}
# AUTOMATION mode ALWAYS requires a JSON plan to be provided in userInput # AUTOMATION mode ALWAYS requires a JSON plan to be provided in userInput
# Try to extract plan from userInput (embedded JSON or direct JSON) # Try to extract plan from userInput (embedded JSON or direct JSON)
templatePlan = None templatePlan = None
@ -340,78 +343,6 @@ class AutomationMode(BaseMode):
error=str(e) error=str(e)
) )
def _createActionItem(self, actionData: Dict[str, Any]) -> Optional[ActionItem]:
"""Create ActionItem from action data"""
try:
import uuid
from datetime import datetime, timezone
# Ensure ID is present
if "id" not in actionData or not actionData["id"]:
actionData["id"] = f"action_{uuid.uuid4()}"
# Ensure required fields
if "status" not in actionData:
actionData["status"] = TaskStatus.PENDING
if "execMethod" not in actionData:
logger.error("execMethod is required for task action")
return None
if "execAction" not in actionData:
logger.error("execAction is required for task action")
return None
if "execParameters" not in actionData:
actionData["execParameters"] = {}
# Use generic field separation based on ActionItem model
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
# Create action in database
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
# Convert to ActionItem model
return ActionItem(
id=createdAction["id"],
execMethod=createdAction["execMethod"],
execAction=createdAction["execAction"],
execParameters=createdAction.get("execParameters", {}),
execResultLabel=createdAction.get("execResultLabel"),
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
status=createdAction.get("status", TaskStatus.PENDING),
error=createdAction.get("error"),
retryCount=createdAction.get("retryCount", 0),
retryMax=createdAction.get("retryMax", 3),
processingTime=createdAction.get("processingTime"),
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
result=createdAction.get("result"),
userMessage=createdAction.get("userMessage")
)
except Exception as e:
logger.error(f"Error creating task action: {str(e)}")
return None
def _updateWorkflowBeforeExecutingTask(self, taskNumber: int):
"""Update workflow object before executing a task"""
try:
workflow = self.services.workflow
updateData = {
"currentTask": taskNumber,
"currentAction": 0,
"totalActions": 0
}
workflow.currentTask = taskNumber
workflow.currentAction = 0
workflow.totalActions = 0
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}")
except Exception as e:
logger.error(f"Error updating workflow before executing task: {str(e)}")
def _updateWorkflowAfterActionPlanning(self, totalActions: int): def _updateWorkflowAfterActionPlanning(self, totalActions: int):
"""Update workflow object after action planning""" """Update workflow object after action planning"""
try: try:
@ -423,17 +354,6 @@ class AutomationMode(BaseMode):
except Exception as e: except Exception as e:
logger.error(f"Error updating workflow after action planning: {str(e)}") logger.error(f"Error updating workflow after action planning: {str(e)}")
def _updateWorkflowBeforeExecutingAction(self, actionNumber: int):
"""Update workflow object before executing an action"""
try:
workflow = self.services.workflow
updateData = {"currentAction": actionNumber}
workflow.currentAction = actionNumber
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}")
except Exception as e:
logger.error(f"Error updating workflow before executing action: {str(e)}")
def _setWorkflowTotals(self, totalTasks: int = None, totalActions: int = None): def _setWorkflowTotals(self, totalTasks: int = None, totalActions: int = None):
"""Set total counts for workflow progress tracking""" """Set total counts for workflow progress tracking"""
try: try:

View file

@ -4,14 +4,16 @@
# Abstract base class for workflow modes # Abstract base class for workflow modes
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import uuid
import logging import logging
from typing import List, Dict, Any from typing import List, Dict, Any, Optional
from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskResult, ActionItem from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus
from modules.datamodels.datamodelChat import ChatWorkflow from modules.datamodels.datamodelChat import ChatWorkflow
from modules.workflows.processing.core.taskPlanner import TaskPlanner from modules.workflows.processing.core.taskPlanner import TaskPlanner
from modules.workflows.processing.core.actionExecutor import ActionExecutor from modules.workflows.processing.core.actionExecutor import ActionExecutor
from modules.workflows.processing.core.messageCreator import MessageCreator from modules.workflows.processing.core.messageCreator import MessageCreator
from modules.workflows.processing.core.validator import WorkflowValidator from modules.workflows.processing.core.validator import WorkflowValidator
from modules.shared.timeUtils import parseTimestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -44,3 +46,75 @@ class BaseMode(ABC):
async def createTaskPlanMessage(self, taskPlan, workflow: ChatWorkflow): async def createTaskPlanMessage(self, taskPlan, workflow: ChatWorkflow):
"""Create task plan message - common to all modes""" """Create task plan message - common to all modes"""
return await self.messageCreator.createTaskPlanMessage(taskPlan, workflow) return await self.messageCreator.createTaskPlanMessage(taskPlan, workflow)
def _createActionItem(self, actionData: Dict[str, Any]) -> Optional[ActionItem]:
"""Create an ActionItem from action data, persist to DB, and return the model instance"""
try:
if "id" not in actionData or not actionData["id"]:
actionData["id"] = f"action_{uuid.uuid4()}"
if "status" not in actionData:
actionData["status"] = TaskStatus.PENDING
if "execMethod" not in actionData:
logger.error("execMethod is required for task action")
return None
if "execAction" not in actionData:
logger.error("execAction is required for task action")
return None
if "execParameters" not in actionData:
actionData["execParameters"] = {}
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
return ActionItem(
id=createdAction["id"],
execMethod=createdAction["execMethod"],
execAction=createdAction["execAction"],
execParameters=createdAction.get("execParameters", {}),
execResultLabel=createdAction.get("execResultLabel"),
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
status=createdAction.get("status", TaskStatus.PENDING),
error=createdAction.get("error"),
retryCount=createdAction.get("retryCount", 0),
retryMax=createdAction.get("retryMax", 3),
processingTime=createdAction.get("processingTime"),
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
result=createdAction.get("result"),
userMessage=createdAction.get("userMessage")
)
except Exception as e:
logger.error(f"Error creating task action: {str(e)}")
return None
def _updateWorkflowBeforeExecutingTask(self, taskNumber: int):
"""Update workflow state before executing a task"""
try:
workflow = self.services.workflow
updateData = {
"currentTask": taskNumber,
"currentAction": 0,
"totalActions": 0
}
workflow.currentTask = taskNumber
workflow.currentAction = 0
workflow.totalActions = 0
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}")
except Exception as e:
logger.error(f"Error updating workflow before executing task: {str(e)}")
def _updateWorkflowBeforeExecutingAction(self, actionNumber: int):
"""Update workflow state before executing an action"""
try:
workflow = self.services.workflow
updateData = {"currentAction": actionNumber}
workflow.currentAction = actionNumber
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}")
except Exception as e:
logger.error(f"Error updating workflow before executing action: {str(e)}")

View file

@ -116,6 +116,7 @@ class DynamicMode(BaseMode):
step = 1 step = 1
decision = None decision = None
lastStepFailed = False
while step <= state.max_steps: while step <= state.max_steps:
checkWorkflowStopped(self.services) checkWorkflowStopped(self.services)
@ -282,6 +283,7 @@ class DynamicMode(BaseMode):
except Exception as e: except Exception as e:
logger.error(f"Dynamic step {step} error: {e}") logger.error(f"Dynamic step {step} error: {e}")
lastStepFailed = True
break break
# NEW: Use adaptive stopping logic # NEW: Use adaptive stopping logic
@ -296,19 +298,24 @@ class DynamicMode(BaseMode):
step += 1 step += 1
# Summarize task result for dynamic mode # Summarize task result for dynamic mode
status = TaskStatus.COMPLETED
success = True
# Get feedback from last decision if available
lastDecision = context.previousReviewResult[-1] if hasattr(context, 'previousReviewResult') and context.previousReviewResult else None lastDecision = context.previousReviewResult[-1] if hasattr(context, 'previousReviewResult') and context.previousReviewResult else None
feedback = lastDecision.reason if lastDecision and isinstance(lastDecision, ReviewResult) else 'Completed' feedback = lastDecision.reason if lastDecision and isinstance(lastDecision, ReviewResult) else 'Completed'
if lastDecision and isinstance(lastDecision, ReviewResult) and lastDecision.status == 'success':
if lastStepFailed:
status = TaskStatus.FAILED
success = False
elif lastDecision and isinstance(lastDecision, ReviewResult) and lastDecision.status in ('stop', 'failed'):
status = TaskStatus.FAILED
success = False
else:
status = TaskStatus.COMPLETED
success = True success = True
# Create proper ReviewResult for completion message # Create proper ReviewResult for completion message
completionReviewResult = ReviewResult( completionReviewResult = ReviewResult(
status='success', status='success' if success else 'failed',
reason=feedback, reason=feedback,
qualityScore=lastDecision.qualityScore if lastDecision and isinstance(lastDecision, ReviewResult) else 8.0, qualityScore=lastDecision.qualityScore if lastDecision and isinstance(lastDecision, ReviewResult) else (8.0 if success else 2.0),
metCriteria=[], metCriteria=[],
improvements=[] improvements=[]
) )
@ -1003,12 +1010,15 @@ class DynamicMode(BaseMode):
# Detect repeated actions # Detect repeated actions
actionCounts = {} actionCounts = {}
for entry in actionHistory: for entry in actionHistory:
# Extract action name (after first space, before next space or {) # Format: "Step N: actionName ..." or "Refinement N: actionName ..."
parts = entry.split() # Extract the action name after "prefix N:"
if len(parts) > 1: colonIdx = entry.find(':')
# Skip "Step", "Refinement" prefixes and get the action name if colonIdx >= 0:
actionName = parts[1] if parts[0] in ['Step', 'Refinement'] else parts[0] afterColon = entry[colonIdx + 1:].strip().split()
actionCounts[actionName] = actionCounts.get(actionName, 0) + 1 actionName = afterColon[0] if afterColon else 'unknown'
else:
actionName = entry.split()[0] if entry.split() else 'unknown'
actionCounts[actionName] = actionCounts.get(actionName, 0) + 1
repeatedActions = [action for action, count in actionCounts.items() if count >= 2] repeatedActions = [action for action, count in actionCounts.items() if count >= 2]
if repeatedActions: if repeatedActions:
@ -1172,150 +1182,6 @@ Return only the user-friendly message, no technical details."""
logger.error(f"Error generating action result message: {str(e)}") logger.error(f"Error generating action result message: {str(e)}")
return f"{method}.{actionName} action completed" return f"{method}.{actionName} action completed"
def _createActionItem(self, actionData: Dict[str, Any]) -> ActionItem:
"""Creates a new task action for Dynamic mode"""
try:
import uuid
# Ensure ID is present
if "id" not in actionData or not actionData["id"]:
actionData["id"] = f"action_{uuid.uuid4()}"
# Ensure required fields
if "status" not in actionData:
actionData["status"] = TaskStatus.PENDING
if "execMethod" not in actionData:
logger.error("execMethod is required for task action")
return None
if "execAction" not in actionData:
logger.error("execAction is required for task action")
return None
if "execParameters" not in actionData:
actionData["execParameters"] = {}
# Use generic field separation based on ActionItem model
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
# Create action in database
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
# Convert to ActionItem model
return ActionItem(
id=createdAction["id"],
execMethod=createdAction["execMethod"],
execAction=createdAction["execAction"],
execParameters=createdAction.get("execParameters", {}),
execResultLabel=createdAction.get("execResultLabel"),
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
status=createdAction.get("status", TaskStatus.PENDING),
error=createdAction.get("error"),
retryCount=createdAction.get("retryCount", 0),
retryMax=createdAction.get("retryMax", 3),
processingTime=createdAction.get("processingTime"),
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
result=createdAction.get("result"),
resultDocuments=createdAction.get("resultDocuments", []),
userMessage=createdAction.get("userMessage")
)
except Exception as e:
logger.error(f"Error creating task action: {str(e)}")
return None
def _updateWorkflowBeforeExecutingTask(self, taskNumber: int):
"""Update workflow object before executing a task"""
try:
workflow = self.services.workflow
updateData = {
"currentTask": taskNumber,
"currentAction": 0,
"totalActions": 0
}
# Update workflow object
workflow.currentTask = taskNumber
workflow.currentAction = 0
workflow.totalActions = 0
# Update in database
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing task {taskNumber}: {updateData}")
except Exception as e:
logger.error(f"Error updating workflow before executing task: {str(e)}")
def _updateWorkflowBeforeExecutingAction(self, actionNumber: int):
"""Update workflow object before executing an action"""
try:
workflow = self.services.workflow
updateData = {
"currentAction": actionNumber
}
# Update workflow object
workflow.currentAction = actionNumber
# Update in database
self.services.interfaceDbChat.updateWorkflow(workflow.id, updateData)
logger.info(f"Updated workflow {workflow.id} before executing action {actionNumber}: {updateData}")
except Exception as e:
logger.error(f"Error updating workflow before executing action: {str(e)}")
def _createActionItem(self, actionData: Dict[str, Any]) -> ActionItem:
"""Creates a new task action for Dynamic mode"""
try:
import uuid
# Ensure ID is present
if "id" not in actionData or not actionData["id"]:
actionData["id"] = f"action_{uuid.uuid4()}"
# Ensure required fields
if "status" not in actionData:
actionData["status"] = TaskStatus.PENDING
if "execMethod" not in actionData:
logger.error("execMethod is required for task action")
return None
if "execAction" not in actionData:
logger.error("execAction is required for task action")
return None
if "execParameters" not in actionData:
actionData["execParameters"] = {}
# Use generic field separation based on ActionItem model
simpleFields, objectFields = self.services.interfaceDbChat._separateObjectFields(ActionItem, actionData)
# Create action in database
createdAction = self.services.interfaceDbChat.db.recordCreate(ActionItem, simpleFields)
# Convert to ActionItem model
return ActionItem(
id=createdAction["id"],
execMethod=createdAction["execMethod"],
execAction=createdAction["execAction"],
execParameters=createdAction.get("execParameters", {}),
execResultLabel=createdAction.get("execResultLabel"),
expectedDocumentFormats=createdAction.get("expectedDocumentFormats"),
status=createdAction.get("status", TaskStatus.PENDING),
error=createdAction.get("error"),
retryCount=createdAction.get("retryCount", 0),
retryMax=createdAction.get("retryMax", 3),
processingTime=createdAction.get("processingTime"),
timestamp=parseTimestamp(createdAction.get("timestamp"), default=self.services.utils.timestampGetUtc()),
result=createdAction.get("result"),
resultDocuments=createdAction.get("resultDocuments", []),
userMessage=createdAction.get("userMessage")
)
except Exception as e:
logger.error(f"Error creating task action: {str(e)}")
return None

View file

@ -5,23 +5,22 @@
import logging import logging
from typing import List, Optional from typing import List, Optional
from modules.datamodels.datamodelChat import TaskStep, ActionResult, Observation from modules.datamodels.datamodelChat import TaskStep, ActionResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class TaskExecutionState: class TaskExecutionState:
"""Manages execution state for a task with retry logic""" """Manages execution state for a task with retry logic"""
def __init__(self, task_step: TaskStep): def __init__(self, taskStep: TaskStep):
self.task_step = task_step self.task_step = taskStep
self.successful_actions: List[ActionResult] = [] # Preserved across retries self.successful_actions: List[ActionResult] = []
self.failed_actions: List[ActionResult] = [] # For analysis self.failed_actions: List[ActionResult] = []
self.current_action_index = 0 self.current_action_index = 0
self.retry_count = 0 self.retry_count = 0
self.max_retries = 3 self.max_retries = 3
# Iterative loop (dynamic mode)
self.current_step = 0 self.current_step = 0
self.max_steps = 0 # Will be overridden by workflow.maxSteps from workflowManager.py self.max_steps = 0
def addSuccessfulAction(self, action_result: ActionResult): def addSuccessfulAction(self, action_result: ActionResult):
"""Add a successful action to the state""" """Add a successful action to the state"""
@ -58,48 +57,25 @@ class TaskExecutionState:
patterns.append("permission_issues") patterns.append("permission_issues")
return list(set(patterns)) return list(set(patterns))
def shouldContinue(observation: Optional[Observation], review=None, current_step: int = 0, max_steps: int = 1) -> bool: def shouldContinue(observation=None, review=None, current_step: int = 0, max_steps: int = 1) -> bool:
"""Helper to decide if the iterative loop should continue """Helper to decide if the iterative loop should continue.
Args: Returns False if max steps reached or review indicates 'stop'/'success'.
observation: Observation Pydantic model with action execution results
review: ReviewResult or dict with review decision (optional)
current_step: Current step number in the iteration
max_steps: Maximum allowed steps
Returns:
bool: True if loop should continue, False if should stop
Logic:
- Stop if max steps reached
- Stop if review indicates 'stop' or success criteria are met
- Continue if observation indicates failure but allow one more step (caller caps by max_steps)
""" """
try: try:
# Stop if max steps reached
if current_step >= max_steps: if current_step >= max_steps:
logger.info(f"Stopping workflow: reached max_steps limit ({current_step} >= {max_steps})") logger.info(f"Stopping workflow: reached max_steps limit ({current_step} >= {max_steps})")
return False return False
# Check review decision (can be ReviewResult model or dict)
if review: if review:
if hasattr(review, 'status'): if hasattr(review, 'status'):
# ReviewResult Pydantic model
if review.status in ('stop', 'success'): if review.status in ('stop', 'success'):
return False return False
elif isinstance(review, dict): elif isinstance(review, dict):
# Legacy dict format
decision = review.get('decision') or review.get('status') decision = review.get('decision') or review.get('status')
if decision in ('stop', 'success'): if decision in ('stop', 'success'):
return False return False
# Check observation: if hard failure with no documents, allow one more step
# The caller will enforce max_steps limit
if observation:
if observation.success is False and observation.documentsCount == 0:
# Allow next step once; the caller caps by max_steps
return True
return True return True
except Exception as e: except Exception as e:
logger.warning(f"Error in shouldContinue: {e}") logger.warning(f"Error in shouldContinue: {e}")

View file

@ -19,117 +19,57 @@ methods = {}
def discoverMethods(serviceCenter): def discoverMethods(serviceCenter):
"""Dynamically discover all method classes and their actions in modules methods package. """Dynamically discover all method classes and their actions in modules methods package.
CRITICAL: If methods are already discovered, updates their Services reference to ensure Always creates fresh method instances bound to the given serviceCenter,
they use the current workflow (self.services.workflow). This prevents stale workflow IDs preventing stale or cross-workflow service references.
from being used when a new workflow starts.
""" """
global methods
try: try:
# Import the methods package
methodsPackage = importlib.import_module('modules.workflows.methods') methodsPackage = importlib.import_module('modules.workflows.methods')
# Discover all modules and packages in the methods package # Clear and rebuild to prevent cross-workflow state contamination
methods.clear()
uniqueCount = 0
for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__): for _, name, isPkg in pkgutil.iter_modules(methodsPackage.__path__):
if name.startswith('method'): if name.startswith('method'):
try: try:
if isPkg: module = importlib.import_module(f'modules.workflows.methods.{name}')
# Package (folder) - import __init__.py which exports the Method class
module = importlib.import_module(f'modules.workflows.methods.{name}')
else:
# Module (file) - import directly
module = importlib.import_module(f'modules.workflows.methods.{name}')
# Find all classes in the module that inherit from MethodBase
for itemName, item in inspect.getmembers(module): for itemName, item in inspect.getmembers(module):
if (inspect.isclass(item) and if (inspect.isclass(item) and
issubclass(item, MethodBase) and issubclass(item, MethodBase) and
item != MethodBase): item != MethodBase):
# Check if method already exists in cache
shortName = itemName.replace('Method', '').lower() shortName = itemName.replace('Method', '').lower()
if itemName in methods or shortName in methods:
# Method already discovered - update Services reference to use current workflow
existingMethodInfo = methods.get(itemName) or methods.get(shortName)
if existingMethodInfo and existingMethodInfo.get('instance'):
existingMethodInfo['instance'].services = serviceCenter
logger.debug(f"Updated Services reference for cached method {itemName} to use current workflow")
else:
# Method exists but instance is missing - recreate it
methodInstance = item(serviceCenter)
actions = methodInstance.actions
methodInfo = {
'instance': methodInstance,
'actions': actions,
'description': item.__doc__ or f"Method {itemName}"
}
methods[itemName] = methodInfo
methods[shortName] = methodInfo
logger.info(f"Recreated method {itemName} (short: {shortName}) with {len(actions)} actions")
else:
# Method not discovered yet - create new instance
methodInstance = item(serviceCenter)
# Use the actions property from MethodBase which handles WorkflowActionDefinition # Skip if already processed (via another module path)
actions = methodInstance.actions if itemName in methods:
continue
# Create method info methodInstance = item(serviceCenter)
methodInfo = { actions = methodInstance.actions
'instance': methodInstance,
'actions': actions,
'description': item.__doc__ or f"Method {itemName}"
}
# Store the method with full class name methodInfo = {
methods[itemName] = methodInfo 'instance': methodInstance,
'actions': actions,
'description': item.__doc__ or f"Method {itemName}"
}
# Also store with short name for action executor access methods[itemName] = methodInfo
methods[shortName] = methodInfo methods[shortName] = methodInfo
uniqueCount += 1
logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions") logger.info(f"Discovered method {itemName} (short: {shortName}) with {len(actions)} actions")
except Exception as e: except Exception as e:
logger.error(f"Error discovering method {name}: {str(e)}") logger.error(f"Error discovering method {name}: {str(e)}")
continue continue
logger.info(f"Discovered/updated {len(methods)} method entries total") logger.info(f"Discovered {uniqueCount} unique methods ({len(methods)} entries with aliases)")
except Exception as e: except Exception as e:
logger.error(f"Error discovering methods: {str(e)}") logger.error(f"Error discovering methods: {str(e)}")
def getMethodsList(serviceCenter):
"""Get a list of available methods with their signatures"""
if not methods:
discoverMethods(serviceCenter)
methodsList = []
for methodName, methodInfo in methods.items():
methodDescription = methodInfo['description']
actionsList = []
for actionName, actionInfo in methodInfo['actions'].items():
actionDescription = actionInfo['description']
parameters = actionInfo['parameters']
# Build parameter signature
paramSig = []
for paramName, paramInfo in parameters.items():
paramType = paramInfo['type']
paramRequired = paramInfo['required']
paramDefault = paramInfo['default']
if paramRequired:
paramSig.append(f"{paramName}: {paramType}")
else:
defaultStr = f" = {paramDefault}" if paramDefault is not None else " = None"
paramSig.append(f"{paramName}: {paramType}{defaultStr}")
paramSignature = f"({', '.join(paramSig)})" if paramSig else "()"
actionsList.append(f"- {actionName}{paramSignature}: {actionDescription}")
actionsStr = "\n".join(actionsList)
methodsList.append(f"**{methodName}**: {methodDescription}\n{actionsStr}")
return "\n\n".join(methodsList)
def getActionParameterList(methodName: str, actionName: str, methods: Dict[str, Any]) -> str: def getActionParameterList(methodName: str, actionName: str, methods: Dict[str, Any]) -> str:
"""Get action parameter list from WorkflowActionParameter structure for AI parameter generation (list only).""" """Get action parameter list from WorkflowActionParameter structure for AI parameter generation (list only)."""
try: try:

View file

@ -39,6 +39,26 @@ from typing import Dict, Any, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from modules.workflows.processing.shared.methodDiscovery import (methods, discoverMethods) from modules.workflows.processing.shared.methodDiscovery import (methods, discoverMethods)
from modules.datamodels.datamodelChat import Observation
def _observationToDict(obs) -> dict:
"""Convert an Observation (Pydantic model or dict) to a plain dict."""
if isinstance(obs, dict):
return obs.copy()
if hasattr(obs, 'model_dump'):
return obs.model_dump(exclude_none=True)
if hasattr(obs, 'dict'):
return obs.dict()
return {"raw": str(obs)}
def _redactSnippets(obsDict: dict):
"""Replace large snippet strings with a metadata indicator."""
if 'previews' in obsDict and isinstance(obsDict['previews'], list):
for preview in obsDict['previews']:
if isinstance(preview, dict) and 'snippet' in preview:
preview['snippet'] = f"[Content: {len(preview.get('snippet', ''))} characters]"
def extractUserPrompt(context: Any) -> str: def extractUserPrompt(context: Any) -> str:
"""Extract user prompt from context. Maps to {{KEY:USER_PROMPT}}. """Extract user prompt from context. Maps to {{KEY:USER_PROMPT}}.
@ -71,22 +91,17 @@ def extractUserPrompt(context: Any) -> str:
def extractNormalizedRequest(services: Any) -> str: def extractNormalizedRequest(services: Any) -> str:
"""Extract normalized user request from services. Maps to {{KEY:NORMALIZED_REQUEST}}. """Extract normalized user request from services. Maps to {{KEY:NORMALIZED_REQUEST}}.
Returns the full normalized request from user input analysis (preserves all constraints and details). Returns the full normalized request from user input analysis (preserves all constraints and details).
CRITICAL: Must return the actual normalizedRequest from analysis, NOT intent.
""" """
try: try:
# Get normalized request from currentUserPromptNormalized (stores the normalizedRequest from analysis)
if services and getattr(services, 'currentUserPromptNormalized', None): if services and getattr(services, 'currentUserPromptNormalized', None):
normalized = services.currentUserPromptNormalized normalized = services.currentUserPromptNormalized
# Validate that it's not the intent (which is shorter and less detailed)
# Intent is typically a concise objective, normalized request should be longer and more detailed
workflowIntent = getattr(services.workflow, '_workflowIntent', {}) if hasattr(services, 'workflow') and services.workflow else {} workflowIntent = getattr(services.workflow, '_workflowIntent', {}) if hasattr(services, 'workflow') and services.workflow else {}
intent = workflowIntent.get('intent', '') intent = workflowIntent.get('intent', '')
# If normalized matches intent exactly, it's wrong - log warning
if intent and normalized == intent: if intent and normalized == intent:
logger.warning(f"extractNormalizedRequest: normalized request matches intent - this is incorrect! normalized={normalized[:100]}...") logger.warning(f"extractNormalizedRequest: normalized request matches intent - this is incorrect! normalized={normalized[:100]}...")
# Try to get from workflow intent or return error message # Fall back to intent rather than injecting an error string into the LLM prompt
return f"ERROR: Normalized request not properly stored. Expected detailed request, got intent: {intent}" return intent
return normalized return normalized
@ -346,49 +361,12 @@ def extractReviewContent(context: Any) -> str:
return result_summary return result_summary
elif hasattr(context, 'observation') and context.observation: elif hasattr(context, 'observation') and context.observation:
# For observation data, show full content but handle documents specially obs_dict = _observationToDict(context.observation)
# Handle both Pydantic Observation model and dict format _redactSnippets(obs_dict)
from modules.datamodels.datamodelChat import Observation
if isinstance(context.observation, Observation):
# Convert Pydantic model to dict
obs_dict = context.observation.model_dump(exclude_none=True) if hasattr(context.observation, 'model_dump') else context.observation.dict()
elif isinstance(context.observation, dict):
obs_dict = context.observation.copy()
else:
# Fallback: try to serialize as-is
obs_dict = context.observation.model_dump(exclude_none=True) if hasattr(context.observation, 'model_dump') else context.observation.dict()
# If there are previews with documents, show only metadata
if 'previews' in obs_dict and isinstance(obs_dict['previews'], list):
for preview in obs_dict['previews']:
if isinstance(preview, dict) and 'snippet' in preview:
# Replace snippet with metadata indicator
preview['snippet'] = f"[Content: {len(preview.get('snippet', ''))} characters]"
return json.dumps(obs_dict, indent=2, ensure_ascii=False) return json.dumps(obs_dict, indent=2, ensure_ascii=False)
elif hasattr(context, 'stepResult') and context.stepResult and 'observation' in context.stepResult: elif hasattr(context, 'stepResult') and context.stepResult and 'observation' in context.stepResult:
# For observation data in stepResult, show full content but handle documents specially obs_dict = _observationToDict(context.stepResult['observation'])
observation = context.stepResult['observation'] _redactSnippets(obs_dict)
# Handle both Pydantic Observation model and dict format
from modules.datamodels.datamodelChat import Observation
if isinstance(observation, Observation):
# Convert Pydantic model to dict
obs_dict = observation.model_dump(exclude_none=True) if hasattr(observation, 'model_dump') else observation.dict()
elif isinstance(observation, dict):
obs_dict = observation.copy()
else:
# Fallback: try to serialize
obs_dict = observation.model_dump(exclude_none=True) if hasattr(observation, 'model_dump') else observation.dict()
# If there are previews with documents, show only metadata
if 'previews' in obs_dict and isinstance(obs_dict['previews'], list):
for preview in obs_dict['previews']:
if isinstance(preview, dict) and 'snippet' in preview:
# Replace snippet with metadata indicator
preview['snippet'] = f"[Content: {len(preview.get('snippet', ''))} characters]"
return json.dumps(obs_dict, indent=2, ensure_ascii=False) return json.dumps(obs_dict, indent=2, ensure_ascii=False)
else: else:
return "No review content available" return "No review content available"
@ -449,41 +427,22 @@ def extractLatestRefinementFeedback(context: Any) -> str:
CRITICAL: If ERROR level logs are found, refinement should stop processing. CRITICAL: If ERROR level logs are found, refinement should stop processing.
""" """
try: try:
# First check for ERROR level logs in workflow
if hasattr(context, 'workflow') and context.workflow:
try:
import modules.interfaces.interfaceDbChat as interfaceDbChat
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
interfaceDbChat = interfaceDbChat.getInterface(rootInterface.currentUser)
# Get workflow logs
chatData = interfaceDbChat.getUnifiedChatData(context.workflow.id, None)
logs = chatData.get("logs", [])
# Check for ERROR level logs
for log in logs:
if isinstance(log, dict):
log_level = log.get("level", "").upper()
log_message = str(log.get("message", ""))
if log_level == "ERROR" or "ERROR" in log_message.upper():
return f"CRITICAL: Processing stopped due to ERROR in logs: {log_message[:200]}"
except Exception as log_check_error:
# If we can't check logs, continue with normal feedback extraction
logger.warning(f"Could not check for ERROR logs: {str(log_check_error)}")
if not hasattr(context, 'previousReviewResult') or not context.previousReviewResult or not isinstance(context.previousReviewResult, list): if not hasattr(context, 'previousReviewResult') or not context.previousReviewResult or not isinstance(context.previousReviewResult, list):
return "No previous refinement feedback available" return "No previous refinement feedback available"
# Get the most recent refinement decision # Get the most recent refinement decision (supports both ReviewResult objects and dicts)
latest_decision = context.previousReviewResult[-1] latest_decision = context.previousReviewResult[-1]
if not isinstance(latest_decision, dict):
# Normalize to dict if it's a Pydantic model (e.g. ReviewResult)
if hasattr(latest_decision, 'model_dump'):
latest_decision = latest_decision.model_dump()
elif not isinstance(latest_decision, dict):
return "No previous refinement feedback available" return "No previous refinement feedback available"
feedback_parts = [] feedback_parts = []
# Add decision and reason # Add decision and reason (ReviewResult uses 'status', legacy uses 'decision')
decision = latest_decision.get('decision', 'unknown') decision = latest_decision.get('status') or latest_decision.get('decision', 'unknown')
reason = latest_decision.get('reason', 'No reason provided') reason = latest_decision.get('reason', 'No reason provided')
feedback_parts.append(f"Latest Decision: {decision}") feedback_parts.append(f"Latest Decision: {decision}")
feedback_parts.append(f"Reason: {reason}") feedback_parts.append(f"Reason: {reason}")

View file

@ -46,13 +46,20 @@ def generateDynamicPlanSelectionPrompt(services, context: Any, learningEngine=No
adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt) adaptiveContext = learningEngine.getAdaptiveContextForActionSelection(workflowId, userPrompt)
if adaptiveContext: if adaptiveContext:
# Add learning-aware placeholders
placeholders.extend([ placeholders.extend([
PromptPlaceholder(label="ADAPTIVE_GUIDANCE", content=adaptiveContext.get('adaptiveGuidance', ''), summaryAllowed=True), PromptPlaceholder(label="ADAPTIVE_GUIDANCE", content=adaptiveContext.get('adaptiveGuidance', ''), summaryAllowed=True),
PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True), PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True),
PromptPlaceholder(label="ESCALATION_LEVEL", content=adaptiveContext.get('escalationLevel', 'low'), summaryAllowed=False), PromptPlaceholder(label="ESCALATION_LEVEL", content=adaptiveContext.get('escalationLevel', 'low'), summaryAllowed=False),
]) ])
# Always provide these placeholders so template tokens don't leak into the LLM prompt
if not adaptiveContext:
placeholders.extend([
PromptPlaceholder(label="ADAPTIVE_GUIDANCE", content="", summaryAllowed=True),
PromptPlaceholder(label="FAILURE_ANALYSIS", content="", summaryAllowed=True),
PromptPlaceholder(label="ESCALATION_LEVEL", content="low", summaryAllowed=False),
])
template = """Select exactly one next action to advance the task incrementally. template = """Select exactly one next action to advance the task incrementally.
=== TASK === === TASK ===
@ -60,7 +67,8 @@ CONTEXT: {{KEY:OVERALL_TASK_CONTEXT}}
OBJECTIVE: {{KEY:TASK_OBJECTIVE}} OBJECTIVE: {{KEY:TASK_OBJECTIVE}}
=== AVAILABLE RESOURCES === === AVAILABLE RESOURCES ===
AVAILABLE_DOCUMENTS_INDEX: {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}} AVAILABLE_DOCUMENTS_SUMMARY: {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}}
AVAILABLE_DOCUMENTS_INDEX:
{{KEY:AVAILABLE_DOCUMENTS_INDEX}} {{KEY:AVAILABLE_DOCUMENTS_INDEX}}
AVAILABLE_CONNECTIONS_INDEX: AVAILABLE_CONNECTIONS_INDEX:
{{KEY:AVAILABLE_CONNECTIONS_INDEX}} {{KEY:AVAILABLE_CONNECTIONS_INDEX}}
@ -228,6 +236,13 @@ Excludes documents/connections/history entirely.
PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True), PromptPlaceholder(label="FAILURE_ANALYSIS", content=json.dumps(adaptiveContext.get('failureAnalysis', {}), indent=2), summaryAllowed=True),
]) ])
if not adaptiveContext:
placeholders.extend([
PromptPlaceholder(label="PARAMETER_GUIDANCE", content="", summaryAllowed=True),
PromptPlaceholder(label="ATTEMPT_NUMBER", content="1", summaryAllowed=False),
PromptPlaceholder(label="FAILURE_ANALYSIS", content="", summaryAllowed=True),
])
template = """You are a parameter generator. Set the parameters for this specific action. template = """You are a parameter generator. Set the parameters for this specific action.
OVERALL TASK CONTEXT: OVERALL TASK CONTEXT:

View file

@ -141,8 +141,9 @@ class WorkflowProcessor:
# Delegate to the appropriate mode # Delegate to the appropriate mode
result = await self.mode.executeTask(taskStep, workflow, context) result = await self.mode.executeTask(taskStep, workflow, context)
# Complete progress tracking # Complete progress tracking based on actual result
self.services.chat.progressLogFinish(operationId, True) taskSuccess = result.success if hasattr(result, 'success') else True
self.services.chat.progressLogFinish(operationId, taskSuccess)
return result return result
except Exception as e: except Exception as e:
@ -329,7 +330,7 @@ class WorkflowProcessor:
return handoverData return handoverData
except Exception as e: except Exception as e:
logger.error(f"Error in prepareTaskHandover: {str(e)}") logger.error(f"Error in prepareTaskHandover: {str(e)}")
return {'error': str(e)} raise
# Fast Path Implementation # Fast Path Implementation
@ -379,10 +380,7 @@ class WorkflowProcessor:
"################ USER INPUT START #################\n" "################ USER INPUT START #################\n"
) )
# Add sanitized user input with clear delimiters complexityPrompt += f"{prompt or ''}\n"
# Escape curly braces for f-string safety, but preserve format (no quote wrapping)
sanitizedPrompt = prompt.replace('{', '{{').replace('}', '}}') if prompt else ""
complexityPrompt += f"{sanitizedPrompt}\n"
complexityPrompt += "################ USER INPUT FINISH #################\n\n" complexityPrompt += "################ USER INPUT FINISH #################\n\n"
@ -469,17 +467,14 @@ class WorkflowProcessor:
"Format your response as plain text (no markdown code blocks unless showing code examples)." "Format your response as plain text (no markdown code blocks unless showing code examples)."
) )
# Prepare AI call options for fast path (balanced, fast processing)
options = AiCallOptions( options = AiCallOptions(
operationType=OperationTypeEnum.DATA_ANALYSE, operationType=OperationTypeEnum.DATA_ANALYSE,
priority=PriorityEnum.BALANCED, priority=PriorityEnum.BALANCED,
processingMode=ProcessingModeEnum.BASIC, processingMode=ProcessingModeEnum.BASIC,
maxCost=0.10, # Low cost for simple requests maxCost=0.10,
maxProcessingTime=15 # Fast path should complete in 15s maxProcessingTime=15
) )
# Call AI via callAi() to ensure stats are stored
aiRequest = AiCallRequest( aiRequest = AiCallRequest(
prompt=fastPathPrompt, prompt=fastPathPrompt,
context="", context="",
@ -630,17 +625,23 @@ class WorkflowProcessor:
chatDocuments = [] chatDocuments = []
if taskResult.actionResult and taskResult.actionResult.documents: if taskResult.actionResult and taskResult.actionResult.documents:
for actionDoc in taskResult.actionResult.documents: for actionDoc in taskResult.actionResult.documents:
if hasattr(actionDoc, 'documentData') and actionDoc.documentData: if hasattr(actionDoc, 'documentData') and actionDoc.documentData is not None:
# Create file in component storage rawData = actionDoc.documentData
if isinstance(rawData, bytes):
contentBytes = rawData
elif isinstance(rawData, str):
contentBytes = rawData.encode('utf-8')
else:
contentBytes = json.dumps(rawData, ensure_ascii=False).encode('utf-8')
fileItem = self.services.interfaceDbComponent.createFile( fileItem = self.services.interfaceDbComponent.createFile(
name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else f"task_{taskResult.taskId}_result.txt", name=actionDoc.documentName if hasattr(actionDoc, 'documentName') else f"task_{taskResult.taskId}_result.txt",
mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain", mimeType=actionDoc.mimeType if hasattr(actionDoc, 'mimeType') else "text/plain",
content=actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8') content=contentBytes
) )
# Persist file data
self.services.interfaceDbComponent.createFileData( self.services.interfaceDbComponent.createFileData(
fileItem.id, fileItem.id,
actionDoc.documentData if isinstance(actionDoc.documentData, bytes) else actionDoc.documentData.encode('utf-8') contentBytes
) )
# Get file info # Get file info
@ -651,7 +652,7 @@ class WorkflowProcessor:
chatDoc = { chatDoc = {
"fileId": fileItem.id, "fileId": fileItem.id,
"fileName": fileInfo.get("fileName", actionDoc.documentName) if fileInfo else actionDoc.documentName, "fileName": fileInfo.get("fileName", actionDoc.documentName) if fileInfo else actionDoc.documentName,
"fileSize": fileInfo.get("size", len(actionDoc.documentData) if isinstance(actionDoc.documentData, bytes) else len(actionDoc.documentData.encode('utf-8'))) if fileInfo else (len(actionDoc.documentData) if isinstance(actionDoc.documentData, bytes) else len(actionDoc.documentData.encode('utf-8'))), "fileSize": fileInfo.get("size", len(contentBytes)) if fileInfo else len(contentBytes),
"mimeType": fileInfo.get("mimeType", actionDoc.mimeType) if fileInfo else actionDoc.mimeType, "mimeType": fileInfo.get("mimeType", actionDoc.mimeType) if fileInfo else actionDoc.mimeType,
"roundNumber": workflow.currentRound, "roundNumber": workflow.currentRound,
"taskNumber": workflow.getTaskIndex(), "taskNumber": workflow.getTaskIndex(),

View file

@ -8,7 +8,6 @@ import json
from modules.datamodels.datamodelChat import ( from modules.datamodels.datamodelChat import (
UserInputRequest, UserInputRequest,
ChatMessage,
ChatWorkflow, ChatWorkflow,
ChatDocument, ChatDocument,
WorkflowModeEnum WorkflowModeEnum
@ -44,11 +43,6 @@ class WorkflowManager:
# Store workflow in services for reference (this is the ChatWorkflow object) # Store workflow in services for reference (this is the ChatWorkflow object)
self.services.workflow = workflow self.services.workflow = workflow
# CRITICAL: Update all method instances to use the current Services object with the correct workflow
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(self.services)
logger.debug(f"Updated method instances to use workflow {self.services.workflow.id}")
if workflow.status == "running": if workflow.status == "running":
logger.info(f"Stopping running workflow {workflowId} before processing new prompt") logger.info(f"Stopping running workflow {workflowId} before processing new prompt")
workflow.status = "stopped" workflow.status = "stopped"
@ -57,12 +51,13 @@ class WorkflowManager:
"status": "stopped", "status": "stopped",
"lastActivity": currentTime "lastActivity": currentTime
}) })
self.services.chat.storeLog(workflow, { if workflow.status == "stopped":
"message": "Workflow stopped for new prompt", self.services.chat.storeLog(workflow, {
"type": "info", "message": "Workflow stopped for new prompt",
"status": "stopped", "type": "info",
"progress": 1.0 "status": "stopped",
}) "progress": 1.0
})
newRound = workflow.currentRound + 1 newRound = workflow.currentRound + 1
self.services.chat.updateWorkflow(workflowId, { self.services.chat.updateWorkflow(workflowId, {
@ -170,7 +165,10 @@ class WorkflowManager:
self.services.currentUserPrompt = userInput.prompt self.services.currentUserPrompt = userInput.prompt
# Reset progress logger for new workflow # Reset progress logger for new workflow
self.services.chat._progressLogger = None if hasattr(self.services.chat, 'resetProgressLogger'):
self.services.chat.resetProgressLogger()
else:
self.services.chat._progressLogger = None
# Reset workflow history flag at start of each workflow # Reset workflow history flag at start of each workflow
setattr(self.services, '_needsWorkflowHistory', False) setattr(self.services, '_needsWorkflowHistory', False)
@ -565,9 +563,10 @@ The following is the user's original input message. Analyze intent, normalize th
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars") logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
except WorkflowStoppedException:
raise
except Exception as e: except Exception as e:
logger.error(f"Error in _executeFastPath: {str(e)}") logger.error(f"Error in _executeFastPath: {str(e)}")
# Fall back to full workflow on error
logger.info("Falling back to full workflow due to fast path error") logger.info("Falling back to full workflow due to fast path error")
taskPlan = await self._planTasks(userInput) taskPlan = await self._planTasks(userInput)
await self._executeTasks(taskPlan) await self._executeTasks(taskPlan)
@ -897,8 +896,8 @@ The following is the user's original input message. Analyze intent, normalize th
failedActions=[], failedActions=[],
successfulActions=[], successfulActions=[],
criteriaProgress={ criteriaProgress={
'met_criteria': set(), 'met_criteria': [],
'unmet_criteria': set(), 'unmet_criteria': [],
'attempt_history': [] 'attempt_history': []
} }
) )
@ -1021,11 +1020,11 @@ The following is the user's original input message. Analyze intent, normalize th
}) })
return return
elif workflow.status == 'failed': elif workflow.status == 'failed':
# Create error message lastError = getattr(workflow, '_lastError', None) or "Processing failed"
errorMessage = { errorMessage = {
"workflowId": workflow.id, "workflowId": workflow.id,
"role": "assistant", "role": "assistant",
"message": f"Workflow failed: {'Unknown error'}", "message": f"Workflow failed: {lastError}",
"status": "last", "status": "last",
"sequenceNr": len(workflow.messages) + 1, "sequenceNr": len(workflow.messages) + 1,
"publishedAt": self.services.utils.timestampGetUtc(), "publishedAt": self.services.utils.timestampGetUtc(),
@ -1051,9 +1050,8 @@ The following is the user's original input message. Analyze intent, normalize th
"totalActions": workflow.totalActions "totalActions": workflow.totalActions
}) })
# Add failed log entry
self.services.chat.storeLog(workflow, { self.services.chat.storeLog(workflow, {
"message": "Workflow failed: Unknown error", "message": f"Workflow failed: {lastError}",
"type": "error", "type": "error",
"status": "failed", "status": "failed",
"progress": 1.0 "progress": 1.0
@ -1155,7 +1153,6 @@ The following is the user's original input message. Analyze intent, normalize th
"""Generate feedback message for workflow completion""" """Generate feedback message for workflow completion"""
try: try:
workflow = self.services.workflow workflow = self.services.workflow
checkWorkflowStopped(self.services)
# Count messages by role # Count messages by role
userMessages = [msg for msg in workflow.messages if msg.role == 'user'] userMessages = [msg for msg in workflow.messages if msg.role == 'user']
@ -1227,7 +1224,6 @@ The following is the user's original input message. Analyze intent, normalize th
workflow = self.services.workflow workflow = self.services.workflow
logger.error(f"Workflow processing error: {str(error)}") logger.error(f"Workflow processing error: {str(error)}")
# Update workflow status to failed
workflow.status = "failed" workflow.status = "failed"
workflow.lastActivity = self.services.utils.timestampGetUtc() workflow.lastActivity = self.services.utils.timestampGetUtc()
self.services.chat.updateWorkflow(workflow.id, { self.services.chat.updateWorkflow(workflow.id, {
@ -1237,11 +1233,10 @@ The following is the user's original input message. Analyze intent, normalize th
"totalActions": workflow.totalActions "totalActions": workflow.totalActions
}) })
# Create error message
error_message = { error_message = {
"workflowId": workflow.id, "workflowId": workflow.id,
"role": "assistant", "role": "assistant",
"message": f"Workflow processing failed: {str(error)}", "message": "Workflow processing encountered an error. Please try again.",
"status": "last", "status": "last",
"sequenceNr": len(workflow.messages) + 1, "sequenceNr": len(workflow.messages) + 1,
"publishedAt": self.services.utils.timestampGetUtc(), "publishedAt": self.services.utils.timestampGetUtc(),
@ -1257,7 +1252,6 @@ The following is the user's original input message. Analyze intent, normalize th
} }
self.services.chat.storeMessageWithDocuments(workflow, error_message, []) self.services.chat.storeMessageWithDocuments(workflow, error_message, [])
# Add error log entry
self.services.chat.storeLog(workflow, { self.services.chat.storeLog(workflow, {
"message": f"Workflow failed: {str(error)}", "message": f"Workflow failed: {str(error)}",
"type": "error", "type": "error",
@ -1265,8 +1259,6 @@ The following is the user's original input message. Analyze intent, normalize th
"progress": 1.0 "progress": 1.0
}) })
raise
async def _processFileIds(self, fileIds: List[str], messageId: str = None) -> List[ChatDocument]: async def _processFileIds(self, fileIds: List[str], messageId: str = None) -> List[ChatDocument]:
"""Process file IDs from existing files and return ChatDocument objects. """Process file IDs from existing files and return ChatDocument objects.
@ -1365,21 +1357,3 @@ The following is the user's original input message. Analyze intent, normalize th
# Return original content on error # Return original content on error
return contentBytes return contentBytes
def _checkIfHistoryAvailable(self) -> bool:
"""Check if workflow history is available (previous rounds exist).
Returns True if there are previous workflow rounds with messages.
"""
try:
from modules.workflows.processing.shared.placeholderFactory import getPreviousRoundContext
history = getPreviousRoundContext(self.services)
# Check if history contains actual content (not just "No previous round context available")
if history and history != "No previous round context available":
return True
return False
except Exception as e:
logger.error(f"Error checking if history is available: {str(e)}")
return False