diff --git a/modules/interfaces/interfaceDbChatObjects.py b/modules/interfaces/interfaceDbChatObjects.py index 48ed60d6..319c1703 100644 --- a/modules/interfaces/interfaceDbChatObjects.py +++ b/modules/interfaces/interfaceDbChatObjects.py @@ -1053,7 +1053,7 @@ class ChatObjects: def _storeDebugMessageAndDocuments(self, message: ChatMessage) -> None: """ Store message and documents for debugging purposes in fileshare. - Structure: gateway/test-chat/obj/m_round_task_action_timestamp/documentlist_label/documents + Structure: gateway/test-chat/messages/m_round_task_action_timestamp/documentlist_label/documents Args: message: ChatMessage object to store @@ -1061,21 +1061,21 @@ class ChatObjects: try: import os import json - from datetime import datetime + from datetime import datetime, UTC # Create base debug directory - debug_root = "./test-chat/obj" + debug_root = "./test-chat/messages" os.makedirs(debug_root, exist_ok=True) # Generate timestamp - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + timestamp = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3] # Create message folder name: m_round_task_action_timestamp # Use actual values from message, not defaults round_str = str(message.roundNumber) if message.roundNumber is not None else "0" task_str = str(message.taskNumber) if message.taskNumber is not None else "0" action_str = str(message.actionNumber) if message.actionNumber is not None else "0" - message_folder = f"m{timestamp}_{round_str}_{task_str}_{action_str}" + message_folder = f"{timestamp}_m_{round_str}_{task_str}_{action_str}" message_path = os.path.join(debug_root, message_folder) os.makedirs(message_path, exist_ok=True) diff --git a/modules/services/serviceAi/mainServiceAi.py b/modules/services/serviceAi/mainServiceAi.py index 95e0c108..16619a52 100644 --- a/modules/services/serviceAi/mainServiceAi.py +++ b/modules/services/serviceAi/mainServiceAi.py @@ -559,10 +559,10 @@ class AiService: # Prepare debug directory TODO TO REMOVE import os - from datetime import datetime - debug_root = "./test-chat/extraction" - ts = datetime.now().strftime("%Y%m%d-%H%M%S") - debug_dir = os.path.join(debug_root, f"per_chunk_{ts}") + from datetime import datetime, UTC + debug_root = "./test-chat/ai" + ts = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3] + debug_dir = os.path.join(debug_root, f"{ts}_extraction_per_chunk") try: os.makedirs(debug_dir, exist_ok=True) except Exception: @@ -744,26 +744,93 @@ class AiService: call_type = self._determineCallType(documents, options.operationType) options.callType = call_type + # Log the prompt being sent to AI for debugging (before routing) TODO TO REMOVE + try: + # Build the full prompt that will be sent to AI + if placeholders: + full_prompt = prompt + for p in placeholders: + placeholder = f"{{{{KEY:{p.label}}}}}" + full_prompt = full_prompt.replace(placeholder, p.content) + else: + full_prompt = prompt + + self._writeAiResponseDebug( + label='ai_prompt_debug', + content=full_prompt, + partIndex=1, + modelName=None, + continuation=False + ) + except Exception: + pass + # Handle document generation with specific output format if outputFormat: - return await self._callAiWithDocumentGeneration(prompt, documents, options, outputFormat, title) + result = await self._callAiWithDocumentGeneration(prompt, documents, options, outputFormat, title) + # Log AI response for debugging TODO TO REMOVE + try: + if isinstance(result, dict) and 'content' in result: + self._writeAiResponseDebug( + label='ai_document_generation', + content=result['content'], + partIndex=1, + modelName=None, # Document generation doesn't return model info + continuation=False + ) + except Exception: + pass + return result if call_type == "planning": - return await self._callAiPlanning(prompt, placeholders_dict, placeholders_meta, options) + result = await self._callAiPlanning(prompt, placeholders_dict, placeholders_meta, options) + # Log AI response for debugging TODO TO REMOVE + try: + self._writeAiResponseDebug( + label='ai_planning', + content=result or "", + partIndex=1, + modelName=None, # Planning doesn't return model info + continuation=False + ) + except Exception: + pass + return result else: # Set processDocumentsIndividually from the legacy parameter if not set in options if options.processDocumentsIndividually is None and documents: options.processDocumentsIndividually = False # Default to batch processing - return await self._callAiText(prompt, documents, options) + + # For text calls, we need to build the full prompt with placeholders here + # since _callAiText doesn't handle placeholders directly + if placeholders_dict: + full_prompt = self._buildPromptWithPlaceholders(prompt, placeholders_dict) + else: + full_prompt = prompt + + result = await self._callAiText(full_prompt, documents, options) + # Log AI response for debugging (additional logging for text calls) TODO TO REMOVE + try: + self._writeAiResponseDebug( + label='ai_text_main', + content=result or "", + partIndex=1, + modelName=None, # Text calls already log internally + continuation=False + ) + except Exception: + pass + return result def _determineCallType(self, documents: Optional[List[ChatDocument]], operation_type: str) -> str: """ Determine call type based on documents and operation type. - Criteria: no documents AND (operationType is "generate_plan" or "analyse_content") -> planning + Criteria: no documents AND operationType is "generate_plan" -> planning + All other cases -> text """ has_documents = documents is not None and len(documents) > 0 - is_planning_operation = operation_type in [OperationType.GENERATE_PLAN, OperationType.ANALYSE_CONTENT] + is_planning_operation = operation_type == OperationType.GENERATE_PLAN if not has_documents and is_planning_operation: return "planning" @@ -857,24 +924,6 @@ class AiService: logger.debug(f"AI model selected (planning): {getattr(response, 'modelName', 'unknown')}") except Exception: pass - # Write full planning response as JSON dump when possible (no duplicates) - try: - import json - content = response.content - cleaned = content.strip() - if cleaned.startswith('```json'): - cleaned = cleaned[7:] - if cleaned.endswith('```'): - cleaned = cleaned[:-3] - cleaned = cleaned.strip() - obj = json.loads(cleaned) - self._writeTraceLog("AI Planning Raw Response", obj) - except Exception: - # Fallback to plain text once - try: - self._writeTraceLog("AI Planning Raw Response", response.content) - except Exception: - pass return response.content async def _callAiText( @@ -1027,16 +1076,6 @@ class AiService: pass content_first = response.content or "" merged_content, needs_more = _split_content_and_flag(content_first) - try: - self._writeAiResponseDebug( - label='ai_text', - content=content_first, - partIndex=1, - modelName=getattr(response, 'modelName', None), - continuation=needs_more - ) - except Exception: - pass # Iteratively request next parts if flagged # Allow configurable max parts via options; default = 1000 @@ -1064,16 +1103,6 @@ class AiService: next_response = await self.aiObjects.call(next_request) part_text = next_response.content or "" part_clean, needs_more = _split_content_and_flag(part_text) - try: - self._writeAiResponseDebug( - label='ai_text', - content=part_text, - partIndex=part_index, - modelName=getattr(next_response, 'modelName', None), - continuation=needs_more - ) - except Exception: - pass if part_clean: # Separate parts clearly merged_content = (merged_content + "\n\n" + part_clean).strip() @@ -1247,14 +1276,14 @@ class AiService: pass def _writeAiResponseDebug(self, label: str, content: str, partIndex: int = 1, modelName: str = None, continuation: bool = None) -> None: - """Persist raw AI response parts for debugging under test-chat/ai-responses.""" + """Persist raw AI response parts for debugging under test-chat/ai.""" try: import os from datetime import datetime, UTC - # Base dir: gateway/test-chat/ai-responses (go up 4 levels from this file) + # Base dir: gateway/test-chat/ai (go up 4 levels from this file) # .../gateway/modules/services/serviceAi/mainServiceAi.py -> up to gateway root gatewayDir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - outDir = os.path.join(gatewayDir, 'test-chat', 'ai-responses') + outDir = os.path.join(gatewayDir, 'test-chat', 'ai') os.makedirs(outDir, exist_ok=True) ts = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3] suffix = [] @@ -1266,7 +1295,7 @@ class AiService: safeModel = ''.join(c if c.isalnum() or c in ('-', '_') else '-' for c in modelName) suffix.append(safeModel) suffixStr = ('_' + '_'.join(suffix)) if suffix else '' - fname = f"{label}_{ts}{suffixStr}.txt" + fname = f"{ts}_{label}{suffixStr}.txt" fpath = os.path.join(outDir, fname) with open(fpath, 'w', encoding='utf-8') as f: f.write(content or '') diff --git a/modules/services/serviceExtraction/subPipeline.py b/modules/services/serviceExtraction/subPipeline.py index 1dda4c0e..fd7eb20c 100644 --- a/modules/services/serviceExtraction/subPipeline.py +++ b/modules/services/serviceExtraction/subPipeline.py @@ -92,11 +92,15 @@ def runExtraction(extractorRegistry: ExtractorRegistry, chunkerRegistry: Chunker parts = non_chunk_parts + chunk_parts logger.debug(f"runExtraction: Final parts after merging: {len(parts)} (chunks: {len(chunk_parts)})") - # DEBUG: dump parts and chunks to files under @testing_extraction/ TODO TO REMOVE + # DEBUG: dump parts and chunks to files TODO TO REMOVE try: - base_dir = "./test-chat/extraction" - doc_dir = os.path.join(base_dir, f"extraction_{fileName}") - os.makedirs(doc_dir, exist_ok=True) + base_dir = "./test-chat/ai" + os.makedirs(base_dir, exist_ok=True) + + # Generate timestamp for consistent naming + from datetime import datetime, UTC + ts = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3] + # Write a summary file summary_lines: List[str] = [f"fileName: {fileName}", f"mimeType: {mimeType}", f"totalParts: {len(parts)}"] text_index = 0 @@ -109,12 +113,16 @@ def runExtraction(extractorRegistry: ExtractorRegistry, chunkerRegistry: Chunker ) if is_texty and getattr(part, "data", None): text_index += 1 - fname = f"part_{idx:03d}_{'chunk' if is_chunk else 'full'}_{text_index:03d}.txt" - fpath = os.path.join(doc_dir, fname) + fname = f"{ts}_extract_{fileName}_part_{idx:03d}_{'chunk' if is_chunk else 'full'}_{text_index:03d}.txt" + fpath = os.path.join(base_dir, fname) with open(fpath, "w", encoding="utf-8") as f: f.write(f"# typeGroup: {part.typeGroup}\n# label: {part.label}\n# chunk: {is_chunk}\n# size: {size}\n\n") f.write(str(part.data)) - with open(os.path.join(doc_dir, "summary.txt"), "w", encoding="utf-8") as f: + + # Write summary file + summary_fname = f"{ts}_extract_{fileName}_summary.txt" + summary_fpath = os.path.join(base_dir, summary_fname) + with open(summary_fpath, "w", encoding="utf-8") as f: f.write("\n".join(summary_lines)) except Exception as _e: logger.debug(f"Debug dump skipped: {_e}") diff --git a/modules/services/serviceGeneration/mainServiceGeneration.py b/modules/services/serviceGeneration/mainServiceGeneration.py index 76883be4..72301768 100644 --- a/modules/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/services/serviceGeneration/mainServiceGeneration.py @@ -309,11 +309,11 @@ class GenerationService: tuple: (rendered_content, mime_type) """ try: - # DEBUG: dump renderer input to @testing_extraction to diagnose JSON+HTML mixtures TODO REMOVE + # DEBUG: dump renderer input to diagnose JSON+HTML mixtures TODO REMOVE try: import os ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") - debug_root = "./test-chat/extraction" + debug_root = "./test-chat/ai" debug_dir = os.path.join(debug_root, f"render_input_{ts}") os.makedirs(debug_dir, exist_ok=True) with open(os.path.join(debug_dir, "meta.txt"), "w", encoding="utf-8") as f: diff --git a/modules/test-chat/ai-responses/ai_text_20251006-132457-767_part1_cont_false_openai_callAiBasic_gpt35.txt b/modules/test-chat/ai-responses/ai_text_20251006-132457-767_part1_cont_false_openai_callAiBasic_gpt35.txt deleted file mode 100644 index fe6e11bc..00000000 --- a/modules/test-chat/ai-responses/ai_text_20251006-132457-767_part1_cont_false_openai_callAiBasic_gpt35.txt +++ /dev/null @@ -1,8 +0,0 @@ -```json -{ - "detectedLanguage": "de", - "intent": "Erstelle ein Word-Dokument mit den ersten 1000 Primzahlen.", - "contextItems": [], - "CONTINUATION": false -} -``` \ No newline at end of file diff --git a/modules/test-chat/ai-responses/ai_text_20251006-132542-878_part1_cont_false_perplexity_callAiBasic.txt b/modules/test-chat/ai-responses/ai_text_20251006-132542-878_part1_cont_false_perplexity_callAiBasic.txt deleted file mode 100644 index 1482c85e..00000000 --- a/modules/test-chat/ai-responses/ai_text_20251006-132542-878_part1_cont_false_perplexity_callAiBasic.txt +++ /dev/null @@ -1,78 +0,0 @@ -FILENAME: first-1000-primes.docx - -AI Generated Document - -Title Page - -AI Generated Document - ---- - -Page 1 - -**List of the First 1000 Prime Numbers** - -This document contains the first 1000 prime numbers, organized into sections of 100 numbers each. Each section is presented in a table format with 5 columns and column headers for clarity and readability. Page numbers are included at the bottom of each page. - ---- - -Section 1: Primes 1 to 100 - -| Index | Prime Number | Index | Prime Number | Index | Prime Number | Index | Prime Number | Index | Prime Number | -|-------|--------------|-------|--------------|-------|--------------|-------|--------------|-------|--------------| -| 1 | 2 | 2 | 3 | 3 | 5 | 4 | 7 | 5 | 11 | -| 6 | 13 | 7 | 17 | 8 | 19 | 9 | 23 | 10 | 29 | -| 11 | 31 | 12 | 37 | 13 | 41 | 14 | 43 | 15 | 47 | -| 16 | 53 | 17 | 59 | 18 | 61 | 19 | 67 | 20 | 71 | -| 21 | 73 | 22 | 79 | 23 | 83 | 24 | 89 | 25 | 97 | -| 26 | 101 | 27 | 103 | 28 | 107 | 29 | 109 | 30 | 113 | -| 31 | 127 | 32 | 131 | 33 | 137 | 34 | 139 | 35 | 149 | -| 36 | 151 | 37 | 157 | 38 | 163 | 39 | 167 | 40 | 173 | -| 41 | 179 | 42 | 181 | 43 | 191 | 44 | 193 | 45 | 197 | -| 46 | 199 | 47 | 211 | 48 | 223 | 49 | 227 | 50 | 229 | -| 51 | 233 | 52 | 239 | 53 | 241 | 54 | 251 | 55 | 257 | -| 56 | 263 | 57 | 269 | 58 | 271 | 59 | 277 | 60 | 281 | -| 61 | 283 | 62 | 293 | 63 | 307 | 64 | 311 | 65 | 313 | -| 66 | 317 | 67 | 331 | 68 | 337 | 69 | 347 | 70 | 349 | -| 71 | 353 | 72 | 359 | 73 | 367 | 74 | 373 | 75 | 379 | -| 76 | 383 | 77 | 389 | 78 | 397 | 79 | 401 | 80 | 409 | -| 81 | 419 | 82 | 421 | 83 | 431 | 84 | 433 | 85 | 439 | -| 86 | 443 | 87 | 449 | 88 | 457 | 89 | 461 | 90 | 463 | -| 91 | 467 | 92 | 479 | 93 | 487 | 94 | 491 | 95 | 499 | -| 96 | 503 | 97 | 509 | 98 | 521 | 99 | 523 | 100 | 541 | - ---- - -Section 2: Primes 101 to 200 - -| Index | Prime Number | Index | Prime Number | Index | Prime Number | Index | Prime Number | Index | Prime Number | -|-------|--------------|-------|--------------|-------|--------------|-------|--------------|-------|--------------| -| 101 | 547 | 102 | 557 | 103 | 563 | 104 | 569 | 105 | 571 | -| 106 | 577 | 107 | 587 | 108 | 593 | 109 | 599 | 110 | 601 | -| 111 | 607 | 112 | 613 | 113 | 617 | 114 | 619 | 115 | 631 | -| 116 | 641 | 117 | 643 | 118 | 647 | 119 | 653 | 120 | 659 | -| 121 | 661 | 122 | 673 | 123 | 677 | 124 | 683 | 125 | 691 | -| 126 | 701 | 127 | 709 | 128 | 719 | 129 | 727 | 130 | 733 | -| 131 | 739 | 132 | 743 | 133 | 751 | 134 | 757 | 135 | 761 | -| 136 | 769 | 137 | 773 | 138 | 787 | 139 | 797 | 140 | 809 | -| 141 | 811 | 142 | 821 | 143 | 823 | 144 | 827 | 145 | 829 | -| 146 | 839 | 147 | 853 | 148 | 857 | 149 | 859 | 150 | 863 | -| 151 | 877 | 152 | 881 | 153 | 883 | 154 | 887 | 155 | 907 | -| 156 | 911 | 157 | 919 | 158 | 929 | 159 | 937 | 160 | 941 | -| 161 | 947 | 162 | 953 | 163 | 967 | 164 | 971 | 165 | 977 | -| 166 | 983 | 167 | 991 | 168 | 997 | 169 | 1009 | 170 | 1013 | -| 171 | 1019 | 172 | 1021 | 173 | 1031 | 174 | 1033 | 175 | 1039 | -| 176 | 1049 | 177 | 1051 | 178 | 1061 | 179 | 1063 | 180 | 1069 | -| 181 | 1087 | 182 | 1091 | 183 | 1093 | 184 | 1097 | 185 | 1103 | -| 186 | 1109 | 187 | 1117 | 188 | 1123 | 189 | 1129 | 190 | 1151 | -| 191 | 1153 | 192 | 1163 | 193 | 1171 | 194 | 1181 | 195 | 1187 | -| 196 | 1193 | 197 | 1201 | 198 | 1213 | 199 | 1217 | 200 | 1223 | - ---- - -Section 3: Primes 201 to 300 - -| Index | Prime Number | Index | Prime Number | Index | Prime Number | Index | Prime Number | Index | Prime Number | -|-------|--------------|-------|--------------|-------|--------------|-------|--------------|-------|--------------| -| 201 | 1229 | 202 | 1231 | 203 | 1237 | 204 | 1249 | 205 | 1259 | -| 206 | 1277 | 207 \ No newline at end of file diff --git a/modules/workflows/methods/methodOutlook.py b/modules/workflows/methods/methodOutlook.py index 0cb23ce4..b6411503 100644 --- a/modules/workflows/methods/methodOutlook.py +++ b/modules/workflows/methods/methodOutlook.py @@ -127,7 +127,9 @@ class MethodOutlook(MethodBase): clean_query = clean_query.replace('"', '') # Handle common search operators - if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']): + # Recognize Graph operators including both singular and plural forms for hasAttachments + lowered = clean_query.lower() + if any(op in lowered for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:', 'hasattachments:']): # This is an advanced search query, return as-is return clean_query @@ -170,7 +172,9 @@ class MethodOutlook(MethodBase): return params # Check if this is a complex search query with multiple operators - if any(op in clean_query.lower() for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']): + # Recognize Graph operators including both singular and plural forms for hasAttachments + lowered = clean_query.lower() + if any(op in lowered for op in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:', 'hasattachments:']): # This is an advanced search query, use $search # Microsoft Graph API supports complex search syntax params["$search"] = f'"{clean_query}"' @@ -222,7 +226,9 @@ class MethodOutlook(MethodBase): return {} # Handle search queries (from:, to:, subject:, etc.) - check this FIRST - if any(filter_text.startswith(prefix) for prefix in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:']): + # Support both singular and plural forms for hasAttachments + lt = filter_text.lower() + if any(lt.startswith(prefix) for prefix in ['from:', 'to:', 'subject:', 'received:', 'hasattachment:', 'hasattachments:']): return {"$search": f'"{filter_text}"'} # Handle email address filters (only if it's NOT a search query) @@ -1037,165 +1043,6 @@ class MethodOutlook(MethodBase): logger.error(f"Error checking Drafts folder: {str(e)}") return ActionResult.isFailure(error=str(e)) - @action - async def composeAndSendEmailDirect(self, parameters: Dict[str, Any]) -> ActionResult: - """ - GENERAL: - - Purpose: Create and send/prepare email using provided subject, body, and recipients. - - Input requirements: connectionReference (required); to (required); subject (required); body (required); optional cc, bcc, attachmentDocumentList. - - Output format: JSON confirmation with draft/send metadata. - - Parameters: - - connectionReference (str, required): Microsoft connection label. - - to (list, required): Recipient email addresses. - - subject (str, required): Email subject. - - body (str, required): Email body (plain text or HTML). - - cc (list, optional): CC recipients. - - bcc (list, optional): BCC recipients. - - attachmentDocumentList (list, optional): Attachment document references. - """ - try: - connectionReference = parameters.get("connectionReference") - to = parameters.get("to") - subject = parameters.get("subject") - body = parameters.get("body") - cc = parameters.get("cc", []) - bcc = parameters.get("bcc", []) - attachmentDocumentList = parameters.get("attachmentDocumentList", []) - - if not connectionReference or not to or not subject or not body: - return ActionResult.isFailure(error="connectionReference, to, subject, and body are required") - - # Convert single values to lists for all recipient parameters - if isinstance(to, str): - to = [to] - if isinstance(cc, str): - cc = [cc] - if isinstance(bcc, str): - bcc = [bcc] - if isinstance(attachmentDocumentList, str): - attachmentDocumentList = [attachmentDocumentList] - - # Get Microsoft connection - connection = self._getMicrosoftConnection(connectionReference) - if not connection: - return ActionResult.isFailure(error="No valid Microsoft connection found") - - # Check permissions - permissions_ok = await self._checkPermissions(connection) - if not permissions_ok: - return ActionResult.isFailure(error="Connection lacks necessary permissions for Outlook operations") - - # Create and send the email message - try: - graph_url = "https://graph.microsoft.com/v1.0" - headers = { - "Authorization": f"Bearer {connection['accessToken']}", - "Content-Type": "application/json" - } - - # Clean and format body content - cleaned_body = body.strip() - - # Check if body is already HTML - if cleaned_body.startswith('') or cleaned_body.startswith('') or '
' in cleaned_body: - html_body = cleaned_body - else: - # Convert plain text to proper HTML formatting - html_body = cleaned_body.replace('\n', '
') - html_body = f"{html_body}" - - # Build the email message - message = { - "subject": subject, - "body": { - "contentType": "HTML", - "content": html_body - }, - "toRecipients": [{"emailAddress": {"address": email}} for email in to], - "ccRecipients": [{"emailAddress": {"address": email}} for email in cc] if cc else [], - "bccRecipients": [{"emailAddress": {"address": email}} for email in bcc] if bcc else [] - } - - # Add attachments if provided - if attachmentDocumentList: - message["attachments"] = [] - for attachment_ref in attachmentDocumentList: - # Get attachment document from service center - attachment_docs = self.services.workflow.getChatDocumentsFromDocumentList([attachment_ref]) - if attachment_docs: - for doc in attachment_docs: - file_id = getattr(doc, 'fileId', None) - if file_id: - try: - file_content = self.services.workflow.getFileData(file_id) - if file_content: - if isinstance(file_content, bytes): - content_bytes = file_content - else: - content_bytes = str(file_content).encode('utf-8') - - base64_content = base64.b64encode(content_bytes).decode('utf-8') - - attachment = { - "@odata.type": "#microsoft.graph.fileAttachment", - "name": doc.fileName, - "contentType": doc.mimeType or "application/octet-stream", - "contentBytes": base64_content - } - message["attachments"].append(attachment) - except Exception as e: - logger.error(f"Error reading attachment file {doc.fileName}: {str(e)}") - - # Create the draft message - drafts_folder_id = self._getFolderId("Drafts", connection) - - if drafts_folder_id: - api_url = f"{graph_url}/me/mailFolders/{drafts_folder_id}/messages" - else: - api_url = f"{graph_url}/me/messages" - logger.warning("Could not find Drafts folder, creating draft in default location") - - response = requests.post(api_url, headers=headers, json=message) - - if response.status_code in [200, 201]: - draft_data = response.json() - draft_id = draft_data.get("id", "Unknown") - - result_data = { - "status": "success", - "message": "Email draft created successfully", - "draftId": draft_id, - "folder": "Drafts (EntwΓΌrfe)", - "mailbox": connection.get('userEmail', 'Unknown'), - "subject": subject, - "recipients": to, - "cc": cc, - "bcc": bcc, - "attachments": len(attachmentDocumentList), - "timestamp": self.services.utils.getUtcTimestamp() - } - - return ActionResult( - success=True, - documents=[ActionDocument( - documentName=f"email_draft_created_{self._format_timestamp_for_filename()}.json", - documentData=json.dumps(result_data, indent=2), - mimeType="application/json" - )] - ) - else: - logger.error(f"Failed to create draft. Status: {response.status_code}, Response: {response.text}") - return ActionResult.isFailure(error=f"Failed to create email draft: {response.status_code} - {response.text}") - - except Exception as e: - logger.error(f"Error creating email via Microsoft Graph API: {str(e)}") - return ActionResult.isFailure(error=f"Failed to create email: {str(e)}") - - except Exception as e: - logger.error(f"Error in composeAndSendEmailDirect: {str(e)}") - return ActionResult.isFailure(error=str(e)) - @action async def composeAndSendEmailWithContext(self, parameters: Dict[str, Any]) -> ActionResult: """ @@ -1207,7 +1054,7 @@ class MethodOutlook(MethodBase): Parameters: - connectionReference (str, required): Microsoft connection label. - to (list, required): Recipient email addresses. - - context (str, required): Context for composing the email. + - context (str, required): Detailled context for composing the email. - documentList (list, optional): Document references for context/attachments. - cc (list, optional): CC recipients. - bcc (list, optional): BCC recipients. @@ -1253,6 +1100,14 @@ class MethodOutlook(MethodBase): chatDocuments = self.services.workflow.getChatDocumentsFromDocumentList(documentList) # Create AI prompt for email composition + # Build document reference list for AI + doc_references = documentList + doc_list_text = "" + if doc_references: + doc_list_text = f"Available_Document_References: {', '.join(doc_references)}" + else: + doc_list_text = "Available_Document_References: (No documents available for attachment)" + ai_prompt = f""" Compose a professional email based on the following context and requirements: @@ -1263,15 +1118,19 @@ RECIPIENT: {to} EMAIL STYLE: {emailStyle} MAX LENGTH: {maxLength} characters +{doc_list_text} + Please generate: 1. A clear, professional subject line 2. A well-structured email body that addresses the context appropriately 3. Use the {emailStyle} tone throughout +4. Decide which documents from Available_Document_References (if any) should be attached to the email Return your response in the following JSON format: {{ "subject": "Your generated subject line here", - "body": "Your generated email body here (can include HTML formatting like
for line breaks)" + "body": "Your generated email body here (can include HTML formatting like
for line breaks)", + "attachments": ["document_reference", "document_reference", ...] }} Make sure the email is: @@ -1279,6 +1138,7 @@ Make sure the email is: - Clear and concise - Well-structured with proper greeting and closing - Relevant to the provided context +- Include only relevant documents as attachments (use EXACT document references from the Available_Document_References) """ # Call AI service to generate email content @@ -1291,7 +1151,7 @@ Make sure the email is: priority="normal", compressPrompt=False, compressContext=True, - processDocumentsIndividually=True, + processDocumentsIndividually=False, # Process all documents together for email composition processingMode="detailed", resultFormat="json", maxCost=0.50, @@ -1317,9 +1177,22 @@ Make sure the email is: email_data = json.loads(json_content) subject = email_data.get("subject", "") body = email_data.get("body", "") + ai_attachments = email_data.get("attachments", []) if not subject or not body: return ActionResult.isFailure(error="AI did not generate valid subject and body") + + # Use AI-selected attachments if provided, otherwise use all documents + if ai_attachments: + # Filter documentList to only include AI-selected attachments + selected_docs = [doc_ref for doc_ref in documentList if doc_ref in ai_attachments] + if selected_docs: + documentList = selected_docs + logger.info(f"AI selected {len(selected_docs)} documents for attachment: {selected_docs}") + else: + logger.warning("AI selected attachments not found in available documents, using all documents") + else: + logger.info("AI did not specify attachments, using all available documents") except json.JSONDecodeError as e: logger.error(f"Failed to parse AI response as JSON: {str(e)}") @@ -1418,6 +1291,7 @@ Make sure the email is: "cc": cc, "bcc": bcc, "attachments": len(documentList), + "aiSelectedAttachments": ai_attachments if ai_attachments else "all documents", "aiGenerated": True, "context": context, "emailStyle": emailStyle, diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py index ccd718d0..6b9422d1 100644 --- a/modules/workflows/processing/core/messageCreator.py +++ b/modules/workflows/processing/core/messageCreator.py @@ -155,6 +155,12 @@ class MessageCreator: messageText += f"❌ {taskObjective}\n\n" messageText += f"{errorDetails}\n\n" + # Build concise summary to persist for history context + doc_count = len(createdDocuments) if createdDocuments else 0 + trimmed_msg = (messageText or "").strip().replace("\n", " ") + if len(trimmed_msg) > 160: + trimmed_msg = trimmed_msg[:157] + "..." + messageData = { "workflowId": workflow.id, "role": "assistant", @@ -171,7 +177,8 @@ class MessageCreator: "roundNumber": currentRound, "taskNumber": currentTask, "actionNumber": currentAction, - "actionProgress": "success" if result.success else "fail" + "actionProgress": "success" if result.success else "fail", + "summary": f"{action.execMethod}.{action.execAction}: {doc_count} docs | msg='{trimmed_msg}'" } # Add debugging for error messages diff --git a/modules/workflows/processing/modes/modeReact.py b/modules/workflows/processing/modes/modeReact.py index 7c3497ac..330e387d 100644 --- a/modules/workflows/processing/modes/modeReact.py +++ b/modules/workflows/processing/modes/modeReact.py @@ -204,6 +204,13 @@ class ReactMode(BaseMode): selection = json.loads(response[jsonStart:jsonEnd]) if 'action' not in selection or not isinstance(selection['action'], str): raise ValueError("Selection missing 'action' as string") + # Enforce spec: Stage 1 must NOT include 'parameters' + if 'parameters' in selection: + # Remove to avoid accidental carryover + try: + del selection['parameters'] + except Exception: + selection['parameters'] = None return selection async def _actExecute(self, context: TaskContext, selection: Dict[str, Any], taskStep: TaskStep, @@ -217,57 +224,101 @@ class ReactMode(BaseMode): methodName, actionName = compoundActionName.split('.', 1) - # Check if parameters are already provided in the selection - if 'parameters' in selection and selection['parameters']: - logger.info("Using parameters from action selection") - parameters = selection['parameters'] + # Always request parameters in Stage 2 (spec: Stage 1 must not provide them) + logger.info("Requesting parameters in Stage 2 based on Stage 1 outputs") + + # Create a permissive Stage 2 context to avoid TaskContext attribute restrictions + from types import SimpleNamespace + stage2Context = SimpleNamespace() + + # Copy essential fields from original context for fallbacks (snake_case for placeholderFactory compatibility) + stage2Context.task_step = getattr(context, 'task_step', None) + stage2Context.workflow_id = getattr(context, 'workflow_id', None) + + # Set Stage 1 data directly on the permissive context (snake_case for promptGenerationActionsReact compatibility) + if isinstance(selection, dict): + stage2Context.action_objective = selection.get('actionObjective', '') + stage2Context.parameters_context = selection.get('parametersContext', '') + stage2Context.learnings = selection.get('learnings', []) else: - logger.info("No parameters in action selection, requesting from AI") - bundle = generateReactParametersPrompt(self.services, context, compoundActionName) - promptTemplate = bundle.prompt - placeholders = bundle.placeholders - - self._writeTraceLog("React Parameters Prompt", promptTemplate) - self._writeTraceLog("React Parameters Placeholders", placeholders) - - # Centralized AI call for parameter suggestion (balanced analysis) - options = AiCallOptions( - operationType=OperationType.ANALYSE_CONTENT, - priority=Priority.BALANCED, - compressPrompt=True, - compressContext=False, - processingMode=ProcessingMode.ADVANCED, - maxCost=0.05, - maxProcessingTime=30, - temperature=0.3, # Slightly higher temperature for better instruction following - # maxTokens not set - use model's maximum for big JSON responses - resultFormat="json" # Explicitly request JSON format - ) - - paramsResp = await self.services.ai.callAi( - prompt=promptTemplate, - placeholders=placeholders, - options=options - ) - # Parse JSON response - js = paramsResp[paramsResp.find('{'):paramsResp.rfind('}')+1] if paramsResp else '{}' - try: - paramObj = json.loads(js) - parameters = paramObj.get('parameters', {}) if isinstance(paramObj, dict) else {} - # Log only the parsed JSON object to avoid duplicated raw text - try: - self._writeTraceLog("React Parameters Response", paramObj) - except Exception: - pass - except Exception as e: - logger.error(f"Failed to parse AI parameters response as JSON: {str(e)}") - logger.error(f"Response was: {paramsResp}") - parameters = {} + stage2Context.action_objective = '' + stage2Context.parameters_context = '' + stage2Context.learnings = [] + + # Build and send the Stage 2 parameters prompt (always) + bundle = generateReactParametersPrompt(self.services, stage2Context, compoundActionName) + promptTemplate = bundle.prompt + placeholders = bundle.placeholders + + self._writeTraceLog("React Parameters Prompt", promptTemplate) + self._writeTraceLog("React Parameters Placeholders", placeholders) + + # Centralized AI call for parameter suggestion (balanced analysis) + options = AiCallOptions( + operationType=OperationType.ANALYSE_CONTENT, + priority=Priority.BALANCED, + compressPrompt=True, + compressContext=False, + processingMode=ProcessingMode.ADVANCED, + maxCost=0.05, + maxProcessingTime=30, + temperature=0.3, # Slightly higher temperature for better instruction following + # maxTokens not set - use model's maximum for big JSON responses + resultFormat="json" # Explicitly request JSON format + ) + + paramsResp = await self.services.ai.callAi( + prompt=promptTemplate, + placeholders=placeholders, + options=options + ) + # Parse JSON response + js = paramsResp[paramsResp.find('{'):paramsResp.rfind('}')+1] if paramsResp else '{}' + try: + paramObj = json.loads(js) + parameters = paramObj.get('parameters', {}) if isinstance(paramObj, dict) else {} + except Exception as e: + logger.error(f"Failed to parse AI parameters response as JSON: {str(e)}") + logger.error(f"Response was: {paramsResp}") + parameters = {} + + # Merge Stage 1 resource selections into Stage 2 parameters (only if action expects them) + try: + requiredDocs = selection.get('requiredInputDocuments') + if requiredDocs: + # Ensure list + if isinstance(requiredDocs, list): + # Only attach if target action defines 'documentList' + methodName, actionName = compoundActionName.split('.', 1) + from modules.workflows.processing.shared.methodDiscovery import getActionParameterList, methods as _methods + expectedParams = getActionParameterList(methodName, actionName, _methods) + if 'documentList' in expectedParams: + parameters['documentList'] = requiredDocs + requiredConn = selection.get('requiredConnection') + if requiredConn: + # Only attach if target action defines 'connectionReference' + methodName, actionName = compoundActionName.split('.', 1) + from modules.workflows.processing.shared.methodDiscovery import getActionParameterList, methods as _methods + expectedParams = getActionParameterList(methodName, actionName, _methods) + if 'connectionReference' in expectedParams: + parameters['connectionReference'] = requiredConn + except Exception: + pass # Apply minimal defaults in-code (language) if 'language' not in parameters and hasattr(self.services, 'user') and getattr(self.services.user, 'language', None): parameters['language'] = self.services.user.language + # Write merged parameters to trace BEFORE continuing + try: + mergedParamObj = { + "schema": (paramObj.get('schema') if isinstance(paramObj, dict) else 'parameters_v1'), + "parameters": parameters + } + self._writeTraceLog("React Parameters Response", mergedParamObj) + except Exception: + pass + # Build a synthetic ActionItem for execution routing and labels currentRound = getattr(self.workflow, 'currentRound', 0) currentTask = getattr(self.workflow, 'currentTask', 0) @@ -295,7 +346,7 @@ class ReactMode(BaseMode): for doc in actionResult.documents: # Extract all available metadata without content docMetadata = { - "name": getattr(doc, 'documentName', 'Unknown'), + "name": getattr(doc, 'fileName', None) or getattr(doc, 'documentName', 'Unknown'), "mimeType": getattr(doc, 'mimeType', 'Unknown'), "size": getattr(doc, 'size', 'Unknown'), "created": getattr(doc, 'created', 'Unknown'), diff --git a/modules/workflows/processing/shared/placeholderFactory.py b/modules/workflows/processing/shared/placeholderFactory.py index 0bc003f9..5c27ec30 100644 --- a/modules/workflows/processing/shared/placeholderFactory.py +++ b/modules/workflows/processing/shared/placeholderFactory.py @@ -11,7 +11,7 @@ NAMING CONVENTION: MAPPING TABLE (keys β†’ function) with usage [taskplan | actionplan | react]: {{KEY:USER_PROMPT}} -> extractUserPrompt() [taskplan, actionplan, react] {{KEY:USER_LANGUAGE}} -> extractUserLanguage() [actionplan, react] -{{KEY:WORKFLOW_HISTORY}} -> extractWorkflowHistory() [taskplan, actionplan] +{{KEY:WORKFLOW_HISTORY}} -> extractWorkflowHistory() [taskplan, actionplan, react] {{KEY:AVAILABLE_CONNECTIONS_INDEX}} -> extractAvailableConnectionsIndex() [actionplan, react] {{KEY:AVAILABLE_CONNECTIONS_SUMMARY}} -> extractAvailableConnectionsSummary() [] {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}} -> extractAvailableDocumentsSummary() [taskplan, actionplan, react] @@ -54,7 +54,9 @@ def extractUserPrompt(context: Any) -> str: return 'No request specified' def extractWorkflowHistory(service: Any, context: Any) -> str: - """Extract workflow history from context. Maps to {{KEY:WORKFLOW_HISTORY}}""" + """Extract workflow history from context. Maps to {{KEY:WORKFLOW_HISTORY}} + Reverse-chronological, enriched with message summaries and document labels. + """ # Prefer explicit workflow on context; else fall back to services.workflow workflow = None try: @@ -99,30 +101,10 @@ def extractUserLanguage(service: Any) -> str: """Extract user language from service. Maps to {{KEY:USER_LANGUAGE}}""" return service.user.language if service and service.user else 'en' -def getConnectionReferenceList(services) -> List[str]: - """Get list of available connections""" - try: - # Get connections from the database - if hasattr(services, 'interfaceDbApp') and hasattr(services, 'user'): - userId = services.user.id - connections = services.interfaceDbApp.getUserConnections(userId) - if connections: - # Format connections as reference strings - connectionRefs = [] - for conn in connections: - # Create reference string in format: conn_{authority}_{id} - ref = f"conn_{conn.authority.value}_{conn.id}" - connectionRefs.append(ref) - return connectionRefs - - return [] - except Exception as e: - logger.error(f"Error getting connection reference list: {str(e)}") - return [] def _computeMessageSummary(msg) -> str: """Create a concise summary for a ChatMessage with documents only. - Fields: documentCount, roundNumber, documentsLabel, document names, message (trimmed), success flag. + Fields: documentCount, roundNumber, documentsLabel, document names, message (full), success flag. """ try: docs = getattr(msg, 'documents', []) or [] @@ -131,26 +113,39 @@ def _computeMessageSummary(msg) -> str: document_count = len(docs) round_number = getattr(msg, 'roundNumber', None) or 0 label = getattr(msg, 'documentsLabel', None) or "" - # Collect up to 3 document names (supports dicts or objects) + # Collect ALL document names (supports ChatDocument objects and dicts) doc_names = [] - for d in docs[:3]: + for d in docs: name = None try: if isinstance(d, dict): - name = d.get('documentName') or d.get('name') or d.get('filename') + # For dict objects, try multiple possible field names + name = d.get('fileName') or d.get('documentName') or d.get('name') or d.get('filename') else: - name = getattr(d, 'documentName', None) or getattr(d, 'name', None) or getattr(d, 'filename', None) + # For ChatDocument objects, use fileName field + name = getattr(d, 'fileName', None) or getattr(d, 'documentName', None) or getattr(d, 'name', None) or getattr(d, 'filename', None) except Exception: name = None doc_names.append(name or "(unnamed)") - names_part = ", ".join(doc_names) + (" +more" if document_count > 3 else "") + # Format document names in brackets + if doc_names: + names_part = f"({', '.join(doc_names)})" + else: + names_part = "(no documents)" + + # Don't truncate the message - show full content user_message = (getattr(msg, 'message', '') or '').strip().replace("\n", " ") - if len(user_message) > 120: - user_message = user_message[:117] + "..." + # Read success from ChatMessage.success field success_flag = getattr(msg, 'success', None) success_text = "success=True" if success_flag is True else ("success=False" if success_flag is False else "success=Unknown") label_part = f" label='{label}'" if label else "" - return f"Round {round_number}: {document_count} docs - {names_part}{label_part} | {success_text} | msg='{user_message}'" + + # Add learning/feedback if available + learning_part = "" + if hasattr(msg, 'summary') and msg.summary and 'learnings' in msg.summary.lower(): + learning_part = " | learnings available" + + return f"Round {round_number}: {document_count} docs {names_part}{label_part} | {success_text}{learning_part} | msg='{user_message}'" except Exception: return "" @@ -171,17 +166,35 @@ def getMessageSummary(msg) -> str: return "" def getPreviousRoundContext(services, workflow: Any) -> str: - """Get previous round context listing only messages that produced documents, using summaries (full history).""" + """Get enriched context: + - Reverse-chronological ordering + - Current round first (newest β†’ oldest), then older rounds + - Only messages with documents summarized + - Include available documents snapshot at end + """ try: if not workflow: return "No previous round context available" lines: List[str] = [] - # Summarize ALL messages WITH documents only, in chronological order + # Reverse-chronological, current round first try: msgs = getattr(workflow, 'messages', []) or [] + current_round = getattr(workflow, 'currentRound', None) + current_round_msgs: List[Any] = [] + previous_round_msgs: List[Any] = [] for m in msgs: + if current_round is not None and getattr(m, 'roundNumber', None) == current_round: + current_round_msgs.append(m) + else: + previous_round_msgs.append(m) + + for m in reversed(current_round_msgs): + s = getMessageSummary(m) + if s: + lines.append(f"- {s}") + for m in reversed(previous_round_msgs): s = getMessageSummary(m) if s: lines.append(f"- {s}") @@ -222,7 +235,7 @@ def extractReviewContent(context: Any) -> str: for doc in result.documents: # Extract all available metadata without content doc_metadata = { - "name": getattr(doc, 'documentName', 'Unknown'), + "name": getattr(doc, 'fileName', None) or getattr(doc, 'documentName', 'Unknown'), "mimeType": getattr(doc, 'mimeType', 'Unknown'), "size": getattr(doc, 'size', 'Unknown'), "created": getattr(doc, 'created', 'Unknown'), @@ -358,12 +371,10 @@ def extractLatestRefinementFeedback(context: Any) -> str: def extractAvailableDocumentsSummary(service: Any, context: Any) -> str: """Summary of available documents (count only).""" try: - if hasattr(context, 'workflow') and context.workflow: - documents = service.workflow.getAvailableDocuments(context.workflow) - if documents and documents != "No documents available": - doc_count = documents.count("docList:") + documents.count("docItem:") - return f"{doc_count} documents available from previous tasks" - return "No documents available" + documents = service.workflow.getAvailableDocuments(context.workflow) + if documents and documents != "No documents available": + doc_count = documents.count("docList:") + documents.count("docItem:") + return f"{doc_count} documents available from previous tasks" return "No documents available" except Exception as e: logger.error(f"Error getting document summary: {str(e)}") @@ -372,9 +383,7 @@ def extractAvailableDocumentsSummary(service: Any, context: Any) -> str: def extractAvailableDocumentsIndex(service: Any, context: Any) -> str: """Index of available documents with detailed references for parameter generation.""" try: - if hasattr(context, 'workflow') and context.workflow: - return service.workflow.getAvailableDocuments(context.workflow) - return "No documents available" + return service.workflow.getAvailableDocuments(context.workflow) except Exception as e: logger.error(f"Error getting document index: {str(e)}") return "No documents available" @@ -382,7 +391,7 @@ def extractAvailableDocumentsIndex(service: Any, context: Any) -> str: def extractAvailableConnectionsSummary(service: Any) -> str: """Summary of available connections (count only).""" try: - connections = getConnectionReferenceList(service) + connections = service.workflow.getConnectionReferenceList() if connections: return f"{len(connections)} connections available" return "No connections available" @@ -393,7 +402,7 @@ def extractAvailableConnectionsSummary(service: Any) -> str: def extractAvailableConnectionsIndex(service: Any) -> str: """Index of available connections with detailed references for parameter generation.""" try: - connections = getConnectionReferenceList(service) + connections = service.workflow.getConnectionReferenceList() if connections: return '\n'.join(f"- {conn}" for conn in connections) return "No connections available" diff --git a/modules/workflows/processing/shared/promptGenerationActionsReact.py b/modules/workflows/processing/shared/promptGenerationActionsReact.py index 46d378d5..b0c876f7 100644 --- a/modules/workflows/processing/shared/promptGenerationActionsReact.py +++ b/modules/workflows/processing/shared/promptGenerationActionsReact.py @@ -25,41 +25,112 @@ def generateReactPlanSelectionPrompt(services, context: Any) -> PromptBundle: PromptPlaceholder(label="USER_PROMPT", content=extractUserPrompt(context), summaryAllowed=False), PromptPlaceholder(label="AVAILABLE_DOCUMENTS_SUMMARY", content=extractAvailableDocumentsSummary(services, context), summaryAllowed=True), PromptPlaceholder(label="AVAILABLE_METHODS", content=extractAvailableMethods(services), summaryAllowed=False), + # Provide enriched history context for Stage 1 to craft parametersContext + PromptPlaceholder(label="WORKFLOW_HISTORY", content=extractWorkflowHistory(services, context), summaryAllowed=True), + # Provide deterministic indexes so the planner can choose exact labels + PromptPlaceholder(label="AVAILABLE_DOCUMENTS_INDEX", content=extractAvailableDocumentsIndex(services, context), summaryAllowed=True), + PromptPlaceholder(label="AVAILABLE_CONNECTIONS_INDEX", content=extractAvailableConnectionsIndex(services), summaryAllowed=False), ] - template = """Select one action to advance the task. + template = """Select exactly one action to advance the task. - OBJECTIVE: - {{KEY:USER_PROMPT}} +OBJECTIVE: +{{KEY:USER_PROMPT}} - AVAILABLE_DOCUMENTS_SUMMARY: - {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}} +AVAILABLE_DOCUMENTS_SUMMARY: +{{KEY:AVAILABLE_DOCUMENTS_SUMMARY}} - AVAILABLE_METHODS: - {{KEY:AVAILABLE_METHODS}} +AVAILABLE_METHODS: +{{KEY:AVAILABLE_METHODS}} - REPLY: Return only a JSON object with the selected action: - {{ - "action": "method.action_name" - }} +WORKFLOW_HISTORY (reverse-chronological, enriched): +{{KEY:WORKFLOW_HISTORY}} - RULES: - 1. Use EXACT action names from AVAILABLE_METHODS - 2. Return ONLY JSON - no other text - 3. Do NOT use markdown code blocks - 4. Do NOT add explanations - """ +AVAILABLE_DOCUMENTS_INDEX: +{{KEY:AVAILABLE_DOCUMENTS_INDEX}} + +AVAILABLE_CONNECTIONS_INDEX: +{{KEY:AVAILABLE_CONNECTIONS_INDEX}} + +REPLY: Return ONLY a JSON object with the following structure (no comments, no extra text): +{{ + "action": "method.action_name", + "actionObjective": "...", + "learnings": ["..."], + "requiredInputDocuments": ["docList:..."], + "requiredConnection": "connection:..." | null, + "parametersContext": "concise text that Stage 2 will use to set business parameters" +}} + +EXAMPLE how to assign references from AVAILABLE_DOCUMENTS_INDEX and AVAILABLE_CONNECTIONS_INDEX: +"requiredInputDocuments": ["docList:msg_47a7a578-e8f2-4ba8-ac66-0dbff40605e0:round8_task1_action1_results","docItem:5d8b7aee-b546-4487-b6a8-835c86f7b186:AI_Generated_Document_20251006-104256.docx"], +"requiredConnection": "connection:msft:p.motsch@valueon.ch:1ae8b8e5-128b-49b8-b1cb-7c632669eeae", + +RULES: +1. Use EXACT action names from AVAILABLE_METHODS +2. Do NOT output a "parameters" object +3. parametersContext must be short and sufficient for Stage 2 +4. Return ONLY JSON - no markdown, no explanations +5. For requiredInputDocuments, use ONLY exact references from AVAILABLE_DOCUMENTS_INDEX (docList:... or docItem:...) +6. For requiredConnection, use ONLY an exact label from AVAILABLE_CONNECTIONS_INDEX +""" return PromptBundle(prompt=template, placeholders=placeholders) def generateReactParametersPrompt(services, context: Any, compoundActionName: str) -> PromptBundle: - """Define placeholders first, then the template; return PromptBundle.""" + """Define placeholders first, then the template; return PromptBundle. + +Minimal Stage 2 (no fallback): consumes actionObjective, selectedAction, parametersContext only. +Excludes documents/connections/history entirely. +""" # derive method/action and parameter list methodName, actionName = (compoundActionName.split('.', 1) if '.' in compoundActionName else (compoundActionName, '')) actionParameterList = getActionParameterList(methodName, actionName, methods) + def _formatBusinessParameters(params) -> str: + excluded = {"documentList", "connectionReference"} + # Case 1: params is a list of dicts or objects with 'name' + if isinstance(params, (list, tuple)): + entries = [] + for p in params: + try: + if isinstance(p, dict): + name = p.get("name") + if not name or name in excluded: + continue + ptype = p.get("type") or p.get("dataType") or "" + req = p.get("required") + reqTxt = "required" if (req is True or str(req).lower() == "true") else "optional" + desc = p.get("description") or p.get("desc") or "" + entry = f"- {name} ({ptype}, {reqTxt})" + (f": {desc}" if desc else "") + entries.append(entry) + else: + # Try attribute access + name = getattr(p, "name", None) + if not name or name in excluded: + continue + ptype = getattr(p, "type", "") or getattr(p, "dataType", "") + req = getattr(p, "required", False) + reqTxt = "required" if (req is True or str(req).lower() == "true") else "optional" + desc = getattr(p, "description", None) or getattr(p, "desc", None) or "" + entry = f"- {name} ({ptype}, {reqTxt})" + (f": {desc}" if desc else "") + entries.append(entry) + except Exception: + continue + return "\n".join(entries) + # Case 2: params is a string description: filter out lines mentioning excluded names + if isinstance(params, str): + lines = [ln for ln in params.splitlines() if not any(ex in ln for ex in excluded)] + return "\n".join(lines).strip() + # Fallback: plain string + try: + return str(params) + except Exception: + return "" + + actionParametersText = _formatBusinessParameters(actionParameterList) + # determine action objective if available, else fall back to user prompt - actionObjective = None if hasattr(context, 'action_objective') and context.action_objective: actionObjective = context.action_objective elif hasattr(context, 'task_step') and context.task_step and getattr(context.task_step, 'objective', None): @@ -67,107 +138,62 @@ def generateReactParametersPrompt(services, context: Any, compoundActionName: st else: actionObjective = extractUserPrompt(context) + # Minimal Stage 2 (no fallback) + parametersContext = getattr(context, 'parameters_context', None) + learningsText = "" + try: + # If Stage 1 learnings were attached to context, pass them textually + if hasattr(context, 'learnings') and context.learnings: + if isinstance(context.learnings, (list, tuple)): + learningsText = "\n".join(f"- {str(x)}" for x in context.learnings) + else: + learningsText = str(context.learnings) + except Exception: + learningsText = "" + placeholders: List[PromptPlaceholder] = [ PromptPlaceholder(label="ACTION_OBJECTIVE", content=actionObjective, summaryAllowed=False), - PromptPlaceholder(label="ACTION_PARAMETER_LIST", content=actionParameterList, summaryAllowed=False), - PromptPlaceholder(label="AVAILABLE_DOCUMENTS_INDEX", content=extractAvailableDocumentsIndex(services, context), summaryAllowed=True), - PromptPlaceholder(label="AVAILABLE_CONNECTIONS_INDEX", content=extractAvailableConnectionsIndex(services), summaryAllowed=False), - PromptPlaceholder(label="USER_PROMPT", content=extractUserPrompt(context), summaryAllowed=False), - PromptPlaceholder(label="USER_LANGUAGE", content=extractUserLanguage(services), summaryAllowed=False), - PromptPlaceholder(label="PREVIOUS_ACTION_RESULTS", content=extractPreviousActionResults(context), summaryAllowed=True), - PromptPlaceholder(label="LEARNINGS_AND_IMPROVEMENTS", content=extractLearningsAndImprovements(context), summaryAllowed=True), - PromptPlaceholder(label="LATEST_REFINEMENT_FEEDBACK", content=extractLatestRefinementFeedback(context), summaryAllowed=True), - PromptPlaceholder(label="WORKFLOW_HISTORY", content=extractWorkflowHistory(services, context), summaryAllowed=True), PromptPlaceholder(label="SELECTED_ACTION", content=compoundActionName, summaryAllowed=False), + PromptPlaceholder(label="PARAMETERS_CONTEXT", content=(parametersContext or ""), summaryAllowed=True), + PromptPlaceholder(label="ACTION_PARAMETERS", content=actionParametersText, summaryAllowed=False), + PromptPlaceholder(label="LEARNINGS", content=learningsText, summaryAllowed=True), ] - template = """Generate parameters for this action. - - ## Return ONLY a JSON RESPONSEOBJECT without comments. - - ACTION_OBJECTIVE (the objective for this action to fulfill): - {{KEY:ACTION_OBJECTIVE}} - - SELECTED_ACTION: - {{KEY:SELECTED_ACTION}} + template = """You are a parameter generator. Set the parameters for this specific action. - JSON RESPONSEOBJECT: - {{ +CONTEXT AND OBJECTIVE: +{{KEY:ACTION_OBJECTIVE}} + +SELECTED_ACTION: +{{KEY:SELECTED_ACTION}} + +CONTEXT FOR PARAMETER VALUES: +{{KEY:PARAMETERS_CONTEXT}} + +LEARNINGS (from prior attempts, if any): +{{KEY:LEARNINGS}} + +REQUIRED PARAMETERS FOR THIS ACTION (use these exact parameter names): +{{KEY:ACTION_PARAMETERS}} + +INSTRUCTIONS: +- Use ONLY the parameter names listed above +- Fill in appropriate values based on the context and objective +- Do NOT invent new parameters +- Do NOT include: documentList, connectionReference, history, documents, connections + +REPLY (ONLY JSON): +{{ "schema": "parameters_v1", "parameters": {{ - "paramName": "value" + "paramName": "value" }} - }} +}} - EXAMPLE of the result format to deliver: - {{ - "schema": "parameters_v1", - "parameters": {{ - "aiPrompt": "...", - "resultType": "docx", - "processingMode": "detailed" - }} - }} - - ## RULES: - 1. Use ONLY parameter names from ACTION_PARAMETER_LIST - 2. For connectionReference, use an EXACT label from AVAILABLE_CONNECTIONS_INDEX (do NOT invent labels) - 3. Use exact document references from AVAILABLE_DOCUMENTS_INDEX for documentList parameters (do NOT invent names like "doc1"): pick specific docItem references; to include all from a list, use its docList reference - 4. Learn from PREVIOUS_ACTION_RESULTS and LEARNINGS_AND_IMPROVEMENTS to avoid repeating mistakes - 5. Consider LATEST_REFINEMENT_FEEDBACK when generating parameters - 6. Use the ACTION_OBJECTIVE to understand the specific goal for this action - 7. Generate parameters that align with the USER_LANGUAGE when applicable - - ## ACTION_PARAMETER_LIST: - - {{KEY:ACTION_PARAMETER_LIST}} - - - ## AVAILABLE_DOCUMENTS_INDEX: - - (Use these references in parameter "documentList" if given; to include all docs from a list, pass its docList reference) - {{KEY:AVAILABLE_DOCUMENTS_INDEX}} - - - ## AVAILABLE_CONNECTIONS_INDEX: - - {{KEY:AVAILABLE_CONNECTIONS_INDEX}} - (Use an EXACT label here for parameter "connectionReference") - - - ## Example how to assign references from AVAILABLE_DOCUMENTS_INDEX and AVAILABLE_CONNECTIONS_INDEX: - - {{ - "schema": "parameters_v1", - "parameters": {{ - "documentList": ["docList:msg_47a7a578-e8f2-4ba8-ac66-0dbff40605e0:round8_task1_action1_results", "docItem:5d8b7aee-b546-4487-b6a8-835c86f7b186:AI_Generated_Document_20251006-104256.docx"], - "connectionReference": "conn_msft_1ae8b8e5-128b-49b8-b1cb-7c632669eeae", - "aiPrompt": "...", - "resultType": "xlsx", - "processingMode": "basic" - }} - }} - - ## CONTEXT - - USER_REQUEST (final user prompt to deliver): - {{KEY:USER_PROMPT}} - - USER_LANGUAGE: - {{KEY:USER_LANGUAGE}} - - PREVIOUS_ACTION_RESULTS: - {{KEY:PREVIOUS_ACTION_RESULTS}} - - LEARNINGS_AND_IMPROVEMENTS: - {{KEY:LEARNINGS_AND_IMPROVEMENTS}} - - LATEST_REFINEMENT_FEEDBACK: - {{KEY:LATEST_REFINEMENT_FEEDBACK}} - - WORKFLOW_HISTORY: - {{KEY:WORKFLOW_HISTORY}} - """ +RULES: +- Return ONLY JSON (no markdown, no prose) +- Use only the parameters listed in REQUIRED PARAMETERS FOR THIS ACTION +""" return PromptBundle(prompt=template, placeholders=placeholders) @@ -180,24 +206,24 @@ def generateReactRefinementPrompt(services, context: Any, reviewContent: str) -> template = """Decide the next step based on the observation. - OBJECTIVE: - {{KEY:USER_PROMPT}} +OBJECTIVE: +{{KEY:USER_PROMPT}} - OBSERVATION: - {{KEY:REVIEW_CONTENT}} +OBSERVATION: +{{KEY:REVIEW_CONTENT}} - REPLY: Return only a JSON object with your decision: - {{ - "decision": "continue|stop", - "reason": "brief explanation" - }} +REPLY: Return only a JSON object with your decision: +{{ +"decision": "continue|stop", +"reason": "brief explanation" +}} - RULES: - 1. Use "continue" if objective NOT fulfilled - 2. Use "stop" if objective fulfilled - 3. Return ONLY JSON - no other text - 4. Do NOT use markdown code blocks - 5. Do NOT add explanations - """ +RULES: +1. Use "continue" if objective NOT fulfilled +2. Use "stop" if objective fulfilled +3. Return ONLY JSON - no other text +4. Do NOT use markdown code blocks +5. Do NOT add explanations +""" return PromptBundle(prompt=template, placeholders=placeholders) diff --git a/modules/workflows/processing/shared/promptGenerationTaskplan.py b/modules/workflows/processing/shared/promptGenerationTaskplan.py index 49085f80..08f18493 100644 --- a/modules/workflows/processing/shared/promptGenerationTaskplan.py +++ b/modules/workflows/processing/shared/promptGenerationTaskplan.py @@ -26,96 +26,96 @@ def generateTaskPlanningPrompt(services, context: Any) -> PromptBundle: template = """# Task Planning - Break down user requests into logical, executable task steps. +Break down user requests into logical, executable task steps. - ## πŸ“‹ Context +## πŸ“‹ Context - ### User Request - {{KEY:USER_PROMPT}} +### User Request +{{KEY:USER_PROMPT}} - ### Available Documents - {{KEY:AVAILABLE_DOCUMENTS_SUMMARY}} +### Available Documents +{{KEY:AVAILABLE_DOCUMENTS_SUMMARY}} - ### Previous Workflow Rounds - {{KEY:WORKFLOW_HISTORY}} +### Previous Workflow Rounds +{{KEY:WORKFLOW_HISTORY}} - ## πŸ“ Task Planning Rules +## πŸ“ Task Planning Rules - ### Strategic Task Grouping - - **GROUP RELATED ACTIONS** - Combine all actions for the same business topic into ONE task - - **ONE TOPIC PER TASK** - Each task should handle one complete business objective - - **HIGH-LEVEL FOCUS** - Plan strategic outcomes, not implementation steps - - **AVOID MICRO-TASKS** - Don't create separate tasks for each small action +### Strategic Task Grouping +- **GROUP RELATED ACTIONS** - Combine all actions for the same business topic into ONE task +- **ONE TOPIC PER TASK** - Each task should handle one complete business objective +- **HIGH-LEVEL FOCUS** - Plan strategic outcomes, not implementation steps +- **AVOID MICRO-TASKS** - Don't create separate tasks for each small action - ### Task Grouping Examples - - **Research + Analysis + Report** β†’ ONE task: "Web research report" - - **Data Collection + Processing + Visualization** β†’ ONE task: "Collect and present data" - - **Different topics** (email + flowers) β†’ SEPARATE tasks: "Send formal email..." + "Order flowers from Fleurop for delivery to 123 Main St, include card message" +### Task Grouping Examples +- **Research + Analysis + Report** β†’ ONE task: "Web research report" +- **Data Collection + Processing + Visualization** β†’ ONE task: "Collect and present data" +- **Different topics** (email + flowers) β†’ SEPARATE tasks: "Send formal email..." + "Order flowers from Fleurop for delivery to 123 Main St, include card message" - ### Retry Handling - - **If retry request**: Analyze previous rounds to understand what failed - - **Learn from mistakes**: Improve the plan based on previous failures +### Retry Handling +- **If retry request**: Analyze previous rounds to understand what failed +- **Learn from mistakes**: Improve the plan based on previous failures - ## πŸ“Š Required JSON Structure +## πŸ“Š Required JSON Structure - ```json - { - "overview": "Brief description of the overall plan", - "languageUserDetected": "en", - "userMessage": "User-friendly message explaining the task plan", - "tasks": [ - { - "id": "task_1", - "objective": "Clear business objective focusing on what to deliver", - "dependencies": ["task_0"], - "success_criteria": ["measurable criteria 1", "measurable criteria 2"], - "estimated_complexity": "low|medium|high", - "userMessage": "What this task will accomplish" - } - ] - } - ``` +```json +{ + "overview": "Brief description of the overall plan", + "languageUserDetected": "en", + "userMessage": "User-friendly message explaining the task plan", + "tasks": [ + { + "id": "task_1", + "objective": "Clear business objective focusing on what to deliver", + "dependencies": ["task_0"], + "success_criteria": ["measurable criteria 1", "measurable criteria 2"], + "estimated_complexity": "low|medium|high", + "userMessage": "What this task will accomplish" + } + ] +} +``` - ## 🎯 Task Structure Guidelines +## 🎯 Task Structure Guidelines - ### Task ID Format - - Use sequential numbering: `task_1`, `task_2`, `task_3` - - Keep IDs simple and clear +### Task ID Format +- Use sequential numbering: `task_1`, `task_2`, `task_3` +- Keep IDs simple and clear - ### Objective Writing - - **Be VERY SPECIFIC** - Include exact details needed for action planning - - **Include all requirements** - recipient, attachments, format, recipients, etc. - - **State the complete deliverable** - What exactly will be produced - - **Include context and constraints** - When, where, how, with what - - **Make it actionable** - Clear enough to plan specific actions +### Objective Writing +- **Be VERY SPECIFIC** - Include exact details needed for action planning +- **Include all requirements** - recipient, attachments, format, recipients, etc. +- **State the complete deliverable** - What exactly will be produced +- **Include context and constraints** - When, where, how, with what +- **Make it actionable** - Clear enough to plan specific actions - ### Specific Objective Examples - - **Good**: "Send formal email to ceo and board of directors with annual report as attachment" - - **Bad**: "Handle email communication" - - **Good**: "Order flowers from Fleurop for delivery to 123 Main St, include card message 'Happy Birthday', deliver on March 15th" - - **Bad**: "Order flowers" +### Specific Objective Examples +- **Good**: "Send formal email to ceo and board of directors with annual report as attachment" +- **Bad**: "Handle email communication" +- **Good**: "Order flowers from Fleurop for delivery to 123 Main St, include card message 'Happy Birthday', deliver on March 15th" +- **Bad**: "Order flowers" - ### Action Planning Requirements - - **Include all necessary details** - The objective must contain everything needed to plan actions - - **Specify recipients and destinations** - Who should receive what - - **Include file names and formats** - What documents to use/create - - **State timing and deadlines** - When things need to be done - - **Include context and constraints** - Any special requirements or limitations +### Action Planning Requirements +- **Include all necessary details** - The objective must contain everything needed to plan actions +- **Specify recipients and destinations** - Who should receive what +- **Include file names and formats** - What documents to use/create +- **State timing and deadlines** - When things need to be done +- **Include context and constraints** - Any special requirements or limitations - ### Success Criteria - - **Make them measurable** - specific, quantifiable outcomes - - **Focus on deliverables** - what the user will receive - - **Keep criteria realistic** - achievable within the task scope - - **Include all related actions** - success means completing the entire business objective - - **Be specific about requirements** - Include exact details like recipients, formats, deadlines - - **State clear completion criteria** - How to know the task is fully done +### Success Criteria +- **Make them measurable** - specific, quantifiable outcomes +- **Focus on deliverables** - what the user will receive +- **Keep criteria realistic** - achievable within the task scope +- **Include all related actions** - success means completing the entire business objective +- **Be specific about requirements** - Include exact details like recipients, formats, deadlines +- **State clear completion criteria** - How to know the task is fully done - ### Complexity Estimation - - **Low**: Simple, single-action tasks (1-2 actions) - - **Medium**: Multi-action tasks for one topic (3-5 actions) - - **High**: Complex strategic tasks (6+ actions) +### Complexity Estimation +- **Low**: Simple, single-action tasks (1-2 actions) +- **Medium**: Multi-action tasks for one topic (3-5 actions) +- **High**: Complex strategic tasks (6+ actions) - ## πŸš€ Response Format - Return ONLY the JSON object.""" +## πŸš€ Response Format +Return ONLY the JSON object.""" return PromptBundle(prompt=template, placeholders=placeholders) diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index 4ac6b26c..88ef4023 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -524,7 +524,7 @@ class WorkflowManager: # Add failed log entry self.services.workflow.createLog({ "workflowId": workflow.id, - "message": f"Workflow failed: {workflow_result.error or 'Unknown error'}", + "message": "Workflow failed: Unknown error", "type": "error", "status": "failed", "progress": 100