# Copyright (c) 2025 Patrick Motsch # All rights reserved. import json import logging import re import time import base64 from typing import Dict, Any, List, Optional, Tuple from modules.datamodels.datamodelChat import PromptPlaceholder, ChatDocument from modules.services.serviceExtraction.mainServiceExtraction import ExtractionService from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent from modules.datamodels.datamodelWorkflow import AiResponse, AiResponseMetadata, DocumentData from modules.interfaces.interfaceAiObjects import AiObjects from modules.shared.jsonUtils import ( extractJsonString, repairBrokenJson, extractSectionsFromDocument, buildContinuationContext, parseJsonWithModel ) from modules.services.serviceAi.subJsonResponseHandling import JsonResponseHandler from modules.datamodels.datamodelAi import JsonAccumulationState logger = logging.getLogger(__name__) # Rebuild the model to resolve forward references AiCallRequest.model_rebuild() class AiService: """AI service with core operations integrated.""" def __init__(self, serviceCenter=None) -> None: """Initialize AI service with service center access. Args: serviceCenter: Service center instance for accessing other services """ self.services = serviceCenter # Only depend on interfaces self.aiObjects = None # Will be initialized in create() or ensureAiObjectsInitialized() # Submodules initialized as None - will be set in _initializeSubmodules() after aiObjects is ready self.extractionService = None def _initializeSubmodules(self): """Initialize all submodules after aiObjects is ready.""" if self.aiObjects is None: raise RuntimeError("aiObjects must be initialized before initializing submodules") if self.extractionService is None: logger.info("Initializing ExtractionService...") self.extractionService = ExtractionService(self.services) # Initialize new submodules from modules.services.serviceAi.subResponseParsing import ResponseParser from modules.services.serviceAi.subDocumentIntents import DocumentIntentAnalyzer from modules.services.serviceAi.subContentExtraction import ContentExtractor from modules.services.serviceAi.subStructureGeneration import StructureGenerator from modules.services.serviceAi.subStructureFilling import StructureFiller if not hasattr(self, 'responseParser'): logger.info("Initializing ResponseParser...") self.responseParser = ResponseParser(self.services) if not hasattr(self, 'intentAnalyzer'): logger.info("Initializing DocumentIntentAnalyzer...") self.intentAnalyzer = DocumentIntentAnalyzer(self.services, self) if not hasattr(self, 'contentExtractor'): logger.info("Initializing ContentExtractor...") self.contentExtractor = ContentExtractor(self.services, self, self.intentAnalyzer) if not hasattr(self, 'structureGenerator'): logger.info("Initializing StructureGenerator...") self.structureGenerator = StructureGenerator(self.services, self) if not hasattr(self, 'structureFiller'): logger.info("Initializing StructureFiller...") self.structureFiller = StructureFiller(self.services, self) async def callAi(self, request: AiCallRequest, progressCallback=None): """Router: handles content parts via extractionService, text context via interface. Replaces direct calls to self.aiObjects.call() to route content parts processing through serviceExtraction layer. """ if hasattr(request, 'contentParts') and request.contentParts: return await self.extractionService.processContentPartsWithAi( request, self.aiObjects, progressCallback ) return await self.aiObjects.callWithTextContext(request) async def ensureAiObjectsInitialized(self): """Ensure aiObjects is initialized and submodules are ready.""" if self.aiObjects is None: logger.info("Lazy initializing AiObjects...") self.aiObjects = await AiObjects.create() logger.info("AiObjects initialization completed") # Initialize submodules after aiObjects is ready self._initializeSubmodules() @classmethod async def create(cls, serviceCenter=None) -> "AiService": """Create AiService instance with all connectors and submodules initialized.""" logger.info("AiService.create() called") instance = cls(serviceCenter) logger.info("AiService created, about to call AiObjects.create()...") instance.aiObjects = await AiObjects.create() logger.info("AiObjects.create() completed") # Initialize all submodules after aiObjects is ready instance._initializeSubmodules() logger.info("AiService submodules initialized") return instance # Helper methods def _buildPromptWithPlaceholders(self, prompt: str, placeholders: Optional[Dict[str, str]]) -> str: """ Build full prompt by replacing placeholders with their content. Uses the new {{KEY:placeholder}} format. Args: prompt: The base prompt template placeholders: Dictionary of placeholder key-value pairs Returns: Prompt with placeholders replaced """ if not placeholders: return prompt full_prompt = prompt for placeholder, content in placeholders.items(): # Skip if content is None or empty if content is None: continue # Replace {{KEY:placeholder}} full_prompt = full_prompt.replace(f"{{{{KEY:{placeholder}}}}}", str(content)) return full_prompt async def _analyzePromptAndCreateOptions(self, prompt: str) -> AiCallOptions: """Analyze prompt to determine appropriate AiCallOptions parameters.""" try: # Get dynamic enum values from Pydantic models operationTypes = [e.value for e in OperationTypeEnum] priorities = [e.value for e in PriorityEnum] processingModes = [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.utils.sanitizePromptContent(prompt, 'userinput')} Based on the prompt content, determine: 1. operationType: Choose the most appropriate from: {', '.join(operationTypes)} 2. priority: Choose from: {', '.join(priorities)} 3. processingMode: Choose from: {', '.join(processingModes)} 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.callAi(request) # Parse AI response using structured parsing with AiCallOptions model try: # Use parseJsonWithModel to parse response into AiCallOptions (handles enum conversion automatically) analysis = parseJsonWithModel(response.content, AiCallOptions) return analysis 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 ) async def _callAiWithLooping( self, prompt: str, options: AiCallOptions, debugPrefix: str = "ai_call", promptBuilder: Optional[callable] = None, promptArgs: Optional[Dict[str, Any]] = None, operationId: Optional[str] = None, userPrompt: Optional[str] = None, contentParts: Optional[List[ContentPart]] = None # ARCHITECTURE: Support ContentParts for large content ) -> str: """ Shared core function for AI calls with repair-based looping system. Automatically repairs broken JSON and continues generation seamlessly. Args: prompt: The prompt to send to AI options: AI call configuration options debugPrefix: Prefix for debug file names promptBuilder: Optional function to rebuild prompts for continuation promptArgs: Optional arguments for prompt builder operationId: Optional operation ID for progress tracking Returns: Complete AI response after all iterations """ maxIterations = 50 # Prevent infinite loops iteration = 0 allSections = [] # Accumulate all sections across iterations lastRawResponse = None # Store last raw JSON response for continuation documentMetadata = None # Store document metadata (title, filename) from first iteration accumulationState = None # Track accumulation state for string accumulation # Get parent operation ID for iteration operations (parentId should be operationId, not log entry ID) parentOperationId = operationId # Use the parent's operationId directly while iteration < maxIterations: iteration += 1 # Create separate operation for each iteration with parent reference iterationOperationId = None if operationId: iterationOperationId = f"{operationId}_iter_{iteration}" self.services.chat.progressLogStart( iterationOperationId, "AI Call", f"Iteration {iteration}", "", parentOperationId=parentOperationId ) # Build iteration prompt # CRITICAL: Build continuation prompt if we have sections OR if we have a previous response (even if broken) # This ensures continuation prompts are built even when JSON is so broken that no sections can be extracted if (len(allSections) > 0 or lastRawResponse) and promptBuilder and promptArgs: # This is a continuation - build continuation context with raw JSON and rebuild prompt continuationContext = buildContinuationContext(allSections, lastRawResponse) if not lastRawResponse: logger.warning(f"Iteration {iteration}: No previous response available for continuation!") # Filter promptArgs to only include parameters that buildGenerationPrompt accepts # buildGenerationPrompt accepts: outputFormat, userPrompt, title, extracted_content, continuationContext, services filteredPromptArgs = { k: v for k, v in promptArgs.items() if k in ['outputFormat', 'userPrompt', 'title', 'extracted_content', 'services'] } # Always include services if available if not filteredPromptArgs.get('services') and hasattr(self, 'services'): filteredPromptArgs['services'] = self.services # Rebuild prompt with continuation context using the provided prompt builder iterationPrompt = await promptBuilder(**filteredPromptArgs, continuationContext=continuationContext) else: # First iteration - use original prompt iterationPrompt = prompt # Make AI call try: if iterationOperationId: self.services.chat.progressLogUpdate(iterationOperationId, 0.3, "Calling AI model") # ARCHITECTURE: Pass ContentParts directly to AiCallRequest # This allows model-aware chunking to handle large content properly # ContentParts are only passed in first iteration (continuations don't need them) request = AiCallRequest( prompt=iterationPrompt, context="", options=options, contentParts=contentParts if iteration == 1 else None # Only pass ContentParts in first iteration ) # Write the ACTUAL prompt sent to AI if iteration == 1: self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt") else: self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt_iteration_{iteration}") response = await self.callAi(request) result = response.content # Track bytes for progress reporting bytesReceived = len(result.encode('utf-8')) if result else 0 totalBytesSoFar = sum(len(section.get('content', '').encode('utf-8')) if isinstance(section.get('content'), str) else 0 for section in allSections) + bytesReceived # Update progress after AI call with byte information if iterationOperationId: # Format bytes for display (kB or MB) if totalBytesSoFar < 1024: bytesDisplay = f"{totalBytesSoFar}B" elif totalBytesSoFar < 1024 * 1024: bytesDisplay = f"{totalBytesSoFar / 1024:.1f}kB" else: bytesDisplay = f"{totalBytesSoFar / (1024 * 1024):.1f}MB" self.services.chat.progressLogUpdate(iterationOperationId, 0.6, f"AI response received ({bytesDisplay})") # Write raw AI response to debug file if iteration == 1: self.services.utils.writeDebugFile(result, f"{debugPrefix}_response") else: self.services.utils.writeDebugFile(result, f"{debugPrefix}_response_iteration_{iteration}") # Emit stats for this iteration (only if workflow exists and has id) if self.services.workflow and hasattr(self.services.workflow, 'id') and self.services.workflow.id: try: self.services.chat.storeWorkflowStat( self.services.workflow, response, f"ai.call.{debugPrefix}.iteration_{iteration}" ) except Exception as statError: # Don't break the main loop if stat storage fails logger.warning(f"Failed to store workflow stat: {str(statError)}") # Check for error response using generic error detection (errorCount > 0 or modelName == "error") if hasattr(response, 'errorCount') and response.errorCount > 0: errorMsg = f"Iteration {iteration}: Error response detected (errorCount={response.errorCount}), stopping loop: {result[:200] if result else 'empty'}" logger.error(errorMsg) break if hasattr(response, 'modelName') and response.modelName == "error": errorMsg = f"Iteration {iteration}: Error response detected (modelName=error), stopping loop: {result[:200] if result else 'empty'}" logger.error(errorMsg) break if not result or not result.strip(): logger.warning(f"Iteration {iteration}: Empty response, stopping") break # Check if this is a text response (not document generation) # Text responses don't need JSON parsing - return immediately after first successful response isTextResponse = (promptBuilder is None and promptArgs is None) or debugPrefix == "text" if isTextResponse: # For text responses, return the text immediately - no JSON parsing needed logger.info(f"Iteration {iteration}: Text response received, returning immediately") if iterationOperationId: self.services.chat.progressLogFinish(iterationOperationId, True) return result # Store raw response for continuation (even if broken) lastRawResponse = result # Extract sections from response (handles both valid and broken JSON) # Only for document generation (JSON responses) # CRITICAL: Pass allSections and accumulationState to enable string accumulation extractedSections, wasJsonComplete, parsedResult, accumulationState = self._extractSectionsFromResponse( result, iteration, debugPrefix, allSections, accumulationState ) # CRITICAL: Merge sections BEFORE KPI validation # This ensures sections are preserved even if KPI validation fails if extractedSections: allSections = JsonResponseHandler.mergeSectionsIntelligently(allSections, extractedSections, iteration) # Define KPIs if we just entered accumulation mode (iteration 1, incomplete JSON) if accumulationState and accumulationState.isAccumulationMode and iteration == 1 and not accumulationState.kpis: logger.info(f"Iteration {iteration}: Defining KPIs for accumulation tracking") continuationContext = buildContinuationContext(allSections, result) # Pass raw response string from first iteration for KPI definition kpiDefinitions = await self._defineKpisFromPrompt( userPrompt or prompt, result, # Pass raw JSON string from first iteration continuationContext, debugPrefix ) # Initialize KPIs with currentValue = 0 accumulationState.kpis = [{**kpi, "currentValue": 0} for kpi in kpiDefinitions] logger.info(f"Defined {len(accumulationState.kpis)} KPIs: {[kpi.get('id') for kpi in accumulationState.kpis]}") # Extract and validate KPIs (if in accumulation mode with KPIs defined) if accumulationState and accumulationState.isAccumulationMode and accumulationState.kpis: # For KPI extraction, prefer accumulated JSON string over repaired JSON # because repairBrokenJson may lose data (e.g., empty rows array when JSON is incomplete) updatedKpis = [] # First try to extract from parsedResult (repaired JSON) if parsedResult: try: updatedKpis = JsonResponseHandler.extractKpiValuesFromJson( parsedResult, accumulationState.kpis ) # Check if we got meaningful values (non-zero) hasValidValues = any(kpi.get("currentValue", 0) > 0 for kpi in updatedKpis) if not hasValidValues and accumulationState.accumulatedJsonString: # Repaired JSON has empty values, try accumulated string logger.debug("Repaired JSON has empty KPI values, trying accumulated JSON string") updatedKpis = JsonResponseHandler.extractKpiValuesFromIncompleteJson( accumulationState.accumulatedJsonString, accumulationState.kpis ) except Exception as e: logger.debug(f"Error extracting KPIs from parsedResult: {e}") updatedKpis = [] # If no parsedResult or extraction failed, try accumulated string if not updatedKpis and accumulationState.accumulatedJsonString: try: updatedKpis = JsonResponseHandler.extractKpiValuesFromIncompleteJson( accumulationState.accumulatedJsonString, accumulationState.kpis ) except Exception as e: logger.debug(f"Error extracting KPIs from accumulated JSON string: {e}") updatedKpis = [] if updatedKpis: shouldProceed, reason = JsonResponseHandler.validateKpiProgression( accumulationState, updatedKpis ) if not shouldProceed: logger.warning(f"Iteration {iteration}: KPI validation failed: {reason}") if iterationOperationId: self.services.chat.progressLogFinish(iterationOperationId, False) if operationId: self.services.chat.progressLogUpdate(operationId, 0.9, f"KPI validation failed: {reason} ({iteration} iterations)") break # Update KPIs in accumulation state accumulationState.kpis = updatedKpis logger.info(f"Iteration {iteration}: KPIs updated: {[(kpi.get('id'), kpi.get('currentValue')) for kpi in updatedKpis]}") # Check if all KPIs completed allCompleted = True for kpi in updatedKpis: targetValue = kpi.get("targetValue", 0) currentValue = kpi.get("currentValue", 0) if currentValue < targetValue: allCompleted = False break if allCompleted: logger.info(f"Iteration {iteration}: All KPIs completed, finishing accumulation") wasJsonComplete = True # Mark as complete to exit loop # CRITICAL: Handle JSON fragments (continuation content) # Fragment merging happens inside _extractSectionsFromResponse # If merge fails (returns wasJsonComplete=True), stop iterations and complete JSON if not extractedSections and allSections: if wasJsonComplete: # Merge failed - stop iterations, complete JSON with available data logger.error(f"Iteration {iteration}: ❌ MERGE FAILED - Stopping iterations, completing JSON with available data") if iterationOperationId: self.services.chat.progressLogFinish(iterationOperationId, False) if operationId: self.services.chat.progressLogUpdate(operationId, 0.9, f"Merge failed, completing JSON ({iteration} iterations)") break # Fragment was detected and merged successfully logger.info(f"Iteration {iteration}: JSON fragment detected and merged, continuing") # Don't break - fragment was merged, continue to get more content if needed # Check if we should continue based on JSON completeness shouldContinue = self._shouldContinueGeneration( allSections, iteration, wasJsonComplete, result ) if shouldContinue: if iterationOperationId: self.services.chat.progressLogUpdate(iterationOperationId, 0.8, "Fragment merged, continuing") self.services.chat.progressLogFinish(iterationOperationId, True) continue else: # Done - fragment was merged and JSON is complete if iterationOperationId: self.services.chat.progressLogFinish(iterationOperationId, True) if operationId: self.services.chat.progressLogUpdate(operationId, 0.95, f"Generation complete ({iteration} iterations, fragment merged)") logger.info(f"Generation complete after {iteration} iterations: fragment merged") break # Extract document metadata from first iteration if available if iteration == 1 and parsedResult and not documentMetadata: documentMetadata = self._extractDocumentMetadata(parsedResult) # Update progress after parsing if iterationOperationId: if extractedSections: self.services.chat.progressLogUpdate(iterationOperationId, 0.8, f"Extracted {len(extractedSections)} sections") if not extractedSections: # CRITICAL: If JSON was incomplete/broken, continue even if no sections extracted # This allows the AI to retry and complete the broken JSON if not wasJsonComplete: logger.warning(f"Iteration {iteration}: No sections extracted from broken JSON, continuing for another attempt") continue # If JSON was complete but no sections extracted - check if it was a fragment # Fragments are handled above, so if we get here and it's complete, it's an error logger.warning(f"Iteration {iteration}: No sections extracted from complete JSON, stopping") break # NOTE: Section merging now happens BEFORE KPI validation (see above) # This ensures sections are preserved even if KPI validation fails # Calculate total bytes in merged content for progress display merged_json_str = json.dumps(allSections, indent=2, ensure_ascii=False) totalBytesGenerated = len(merged_json_str.encode('utf-8')) # Update main operation with byte progress if operationId: # Format bytes for display if totalBytesGenerated < 1024: bytesDisplay = f"{totalBytesGenerated}B" elif totalBytesGenerated < 1024 * 1024: bytesDisplay = f"{totalBytesGenerated / 1024:.1f}kB" else: bytesDisplay = f"{totalBytesGenerated / (1024 * 1024):.1f}MB" # Estimate progress based on iterations (rough estimate) estimatedProgress = min(0.9, 0.4 + (iteration * 0.1)) self.services.chat.progressLogUpdate(operationId, estimatedProgress, f"Pipeline: {bytesDisplay} (iteration {iteration})") # Log merged sections for debugging self.services.utils.writeDebugFile(merged_json_str, f"{debugPrefix}_merged_sections_iteration_{iteration}") # Check if we should continue (completion detection) # Simple logic: JSON completeness determines continuation shouldContinue = self._shouldContinueGeneration( allSections, iteration, wasJsonComplete, result ) if shouldContinue: # Finish iteration operation (will continue with next iteration) if iterationOperationId: # Show byte progress in iteration completion iterBytes = len(result.encode('utf-8')) if result else 0 if iterBytes < 1024: iterBytesDisplay = f"{iterBytes}B" elif iterBytes < 1024 * 1024: iterBytesDisplay = f"{iterBytes / 1024:.1f}kB" else: iterBytesDisplay = f"{iterBytes / (1024 * 1024):.1f}MB" self.services.chat.progressLogUpdate(iterationOperationId, 0.95, f"Completed ({iterBytesDisplay})") self.services.chat.progressLogFinish(iterationOperationId, True) continue else: # Done - finish iteration and update main operation if iterationOperationId: # Show final byte count finalBytes = len(merged_json_str.encode('utf-8')) if finalBytes < 1024: finalBytesDisplay = f"{finalBytes}B" elif finalBytes < 1024 * 1024: finalBytesDisplay = f"{finalBytes / 1024:.1f}kB" else: finalBytesDisplay = f"{finalBytes / (1024 * 1024):.1f}MB" self.services.chat.progressLogUpdate(iterationOperationId, 0.95, f"Complete ({finalBytesDisplay})") self.services.chat.progressLogFinish(iterationOperationId, True) if operationId: # Show final size in main operation finalBytes = len(merged_json_str.encode('utf-8')) if finalBytes < 1024: finalBytesDisplay = f"{finalBytes}B" elif finalBytes < 1024 * 1024: finalBytesDisplay = f"{finalBytes / 1024:.1f}kB" else: finalBytesDisplay = f"{finalBytes / (1024 * 1024):.1f}MB" self.services.chat.progressLogUpdate(operationId, 0.95, f"Generation complete: {finalBytesDisplay} ({iteration} iterations, {len(allSections)} sections)") logger.info(f"Generation complete after {iteration} iterations: {len(allSections)} sections") break except Exception as e: logger.error(f"Error in AI call iteration {iteration}: {str(e)}") if iterationOperationId: self.services.chat.progressLogFinish(iterationOperationId, False) break if iteration >= maxIterations: logger.warning(f"AI call stopped after maximum iterations ({maxIterations})") # CRITICAL: Complete any incomplete structures in sections before building final result # This ensures JSON is properly closed even if merge failed or iterations stopped early allSections = JsonResponseHandler.completeIncompleteStructures(allSections) # Build final result from accumulated sections final_result = self._buildFinalResultFromSections(allSections, documentMetadata) # Write final result to debug file self.services.utils.writeDebugFile(final_result, f"{debugPrefix}_final_result") return final_result # JSON merging logic moved to subJsonResponseHandling.py async def _defineKpisFromPrompt( self, userPrompt: str, rawJsonString: Optional[str], continuationContext: Dict[str, Any], debugPrefix: str = "kpi" ) -> List[Dict[str, Any]]: """ Make separate AI call to define KPIs based on user prompt and incomplete JSON. Args: userPrompt: Original user prompt rawJsonString: Raw JSON string from first iteration response continuationContext: Continuation context (not used for JSON, kept for compatibility) Returns: List of KPI definitions: [{"id": str, "description": str, "jsonPath": str, "targetValue": int}, ...] """ # Use raw JSON string from first iteration response if rawJsonString: # Remove markdown code fences if present from modules.shared.jsonUtils import stripCodeFences incompleteJson = stripCodeFences(rawJsonString.strip()) else: incompleteJson = "Not available" kpiDefinitionPrompt = f"""Analyze the user request and incomplete JSON to define KPIs (Key Performance Indicators) for tracking progress. User Request: {userPrompt} Delivered JSON part: {incompleteJson} Task: Define which JSON items should be tracked to measure completion progress. IMPORTANT: Analyze the Delivered JSON part structure to understand what is being tracked: 1. Identify the structure type (table with rows, list with items, etc.) 2. Determine what the jsonPath actually counts (number of rows, number of items, etc.) 3. Calculate targetValue based on what is being tracked, NOT the total quantity requested For each trackable item, provide: - id: Unique identifier (use descriptive name) - description: What this KPI measures (be specific about what is counted) - jsonPath: Path to extract value from JSON (use dot notation with array indices, e.g., "documents[0].sections[1].elements[0].rows") - targetValue: Target value to reach (integer) - MUST match what jsonPath actually tracks (rows count, items count, etc.) Return ONLY valid JSON in this format: {{ "kpis": [ {{ "id": "unique_id", "description": "Description of what is measured", "jsonPath": "path.to.value", "targetValue": 0 }} ] }} If no trackable items can be identified, return: {{"kpis": []}} """ try: request = AiCallRequest( prompt=kpiDefinitionPrompt, options=AiCallOptions( operationType=OperationTypeEnum.DATA_ANALYSE, priority=PriorityEnum.SPEED, processingMode=ProcessingModeEnum.BASIC ) ) # Write KPI definition prompt to debug file self.services.utils.writeDebugFile(kpiDefinitionPrompt, f"{debugPrefix}_kpi_definition_prompt") response = await self.callAi(request) # Write KPI definition response to debug file self.services.utils.writeDebugFile(response.content, f"{debugPrefix}_kpi_definition_response") # Parse response extracted = extractJsonString(response.content) kpiResponse = json.loads(extracted) kpiDefinitions = kpiResponse.get("kpis", []) logger.info(f"Defined {len(kpiDefinitions)} KPIs for tracking") return kpiDefinitions except Exception as e: logger.warning(f"Failed to define KPIs: {e}, continuing without KPI tracking") return [] def _extractSectionsFromResponse( self, result: str, iteration: int, debugPrefix: str, allSections: List[Dict[str, Any]] = None, accumulationState: Optional[JsonAccumulationState] = None ) -> Tuple[List[Dict[str, Any]], bool, Optional[Dict[str, Any]], Optional[JsonAccumulationState]]: """Delegate to ResponseParser.""" return self.responseParser.extractSectionsFromResponse( result, iteration, debugPrefix, allSections, accumulationState ) def _extractSectionsFromResponse_OLD( self, result: str, iteration: int, debugPrefix: str, allSections: List[Dict[str, Any]] = None, accumulationState: Optional[JsonAccumulationState] = None ) -> Tuple[List[Dict[str, Any]], bool, Optional[Dict[str, Any]], Optional[JsonAccumulationState]]: """ Extract sections from AI response, handling both valid and broken JSON. NEW BEHAVIOR: - First iteration: Check if complete, if not start accumulation - Subsequent iterations: Accumulate strings, parse when complete Returns: Tuple of: - sections: Extracted sections - wasJsonComplete: True if JSON is complete - parsedResult: Parsed JSON object - updatedAccumulationState: Updated accumulation state (None if not in accumulation mode) """ if allSections is None: allSections = [] if iteration == 1: # First iteration - check if complete parsed = None try: extracted = extractJsonString(result) parsed = json.loads(extracted) # Check completeness if JsonResponseHandler.isJsonComplete(parsed): # Complete JSON - no accumulation needed sections = extractSectionsFromDocument(parsed) logger.info(f"Iteration 1: Complete JSON detected, no accumulation needed") return sections, True, parsed, None # No accumulation except Exception: pass # Incomplete - try to extract partial sections from broken JSON logger.info(f"Iteration 1: Incomplete JSON detected, attempting to extract partial sections") partialSections = [] if parsed: # Try to extract sections from parsed (even if incomplete) partialSections = extractSectionsFromDocument(parsed) else: # Try to repair broken JSON and extract sections try: repaired = repairBrokenJson(result) if repaired: partialSections = extractSectionsFromDocument(repaired) parsed = repaired # Use repaired version for accumulation state except Exception: pass # If repair fails, continue with empty sections # Define KPIs (async call - need to handle this) # For now, create accumulation state without KPIs, will be updated after async call accumulationState = JsonAccumulationState( accumulatedJsonString=result, isAccumulationMode=True, lastParsedResult=parsed, allSections=partialSections, kpis=[] ) # Note: KPI definition will be done in the caller (async context) return partialSections, False, parsed, accumulationState else: # Subsequent iterations - accumulate if accumulationState and accumulationState.isAccumulationMode: accumulated, sections, isComplete, parsedResult = \ JsonResponseHandler.accumulateAndParseJsonFragments( accumulationState.accumulatedJsonString, result, allSections, iteration ) # Update accumulation state accumulationState.accumulatedJsonString = accumulated accumulationState.lastParsedResult = parsedResult accumulationState.allSections = allSections + sections if sections else allSections accumulationState.isAccumulationMode = not isComplete # Log accumulated JSON for debugging if parsedResult: accumulated_json_str = json.dumps(parsedResult, indent=2, ensure_ascii=False) self.services.utils.writeDebugFile(accumulated_json_str, f"{debugPrefix}_accumulated_json_iteration_{iteration}.json") return sections, isComplete, parsedResult, accumulationState else: # No accumulation mode - process normally (shouldn't happen) logger.warning(f"Iteration {iteration}: No accumulation state but iteration > 1") return [], False, None, None def _shouldContinueGeneration( self, allSections: List[Dict[str, Any]], iteration: int, wasJsonComplete: bool, rawResponse: str = None ) -> bool: """Delegate to ResponseParser.""" return self.responseParser.shouldContinueGeneration( allSections, iteration, wasJsonComplete, rawResponse ) def _shouldContinueGeneration_OLD( self, allSections: List[Dict[str, Any]], iteration: int, wasJsonComplete: bool, rawResponse: str = None ) -> bool: """ Determine if AI generation loop should continue. CRITICAL: This is ONLY about AI Loop Completion, NOT Action DoD! Action DoD is checked AFTER the AI Loop completes in _refineDecide. Simple logic: - If JSON parsing failed or incomplete → continue (needs more content) - If JSON parses successfully and is complete → stop (all content delivered) - Loop detection prevents infinite loops CRITICAL: JSON completeness is determined by parsing, NOT by last character check! Returns True if we should continue, False if AI Loop is done. """ if len(allSections) == 0: return True # No sections yet, continue # CRITERION 1: If JSON was incomplete/broken (parsing failed or incomplete) - continue to repair/complete if not wasJsonComplete: logger.info(f"Iteration {iteration}: JSON incomplete/broken - continuing to complete") return True # CRITERION 2: JSON is complete (parsed successfully) - check for loop detection if self._isStuckInLoop(allSections, iteration): logger.warning(f"Iteration {iteration}: Detected potential infinite loop - stopping AI loop") return False # JSON is complete and not stuck in loop - done logger.info(f"Iteration {iteration}: JSON complete - AI loop done") return False def _isStuckInLoop( self, allSections: List[Dict[str, Any]], iteration: int ) -> bool: """ Detect if we're stuck in a loop (same content being repeated). Generic approach: Check if recent iterations are adding minimal or duplicate content. """ if iteration < 3: return False # Need at least 3 iterations to detect a loop if len(allSections) == 0: return False # Check if last section is very small (might be stuck) lastSection = allSections[-1] elements = lastSection.get("elements", []) if isinstance(elements, list) and elements: lastElem = elements[-1] if elements else {} else: lastElem = elements if isinstance(elements, dict) else {} # Check content size of last section lastSectionSize = 0 if isinstance(lastElem, dict): for key, value in lastElem.items(): if isinstance(value, str): lastSectionSize += len(value) elif isinstance(value, list): lastSectionSize += len(str(value)) # If last section is very small and we've done many iterations, might be stuck if lastSectionSize < 100 and iteration > 10: logger.warning(f"Potential loop detected: iteration {iteration}, last section size {lastSectionSize}") return True return False def _extractDocumentMetadata( self, parsedResult: Dict[str, Any] ) -> Optional[Dict[str, Any]]: """Delegate to ResponseParser.""" return self.responseParser.extractDocumentMetadata(parsedResult) def _extractDocumentMetadata_OLD( self, parsedResult: Dict[str, Any] ) -> Optional[Dict[str, Any]]: """ Extract document metadata (title, filename) from parsed AI response. Returns dict with 'title' and 'filename' keys if found, None otherwise. """ if not isinstance(parsedResult, dict): return None # Try to get from documents array (preferred structure) if "documents" in parsedResult and isinstance(parsedResult["documents"], list) and len(parsedResult["documents"]) > 0: firstDoc = parsedResult["documents"][0] if isinstance(firstDoc, dict): title = firstDoc.get("title") filename = firstDoc.get("filename") if title or filename: return { "title": title, "filename": filename } return None def _buildFinalResultFromSections( self, allSections: List[Dict[str, Any]], documentMetadata: Optional[Dict[str, Any]] = None ) -> str: """Delegate to ResponseParser.""" return self.responseParser.buildFinalResultFromSections(allSections, documentMetadata) def _buildFinalResultFromSections_OLD( self, allSections: List[Dict[str, Any]], documentMetadata: Optional[Dict[str, Any]] = None ) -> str: """ Build final JSON result from accumulated sections. Uses AI-provided metadata (title, filename) if available. """ if not allSections: return "" # Extract metadata from AI response if available title = "Generated Document" filename = "document.json" if documentMetadata: if documentMetadata.get("title"): title = documentMetadata["title"] if documentMetadata.get("filename"): filename = documentMetadata["filename"] # Build documents structure # Assuming single document for now documents = [{ "id": "doc_1", "title": title, "filename": filename, "sections": allSections }] result = { "metadata": { "split_strategy": "single_document", "source_documents": [], "extraction_method": "ai_generation" }, "documents": documents } return json.dumps(result, indent=2) # Public API Methods # Planning AI Call async def callAiPlanning( self, prompt: str, placeholders: Optional[List[PromptPlaceholder]] = None, debugType: 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 debugType: Optional debug file type identifier (e.g., 'taskplan', 'dynamic', 'intentanalysis') If not provided, defaults to 'plan' Returns: Planning JSON response """ await self.ensureAiObjectsInitialized() # Planning calls always use static parameters options = AiCallOptions( operationType=OperationTypeEnum.PLAN, priority=PriorityEnum.QUALITY, processingMode=ProcessingModeEnum.DETAILED, compressPrompt=False, compressContext=False ) # Build full prompt with placeholders if placeholders: placeholdersDict = {p.label: p.content for p in placeholders} fullPrompt = self._buildPromptWithPlaceholders(prompt, placeholdersDict) else: fullPrompt = prompt # Root-cause fix: planning must return raw single-shot JSON, not section-based output request = AiCallRequest( prompt=fullPrompt, context="", options=options ) # Debug: persist prompt/response for analysis with context-specific naming debugPrefix = debugType if debugType else "plan" self.services.utils.writeDebugFile(fullPrompt, f"{debugPrefix}_prompt") response = await self.aiObjects.callWithTextContext(request) result = response.content or "" self.services.utils.writeDebugFile(result, f"{debugPrefix}_response") return result # Helper methods for callAiContent refactoring async def _handleImageGeneration( self, prompt: str, options: AiCallOptions, title: Optional[str], aiOperationId: str ) -> AiResponse: """Handle IMAGE_GENERATE operation type.""" self.services.chat.progressLogUpdate(aiOperationId, 0.4, "Calling AI for image generation") request = AiCallRequest( prompt=prompt, context="", options=options ) response = await self.callAi(request) if not response.content: errorMsg = f"No image data returned: {response.content}" logger.error(f"Error in AI image generation: {errorMsg}") self.services.chat.progressLogFinish(aiOperationId, False) raise ValueError(errorMsg) imageDoc = DocumentData( documentName="generated_image.png", documentData=response.content, mimeType="image/png" ) metadata = AiResponseMetadata( title=title or "Generated Image", operationType=options.operationType.value ) self.services.chat.storeWorkflowStat( self.services.workflow, response, "ai.generate.image" ) self.services.chat.progressLogUpdate(aiOperationId, 0.9, "Image generated") self.services.chat.progressLogFinish(aiOperationId, True) return AiResponse( content=response.content, metadata=metadata, documents=[imageDoc] ) async def _handleWebOperation( self, prompt: str, options: AiCallOptions, opType: OperationTypeEnum, aiOperationId: str ) -> AiResponse: """Handle WEB_SEARCH and WEB_CRAWL operation types.""" self.services.chat.progressLogUpdate(aiOperationId, 0.4, f"Calling AI for {opType.name}") request = AiCallRequest( prompt=prompt, # Raw JSON prompt - connector will parse it context="", options=options ) response = await self.callAi(request) if not response.content: errorMsg = f"No content returned from {opType.name}: {response.content}" logger.error(f"Error in {opType.name}: {errorMsg}") self.services.chat.progressLogFinish(aiOperationId, False) raise ValueError(errorMsg) metadata = AiResponseMetadata( operationType=opType.value ) self.services.chat.storeWorkflowStat( self.services.workflow, response, f"ai.{opType.name.lower()}" ) self.services.chat.progressLogUpdate(aiOperationId, 0.9, f"{opType.name} completed") self.services.chat.progressLogFinish(aiOperationId, True) return AiResponse( content=response.content, metadata=metadata ) def _getIntentForDocument( self, docId: str, intents: Optional[List[DocumentIntent]] ) -> Optional[DocumentIntent]: """Find DocumentIntent for given documentId.""" if not intents: return None for intent in intents: if intent.documentId == docId: return intent return None async def _clarifyDocumentIntents( self, documents: List[ChatDocument], userPrompt: str, actionParameters: Dict[str, Any], parentOperationId: str ) -> List[DocumentIntent]: """Delegate to DocumentIntentAnalyzer.""" return await self.intentAnalyzer.clarifyDocumentIntents( documents, userPrompt, actionParameters, parentOperationId ) async def _clarifyDocumentIntents_OLD( self, documents: List[ChatDocument], userPrompt: str, actionParameters: Dict[str, Any], parentOperationId: str ) -> List[DocumentIntent]: """ Phase 5A: Analysiert, welche Dokumente Extraktion vs Referenz benötigen. Gibt DocumentIntent für jedes Dokument zurück. Args: documents: Liste der zu verarbeitenden Dokumente userPrompt: User-Anfrage actionParameters: Action-spezifische Parameter (z.B. resultType, outputFormat) parentOperationId: Parent Operation-ID für ChatLog-Hierarchie Returns: Liste von DocumentIntent-Objekten """ from modules.datamodels.datamodelChat import ChatDocument # Erstelle Operation-ID für Intent-Analyse intentOperationId = f"{parentOperationId}_intent_analysis" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( intentOperationId, "Document Intent Analysis", "Intent Analysis", f"Analyzing {len(documents)} documents", parentOperationId=parentOperationId ) try: # Mappe pre-extracted JSONs zu ursprünglichen Dokument-IDs für Intent-Analyse documentMapping = {} # Maps original doc ID -> JSON doc ID resolvedDocuments = [] for doc in documents: preExtracted = self._resolvePreExtractedDocument(doc) if preExtracted: originalDocId = preExtracted["originalDocument"]["id"] documentMapping[originalDocId] = doc.id # Erstelle temporäres ChatDocument für ursprüngliches Dokument from modules.datamodels.datamodelChat import ChatDocument originalDoc = ChatDocument( id=originalDocId, fileName=preExtracted["originalDocument"]["fileName"], mimeType=preExtracted["originalDocument"]["mimeType"], fileSize=preExtracted["originalDocument"].get("fileSize", doc.fileSize), fileId=doc.fileId, # Behalte fileId vom JSON messageId=doc.messageId if hasattr(doc, 'messageId') else None # Behalte messageId falls vorhanden ) resolvedDocuments.append(originalDoc) else: resolvedDocuments.append(doc) # Baue Intent-Analyse-Prompt mit ursprünglichen Dokumenten intentPrompt = self._buildIntentAnalysisPrompt(userPrompt, resolvedDocuments, actionParameters) # AI-Call (verwende callAiPlanning für einfache JSON-Responses) # Debug-Logs werden bereits von callAiPlanning geschrieben aiResponse = await self.callAiPlanning( prompt=intentPrompt, debugType="document_intent_analysis" ) # Parse Result und mappe zurück zu JSON-Dokument-IDs falls nötig intentsData = json.loads(self.services.utils.jsonExtractString(aiResponse)) documentIntents = [] for intent in intentsData.get("intents", []): docId = intent.get("documentId") # Wenn Intent für ursprüngliches Dokument, mappe zurück zu JSON-Dokument-ID if docId in documentMapping: intent["documentId"] = documentMapping[docId] documentIntents.append(DocumentIntent(**intent)) # Debug-Log (harmonisiert) self.services.utils.writeDebugFile( json.dumps([intent.dict() for intent in documentIntents], indent=2), "document_intent_analysis_result" ) # ChatLog abschließen self.services.chat.progressLogFinish(intentOperationId, True) return documentIntents except Exception as e: self.services.chat.progressLogFinish(intentOperationId, False) logger.error(f"Error in _clarifyDocumentIntents: {str(e)}") raise def _resolvePreExtractedDocument(self, document: ChatDocument) -> Optional[Dict[str, Any]]: """ Prüft ob ein JSON-Dokument bereits extrahierte ContentParts enthält. Gibt Dict zurück mit: - originalDocument: ChatDocument-Info des ursprünglichen Dokuments - contentExtracted: ContentExtracted-Objekt mit Parts - parts: Liste der ContentParts Returns None wenn kein pre-extracted Format erkannt wird. """ if document.mimeType != "application/json": logger.debug(f"Document {document.id} is not JSON (mimeType={document.mimeType}), skipping pre-extracted check") return None try: docBytes = self.services.interfaceDbComponent.getFileData(document.fileId) if not docBytes: return None docData = docBytes.decode('utf-8') jsonData = json.loads(docData) if not isinstance(jsonData, dict): return None # Check for ContentExtracted format # Nur Format 1 (ActionDocument-Format mit validationMetadata) wird unterstützt documentData = None validationMetadata = jsonData.get("validationMetadata", {}) actionType = validationMetadata.get("actionType") logger.debug(f"JSON document {document.id}: validationMetadata.actionType={actionType}, keys={list(jsonData.keys())}") if actionType == "context.extractContent": # Format: {"validationMetadata": {"actionType": "context.extractContent"}, "documentData": {...}} documentData = jsonData.get("documentData") logger.debug(f"Found ContentExtracted via validationMetadata for {document.fileName}, documentData keys: {list(documentData.keys()) if documentData else None}") else: logger.debug(f"JSON document {document.id} does not have actionType='context.extractContent' (got: {actionType})") if documentData: from modules.datamodels.datamodelExtraction import ContentExtracted try: # Stelle sicher, dass "id" vorhanden ist if "id" not in documentData: documentData["id"] = document.id contentExtracted = ContentExtracted(**documentData) if contentExtracted.parts: # Extrahiere ursprüngliche Dokument-Info aus den Parts originalDocId = None originalFileName = None originalMimeType = None for part in contentExtracted.parts: if part.metadata: # Versuche ursprüngliche Dokument-Info zu finden if not originalDocId and part.metadata.get("documentId"): originalDocId = part.metadata.get("documentId") if not originalFileName and part.metadata.get("originalFileName"): originalFileName = part.metadata.get("originalFileName") if not originalMimeType and part.metadata.get("documentMimeType"): originalMimeType = part.metadata.get("documentMimeType") # Falls nicht gefunden, versuche aus documentName zu extrahieren if not originalFileName: # Versuche aus documentName zu extrahieren (z.B. "B2025-02c_28_extracted_...json" -> "B2025-02c_28.pdf") if document.fileName and "_extracted_" in document.fileName: originalFileName = document.fileName.split("_extracted_")[0] + ".pdf" return { "originalDocument": { "id": originalDocId or document.id, "fileName": originalFileName or document.fileName, "mimeType": originalMimeType or "application/pdf", "fileSize": document.fileSize }, "contentExtracted": contentExtracted, "parts": contentExtracted.parts } except Exception as parseError: logger.warning(f"Could not parse ContentExtracted format from {document.fileName}: {str(parseError)}") logger.debug(f"JSON keys: {list(jsonData.keys())}, has parts: {'parts' in jsonData}") import traceback logger.debug(f"Parse error traceback: {traceback.format_exc()}") return None else: logger.debug(f"JSON document {document.id} has no documentData (actionType={actionType})") return None except Exception as e: logger.debug(f"Error resolving pre-extracted document {document.fileName}: {str(e)}") return None async def _extractTextFromImage(self, imagePart: ContentPart, extractionPrompt: str) -> Optional[str]: """ Extrahiere Text aus einem Image-Part mit Vision AI. Args: imagePart: ContentPart mit typeGroup="image" extractionPrompt: Prompt für die Text-Extraktion Returns: Extrahierter Text oder None bei Fehler """ try: from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum # Final extraction prompt finalPrompt = extractionPrompt or "Extract all text content from this image. Return only the extracted text, no additional formatting." # Debug-Log (harmonisiert) self.services.utils.writeDebugFile( finalPrompt, f"content_extraction_prompt_image_{imagePart.id}" ) # Erstelle AI-Call-Request mit Image-Part request = AiCallRequest( prompt=finalPrompt, context="", options=AiCallOptions(operationType=OperationTypeEnum.IMAGE_ANALYSE), contentParts=[imagePart] ) # Verwende AI-Service für Vision AI-Verarbeitung response = await self.services.ai.callAi(request) # Debug-Log für Response (harmonisiert) if response and response.content: self.services.utils.writeDebugFile( response.content, f"content_extraction_response_image_{imagePart.id}" ) if response and response.content: return response.content.strip() # Kein Content zurückgegeben - return error message für Debugging errorMsg = f"Vision AI extraction failed: No content returned for image {imagePart.id}" logger.warning(errorMsg) return f"[ERROR: {errorMsg}]" except Exception as e: errorMsg = f"Vision AI extraction failed for image {imagePart.id}: {str(e)}" logger.error(errorMsg) import traceback logger.debug(f"Traceback: {traceback.format_exc()}") # Return error message statt None für Debugging return f"[ERROR: {errorMsg}]" async def _processTextContentWithAi(self, textPart: ContentPart, extractionPrompt: str) -> Optional[str]: """ Verarbeite Text-Content mit AI basierend auf extractionPrompt. WICHTIG: Pre-extracted ContentParts von context.extractContent enthalten RAW extrahierten Text (z.B. aus PDF-Text-Layer). Wenn "extract" Intent vorhanden ist, muss dieser Text mit AI verarbeitet werden (Transformation, Strukturierung, etc.) basierend auf extractionPrompt. Args: textPart: ContentPart mit typeGroup="text" (oder anderer Text-basierter Typ) extractionPrompt: Prompt für die AI-Verarbeitung des Textes Returns: AI-verarbeiteter Text oder None bei Fehler """ try: from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum # Final extraction prompt finalPrompt = extractionPrompt or "Process and extract the key information from the following text content." # Debug-Log (harmonisiert) - log prompt with text preview textPreview = textPart.data[:500] + "..." if textPart.data and len(textPart.data) > 500 else (textPart.data or "") promptWithContext = f"{finalPrompt}\n\n--- Text Content (preview) ---\n{textPreview}" self.services.utils.writeDebugFile( promptWithContext, f"content_extraction_prompt_text_{textPart.id}" ) # Erstelle Text-ContentPart für AI-Verarbeitung # Verwende den vorhandenen Text als Input textContentPart = ContentPart( id=textPart.id, label=textPart.label, typeGroup="text", mimeType="text/plain", data=textPart.data if textPart.data else "", metadata=textPart.metadata.copy() if textPart.metadata else {} ) # Erstelle AI-Call-Request mit Text-Part request = AiCallRequest( prompt=finalPrompt, context="", options=AiCallOptions(operationType=OperationTypeEnum.DATA_EXTRACT), contentParts=[textContentPart] ) # Verwende AI-Service für Text-Verarbeitung response = await self.services.ai.callAi(request) # Debug-Log für Response (harmonisiert) if response and response.content: self.services.utils.writeDebugFile( response.content, f"content_extraction_response_text_{textPart.id}" ) if response and response.content: return response.content.strip() # Kein Content zurückgegeben - return error message für Debugging errorMsg = f"AI text processing failed: No content returned for text part {textPart.id}" logger.warning(errorMsg) return f"[ERROR: {errorMsg}]" except Exception as e: errorMsg = f"AI text processing failed for text part {textPart.id}: {str(e)}" logger.error(errorMsg) import traceback logger.debug(f"Traceback: {traceback.format_exc()}") # Return error message statt None für Debugging return f"[ERROR: {errorMsg}]" def _buildIntentAnalysisPrompt( self, userPrompt: str, documents: List[ChatDocument], actionParameters: Dict[str, Any] ) -> str: """Baue Prompt für Intent-Analyse.""" # Baue Dokument-Liste - zeige ursprüngliche Dokumente für pre-extracted JSONs docListText = "" for i, doc in enumerate(documents, 1): # Prüfe ob es ein pre-extracted JSON ist preExtracted = self._resolvePreExtractedDocument(doc) if preExtracted: # Zeige ursprüngliches Dokument statt JSON originalDoc = preExtracted["originalDocument"] partsInfo = f" (contains {len(preExtracted['parts'])} pre-extracted parts: {', '.join([p.typeGroup for p in preExtracted['parts'] if p.data and len(str(p.data)) > 0])})" docListText += f"\n{i}. Document ID: {originalDoc['id']}\n" docListText += f" File Name: {originalDoc['fileName']}{partsInfo}\n" docListText += f" MIME Type: {originalDoc['mimeType']}\n" docListText += f" File Size: {originalDoc.get('fileSize', doc.fileSize)} bytes\n" else: # Normales Dokument docListText += f"\n{i}. Document ID: {doc.id}\n" docListText += f" File Name: {doc.fileName}\n" docListText += f" MIME Type: {doc.mimeType}\n" docListText += f" File Size: {doc.fileSize} bytes\n" outputFormat = actionParameters.get("outputFormat", "txt") prompt = f"""USER REQUEST: {userPrompt} DOCUMENTS TO ANALYZE: {docListText} TASK: For each document, determine its intents (can be multiple): - "extract": Content extraction needed (text, structure, OCR, etc.) - "render": Image/binary should be rendered as-is (visual element) - "reference": Document reference/attachment (no extraction, just reference) OUTPUT FORMAT: {outputFormat} RETURN JSON: {{ "intents": [ {{ "documentId": "doc_1", "intents": ["extract"], # Array - can contain multiple! "extractionPrompt": "Extract all text content, preserving structure", "reasoning": "User needs text content for document generation" }}, {{ "documentId": "doc_2", "intents": ["extract", "render"], # Both! Image needs text extraction AND visual rendering "extractionPrompt": "Extract text content from image using vision AI", "reasoning": "Image contains text that needs extraction, but also should be rendered visually" }}, {{ "documentId": "doc_3", "intents": ["reference"], "extractionPrompt": null, "reasoning": "Document is only used as reference, no extraction needed" }} ] }} CRITICAL RULES: 1. For images (mimeType starts with "image/"): - If user wants to "include" or "show" images → add "render" - If user wants to "analyze", "read text", or "extract text" from images → add "extract" - Can have BOTH "extract" and "render" if image needs both text extraction and visual rendering 2. For text documents: - If user mentions "template" or "structure" → "reference" or "extract" based on context - If user mentions "reference" or "context" → "reference" - Default → "extract" 3. Consider output format: - For formats like PDF, DOCX, PPTX: images usually need "render" - For formats like CSV, JSON: usually "extract" only - For HTML: can have both "extract" and "render" Return ONLY valid JSON following the structure above. """ return prompt async def _extractAndPrepareContent( self, documents: List[ChatDocument], documentIntents: List[DocumentIntent], parentOperationId: str ) -> List[ContentPart]: """Delegate to ContentExtractor.""" return await self.contentExtractor.extractAndPrepareContent( documents, documentIntents, parentOperationId, self._getIntentForDocument ) async def _extractAndPrepareContent_OLD( self, documents: List[ChatDocument], documentIntents: List[DocumentIntent], parentOperationId: str ) -> List[ContentPart]: """ Phase 5B: Extrahiert Content basierend auf Intents und bereitet ContentParts mit Metadaten vor. Gibt Liste von ContentParts im passenden Format zurück. WICHTIG: Ein Dokument kann mehrere ContentParts erzeugen, wenn mehrere Intents vorhanden sind. Beispiel: Bild mit intents=["extract", "render"] erzeugt: - ContentPart(contentFormat="object", ...) für Rendering - ContentPart(contentFormat="extracted", ...) für Text-Analyse Args: documents: Liste der zu verarbeitenden Dokumente documentIntents: Liste von DocumentIntent-Objekten parentOperationId: Parent Operation-ID für ChatLog-Hierarchie Returns: Liste von ContentParts mit vollständigen Metadaten """ # Erstelle Operation-ID für Extraktion extractionOperationId = f"{parentOperationId}_content_extraction" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( extractionOperationId, "Content Extraction", "Extraction", f"Extracting from {len(documents)} documents", parentOperationId=parentOperationId ) try: allContentParts = [] for document in documents: # Check if document is already a ContentExtracted document (pre-extracted JSON) logger.debug(f"Checking document {document.id} ({document.fileName}, mimeType={document.mimeType}) for pre-extracted content") preExtracted = self._resolvePreExtractedDocument(document) if preExtracted: logger.info(f"✅ Found pre-extracted document: {document.fileName} -> Original: {preExtracted['originalDocument']['fileName']}") logger.info(f" Pre-extracted document ID: {document.id}, Original document ID: {preExtracted['originalDocument']['id']}") logger.info(f" ContentParts count: {len(preExtracted['contentExtracted'].parts) if preExtracted['contentExtracted'].parts else 0}") # Verwende bereits extrahierte ContentParts direkt contentExtracted = preExtracted["contentExtracted"] # WICHTIG: Intent muss für das JSON-Dokument gefunden werden, nicht für das Original # (Intent-Analyse mappt bereits zurück zu JSON-Dokument-ID) intent = self._getIntentForDocument(document.id, documentIntents) logger.info(f" Intent lookup for document {document.id}: found={intent is not None}") if intent: logger.info(f" Intent: {intent.intents}, extractionPrompt: {intent.extractionPrompt[:100] if intent.extractionPrompt else None}...") else: logger.warning(f" ⚠️ No intent found for pre-extracted document {document.id}! Available intent documentIds: {[i.documentId for i in documentIntents]}") if contentExtracted.parts: for part in contentExtracted.parts: # Überspringe leere Parts (Container ohne Daten) if not part.data or (isinstance(part.data, str) and len(part.data.strip()) == 0): if part.typeGroup == "container": continue # Überspringe leere Container if not part.metadata: part.metadata = {} # Ensure metadata is complete if "documentId" not in part.metadata: part.metadata["documentId"] = document.id # WICHTIG: Prüfe Intent für dieses Part partIntent = intent.intents if intent else ["extract"] # Debug-Logging für Intent-Verarbeitung logger.debug(f"Processing part {part.id}: typeGroup={part.typeGroup}, intents={partIntent}, hasData={bool(part.data)}, dataLength={len(str(part.data)) if part.data else 0}") # WICHTIG: Ein Part kann mehrere Intents haben - erstelle für jeden Intent einen ContentPart # Generische Intent-Verarbeitung für ALLE Content-Typen hasReferenceIntent = "reference" in partIntent hasRenderIntent = "render" in partIntent hasExtractIntent = "extract" in partIntent hasPartData = bool(part.data) and (not isinstance(part.data, str) or len(part.data.strip()) > 0) logger.debug(f"Part {part.id}: reference={hasReferenceIntent}, render={hasRenderIntent}, extract={hasExtractIntent}, hasData={hasPartData}") # Track ob der originale Part bereits hinzugefügt wurde originalPartAdded = False # 1. Reference Intent: Erstelle Reference ContentPart if hasReferenceIntent: referencePart = ContentPart( id=f"ref_{document.id}_{part.id}", label=f"Reference: {part.label or 'Content'}", typeGroup="reference", mimeType=part.mimeType or "application/octet-stream", data="", # Leer - nur Referenz metadata={ "contentFormat": "reference", "documentId": document.id, "documentReference": f"docItem:{document.id}:{preExtracted['originalDocument']['fileName']}", "intent": "reference", "usageHint": f"Reference: {preExtracted['originalDocument']['fileName']}", "originalFileName": preExtracted["originalDocument"]["fileName"] } ) allContentParts.append(referencePart) logger.debug(f"✅ Created reference ContentPart for {part.id}") # 2. Render Intent: Erstelle Object ContentPart (für Binary/Image Rendering) if hasRenderIntent and hasPartData: # Prüfe ob es ein Binary/Image ist (kann gerendert werden) isRenderable = ( part.typeGroup == "image" or part.typeGroup == "binary" or (part.mimeType and ( part.mimeType.startswith("image/") or part.mimeType.startswith("video/") or part.mimeType.startswith("audio/") or self._isBinary(part.mimeType) )) ) if isRenderable: objectPart = ContentPart( id=f"obj_{document.id}_{part.id}", label=f"Object: {part.label or 'Content'}", typeGroup=part.typeGroup, mimeType=part.mimeType or "application/octet-stream", data=part.data, # Base64/Binary data ist bereits vorhanden metadata={ "contentFormat": "object", "documentId": document.id, "intent": "render", "usageHint": f"Render as visual element: {preExtracted['originalDocument']['fileName']}", "originalFileName": preExtracted["originalDocument"]["fileName"], "relatedExtractedPartId": f"extracted_{document.id}_{part.id}" if hasExtractIntent else None } ) allContentParts.append(objectPart) logger.debug(f"✅ Created object ContentPart for {part.id} (render intent)") else: logger.warning(f"⚠️ Part {part.id} has render intent but is not renderable (typeGroup={part.typeGroup}, mimeType={part.mimeType})") elif hasRenderIntent and not hasPartData: logger.warning(f"⚠️ Part {part.id} has render intent but no data, skipping render part") # 3. Extract Intent: Erstelle Extracted ContentPart (möglicherweise mit zusätzlicher Verarbeitung) if hasExtractIntent: # Spezielle Behandlung für Images: Vision AI für Text-Extraktion if part.typeGroup == "image" and hasPartData: logger.info(f"🔄 Processing image {part.id} with Vision AI (extract intent)") try: extractionPrompt = intent.extractionPrompt if intent and intent.extractionPrompt else "Extract all text content from this image. Return only the extracted text, no additional formatting." extractedText = await self._extractTextFromImage(part, extractionPrompt) if extractedText: # Prüfe ob es ein Error-Message ist isError = extractedText.startswith("[ERROR:") # Erstelle neuen Text-Part mit extrahiertem Text oder Error-Message textPart = ContentPart( id=f"extracted_{document.id}_{part.id}", label=f"Extracted text from {part.label or 'Image'}" if not isError else f"Error extracting from {part.label or 'Image'}", typeGroup="text", mimeType="text/plain", data=extractedText, metadata={ "contentFormat": "extracted", "documentId": document.id, "intent": "extract", "originalFileName": preExtracted["originalDocument"]["fileName"], "relatedObjectPartId": f"obj_{document.id}_{part.id}" if hasRenderIntent else None, "extractionPrompt": extractionPrompt, "extractionMethod": "vision", "isError": isError } ) allContentParts.append(textPart) if isError: logger.error(f"❌ Vision AI extraction failed for image {part.id}: {extractedText}") else: logger.info(f"✅ Extracted text from image {part.id} using Vision AI: {len(extractedText)} chars") else: # Sollte nicht vorkommen (Funktion gibt jetzt immer Error-Message zurück) errorMsg = f"Vision AI extraction failed: Unexpected empty response for image {part.id}" logger.error(errorMsg) errorPart = ContentPart( id=f"extracted_{document.id}_{part.id}", label=f"Error extracting from {part.label or 'Image'}", typeGroup="text", mimeType="text/plain", data=f"[ERROR: {errorMsg}]", metadata={ "contentFormat": "extracted", "documentId": document.id, "intent": "extract", "originalFileName": preExtracted["originalDocument"]["fileName"], "extractionPrompt": extractionPrompt, "extractionMethod": "vision", "isError": True } ) allContentParts.append(errorPart) except Exception as e: logger.error(f"❌ Failed to extract text from image {part.id}: {str(e)}") import traceback logger.debug(f"Traceback: {traceback.format_exc()}") # Kein Fallback: Wenn render Intent vorhanden, haben wir bereits object Part # Wenn nur extract Intent: Original Part ist kein Text, daher nicht als extracted hinzufügen if not hasRenderIntent: logger.debug(f"Image {part.id} has only extract intent, Vision AI failed - no extracted text available") else: # Für alle anderen Content-Typen: Prüfe ob AI-Verarbeitung benötigt wird # WICHTIG: Pre-extracted ContentParts von context.extractContent enthalten RAW extrahierten Content # (z.B. Text aus PDF-Text-Layer, Tabellen, etc.). Wenn "extract" Intent vorhanden ist, # muss dieser Content mit AI verarbeitet werden basierend auf extractionPrompt. # Prüfe ob Part Text-Content hat (kann mit AI verarbeitet werden) isTextContent = ( part.typeGroup == "text" or part.typeGroup == "table" or (part.data and isinstance(part.data, str) and len(part.data.strip()) > 0) ) if isTextContent and intent and intent.extractionPrompt: # Text-Content mit extractionPrompt: Verarbeite mit AI logger.info(f"🔄 Processing text content {part.id} with AI (extract intent with prompt)") try: extractionPrompt = intent.extractionPrompt processedText = await self._processTextContentWithAi(part, extractionPrompt) if processedText: # Prüfe ob es ein Error-Message ist isError = processedText.startswith("[ERROR:") # Erstelle neuen Text-Part mit AI-verarbeitetem Text oder Error-Message processedPart = ContentPart( id=f"extracted_{document.id}_{part.id}", label=f"AI-processed: {part.label or 'Content'}" if not isError else f"Error processing {part.label or 'Content'}", typeGroup="text", mimeType="text/plain", data=processedText, metadata={ "contentFormat": "extracted", "documentId": document.id, "intent": "extract", "originalFileName": preExtracted["originalDocument"]["fileName"], "relatedObjectPartId": f"obj_{document.id}_{part.id}" if hasRenderIntent else None, "extractionPrompt": extractionPrompt, "extractionMethod": "ai", "sourcePartId": part.id, "fromExtractContent": True, "isError": isError } ) allContentParts.append(processedPart) originalPartAdded = True if isError: logger.error(f"❌ AI text processing failed for part {part.id}: {processedText}") else: logger.info(f"✅ Processed text content {part.id} with AI: {len(processedText)} chars") else: # Sollte nicht vorkommen (Funktion gibt jetzt immer Error-Message zurück) errorMsg = f"AI text processing failed: Unexpected empty response for part {part.id}" logger.error(errorMsg) errorPart = ContentPart( id=f"extracted_{document.id}_{part.id}", label=f"Error processing {part.label or 'Content'}", typeGroup="text", mimeType="text/plain", data=f"[ERROR: {errorMsg}]", metadata={ "contentFormat": "extracted", "documentId": document.id, "intent": "extract", "originalFileName": preExtracted["originalDocument"]["fileName"], "extractionPrompt": extractionPrompt, "extractionMethod": "ai", "sourcePartId": part.id, "isError": True } ) allContentParts.append(errorPart) originalPartAdded = True except Exception as e: logger.error(f"❌ Failed to process text content {part.id} with AI: {str(e)}") import traceback logger.debug(f"Traceback: {traceback.format_exc()}") # Fallback: Verwende Original-Part if not originalPartAdded: part.metadata.update({ "contentFormat": "extracted", "intent": "extract", "fromExtractContent": True, "skipExtraction": True, "originalFileName": preExtracted["originalDocument"]["fileName"], "relatedObjectPartId": f"obj_{document.id}_{part.id}" if hasRenderIntent else None }) allContentParts.append(part) originalPartAdded = True else: # Kein extractionPrompt oder kein Text-Content: Verwende Part direkt als extracted # (Content ist bereits extrahiert von context.extractContent, keine weitere AI-Verarbeitung nötig) # WICHTIG: Nur hinzufügen wenn noch nicht hinzugefügt (z.B. durch render Intent) if not originalPartAdded: part.metadata.update({ "contentFormat": "extracted", "intent": "extract", "fromExtractContent": True, "skipExtraction": True, # Bereits extrahiert "originalFileName": preExtracted["originalDocument"]["fileName"], "relatedObjectPartId": f"obj_{document.id}_{part.id}" if hasRenderIntent else None }) # Stelle sicher dass contentFormat gesetzt ist if "contentFormat" not in part.metadata: part.metadata["contentFormat"] = "extracted" allContentParts.append(part) originalPartAdded = True logger.debug(f"✅ Using pre-extracted ContentPart {part.id} as extracted (no AI processing needed)") # 4. Fallback: Wenn kein Intent vorhanden oder Part wurde noch nicht hinzugefügt # (sollte normalerweise nicht vorkommen, da default "extract" ist) if not hasReferenceIntent and not hasRenderIntent and not hasExtractIntent and not originalPartAdded: logger.warning(f"⚠️ Part {part.id} has no recognized intents, adding as extracted by default") part.metadata.update({ "contentFormat": "extracted", "intent": "extract", "fromExtractContent": True, "skipExtraction": True, "originalFileName": preExtracted["originalDocument"]["fileName"] }) allContentParts.append(part) originalPartAdded = True logger.info(f"✅ Using {len([p for p in contentExtracted.parts if p.data and len(str(p.data)) > 0])} pre-extracted ContentParts from ContentExtracted document {document.fileName}") logger.info(f" Original document: {preExtracted['originalDocument']['fileName']}") continue # Skip normal extraction for this document # Check if it's standardized JSON format (has "documents" or "sections") if document.mimeType == "application/json": try: docBytes = self.services.interfaceDbComponent.getFileData(document.fileId) if docBytes: docData = docBytes.decode('utf-8') jsonData = json.loads(docData) if isinstance(jsonData, dict) and ("documents" in jsonData or "sections" in jsonData): logger.info(f"Document is already in standardized JSON format, using as reference") # Create reference ContentPart for structured JSON contentPart = ContentPart( id=f"ref_{document.id}", label=f"Reference: {document.fileName}", typeGroup="structure", mimeType="application/json", data=docData, metadata={ "contentFormat": "reference", "documentId": document.id, "documentReference": f"docItem:{document.id}:{document.fileName}", "skipExtraction": True, "intent": "reference" } ) allContentParts.append(contentPart) logger.info(f"✅ Using JSON document directly without extraction") continue # Skip normal extraction for this document except Exception as e: logger.warning(f"Could not parse JSON document {document.fileName}, will extract normally: {str(e)}") # Continue with normal extraction # Normal extraction path intent = self._getIntentForDocument(document.id, documentIntents) if not intent: # Default: extract für alle Dokumente ohne Intent logger.warning(f"No intent found for document {document.id}, using default 'extract'") intent = DocumentIntent( documentId=document.id, intents=["extract"], extractionPrompt="Extract all content from the document", reasoning="Default intent: no specific intent found" ) # WICHTIG: Prüfe alle Intents - ein Dokument kann mehrere ContentParts erzeugen if "reference" in intent.intents: # Erstelle Reference ContentPart contentPart = ContentPart( id=f"ref_{document.id}", label=f"Reference: {document.fileName}", typeGroup="reference", mimeType=document.mimeType, data="", metadata={ "contentFormat": "reference", "documentId": document.id, "documentReference": f"docItem:{document.id}:{document.fileName}", "intent": "reference", "usageHint": f"Reference document: {document.fileName}" } ) allContentParts.append(contentPart) # WICHTIG: "render" und "extract" können beide vorhanden sein! # In diesem Fall erzeugen wir BEIDE ContentParts if "render" in intent.intents: # Für Images/Binary: extrahiere als Object if document.mimeType.startswith("image/") or self._isBinary(document.mimeType): try: # Lade Binary-Daten (getFileData ist nicht async - keine await nötig) binaryData = self.services.interfaceDbComponent.getFileData(document.fileId) if not binaryData: logger.warning(f"No binary data found for document {document.id}") continue base64Data = base64.b64encode(binaryData).decode('utf-8') contentPart = ContentPart( id=f"obj_{document.id}", label=f"Object: {document.fileName}", typeGroup="image" if document.mimeType.startswith("image/") else "binary", mimeType=document.mimeType, data=base64Data, metadata={ "contentFormat": "object", "documentId": document.id, "intent": "render", "usageHint": f"Render as visual element: {document.fileName}", "originalFileName": document.fileName, # Verknüpfung zu extracted Part (falls vorhanden) "relatedExtractedPartId": f"ext_{document.id}" if "extract" in intent.intents else None } ) allContentParts.append(contentPart) except Exception as e: logger.error(f"Failed to load binary data for document {document.id}: {str(e)}") if "extract" in intent.intents: # Extrahiere Content mit Extraction Service extractionPrompt = intent.extractionPrompt or "Extract all content from the document" # Debug-Log (harmonisiert) self.services.utils.writeDebugFile( extractionPrompt, f"content_extraction_prompt_{document.id}" ) # Führe Extraktion aus from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy extractionOptions = ExtractionOptions( prompt=extractionPrompt, mergeStrategy=MergeStrategy() ) # extractContent ist nicht async - keine await nötig extractedResults = self.services.extraction.extractContent( [document], extractionOptions, operationId=extractionOperationId, parentOperationId=extractionOperationId ) # Konvertiere extrahierte Ergebnisse zu ContentParts mit Metadaten for extracted in extractedResults: for part in extracted.parts: # Markiere als extracted Format part.metadata.update({ "contentFormat": "extracted", "documentId": document.id, "extractionPrompt": extractionPrompt, "intent": "extract", "usageHint": f"Use extracted content from {document.fileName}", # Verknüpfung zu object Part (falls vorhanden) "relatedObjectPartId": f"obj_{document.id}" if "render" in intent.intents else None }) # Stelle sicher, dass ID eindeutig ist (falls object Part existiert) if "render" in intent.intents: part.id = f"ext_{document.id}_{part.id}" allContentParts.append(part) # Debug-Log (harmonisiert) self.services.utils.writeDebugFile( json.dumps([part.dict() for part in allContentParts], indent=2, default=str), "content_extraction_result" ) # ChatLog abschließen self.services.chat.progressLogFinish(extractionOperationId, True) return allContentParts except Exception as e: self.services.chat.progressLogFinish(extractionOperationId, False) logger.error(f"Error in _extractAndPrepareContent: {str(e)}") raise def _isBinary(self, mimeType: str) -> bool: """Prüfe ob MIME-Type binary ist.""" binaryTypes = [ "application/octet-stream", "application/pdf", "application/zip", "application/x-zip-compressed" ] return mimeType in binaryTypes or mimeType.startswith("image/") or mimeType.startswith("video/") or mimeType.startswith("audio/") async def _generateStructure( self, userPrompt: str, contentParts: List[ContentPart], outputFormat: str, parentOperationId: str ) -> Dict[str, Any]: """Delegate to StructureGenerator.""" return await self.structureGenerator.generateStructure( userPrompt, contentParts, outputFormat, parentOperationId ) async def _generateStructure_OLD( self, userPrompt: str, contentParts: List[ContentPart], outputFormat: str, parentOperationId: str ) -> Dict[str, Any]: """ Phase 5C: Generiert Dokument-Struktur mit Sections. Jede Section spezifiziert: - Welcher Content sollte in dieser Section sein - Welche ContentParts zu verwenden sind - Format für jeden ContentPart Args: userPrompt: User-Anfrage contentParts: Alle vorbereiteten ContentParts mit Metadaten outputFormat: Ziel-Format (html, docx, pdf, etc.) parentOperationId: Parent Operation-ID für ChatLog-Hierarchie Returns: Struktur-Dict mit documents und sections """ # Erstelle Operation-ID für Struktur-Generierung structureOperationId = f"{parentOperationId}_structure_generation" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( structureOperationId, "Structure Generation", "Structure", f"Generating structure for {outputFormat}", parentOperationId=parentOperationId ) try: # Baue Struktur-Prompt mit Content-Index structurePrompt = self._buildStructurePrompt( userPrompt=userPrompt, contentParts=contentParts, outputFormat=outputFormat ) # AI-Call für Struktur-Generierung (verwende callAiPlanning für einfache JSON-Responses) # Debug-Logs werden bereits von callAiPlanning geschrieben aiResponse = await self.callAiPlanning( prompt=structurePrompt, debugType="document_generation_structure" ) # Parse Struktur structure = json.loads(self.services.utils.jsonExtractString(aiResponse)) # ChatLog abschließen self.services.chat.progressLogFinish(structureOperationId, True) return structure except Exception as e: self.services.chat.progressLogFinish(structureOperationId, False) logger.error(f"Error in _generateStructure: {str(e)}") raise def _buildStructurePrompt( self, userPrompt: str, contentParts: List[ContentPart], outputFormat: str ) -> str: """Baue Prompt für Struktur-Generierung.""" # Baue ContentParts-Index - filtere leere Parts heraus contentPartsIndex = "" validParts = [] filteredParts = [] for part in contentParts: contentFormat = part.metadata.get("contentFormat", "unknown") # WICHTIG: Reference Parts haben absichtlich leere Daten - immer einschließen if contentFormat == "reference": validParts.append(part) logger.debug(f"Including reference ContentPart {part.id} (intentionally empty data)") continue # Überspringe leere Parts (keine Daten oder nur Container ohne Inhalt) # ABER: Reference Parts wurden bereits oben behandelt if not part.data or (isinstance(part.data, str) and len(part.data.strip()) == 0): # Überspringe Container-Parts ohne Daten if part.typeGroup == "container" and not part.data: filteredParts.append((part.id, "container without data")) continue # Überspringe andere leere Parts (aber nicht Reference, die wurden bereits behandelt) if not part.data: filteredParts.append((part.id, f"no data (format: {contentFormat})")) continue validParts.append(part) logger.debug(f"Including ContentPart {part.id}: format={contentFormat}, type={part.typeGroup}, dataLength={len(str(part.data)) if part.data else 0}") if filteredParts: logger.debug(f"Filtered out {len(filteredParts)} empty ContentParts: {filteredParts}") logger.info(f"Building structure prompt with {len(validParts)} valid ContentParts (from {len(contentParts)} total)") # Baue Index nur für gültige Parts for i, part in enumerate(validParts, 1): contentFormat = part.metadata.get("contentFormat", "unknown") dataPreview = "" if contentFormat == "extracted": # Für Image-Parts: Zeige dass es ein Image ist if part.typeGroup == "image": dataLength = len(part.data) if part.data else 0 mimeType = part.mimeType or "image" dataPreview = f"Image data ({mimeType}, {dataLength} chars) - base64 encoded image content" elif part.typeGroup == "container": # Container ohne Daten überspringen wir bereits oben dataPreview = "Container structure (no text content)" else: # Zeige Preview von extrahiertem Text if part.data: preview = part.data[:200] + "..." if len(part.data) > 200 else part.data dataPreview = preview else: dataPreview = "(empty)" elif contentFormat == "object": dataLength = len(part.data) if part.data else 0 mimeType = part.mimeType or "binary" if part.typeGroup == "image": dataPreview = f"Base64 encoded image ({mimeType}, {dataLength} chars)" else: dataPreview = f"Base64 encoded binary ({mimeType}, {dataLength} chars)" elif contentFormat == "reference": dataPreview = part.metadata.get("documentReference", "reference") originalFileName = part.metadata.get('originalFileName', 'N/A') contentPartsIndex += f"\n{i}. ContentPart ID: {part.id}\n" contentPartsIndex += f" Format: {contentFormat}\n" contentPartsIndex += f" Type: {part.typeGroup}\n" contentPartsIndex += f" MIME Type: {part.mimeType or 'N/A'}\n" contentPartsIndex += f" Source: {part.metadata.get('documentId', 'unknown')}\n" contentPartsIndex += f" Original file name: {originalFileName}\n" contentPartsIndex += f" Usage hint: {part.metadata.get('usageHint', 'N/A')}\n" contentPartsIndex += f" Data preview: {dataPreview}\n" if not contentPartsIndex: contentPartsIndex = "\n(No content parts available)" prompt = f"""USER REQUEST: {userPrompt} AVAILABLE CONTENT PARTS: {contentPartsIndex} TASK: Generiere Dokument-Struktur mit Sections. Für jede Section, spezifiziere: - section id - content_type (heading, paragraph, image, table, etc.) - contentPartIds: [Liste von ContentPart-IDs zu verwenden] - contentFormats: {{"partId": "reference|object|extracted"}} - Wie jeder ContentPart zu verwenden ist - generation_hint: Was AI für diese Section generieren soll - elements: [] (leer, wird in nächster Phase gefüllt) OUTPUT FORMAT: {outputFormat} RETURN JSON: {{ "metadata": {{ "title": "Document Title", "language": "de" }}, "documents": [{{ "id": "doc_1", "title": "Document Title", "filename": "document.{outputFormat}", "sections": [ {{ "id": "section_1", "content_type": "heading", "generation_hint": "Main title", "contentPartIds": [], "contentFormats": {{}}, "elements": [] }}, {{ "id": "section_2", "content_type": "paragraph", "generation_hint": "Introduction paragraph", "contentPartIds": ["part_ext_1"], "contentFormats": {{ "part_ext_1": "extracted" }}, "elements": [] }} ] }}] }} Return ONLY valid JSON following the structure above. """ return prompt async def _fillStructure( self, structure: Dict[str, Any], contentParts: List[ContentPart], userPrompt: str, parentOperationId: str ) -> Dict[str, Any]: """Delegate to StructureFiller.""" return await self.structureFiller.fillStructure( structure, contentParts, userPrompt, parentOperationId ) async def _fillStructure_OLD( self, structure: Dict[str, Any], contentParts: List[ContentPart], userPrompt: str, parentOperationId: str ) -> Dict[str, Any]: """ Phase 5D: Füllt Struktur mit tatsächlichem Content. Für jede Section: - Wenn contentPartIds spezifiziert: Verwende ContentParts im spezifizierten Format - Wenn generation_hint spezifiziert: Generiere AI-Content **Implementierungsdetails:** - Sections werden **parallel generiert**, wenn möglich (Performance-Optimierung) - Fehlerhafte Sections werden mit Fehlermeldung gerendert (kein Abbruch des gesamten Prozesses) Args: structure: Struktur-Dict mit documents und sections contentParts: Alle vorbereiteten ContentParts userPrompt: User-Anfrage parentOperationId: Parent Operation-ID für ChatLog-Hierarchie Returns: Gefüllte Struktur mit elements in jeder Section """ import copy # Erstelle Operation-ID für Struktur-Abfüllen fillOperationId = f"{parentOperationId}_structure_filling" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( fillOperationId, "Structure Filling", "Filling", f"Filling {len(structure.get('documents', [{}])[0].get('sections', []))} sections", parentOperationId=parentOperationId ) try: filledStructure = copy.deepcopy(structure) # Sammle alle Sections für sequenzielle Verarbeitung (parallel kann später optimiert werden) sections_to_process = [] all_sections_list = [] # Für Kontext-Informationen for doc in filledStructure.get("documents", []): doc_sections = doc.get("sections", []) all_sections_list.extend(doc_sections) for section in doc_sections: sections_to_process.append((doc, section)) # Sequenzielle Section-Generierung (parallel kann später hinzugefügt werden) for sectionIndex, (doc, section) in enumerate(sections_to_process): sectionId = section.get("id") contentPartIds = section.get("contentPartIds", []) contentFormats = section.get("contentFormats", {}) generationHint = section.get("generation_hint") contentType = section.get("content_type", "paragraph") elements = [] # Prüfe ob Aggregation nötig ist needsAggregation = self._needsAggregation( contentType=contentType, contentPartCount=len(contentPartIds) ) if needsAggregation and generationHint: # Aggregation: Alle Parts zusammen verarbeiten sectionParts = [ self._findContentPartById(pid, contentParts) for pid in contentPartIds ] sectionParts = [p for p in sectionParts if p is not None] if sectionParts: # Filtere nur extracted Parts für Aggregation (reference/object werden separat behandelt) extractedParts = [ p for p in sectionParts if contentFormats.get(p.id, p.metadata.get("contentFormat")) == "extracted" ] nonExtractedParts = [ p for p in sectionParts if contentFormats.get(p.id, p.metadata.get("contentFormat")) != "extracted" ] # Verarbeite non-extracted Parts separat (reference, object) for part in nonExtractedParts: contentFormat = contentFormats.get(part.id, part.metadata.get("contentFormat")) if contentFormat == "reference": elements.append({ "type": "reference", "documentReference": part.metadata.get("documentReference"), "label": part.metadata.get("usageHint", part.label) }) elif contentFormat == "object": elements.append({ "type": part.typeGroup, "base64Data": part.data, "mimeType": part.mimeType, "altText": part.metadata.get("usageHint", part.label) }) # Aggregiere extracted Parts mit AI if extractedParts: generationPrompt = self._buildSectionGenerationPrompt( section=section, contentParts=extractedParts, # ALLE PARTS für Aggregation! userPrompt=userPrompt, generationHint=generationHint, allSections=all_sections_list, sectionIndex=sectionIndex, isAggregation=True ) # Erstelle Operation-ID für Section-Generierung sectionOperationId = f"{fillOperationId}_section_{sectionId}" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( sectionOperationId, "Section Generation (Aggregation)", "Section", f"Generating section {sectionId} with {len(extractedParts)} parts", parentOperationId=fillOperationId ) try: # Debug: Log Prompt self.services.utils.writeDebugFile( generationPrompt, f"section_content_{sectionId}_prompt" ) # Verwende callAi für ContentParts-Unterstützung (nicht callAiPlanning!) request = AiCallRequest( prompt=generationPrompt, contentParts=extractedParts, # ALLE PARTS! options=AiCallOptions( operationType=OperationTypeEnum.DATA_ANALYSE, priority=PriorityEnum.BALANCED, processingMode=ProcessingModeEnum.DETAILED ) ) aiResponse = await self.callAi(request) # Debug: Log Response self.services.utils.writeDebugFile( aiResponse.content, f"section_content_{sectionId}_response" ) # Parse und füge zu elements hinzu generatedElements = json.loads( self.services.utils.jsonExtractString(aiResponse.content) ) if isinstance(generatedElements, list): elements.extend(generatedElements) elif isinstance(generatedElements, dict) and "elements" in generatedElements: elements.extend(generatedElements["elements"]) # ChatLog abschließen self.services.chat.progressLogFinish(sectionOperationId, True) except Exception as e: # Fehlerhafte Section mit Fehlermeldung rendern (kein Abbruch!) self.services.chat.progressLogFinish(sectionOperationId, False) elements.append({ "type": "error", "message": f"Error generating section {sectionId}: {str(e)}", "sectionId": sectionId }) logger.error(f"Error generating section {sectionId}: {str(e)}") # NICHT raise - Section wird mit Fehlermeldung gerendert else: # Einzelverarbeitung: Jeder Part einzeln for partId in contentPartIds: part = self._findContentPartById(partId, contentParts) if not part: continue contentFormat = contentFormats.get(partId, part.metadata.get("contentFormat")) if contentFormat == "reference": # Füge Dokument-Referenz hinzu elements.append({ "type": "reference", "documentReference": part.metadata.get("documentReference"), "label": part.metadata.get("usageHint", part.label) }) elif contentFormat == "object": # Füge base64 Object hinzu elements.append({ "type": part.typeGroup, # "image", "binary", etc. "base64Data": part.data, "mimeType": part.mimeType, "altText": part.metadata.get("usageHint", part.label) }) elif contentFormat == "extracted": if generationHint: # AI-Call mit einzelnen ContentPart generationPrompt = self._buildSectionGenerationPrompt( section=section, contentParts=[part], # EIN PART userPrompt=userPrompt, generationHint=generationHint, allSections=all_sections_list, sectionIndex=sectionIndex, isAggregation=False ) # Erstelle Operation-ID für Section-Generierung sectionOperationId = f"{fillOperationId}_section_{sectionId}" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( sectionOperationId, "Section Generation", "Section", f"Generating section {sectionId}", parentOperationId=fillOperationId ) try: # Debug: Log Prompt self.services.utils.writeDebugFile( generationPrompt, f"section_content_{sectionId}_prompt" ) # Verwende callAi für ContentParts-Unterstützung request = AiCallRequest( prompt=generationPrompt, contentParts=[part], options=AiCallOptions( operationType=OperationTypeEnum.DATA_ANALYSE, priority=PriorityEnum.BALANCED, processingMode=ProcessingModeEnum.DETAILED ) ) aiResponse = await self.callAi(request) # Debug: Log Response self.services.utils.writeDebugFile( aiResponse.content, f"section_content_{sectionId}_response" ) # Parse und füge zu elements hinzu generatedElements = json.loads( self.services.utils.jsonExtractString(aiResponse.content) ) if isinstance(generatedElements, list): elements.extend(generatedElements) elif isinstance(generatedElements, dict) and "elements" in generatedElements: elements.extend(generatedElements["elements"]) # ChatLog abschließen self.services.chat.progressLogFinish(sectionOperationId, True) except Exception as e: # Fehlerhafte Section mit Fehlermeldung rendern (kein Abbruch!) self.services.chat.progressLogFinish(sectionOperationId, False) elements.append({ "type": "error", "message": f"Error generating section {sectionId}: {str(e)}", "sectionId": sectionId }) logger.error(f"Error generating section {sectionId}: {str(e)}") # NICHT raise - Section wird mit Fehlermeldung gerendert else: # Füge extrahierten Text direkt hinzu (kein AI-Call) elements.append({ "type": "extracted_text", "content": part.data, "source": part.metadata.get("documentId"), "extractionPrompt": part.metadata.get("extractionPrompt") }) section["elements"] = elements # ChatLog abschließen self.services.chat.progressLogFinish(fillOperationId, True) return filledStructure except Exception as e: self.services.chat.progressLogFinish(fillOperationId, False) logger.error(f"Error in _fillStructure: {str(e)}") raise def _buildSectionGenerationPrompt( self, section: Dict[str, Any], contentParts: List[Optional[ContentPart]], userPrompt: str, generationHint: str, allSections: Optional[List[Dict[str, Any]]] = None, sectionIndex: Optional[int] = None, isAggregation: bool = False ) -> str: """Baue Prompt für Section-Generierung mit vollständigem Kontext.""" # Filtere None-Werte validParts = [p for p in contentParts if p is not None] # Section-Metadaten sectionId = section.get("id", "unknown") contentType = section.get("content_type", "paragraph") # Baue ContentParts-Beschreibung contentPartsText = "" if isAggregation: # Aggregation: Zeige nur Metadaten, nicht Previews contentPartsText += f"\n## CONTENT PARTS (Aggregation)\n" contentPartsText += f"- Anzahl: {len(validParts)} ContentParts\n" contentPartsText += f"- Alle ContentParts werden als Parameter übergeben (nicht im Prompt!)\n" contentPartsText += f"- Jeder Part kann sehr groß sein → Chunking automatisch\n" contentPartsText += f"- WICHTIG: Aggregiere ALLE Parts zu einem Element (z.B. eine Tabelle)\n\n" contentPartsText += f"ContentPart IDs:\n" for part in validParts: contentFormat = part.metadata.get("contentFormat", "unknown") contentPartsText += f" - {part.id} (Format: {contentFormat}, Type: {part.typeGroup}" if part.metadata.get("originalFileName"): contentPartsText += f", Source: {part.metadata.get('originalFileName')}" contentPartsText += ")\n" else: # Einzelverarbeitung: Zeige Previews for part in validParts: contentFormat = part.metadata.get("contentFormat", "unknown") contentPartsText += f"\n- ContentPart {part.id}:\n" contentPartsText += f" Format: {contentFormat}\n" contentPartsText += f" Type: {part.typeGroup}\n" if part.metadata.get("originalFileName"): contentPartsText += f" Source file: {part.metadata.get('originalFileName')}\n" if contentFormat == "extracted": # Zeige Preview von extrahiertem Text (länger für besseren Kontext) previewLength = 1000 if part.data: preview = part.data[:previewLength] + "..." if len(part.data) > previewLength else part.data contentPartsText += f" Content preview:\n```\n{preview}\n```\n" else: contentPartsText += f" Content: (empty)\n" elif contentFormat == "reference": contentPartsText += f" Reference: {part.metadata.get('documentReference')}\n" if part.metadata.get("usageHint"): contentPartsText += f" Usage hint: {part.metadata.get('usageHint')}\n" elif contentFormat == "object": dataLength = len(part.data) if part.data else 0 contentPartsText += f" Object type: {part.typeGroup}\n" contentPartsText += f" MIME type: {part.mimeType}\n" contentPartsText += f" Data size: {dataLength} chars (base64 encoded)\n" if part.metadata.get("usageHint"): contentPartsText += f" Usage hint: {part.metadata.get('usageHint')}\n" # Baue Section-Kontext (vorherige und nachfolgende Sections) contextText = "" if allSections and sectionIndex is not None: prevSections = [] nextSections = [] if sectionIndex > 0: for i in range(max(0, sectionIndex - 2), sectionIndex): prevSection = allSections[i] prevSections.append({ "id": prevSection.get("id"), "content_type": prevSection.get("content_type"), "generation_hint": prevSection.get("generation_hint", "")[:100] }) if sectionIndex < len(allSections) - 1: for i in range(sectionIndex + 1, min(len(allSections), sectionIndex + 3)): nextSection = allSections[i] nextSections.append({ "id": nextSection.get("id"), "content_type": nextSection.get("content_type"), "generation_hint": nextSection.get("generation_hint", "")[:100] }) if prevSections or nextSections: contextText = "\n## DOCUMENT CONTEXT\n" if prevSections: contextText += "\nPrevious sections:\n" for prev in prevSections: contextText += f"- {prev['id']} ({prev['content_type']}): {prev['generation_hint']}\n" if nextSections: contextText += "\nFollowing sections:\n" for next in nextSections: contextText += f"- {next['id']} ({next['content_type']}): {next['generation_hint']}\n" if isAggregation: prompt = f"""# TASK: Generate Section Content (Aggregation) ## SECTION METADATA - Section ID: {sectionId} - Content Type: {contentType} - Generation Hint: {generationHint} {contextText} ## USER REQUEST (for context) ``` {userPrompt} ``` ## AVAILABLE CONTENT FOR THIS SECTION {contentPartsText if contentPartsText else "(No content parts specified for this section)"} ## INSTRUCTIONS 1. Generate content for section "{sectionId}" based on the generation hint above 2. **AGGREGATION**: Combine ALL provided ContentParts into ONE element (e.g., one table with all data) 3. For table content_type: Create a single table with headers and rows from all ContentParts 4. For bullet_list content_type: Create a single list with items from all ContentParts 5. Format appropriately based on content_type ({contentType}) 6. Ensure the generated content fits logically between previous and following sections 7. Return ONLY a JSON object with an "elements" array 8. Each element should match the content_type: {contentType} ## OUTPUT FORMAT Return a JSON object with this structure: ```json {{ "elements": [ {{ "type": "{contentType}", "headers": [...], // if table "rows": [...], // if table "items": [...], // if bullet_list "content": "..." // if paragraph }} ] }} ``` CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside the JSON. """ else: prompt = f"""# TASK: Generate Section Content ## SECTION METADATA - Section ID: {sectionId} - Content Type: {contentType} - Generation Hint: {generationHint} {contextText} ## USER REQUEST (for context) ``` {userPrompt} ``` ## AVAILABLE CONTENT FOR THIS SECTION {contentPartsText if contentPartsText else "(No content parts specified for this section)"} ## INSTRUCTIONS 1. Generate content for section "{sectionId}" based on the generation hint above 2. Use the available content parts to populate this section 3. For images: Use data URI format (data:image/[type];base64,[data]) when embedding base64 image data 4. For extracted text: Format appropriately based on content_type ({contentType}) 5. Ensure the generated content fits logically between previous and following sections 6. Return ONLY a JSON object with an "elements" array 7. Each element should match the content_type: {contentType} ## OUTPUT FORMAT Return a JSON object with this structure: ```json {{ "elements": [ {{ "type": "{contentType}", "content": "..." }} ] }} ``` CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside the JSON. """ return prompt def _findContentPartById(self, partId: str, contentParts: List[ContentPart]) -> Optional[ContentPart]: """Finde ContentPart nach ID.""" for part in contentParts: if part.id == partId: return part return None def _needsAggregation( self, contentType: str, contentPartCount: int ) -> bool: """ Bestimmt ob mehrere ContentParts aggregiert werden müssen. Aggregation nötig wenn: - content_type erfordert Aggregation (table, bullet_list) - UND mehrere ContentParts vorhanden sind (> 1) Args: contentType: Section content_type contentPartCount: Anzahl der ContentParts in dieser Section Returns: True wenn Aggregation nötig, False sonst """ aggregationTypes = ["table", "bullet_list"] if contentType in aggregationTypes and contentPartCount > 1: return True # Optional: Auch für paragraph wenn mehrere Parts vorhanden # (z.B. Vergleich mehrerer Dokumente) # Standard: Keine Aggregation für paragraph return False async def _renderResult( self, filledStructure: Dict[str, Any], outputFormat: str, title: str, userPrompt: str, parentOperationId: str ) -> Tuple[bytes, str]: """ Phase 5E: Rendert gefüllte Struktur zum Ziel-Format. Unterstützt Multi-Dokument-Rendering: Alle Dokumente werden gerendert. Args: filledStructure: Gefüllte Struktur mit elements outputFormat: Ziel-Format (pdf, docx, html, etc.) title: Dokument-Titel userPrompt: User-Anfrage parentOperationId: Parent Operation-ID für ChatLog-Hierarchie Returns: Tuple von (renderedContent, mimeType) """ # Erstelle Operation-ID für Rendering renderOperationId = f"{parentOperationId}_rendering" # Starte ChatLog mit Parent-Referenz self.services.chat.progressLogStart( renderOperationId, "Content Rendering", "Rendering", f"Rendering to {outputFormat} format", parentOperationId=parentOperationId ) try: from modules.services.serviceGeneration.mainServiceGeneration import GenerationService generationService = GenerationService(self.services) # Multi-Dokument-Rendering documents = filledStructure.get("documents", []) if len(documents) == 1: # Einzelnes Dokument - wie bisher renderedContent, mimeType, images = await generationService.renderReport( filledStructure, outputFormat, title, userPrompt, self, parentOperationId=renderOperationId # Parent-Referenz für ChatLog-Hierarchie ) else: # Mehrere Dokumente - rendere alle # Option: Alle Sections zusammenführen und als ein Dokument rendern all_sections = [] for doc in documents: if "sections" in doc: all_sections.extend(doc.get("sections", [])) # Erstelle temporäres Dokument mit allen Sections merged_document = { "metadata": filledStructure["metadata"], "documents": [{ "id": "merged", "title": title, "filename": f"{title}.{outputFormat}", "sections": all_sections }] } renderedContent, mimeType, images = await generationService.renderReport( merged_document, outputFormat, title, userPrompt, self, parentOperationId=renderOperationId # Parent-Referenz für ChatLog-Hierarchie ) # ChatLog abschließen self.services.chat.progressLogFinish(renderOperationId, True) return renderedContent, mimeType except Exception as e: self.services.chat.progressLogFinish(renderOperationId, False) logger.error(f"Error in _renderResult: {str(e)}") raise def _shouldSkipContentPart( self, part: ContentPart ) -> bool: """Check if ContentPart should be skipped (already structured JSON).""" if part.typeGroup == "structure" and part.mimeType == "application/json": if part.metadata.get("skipExtraction", False): logger.debug(f"Skipping already-structured JSON ContentPart {part.id} (skipExtraction=True)") return True try: if isinstance(part.data, str): jsonData = json.loads(part.data) if isinstance(jsonData, dict) and ("documents" in jsonData or "sections" in jsonData): logger.debug(f"Skipping already-structured JSON ContentPart {part.id} (contains documents/sections)") return True except Exception: pass # Not JSON, continue processing return False async def callAiContent( self, prompt: str, options: AiCallOptions, contentParts: Optional[List[ContentPart]] = None, documentList: Optional[Any] = None, # DocumentReferenceList documentIntents: Optional[List[DocumentIntent]] = None, outputFormat: Optional[str] = None, title: Optional[str] = None, parentOperationId: Optional[str] = None ) -> AiResponse: """ Einheitliche AI-Content-Verarbeitung - Single Entry Point für alle AI-Actions. Alle AI-Actions (ai.process, ai.generateDocument, etc.) routen hier durch. Sie unterscheiden sich nur in Parametern, nicht in Logik. Args: prompt: The main prompt for the AI call options: AI call configuration options (REQUIRED - operationType must be set) contentParts: Optional list of already-extracted content parts (preferred) documentList: Optional DocumentReferenceList (wird zu ChatDocuments konvertiert) documentIntents: Optional list of DocumentIntent objects (wird erstellt wenn nicht vorhanden) outputFormat: Optional output format for document generation (e.g., 'pdf', 'docx', 'xlsx') title: Optional title for generated documents parentOperationId: Optional parent operation ID for hierarchical logging Returns: AiResponse with content, metadata, and optional documents """ await self.ensureAiObjectsInitialized() # Erstelle Operation-ID workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" aiOperationId = f"ai_content_{workflowId}_{int(time.time())}" # Starte Progress-Tracking mit Parent-Referenz self.services.chat.progressLogStart( aiOperationId, "AI content processing", "Content Processing", f"Format: {outputFormat or 'text'}", parentOperationId=parentOperationId ) try: # Initialisiere Defaults if not outputFormat: outputFormat = "txt" opType = getattr(options, "operationType", None) if not opType: options.operationType = OperationTypeEnum.DATA_GENERATE opType = OperationTypeEnum.DATA_GENERATE # Route zu Operation-spezifischen Handlern if opType == OperationTypeEnum.IMAGE_GENERATE: return await self._handleImageGeneration(prompt, options, title, aiOperationId) if opType == OperationTypeEnum.WEB_SEARCH or opType == OperationTypeEnum.WEB_CRAWL: return await self._handleWebOperation(prompt, options, opType, aiOperationId) # Dokument-Generierungs-Pfad options.compressPrompt = False options.compressContext = False # Schritt 5A: Kläre Dokument-Intents documents = [] if documentList: documents = self.services.chat.getChatDocumentsFromDocumentList(documentList) if not documentIntents and documents: documentIntents = await self._clarifyDocumentIntents( documents, prompt, {"outputFormat": outputFormat}, aiOperationId ) # Schritt 5B: Extrahiere und bereite Content vor if documents: preparedContentParts = await self._extractAndPrepareContent( documents, documentIntents or [], aiOperationId ) # Merge mit bereitgestellten contentParts (falls vorhanden) if contentParts: # Prüfe auf pre-extracted Content for part in contentParts: if part.metadata.get("skipExtraction", False): # Bereits extrahiert - verwende as-is, stelle sicher dass Metadaten vollständig part.metadata.setdefault("contentFormat", "extracted") part.metadata.setdefault("isPreExtracted", True) preparedContentParts.extend(contentParts) contentParts = preparedContentParts # Schritt 5C: Generiere Struktur structure = await self._generateStructure( prompt, contentParts or [], outputFormat, aiOperationId ) # Schritt 5D: Fülle Struktur filledStructure = await self._fillStructure( structure, contentParts or [], prompt, aiOperationId ) # Schritt 5E: Rendere Resultat renderedContent, mimeType = await self._renderResult( filledStructure, outputFormat, title or "Generated Document", prompt, aiOperationId ) # Baue Response documentName = self._determineDocumentName(filledStructure, outputFormat, title) docData = DocumentData( documentName=documentName, documentData=renderedContent, mimeType=mimeType, sourceJson=filledStructure ) metadata = AiResponseMetadata( title=title or filledStructure.get("metadata", {}).get("title", "Generated Document"), operationType=opType.value ) # Debug-Log (harmonisiert) self.services.utils.writeDebugFile( json.dumps(filledStructure, indent=2, ensure_ascii=False, default=str), "document_generation_response" ) self.services.chat.progressLogFinish(aiOperationId, True) return AiResponse( content=json.dumps(filledStructure), metadata=metadata, documents=[docData] ) except Exception as e: logger.error(f"Error in callAiContent: {str(e)}") self.services.chat.progressLogFinish(aiOperationId, False) raise def _determineDocumentName( self, filledStructure: Dict[str, Any], outputFormat: str, title: Optional[str] ) -> str: """Bestimme Dokument-Namen aus Struktur oder Titel.""" # Versuche aus Struktur zu extrahieren if isinstance(filledStructure, dict) and "documents" in filledStructure: docs = filledStructure["documents"] if isinstance(docs, list) and len(docs) > 0: firstDoc = docs[0] if isinstance(firstDoc, dict) and firstDoc.get("filename"): return firstDoc["filename"] # Fallback zu Titel if title: sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", title) sanitized = re.sub(r"_+", "_", sanitized).strip("_") if sanitized: if not sanitized.lower().endswith(f".{outputFormat}"): return f"{sanitized}.{outputFormat}" return sanitized return f"generated.{outputFormat}"