import json import logging from typing import Dict, Any, List, Optional, Tuple, Union from modules.datamodels.datamodelChat import PromptPlaceholder, ChatDocument from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.services.serviceAi.subSharedAiUtils import ( buildPromptWithPlaceholders, extractTextFromContentParts, reduceText, determineCallType ) logger = logging.getLogger(__name__) # Rebuild the model to resolve forward references AiCallRequest.model_rebuild() # Loop instruction texts for different formats LoopInstructionTexts = { "json": """ CRITICAL LIMITS: tokens total (reserve 20% for JSON structure) MANDATORY RULES: 1. STOP at approximately 80% of limit to ensure valid JSON completion 2. Return ONLY raw JSON (no ```json blocks, no text before/after) CONTINUATION REQUIREMENTS: Refer to the json object below where to set the "continuation" information: - If you can complete the full request: {"continuation": null} - If you must stop early: { "continuation": { "last_data_items": "delivered last data for context (copy them)", "next_instruction": "instruction for next data to deliver" } } BE CONSERVATIVE: Stop generating content when you reach approximately 3200-3500 characters to ensure JSON completion. """, # Add more formats here as needed # "xml": "...", # "text": "...", } class SubCoreAi: """Core AI operations including image analysis, text generation, and planning calls.""" def __init__(self, services, aiObjects): """Initialize core AI operations. Args: services: Service center instance for accessing other services aiObjects: Initialized AiObjects instance """ self.services = services self.aiObjects = aiObjects async def _analyzePromptAndCreateOptions(self, prompt: str) -> AiCallOptions: """Analyze prompt to determine appropriate AiCallOptions parameters.""" try: # Get dynamic enum values from Pydantic models operation_types = [e.value for e in OperationTypeEnum] priorities = [e.value for e in PriorityEnum] processing_modes = [e.value for e in ProcessingModeEnum] # Create analysis prompt for AI to determine operation type and parameters analysisPrompt = f""" You are an AI operation analyzer. Analyze the following prompt and determine the most appropriate operation type and parameters. PROMPT TO ANALYZE: {self.services.ai.sanitizePromptContent(prompt, 'userinput')} Based on the prompt content, determine: 1. operationType: Choose the most appropriate from: {', '.join(operation_types)} 2. priority: Choose from: {', '.join(priorities)} 3. processingMode: Choose from: {', '.join(processing_modes)} 4. compressPrompt: true/false (true for story-like prompts, false for structured prompts with JSON/schemas) 5. compressContext: true/false (true to summarize context, false to process fully) Respond with ONLY a JSON object in this exact format: {{ "operationType": "dataAnalyse", "priority": "balanced", "processingMode": "basic", "compressPrompt": true, "compressContext": true }} """ # Use AI to analyze the prompt request = AiCallRequest( prompt=analysisPrompt, options=AiCallOptions( operationType=OperationTypeEnum.DATA_ANALYSE, priority=PriorityEnum.SPEED, processingMode=ProcessingModeEnum.BASIC, compressPrompt=True, compressContext=False ) ) response = await self.aiObjects.call(request) # Parse AI response try: import json json_start = response.content.find('{') json_end = response.content.rfind('}') + 1 if json_start != -1 and json_end > json_start: analysis = json.loads(response.content[json_start:json_end]) # Map string values to enums operation_type = OperationTypeEnum(analysis.get('operationType', 'dataAnalyse')) priority = PriorityEnum(analysis.get('priority', 'balanced')) processing_mode = ProcessingModeEnum(analysis.get('processingMode', 'basic')) return AiCallOptions( operationType=operation_type, priority=priority, processingMode=processing_mode, compressPrompt=analysis.get('compressPrompt', True), compressContext=analysis.get('compressContext', True) ) except Exception as e: logger.warning(f"Failed to parse AI analysis response: {e}") except Exception as e: logger.warning(f"Prompt analysis failed: {e}") # Fallback to default options return AiCallOptions( operationType=OperationTypeEnum.DATA_ANALYSE, priority=PriorityEnum.BALANCED, processingMode=ProcessingModeEnum.BASIC ) # Shared Core Function for AI Calls with Looping async def _callAiWithLooping( self, prompt: str, options: AiCallOptions, debugPrefix: str = "ai_call", loopInstructionFormat: str = None ) -> str: """ Shared core function for AI calls with looping system. Handles continuation logic when response needs multiple rounds. Delivers prompt and response to debug file log. Args: prompt: The prompt to send to AI options: AI call configuration options debugPrefix: Prefix for debug file names loopInstructionFormat: If provided, replaces LOOP_INSTRUCTION placeholder and includes in continuation prompts Returns: Complete AI response after all iterations """ max_iterations = 100 # Prevent infinite loops iteration = 0 accumulatedContent = [] logger.debug(f"Starting AI call with looping (debug prefix: {debugPrefix}, loopInstructionFormat: {loopInstructionFormat is not None})") # Determine loopInstruction based on loopInstructionFormat (before iterations) if not loopInstructionFormat: loopInstruction = "" elif loopInstructionFormat in LoopInstructionTexts: loopInstruction = LoopInstructionTexts[loopInstructionFormat] else: logger.error(f"Unsupported loopInstructionFormat for prompt: {loopInstructionFormat}") loopInstruction = "" while iteration < max_iterations: iteration += 1 logger.debug(f"AI call iteration {iteration}/{max_iterations}") # Build iteration prompt if iteration == 1: if "LOOP_INSTRUCTION" in prompt: iterationPrompt = prompt.replace("LOOP_INSTRUCTION", loopInstruction) else: iterationPrompt = prompt elif loopInstruction and iteration > 1: continuationContent = self._buildContinuationContent(accumulatedContent, iteration) if "LOOP_INSTRUCTION" in prompt: iterationPrompt = prompt.replace("LOOP_INSTRUCTION", f"{continuationContent}\n\n{loopInstruction}") else: iterationPrompt = prompt else: iterationPrompt = prompt # Make AI call try: from modules.datamodels.datamodelAi import AiCallRequest request = AiCallRequest( prompt=iterationPrompt, context="", options=options ) # Write the ACTUAL prompt sent to AI (including continuation context) if iteration == 1: # First iteration - use the historic naming pattern self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt") else: # Subsequent iterations - include iteration number self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt_iteration_{iteration}") response = await self.aiObjects.call(request) result = response.content # Write raw AI response to debug file if iteration == 1: # First iteration - use the historic naming pattern self.services.utils.writeDebugFile(result, f"{debugPrefix}_response") else: # Subsequent iterations - include iteration number self.services.utils.writeDebugFile(result, f"{debugPrefix}_response_iteration_{iteration}") # Emit stats for this iteration self.services.workflow.storeWorkflowStat( self.services.currentWorkflow, response, f"ai.call.{debugPrefix}.iteration_{iteration}" ) if not result or not result.strip(): logger.warning(f"Iteration {iteration}: Empty response, stopping") break # Check if this is a continuation response (only for supported formats) if loopInstructionFormat in LoopInstructionTexts: try: # Extract JSON substring if wrapped (e.g., ```json ... ```) extracted = self.services.utils.jsonExtractString(result) # Try to parse as JSON to check for continuation attribute parsed_result = json.loads(extracted) if isinstance(parsed_result, dict) and parsed_result.get("continuation") is not None: # This is a continuation response accumulatedContent.append(result) logger.debug(f"Iteration {iteration}: Continuation detected in JSON, continuing...") continue else: # This is the final response (continuation is null or missing) accumulatedContent.append(result) logger.debug(f"Iteration {iteration}: Final response received") break except json.JSONDecodeError: # Not JSON, treat as final response accumulatedContent.append(result) logger.warning(f"Iteration {iteration}: Non-JSON response received") self.services.utils.writeDebugFile(result, f"{debugPrefix}_error_non_json_response_iteration_{iteration}") break else: # This is the final response accumulatedContent.append(result) logger.debug(f"Iteration {iteration}: Final response received") break except Exception as e: logger.error(f"Error in AI call iteration {iteration}: {str(e)}") break if iteration >= max_iterations: logger.warning(f"AI call stopped after maximum iterations ({max_iterations})") # Intelligently merge JSON content from all iterations final_result = self._mergeJsonContent(accumulatedContent) if accumulatedContent else "" # Write final result to debug file self.services.utils.writeDebugFile(final_result, f"{debugPrefix}_final_result") logger.info(f"AI call completed: {len(accumulatedContent)} parts from {iteration} iterations") return final_result def _buildContinuationContent( self, accumulatedContent: List[str], iteration: int ) -> str: """ Build continuation content for follow-up iterations. """ # Extract continuation description from the last response continuation_description = "" if accumulatedContent: try: last_response = accumulatedContent[-1] # Use the same JSON extraction logic as the main loop extracted = self.services.utils.jsonExtractString(last_response) parsed_response = json.loads(extracted) if isinstance(parsed_response, dict): # Check for continuation at root level or in metadata continuation = parsed_response.get("continuation") if continuation is None and "metadata" in parsed_response: continuation = parsed_response["metadata"].get("continuation") if continuation: continuation_description = continuation except (json.JSONDecodeError, KeyError, ValueError): pass # Extract specific attributes from continuation object last_data_items = "" next_instruction = "" if continuation_description: try: if isinstance(continuation_description, str): continuation_obj = json.loads(continuation_description) else: continuation_obj = continuation_description if isinstance(continuation_obj, dict): last_data_items = continuation_obj.get("last_data_items", "") next_instruction = continuation_obj.get("next_instruction", "") except (json.JSONDecodeError, TypeError): pass continuation_content = f"""CONTINUATION REQUEST (Iteration {iteration}): You are continuing a previous response. DO NOT repeat any previous content. {f"Already delivered data: {last_data_items}" if last_data_items else "No previous data specified"} {f"Your task to deliver: {next_instruction}" if next_instruction else "No specific task provided"} CRITICAL REQUIREMENTS: - Start from the exact point specified in continuation instructions - DO NOT repeat any previous content - BE CONSERVATIVE: Stop at approximately 3200-3500 characters to ensure JSON completion - ALWAYS include continuation field - set to null if complete, or provide next instruction if incomplete """ return continuation_content def _mergeJsonContent(self, accumulatedContent: List[str]) -> str: """ Generic JSON merger that combines all lists from multiple iterations. Structure: root attributes + 1..n lists that get merged together. """ if not accumulatedContent: return "" if len(accumulatedContent) == 1: return accumulatedContent[0] try: # Parse all JSON responses parsed_responses = [] for content in accumulatedContent: try: extracted = self.services.utils.jsonExtractString(content) parsed = json.loads(extracted) parsed_responses.append(parsed) except json.JSONDecodeError as e: logger.warning(f"Failed to parse JSON content: {str(e)}") continue if not parsed_responses: return accumulatedContent[0] # Return first response if all parsing failed # Start with first response as base merged = parsed_responses[0].copy() # Merge all lists from all responses for response in parsed_responses[1:]: for key, value in response.items(): if isinstance(value, list) and key in merged and isinstance(merged[key], list): # Merge lists by extending merged[key].extend(value) elif key not in merged: # Add new fields merged[key] = value # Mark as complete merged["continuation"] = None return json.dumps(merged, indent=2) except Exception as e: logger.error(f"Error merging JSON content: {str(e)}") return accumulatedContent[0] # Return first response on error async def _buildGenerationPrompt( self, prompt: str, extracted_content: Optional[str], outputFormat: str, title: str ) -> str: """ Build generation prompt for document generation. """ from modules.services.serviceGeneration.subPromptBuilder import buildGenerationPrompt # Build the generation prompt using the existing system generation_prompt = await buildGenerationPrompt( outputFormat=outputFormat, userPrompt=prompt, title=title ) # If we have extracted content, prepend it to the prompt if extracted_content: generation_prompt = f"""EXTRACTED CONTENT FROM DOCUMENTS: {extracted_content} {generation_prompt}""" return generation_prompt # Planning AI Call async def callAiPlanning( self, prompt: str, placeholders: Optional[List[PromptPlaceholder]] = None, loopInstructionFormat: Optional[str] = None ) -> str: """ Planning AI call for task planning, action planning, action selection, etc. Always uses static parameters optimized for planning tasks. Args: prompt: The planning prompt placeholders: Optional list of placeholder replacements loopInstructionFormat: Optional loop instruction format Returns: Planning JSON response """ # Planning calls always use static parameters logger.debug("Using static parameters for planning call") options = AiCallOptions( operationType=OperationTypeEnum.PLAN, priority=PriorityEnum.QUALITY, processingMode=ProcessingModeEnum.DETAILED, compressPrompt=False, compressContext=False ) # Build full prompt with placeholders if placeholders: placeholders_dict = {p.label: p.content for p in placeholders} full_prompt = buildPromptWithPlaceholders(prompt, placeholders_dict) else: full_prompt = prompt # Use shared core function with planning-specific debug prefix return await self._callAiWithLooping(full_prompt, options, "plan", loopInstructionFormat=loopInstructionFormat) # Document Generation AI Call async def callAiDocuments( self, prompt: str, documents: Optional[List[ChatDocument]] = None, options: Optional[AiCallOptions] = None, outputFormat: Optional[str] = None, title: Optional[str] = None, loopInstructionFormat: Optional[str] = None ) -> Union[str, Dict[str, Any]]: """ Document generation AI call for all non-planning calls. Uses the current unified path with extraction and generation. Args: prompt: The main prompt for the AI call documents: Optional list of documents to process options: AI call configuration options outputFormat: Optional output format for document generation title: Optional title for generated documents Returns: AI response as string, or dict with documents if outputFormat is specified """ if options is None or (hasattr(options, 'operationType') and options.operationType is None): # Use AI to determine parameters ONLY when truly needed (options=None OR operationType=None) logger.debug("Analyzing prompt to determine optimal parameters") options = await self._analyzePromptAndCreateOptions(prompt) else: logger.debug(f"Using provided options: operationType={options.operationType}, priority={options.priority}") # Handle document generation with specific output format using unified approach if outputFormat: # Use unified generation method for all document generation if documents and len(documents) > 0: logger.debug(f"Extracting content from {len(documents)} documents") extracted_content = await self.services.ai.documentProcessor.callAiText(prompt, documents, options) else: logger.debug("No documents provided - using direct generation") extracted_content = None generation_prompt = await self._buildGenerationPrompt(prompt, extracted_content, outputFormat, title) generated_json = await self._callAiWithLooping(generation_prompt, options, "document_generation", loopInstructionFormat=loopInstructionFormat) # Parse the generated JSON (extract fenced/embedded JSON first) try: extracted_json = self.services.utils.jsonExtractString(generated_json) generated_data = json.loads(extracted_json) except json.JSONDecodeError as e: logger.error(f"Failed to parse generated JSON: {str(e)}") logger.error(f"JSON content length: {len(generated_json)}") logger.error(f"JSON content preview (last 200 chars): ...{generated_json[-200:]}") logger.error(f"JSON content around error position: {generated_json[max(0, e.pos-50):e.pos+50]}") # Write the problematic JSON to debug file self.services.utils.writeDebugFile(generated_json, "failed_json_parsing") return {"success": False, "error": f"Generated content is not valid JSON: {str(e)}"} # Render to final format using the existing renderer try: from modules.services.serviceGeneration.mainServiceGeneration import GenerationService generationService = GenerationService(self.services) rendered_content, mime_type = await generationService.renderReport( generated_data, outputFormat, title or "Generated Document", prompt, self ) # Build result in the expected format result = { "success": True, "content": generated_data, "documents": [{ "documentName": f"generated.{outputFormat}", "documentData": rendered_content, "mimeType": mime_type, "title": title or "Generated Document" }], "is_multi_file": False, "format": outputFormat, "title": title, "split_strategy": "single", "total_documents": 1, "processed_documents": 1 } # Log AI response for debugging self.services.utils.writeDebugFile(str(result), "document_generation_response", documents) return result except Exception as e: logger.error(f"Error rendering document: {str(e)}") return {"success": False, "error": f"Rendering failed: {str(e)}"} # Handle text calls (no output format specified) if documents: # Use document processing for text calls with documents result = await self.services.ai.documentProcessor.callAiText(prompt, documents, options) else: # Use shared core function for direct text calls result = await self._callAiWithLooping(prompt, options, "text", loopInstructionFormat=None) return result # AI Image Analysis async def readImage( self, prompt: str, imageData: Union[str, bytes], mimeType: str = None, options: Optional[AiCallOptions] = None, ) -> str: """Call AI for image analysis using interface.call() with contentParts.""" try: # Check if imageData is valid if not imageData: error_msg = "No image data provided" self.services.utils.debugLogToFile(f"Error in AI image analysis: {error_msg}", "AI_SERVICE") logger.error(f"Error in AI image analysis: {error_msg}") return f"Error: {error_msg}" self.services.utils.debugLogToFile(f"readImage called with prompt, imageData type: {type(imageData)}, length: {len(imageData) if imageData else 0}, mimeType: {mimeType}", "AI_SERVICE") logger.info(f"readImage called with prompt, imageData type: {type(imageData)}, length: {len(imageData) if imageData else 0}, mimeType: {mimeType}") # Always use IMAGE_ANALYSE operation type for image processing if options is None: options = AiCallOptions(operationType=OperationTypeEnum.IMAGE_ANALYSE) else: # Override the operation type to ensure image analysis options.operationType = OperationTypeEnum.IMAGE_ANALYSE # Create content parts with image data from modules.datamodels.datamodelExtraction import ContentPart import base64 # ContentPart.data must be a string - convert bytes to base64 if needed if isinstance(imageData, bytes): imageDataStr = base64.b64encode(imageData).decode('utf-8') else: # Already a base64 string imageDataStr = imageData imagePart = ContentPart( id="image_0", parentId=None, label="Image", typeGroup="image", mimeType=mimeType or "image/jpeg", data=imageDataStr, # Must be a string (base64 encoded) metadata={"imageAnalysis": True} ) # Create request with content parts from modules.datamodels.datamodelAi import AiCallRequest request = AiCallRequest( prompt=prompt, context="", options=options, contentParts=[imagePart] ) self.services.utils.debugLogToFile(f"Calling aiObjects.call() with operationType: {options.operationType}", "AI_SERVICE") logger.info(f"Calling aiObjects.call() with operationType: {options.operationType}") # Write image analysis prompt to debug file self.services.utils.writeDebugFile(prompt, "image_analysis_prompt") response = await self.aiObjects.call(request) # Write image analysis response to debug file # response is an AiCallResponse object result = response.content self.services.utils.writeDebugFile(result, "image_analysis_response") # Debug the result self.services.utils.debugLogToFile(f"AI image analysis result type: {type(response)}, content length: {len(result)}", "AI_SERVICE") # Check if result is valid if not result or (isinstance(result, str) and not result.strip()): error_msg = f"No response from AI image analysis (result: {repr(result)})" self.services.utils.debugLogToFile(f"Error in AI image analysis: {error_msg}", "AI_SERVICE") logger.error(f"Error in AI image analysis: {error_msg}") return f"Error: {error_msg}" self.services.utils.debugLogToFile(f"callImage returned: {result[:200]}..." if len(result) > 200 else result, "AI_SERVICE") logger.info(f"callImage returned: {result[:200]}..." if len(result) > 200 else result) return result except Exception as e: self.services.utils.debugLogToFile(f"Error in AI image analysis: {str(e)}", "AI_SERVICE") logger.error(f"Error in AI image analysis: {str(e)}") return f"Error: {str(e)}" # AI Image Generation async def generateImage( self, prompt: str, size: str = "1024x1024", quality: str = "standard", style: str = "vivid", options: Optional[AiCallOptions] = None, ) -> Dict[str, Any]: """Generate an image using AI using interface.generateImage().""" try: response = await self.aiObjects.generateImage(prompt, size, quality, style, options) # Emit stats for image generation self.services.workflow.storeWorkflowStat( self.services.currentWorkflow, response, f"ai.generate.image" ) # Convert response to dict format for backward compatibility if hasattr(response, 'content'): return { "success": True, "content": response.content, "modelName": response.modelName, "priceUsd": response.priceUsd, "processingTime": response.processingTime } else: return response except Exception as e: logger.error(f"Error in AI image generation: {str(e)}") return {"success": False, "error": str(e)}