diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py index 89ffdccf..026be18b 100644 --- a/modules/aicore/aicorePluginOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -354,10 +354,11 @@ class AiOpenai(BaseConnectorAi): if response.status_code != 200: logger.error(f"DALL-E API error: {response.status_code} - {response.text}") - return { - "success": False, - "error": f"DALL-E API error: {response.status_code} - {response.text}" - } + return AiModelResponse( + content="", + success=False, + error=f"DALL-E API error: {response.status_code} - {response.text}" + ) responseJson = response.json() diff --git a/modules/datamodels/datamodelDocument.py b/modules/datamodels/datamodelDocument.py index 2f5af99a..a5cd6b0c 100644 --- a/modules/datamodels/datamodelDocument.py +++ b/modules/datamodels/datamodelDocument.py @@ -13,6 +13,8 @@ class DocumentMetadata(BaseModel): sourceDocuments: List[str] = Field(default_factory=list, description="Source document IDs") extractionMethod: str = Field(default="ai_extraction", description="Method used for extraction") version: str = Field(default="1.0", description="Document version") + documentType: Optional[str] = Field(default=None, description="Type of document (e.g., 'report', 'invoice', 'analysis')") + styles: Optional[Dict[str, Any]] = Field(default=None, description="Document styling configuration") class TableData(BaseModel): @@ -112,6 +114,8 @@ class RenderedDocument(BaseModel): documentData: bytes = Field(description="Document content as bytes") mimeType: str = Field(description="MIME type of the document (e.g., 'text/html', 'application/pdf')") filename: str = Field(description="Filename for the document (e.g., 'report.html', 'image.png')") + documentType: Optional[str] = Field(default=None, description="Type of document (e.g., 'report', 'invoice', 'analysis')") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Document metadata (title, author, etc.)") class Config: json_encoders = { diff --git a/modules/services/serviceAi/subStructureFilling.py b/modules/services/serviceAi/subStructureFilling.py index 548cf128..af1e51f6 100644 --- a/modules/services/serviceAi/subStructureFilling.py +++ b/modules/services/serviceAi/subStructureFilling.py @@ -52,7 +52,7 @@ class StructureFiller: # Erstelle Operation-ID für Struktur-Abfüllen fillOperationId = f"{parentOperationId}_structure_filling" - # Prüfe ob Struktur Chapters oder Sections hat + # Validate structure has chapters hasChapters = False for doc in structure.get("documents", []): if "chapters" in doc: @@ -60,9 +60,9 @@ class StructureFiller: break if not hasChapters: - # Fallback: Alte Struktur mit Sections direkt - verwende alte Logik - logger.warning("Structure has no chapters, using legacy section-based filling") - return await self._fillStructureLegacy(structure, contentParts, userPrompt, fillOperationId) + error_msg = "Structure must have chapters. Legacy section-based structure is not supported." + logger.error(error_msg) + raise ValueError(error_msg) # Starte ChatLog mit Parent-Referenz chapterCount = sum(len(doc.get("chapters", [])) for doc in structure.get("documents", [])) @@ -214,10 +214,11 @@ class StructureFiller: contentType = section.get("content_type", "paragraph") useAiCall = section.get("useAiCall", False) - # WICHTIG: Wenn keine ContentParts vorhanden sind, kann kein AI-Call gemacht werden - if len(contentPartIds) == 0: + # WICHTIG: Wenn keine ContentParts vorhanden sind UND kein generationHint, kann kein AI-Call gemacht werden + # Aber: Wenn generationHint vorhanden ist, kann AI auch ohne ContentParts generieren (z.B. Executive Summary) + if len(contentPartIds) == 0 and not generationHint: useAiCall = False - logger.debug(f"Section {sectionId}: No content parts, setting useAiCall=False") + logger.debug(f"Section {sectionId}: No content parts and no generation hint, setting useAiCall=False") elements = [] @@ -259,12 +260,25 @@ class StructureFiller: "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) - }) + # Nested content structure for objects + if part.typeGroup == "image": + elements.append({ + "type": "image", + "content": { + "base64Data": part.data, + "altText": part.metadata.get("usageHint", part.label), + "caption": part.metadata.get("caption", "") + } + }) + else: + elements.append({ + "type": part.typeGroup, + "content": { + "data": part.data, + "mimeType": part.mimeType, + "label": part.metadata.get("usageHint", part.label) + } + }) # Aggregiere extracted Parts mit AI if extractedParts: @@ -300,11 +314,24 @@ class StructureFiller: logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt (aggregation)") # Verwende callAi für ContentParts-Unterstützung (nicht callAiPlanning!) + # Use IMAGE_GENERATE for image content type + operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE + + # For IMAGE_GENERATE, truncate prompt to 4000 chars (DALL-E limit) + if operationType == OperationTypeEnum.IMAGE_GENERATE: + maxPromptLength = 4000 + if len(generationPrompt) > maxPromptLength: + logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters") + # Keep the beginning (task, metadata, generation hint) and truncate from end + generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0] # Truncate at last newline + + # For IMAGE_GENERATE, don't pass contentParts - image generation uses prompt only, not content chunks + contentPartsForCall = [] if operationType == OperationTypeEnum.IMAGE_GENERATE else extractedParts request = AiCallRequest( prompt=generationPrompt, - contentParts=extractedParts, # ALLE PARTS! + contentParts=contentPartsForCall, # Empty for IMAGE_GENERATE, all parts for others options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, + operationType=operationType, priority=PriorityEnum.BALANCED, processingMode=ProcessingModeEnum.DETAILED ) @@ -318,14 +345,39 @@ class StructureFiller: ) logger.debug(f"Logged section response: section_content_{sectionId}_response (aggregation)") - # 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"]) + # Handle IMAGE_GENERATE differently - returns image data directly + if contentType == "image" and operationType == OperationTypeEnum.IMAGE_GENERATE: + import base64 + # Convert image data to base64 string if needed + if isinstance(aiResponse.content, bytes): + base64Data = base64.b64encode(aiResponse.content).decode('utf-8') + elif isinstance(aiResponse.content, str): + # Already base64 string or data URI + if aiResponse.content.startswith("data:image/"): + # Extract base64 from data URI + base64Data = aiResponse.content.split(",", 1)[1] + else: + base64Data = aiResponse.content + else: + base64Data = "" + + elements.append({ + "type": "image", + "content": { + "base64Data": base64Data, + "altText": generationHint or "Generated image", + "caption": "" + } + }) + else: + # Parse JSON response for other content types + 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) @@ -342,6 +394,117 @@ class StructureFiller: # NICHT raise - Section wird mit Fehlermeldung gerendert else: + # Einzelverarbeitung: Jeder Part einzeln ODER Generation ohne ContentParts + # Handle case where no content parts but generationHint exists (e.g., Executive Summary) + if len(contentPartIds) == 0 and useAiCall and generationHint: + # Generate content from scratch using only generationHint + logger.debug(f"Processing section {sectionId}: No content parts, generating from generationHint only") + generationPrompt = self._buildSectionGenerationPrompt( + section=section, + contentParts=[], # NO PARTS + 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} from generationHint", + parentOperationId=fillOperationId + ) + + try: + # Debug: Log Prompt + self.services.utils.writeDebugFile( + generationPrompt, + f"section_content_{sectionId}_prompt" + ) + logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt") + + # Verwende callAi ohne ContentParts + operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE + + # For IMAGE_GENERATE, truncate prompt to 4000 chars (DALL-E limit) + if operationType == OperationTypeEnum.IMAGE_GENERATE: + maxPromptLength = 4000 + if len(generationPrompt) > maxPromptLength: + logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters") + # Keep the beginning (task, metadata, generation hint) and truncate from end + generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0] # Truncate at last newline + + request = AiCallRequest( + prompt=generationPrompt, + contentParts=[], # NO PARTS + options=AiCallOptions( + operationType=operationType, + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.DETAILED + ) + ) + aiResponse = await self.aiService.callAi(request) + + # Debug: Log Response + self.services.utils.writeDebugFile( + aiResponse.content, + f"section_content_{sectionId}_response" + ) + logger.debug(f"Logged section response: section_content_{sectionId}_response") + + # Handle IMAGE_GENERATE differently - returns image data directly + if contentType == "image" and operationType == OperationTypeEnum.IMAGE_GENERATE: + import base64 + # Convert image data to base64 string if needed + if isinstance(aiResponse.content, bytes): + base64Data = base64.b64encode(aiResponse.content).decode('utf-8') + elif isinstance(aiResponse.content, str): + # Already base64 string or data URI + if aiResponse.content.startswith("data:image/"): + # Extract base64 from data URI + base64Data = aiResponse.content.split(",", 1)[1] + else: + base64Data = aiResponse.content + else: + base64Data = "" + + elements.append({ + "type": "image", + "content": { + "base64Data": base64Data, + "altText": generationHint or "Generated image", + "caption": "" + } + }) + else: + # Parse JSON response for other content types + 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)}") + # Einzelverarbeitung: Jeder Part einzeln for partId in contentPartIds: part = self._findContentPartById(partId, contentParts) @@ -359,13 +522,26 @@ class StructureFiller: }) 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) - }) + # Füge base64 Object hinzu (nested in content structure) + if part.typeGroup == "image": + elements.append({ + "type": "image", + "content": { + "base64Data": part.data, + "altText": part.metadata.get("usageHint", part.label), + "caption": part.metadata.get("caption", "") + } + }) + else: + # For other object types, use generic structure + elements.append({ + "type": part.typeGroup, + "content": { + "data": part.data, + "mimeType": part.mimeType, + "label": part.metadata.get("usageHint", part.label) + } + }) elif contentFormat == "extracted": # WICHTIG: Prüfe sowohl useAiCall als auch generationHint @@ -403,11 +579,24 @@ class StructureFiller: logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt") # Verwende callAi für ContentParts-Unterstützung + # Use IMAGE_GENERATE for image content type + operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE + + # For IMAGE_GENERATE, truncate prompt to 4000 chars (DALL-E limit) + if operationType == OperationTypeEnum.IMAGE_GENERATE: + maxPromptLength = 4000 + if len(generationPrompt) > maxPromptLength: + logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters") + # Keep the beginning (task, metadata, generation hint) and truncate from end + generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0] # Truncate at last newline + + # For IMAGE_GENERATE, don't pass contentParts - image generation uses prompt only, not content chunks + contentPartsForCall = [] if operationType == OperationTypeEnum.IMAGE_GENERATE else [part] request = AiCallRequest( prompt=generationPrompt, - contentParts=[part], + contentParts=contentPartsForCall, options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, + operationType=operationType, priority=PriorityEnum.BALANCED, processingMode=ProcessingModeEnum.DETAILED ) @@ -421,14 +610,39 @@ class StructureFiller: ) logger.debug(f"Logged section response: 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"]) + # Handle IMAGE_GENERATE differently - returns image data directly + if contentType == "image" and operationType == OperationTypeEnum.IMAGE_GENERATE: + import base64 + # Convert image data to base64 string if needed + if isinstance(aiResponse.content, bytes): + base64Data = base64.b64encode(aiResponse.content).decode('utf-8') + elif isinstance(aiResponse.content, str): + # Already base64 string or data URI + if aiResponse.content.startswith("data:image/"): + # Extract base64 from data URI + base64Data = aiResponse.content.split(",", 1)[1] + else: + base64Data = aiResponse.content + else: + base64Data = "" + + elements.append({ + "type": "image", + "content": { + "base64Data": base64Data, + "altText": generationHint or "Generated image", + "caption": "" + } + }) + else: + # Parse JSON response for other content types + 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) @@ -502,16 +716,6 @@ class StructureFiller: if partId in contentPartsMap: section["contentPartsMetadata"].append(contentPartsMap[partId]) - # Prüfe ob Sections direkt vorhanden sind (Legacy-Struktur) - elif "sections" in doc: - for section in doc.get("sections", []): - contentPartIds = section.get("contentPartIds", []) - if contentPartIds: - section["contentPartsMetadata"] = [] - for partId in contentPartIds: - if partId in contentPartsMap: - section["contentPartsMetadata"].append(contentPartsMap[partId]) - return structure def _flattenChaptersToSections( @@ -542,8 +746,10 @@ class StructureFiller: "content_type": "heading", "elements": [{ "type": "heading", - "content": chapter.get("title"), - "level": chapter.get("level", 1) + "content": { + "text": chapter.get("title", ""), + "level": chapter.get("level", 1) + } }] } flattened_doc["sections"].append(heading_section) @@ -555,276 +761,6 @@ class StructureFiller: return result - async def _fillStructureLegacy( - self, - structure: Dict[str, Any], - contentParts: List[ContentPart], - userPrompt: str, - fillOperationId: str - ) -> Dict[str, Any]: - """ - Legacy: Füllt Struktur mit Sections direkt (für Rückwärtskompatibilität). - """ - # Starte ChatLog - self.services.chat.progressLogStart( - fillOperationId, - "Structure Filling (Legacy)", - "Filling", - f"Filling {len(structure.get('documents', [{}])[0].get('sections', []))} sections", - parentOperationId=fillOperationId - ) - - try: - filledStructure = copy.deepcopy(structure) - - # Sammle alle Sections - sections_to_process = [] - all_sections_list = [] - 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)) - - # Verarbeite Sections (bestehende Logik) - for sectionIndex, (doc, section) in enumerate(sections_to_process): - sectionId = section.get("id") - contentPartIds = section.get("contentPartIds", []) - contentFormats = section.get("contentFormats", {}) - # Check both camelCase and snake_case for generationHint - generationHint = section.get("generationHint") or section.get("generation_hint") - contentType = section.get("content_type", "paragraph") - useAiCall = section.get("useAiCall", False) - - # WICHTIG: Wenn keine ContentParts vorhanden sind, kann kein AI-Call gemacht werden - if len(contentPartIds) == 0: - useAiCall = False - logger.debug(f"Section {sectionId} (legacy): No content parts, setting useAiCall=False") - - elements = [] - - # Prüfe ob Aggregation nötig ist - needsAggregation = self._needsAggregation( - contentType=contentType, - contentPartCount=len(contentPartIds) - ) - - logger.info(f"Processing section {sectionId} (legacy): contentType={contentType}, contentPartCount={len(contentPartIds)}, useAiCall={useAiCall}, needsAggregation={needsAggregation}, hasGenerationHint={bool(generationHint)}") - - if needsAggregation and useAiCall 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 - 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 - 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, - userPrompt=userPrompt, - generationHint=generationHint, - allSections=all_sections_list, - sectionIndex=sectionIndex, - isAggregation=True - ) - - sectionOperationId = f"{fillOperationId}_section_{sectionId}" - - self.services.chat.progressLogStart( - sectionOperationId, - "Section Generation (Aggregation)", - "Section", - f"Generating section {sectionId} with {len(extractedParts)} parts", - parentOperationId=fillOperationId - ) - - try: - self.services.utils.writeDebugFile( - generationPrompt, - f"section_content_{sectionId}_prompt" - ) - - request = AiCallRequest( - prompt=generationPrompt, - contentParts=extractedParts, - options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, - priority=PriorityEnum.BALANCED, - processingMode=ProcessingModeEnum.DETAILED - ) - ) - aiResponse = await self.aiService.callAi(request) - - self.services.utils.writeDebugFile( - aiResponse.content, - f"section_content_{sectionId}_response" - ) - - 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"]) - - self.services.chat.progressLogFinish(sectionOperationId, True) - - except Exception as e: - 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)}") - - 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": - 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) - }) - - elif contentFormat == "extracted": - # WICHTIG: Prüfe sowohl useAiCall als auch generationHint - if useAiCall and generationHint: - # AI-Call mit einzelnen ContentPart - logger.debug(f"Processing section {sectionId}: Single extracted part with AI call (useAiCall={useAiCall}, generationHint={bool(generationHint)})") - generationPrompt = self._buildSectionGenerationPrompt( - section=section, - contentParts=[part], - userPrompt=userPrompt, - generationHint=generationHint, - allSections=all_sections_list, - sectionIndex=sectionIndex, - isAggregation=False - ) - - sectionOperationId = f"{fillOperationId}_section_{sectionId}" - - self.services.chat.progressLogStart( - sectionOperationId, - "Section Generation", - "Section", - f"Generating section {sectionId}", - parentOperationId=fillOperationId - ) - - try: - self.services.utils.writeDebugFile( - generationPrompt, - f"section_content_{sectionId}_prompt" - ) - logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt") - - request = AiCallRequest( - prompt=generationPrompt, - contentParts=[part], - options=AiCallOptions( - operationType=OperationTypeEnum.DATA_ANALYSE, - priority=PriorityEnum.BALANCED, - processingMode=ProcessingModeEnum.DETAILED - ) - ) - aiResponse = await self.aiService.callAi(request) - - self.services.utils.writeDebugFile( - aiResponse.content, - f"section_content_{sectionId}_response" - ) - logger.debug(f"Logged section response: section_content_{sectionId}_response") - - 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"]) - - self.services.chat.progressLogFinish(sectionOperationId, True) - - except Exception as e: - 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)}") - else: - # Füge extrahierten Text direkt hinzu (kein AI-Call) - logger.debug(f"Processing section {sectionId}: Single extracted part WITHOUT AI call (useAiCall={useAiCall}, generationHint={bool(generationHint)}) - adding extracted text directly") - elements.append({ - "type": "extracted_text", - "content": part.data, - "source": part.metadata.get("documentId"), - "extractionPrompt": part.metadata.get("extractionPrompt") - }) - - section["elements"] = elements - - # Füge ContentParts-Metadaten zur Struktur hinzu (für Validierung) - filledStructure = self._addContentPartsMetadata(filledStructure, contentParts) - - self.services.chat.progressLogFinish(fillOperationId, True) - return filledStructure - - except Exception as e: - self.services.chat.progressLogFinish(fillOperationId, False) - logger.error(f"Error in _fillStructureLegacy: {str(e)}") - raise - def _buildChapterSectionsStructurePrompt( self, chapterId: str, @@ -899,6 +835,18 @@ CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside th """ return prompt + def _getContentStructureExample(self, contentType: str) -> str: + """Get the JSON structure example for a specific content type.""" + structures = { + "table": '{{"headers": ["Column1", "Column2"], "rows": [["Value1", "Value2"], ["Value3", "Value4"]]}}', + "bullet_list": '{{"items": ["Item 1", "Item 2", "Item 3"]}}', + "heading": '{{"text": "Section Title", "level": 2}}', + "paragraph": '{{"text": "This is paragraph text."}}', + "code_block": '{{"code": "function example() {{ return true; }}", "language": "javascript"}}', + "image": '{{"base64Data": "", "altText": "Description", "caption": "Optional caption"}}' + } + return structures.get(contentType, '{{"text": ""}}') + def _buildSectionGenerationPrompt( self, section: Dict[str, Any], @@ -998,6 +946,8 @@ CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside th for next in nextSections: contextText += f"- {next['id']} ({next['content_type']}): {next['generation_hint']}\n" + contentStructureExample = self._getContentStructureExample(contentType) + if isAggregation: prompt = f"""# TASK: Generate Section Content (Aggregation) @@ -1027,21 +977,17 @@ CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside th ## 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 + "content": {contentStructureExample} }} ] }} -``` -CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside the JSON. +CRITICAL: "content" MUST always be an object (never a string). Return ONLY valid JSON. Do not include any explanatory text outside the JSON. """ else: prompt = f"""# TASK: Generate Section Content @@ -1071,18 +1017,17 @@ CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside th ## OUTPUT FORMAT Return a JSON object with this structure: -```json + {{ "elements": [ {{ "type": "{contentType}", - "content": "..." + "content": {contentStructureExample} }} ] }} -``` -CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside the JSON. +CRITICAL: "content" MUST always be an object (never a string). Return ONLY valid JSON. Do not include any explanatory text outside the JSON. """ return prompt diff --git a/modules/services/serviceExtraction/mainServiceExtraction.py b/modules/services/serviceExtraction/mainServiceExtraction.py index 33edb6c7..06877968 100644 --- a/modules/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/services/serviceExtraction/mainServiceExtraction.py @@ -1129,8 +1129,9 @@ class ExtractionService: logger.warning(f"⚠️ Content part ({contentTokens:.0f} tokens est.) exceeds available space ({availableContentBytes/TOKEN_SAFETY_FACTOR:.0f} tokens est.), chunking required") # If either condition fails, chunk the content - if totalTokens > maxTotalTokens or partSize > availableContentBytes: - # Part too large or total exceeds limit - chunk it + # CRITICAL: IMAGE_GENERATE operations should NOT use chunking - they generate images from prompts, not process content chunks + if (totalTokens > maxTotalTokens or partSize > availableContentBytes) and options.operationType != OperationTypeEnum.IMAGE_GENERATE: + # Part too large or total exceeds limit - chunk it (but not for image generation) chunks = await self.chunkContentPartForAi(contentPart, model, options, prompt) if not chunks: raise ValueError(f"Failed to chunk content part for model {model.name}") diff --git a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py index ebd37885..ee16c5a4 100644 --- a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py +++ b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py @@ -199,29 +199,40 @@ class BaseRenderer(ABC): return "unknown" def _extractTableData(self, sectionData: Dict[str, Any]) -> Tuple[List[str], List[List[str]]]: - """Extract table headers and rows from section data.""" + """Extract table headers and rows from section data. Expects nested content structure.""" # Normalize when elements array was passed in if isinstance(sectionData, list): if sectionData and isinstance(sectionData[0], dict): sectionData = sectionData[0] else: - # Empty list or invalid structure - return empty table return [], [] - # Ensure sectionData is a dict before calling .get() + # Ensure sectionData is a dict if not isinstance(sectionData, dict): return [], [] - headers = sectionData.get("headers", []) - rows = sectionData.get("rows", []) + # Extract from nested content structure + content = sectionData.get("content", {}) + if not isinstance(content, dict): + return [], [] + headers = content.get("headers", []) + rows = content.get("rows", []) return headers, rows def _extractBulletListItems(self, sectionData: Dict[str, Any]) -> List[str]: - """Extract bullet list items from section data.""" - # Normalize when elements array or raw list was passed in + """Extract bullet list items from section data. Expects nested content structure.""" + # Normalize when elements array was passed in if isinstance(sectionData, list): - # Already a list of items (strings or dicts) - items = sectionData - else: - items = sectionData.get("items", []) + if sectionData and isinstance(sectionData[0], dict): + sectionData = sectionData[0] + else: + return [] + # Ensure sectionData is a dict + if not isinstance(sectionData, dict): + return [] + # Extract from nested content structure + content = sectionData.get("content", {}) + if not isinstance(content, dict): + return [] + items = content.get("items", []) result = [] for item in items: if isinstance(item, str): @@ -231,64 +242,89 @@ class BaseRenderer(ABC): return result def _extractHeadingData(self, sectionData: Dict[str, Any]) -> Tuple[int, str]: - """Extract heading level and text from section data.""" + """Extract heading level and text from section data. Expects nested content structure.""" # Normalize when elements array was passed in if isinstance(sectionData, list): if sectionData and isinstance(sectionData[0], dict): sectionData = sectionData[0] else: - # Empty list or invalid structure - return default return 1, "" - # Ensure sectionData is a dict before calling .get() + # Ensure sectionData is a dict if not isinstance(sectionData, dict): return 1, "" - level = sectionData.get("level", 1) - text = sectionData.get("text", "") + # Extract from nested content structure + content = sectionData.get("content", {}) + if not isinstance(content, dict): + return 1, "" + level = content.get("level", 1) + text = content.get("text", "") return level, text def _extractParagraphText(self, sectionData: Dict[str, Any]) -> str: - """Extract paragraph text from section data.""" + """Extract paragraph text from section data. Expects nested content structure.""" if isinstance(sectionData, list): # Join multiple paragraph elements if provided as a list texts = [] for el in sectionData: - if isinstance(el, dict) and "text" in el: - texts.append(el["text"]) + if isinstance(el, dict): + content = el.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" + if text: + texts.append(text) elif isinstance(el, str): texts.append(el) return "\n".join(texts) - return sectionData.get("text", "") + # Extract from nested content structure + if not isinstance(sectionData, dict): + return "" + content = sectionData.get("content", {}) + if isinstance(content, dict): + return content.get("text", "") + elif isinstance(content, str): + return content + return "" def _extractCodeBlockData(self, sectionData: Dict[str, Any]) -> Tuple[str, str]: - """Extract code and language from section data.""" + """Extract code and language from section data. Expects nested content structure.""" # Normalize when elements array was passed in if isinstance(sectionData, list): if sectionData and isinstance(sectionData[0], dict): sectionData = sectionData[0] else: - # Empty list or invalid structure - return default return "", "" - # Ensure sectionData is a dict before calling .get() + # Ensure sectionData is a dict if not isinstance(sectionData, dict): return "", "" - code = sectionData.get("code", "") - language = sectionData.get("language", "") + # Extract from nested content structure + content = sectionData.get("content", {}) + if not isinstance(content, dict): + return "", "" + code = content.get("code", "") + language = content.get("language", "") return code, language def _extractImageData(self, sectionData: Dict[str, Any]) -> Tuple[str, str]: - """Extract base64 data and alt text from section data.""" + """Extract base64 data and alt text from section data. Expects nested content structure.""" # Normalize when elements array was passed in if isinstance(sectionData, list): if sectionData and isinstance(sectionData[0], dict): sectionData = sectionData[0] else: - # Empty list or invalid structure - return default return "", "Image" - # Ensure sectionData is a dict before calling .get() + # Ensure sectionData is a dict if not isinstance(sectionData, dict): return "", "Image" - base64Data = sectionData.get("base64Data", "") - altText = sectionData.get("altText", "Image") + # Extract from nested content structure + content = sectionData.get("content", {}) + if not isinstance(content, dict): + return "", "Image" + base64Data = content.get("base64Data", "") + altText = content.get("altText", "Image") return base64Data, altText def _renderImageSection(self, section: Dict[str, Any], styles: Dict[str, Any] = None) -> Any: diff --git a/modules/services/serviceGeneration/renderers/rendererCsv.py b/modules/services/serviceGeneration/renderers/rendererCsv.py index 52e2933d..83ca41c1 100644 --- a/modules/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/services/serviceGeneration/renderers/rendererCsv.py @@ -41,11 +41,17 @@ class RendererCsv(BaseRenderer): else: filename = self._determineFilename(title, "text/csv") + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + return [ RenderedDocument( documentData=csvContent.encode('utf-8'), mimeType="text/csv", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -130,8 +136,12 @@ class RendererCsv(BaseRenderer): def _renderJsonTableToCsv(self, tableData: Dict[str, Any]) -> List[List[str]]: """Render a JSON table to CSV rows.""" try: - headers = tableData.get("headers", []) - rows = tableData.get("rows", []) + # Extract from nested content structure + content = tableData.get("content", {}) + if not isinstance(content, dict): + return [] + headers = content.get("headers", []) + rows = content.get("rows", []) csvRows = [] @@ -150,7 +160,11 @@ class RendererCsv(BaseRenderer): def _renderJsonListToCsv(self, listData: Dict[str, Any]) -> List[List[str]]: """Render a JSON list to CSV rows.""" try: - items = listData.get("items", []) + # Extract from nested content structure + content = listData.get("content", {}) + if not isinstance(content, dict): + return [] + items = content.get("items", []) csvRows = [] for item in items: @@ -177,8 +191,12 @@ class RendererCsv(BaseRenderer): def _renderJsonHeadingToCsv(self, headingData: Dict[str, Any]) -> List[List[str]]: """Render a JSON heading to CSV rows.""" try: - text = headingData.get("text", "") - level = headingData.get("level", 1) + # Extract from nested content structure + content = headingData.get("content", {}) + if not isinstance(content, dict): + return [] + text = content.get("text", "") + level = content.get("level", 1) if text: # Use # symbols for heading levels @@ -194,7 +212,14 @@ class RendererCsv(BaseRenderer): def _renderJsonParagraphToCsv(self, paragraphData: Dict[str, Any]) -> List[List[str]]: """Render a JSON paragraph to CSV rows.""" try: - text = paragraphData.get("text", "") + # Extract from nested content structure + content = paragraphData.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" if text: # Split long paragraphs into multiple rows if needed @@ -229,8 +254,12 @@ class RendererCsv(BaseRenderer): def _renderJsonCodeToCsv(self, codeData: Dict[str, Any]) -> List[List[str]]: """Render a JSON code block to CSV rows.""" try: - code = codeData.get("code", "") - language = codeData.get("language", "") + # Extract from nested content structure + content = codeData.get("content", {}) + if not isinstance(content, dict): + return [] + code = content.get("code", "") + language = content.get("language", "") csvRows = [] diff --git a/modules/services/serviceGeneration/renderers/rendererDocx.py b/modules/services/serviceGeneration/renderers/rendererDocx.py index ee88369f..43c85c47 100644 --- a/modules/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/services/serviceGeneration/renderers/rendererDocx.py @@ -52,6 +52,10 @@ class RendererDocx(BaseRenderer): # Generate DOCX using AI-analyzed styling docx_content = await self._generateDocxFromJson(extractedContent, title, userPrompt, aiService) + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + # Determine filename from document or title documents = extractedContent.get("documents", []) if documents and isinstance(documents[0], dict): @@ -74,7 +78,9 @@ class RendererDocx(BaseRenderer): RenderedDocument( documentData=docx_bytes, mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -82,11 +88,15 @@ class RendererDocx(BaseRenderer): self.logger.error(f"Error rendering DOCX: {str(e)}") # Return minimal fallback fallbackContent = f"DOCX Generation Error: {str(e)}" + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None return [ RenderedDocument( documentData=fallbackContent.encode('utf-8'), mimeType="text/plain", - filename=self._determineFilename(title, "text/plain") + filename=self._determineFilename(title, "text/plain"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -96,8 +106,8 @@ class RendererDocx(BaseRenderer): # Create new document doc = Document() - # Get style set: default styles, enhanced with AI if style instructions present - styleSet = await self._getStyleSet(userPrompt, aiService) + # Get style set: use styles from metadata if available, otherwise enhance with AI + styleSet = await self._getStyleSet(json_content, userPrompt, aiService) # Setup basic document styles and create all styles from style set self._setupBasicDocumentStyles(doc) @@ -137,12 +147,17 @@ class RendererDocx(BaseRenderer): self.logger.error(f"Error generating DOCX from JSON: {str(e)}") raise Exception(f"DOCX generation failed: {str(e)}") - async def _getStyleSet(self, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: - """Get style set - default styles, enhanced with AI if userPrompt provided. + async def _getStyleSet(self, extractedContent: Dict[str, Any] = None, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: + """Get style set - use styles from document generation metadata if available, + otherwise enhance default styles with AI if userPrompt provided. + + WICHTIG: In a dynamic scalable AI system, styling should come from document generation, + not be generated separately by renderers. Only fall back to AI if styles not provided. Args: + extractedContent: Document content with metadata (may contain styles) userPrompt: User's prompt (AI will detect style instructions in any language) - aiService: AI service (used only if userPrompt provided) + aiService: AI service (used only if styles not in metadata and userPrompt provided) templateName: Name of template style set (None = default) Returns: @@ -156,10 +171,18 @@ class RendererDocx(BaseRenderer): else: defaultStyleSet = self._getDefaultStyleSet() - # Enhance with AI if userPrompt provided (AI handles multilingual style detection) + # FIRST: Check if styles are provided in document generation metadata (preferred approach) + if extractedContent: + metadata = extractedContent.get("metadata", {}) + if isinstance(metadata, dict): + styles = metadata.get("styles") + if styles and isinstance(styles, dict): + self.logger.debug("Using styles from document generation metadata") + return self._validateStylesContrast(styles) + + # FALLBACK: Enhance with AI if userPrompt provided (only if styles not in metadata) if userPrompt and aiService: - # AI will naturally detect style instructions in any language - self.logger.info(f"Enhancing styles with AI based on user prompt...") + self.logger.info(f"Styles not in metadata, enhancing with AI based on user prompt...") enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService) return self._validateStylesContrast(enhancedStyleSet) else: @@ -264,6 +287,10 @@ class RendererDocx(BaseRenderer): section_type = section.get("content_type", "paragraph") elements = section.get("elements", []) + # If no elements, skip this section (it has no content to render) + if not elements: + return + # Process each element in the section for element in elements: element_type = element.get("type", "") @@ -286,22 +313,36 @@ class RendererDocx(BaseRenderer): para.add_run(f" (Source: {source})").italic = True continue - # Standard section types - if section_type == "table": + # Check element type, not section type (elements can have different types than section) + if element_type == "table": self._renderJsonTable(doc, element, styles) - elif section_type == "bullet_list": + elif element_type == "bullet_list": self._renderJsonBulletList(doc, element, styles) - elif section_type == "heading": + elif element_type == "heading": self._renderJsonHeading(doc, element, styles) - elif section_type == "paragraph": + elif element_type == "paragraph": self._renderJsonParagraph(doc, element, styles) - elif section_type == "code_block": + elif element_type == "code_block": self._renderJsonCodeBlock(doc, element, styles) - elif section_type == "image": + elif element_type == "image": self._renderJsonImage(doc, element, styles) else: - # Fallback to paragraph for unknown types - self._renderJsonParagraph(doc, element, styles) + # Fallback: if element_type not set, use section_type + if section_type == "table": + self._renderJsonTable(doc, element, styles) + elif section_type == "bullet_list": + self._renderJsonBulletList(doc, element, styles) + elif section_type == "heading": + self._renderJsonHeading(doc, element, styles) + elif section_type == "paragraph": + self._renderJsonParagraph(doc, element, styles) + elif section_type == "code_block": + self._renderJsonCodeBlock(doc, element, styles) + elif section_type == "image": + self._renderJsonImage(doc, element, styles) + else: + # Fallback to paragraph for unknown types + self._renderJsonParagraph(doc, element, styles) except Exception as e: self.logger.warning(f"Error rendering section {section.get('id', 'unknown')}: {str(e)}") @@ -311,8 +352,12 @@ class RendererDocx(BaseRenderer): def _renderJsonTable(self, doc: Document, table_data: Dict[str, Any], styles: Dict[str, Any]) -> None: """Render a JSON table to DOCX using AI-generated styles.""" try: - headers = table_data.get("headers", []) - rows = table_data.get("rows", []) + # Extract from nested content structure + content = table_data.get("content", {}) + if not isinstance(content, dict): + return + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers or not rows: return @@ -467,7 +512,11 @@ class RendererDocx(BaseRenderer): def _renderJsonBulletList(self, doc: Document, list_data: Dict[str, Any], styles: Dict[str, Any]) -> None: """Render a JSON bullet list to DOCX using AI-generated styles.""" try: - items = list_data.get("items", []) + # Extract from nested content structure + content = list_data.get("content", {}) + if not isinstance(content, dict): + return + items = content.get("items", []) bullet_style = styles["bullet_list"] for item in items: @@ -482,8 +531,12 @@ class RendererDocx(BaseRenderer): def _renderJsonHeading(self, doc: Document, heading_data: Dict[str, Any], styles: Dict[str, Any]) -> None: """Render a JSON heading to DOCX using AI-generated styles.""" try: - level = heading_data.get("level", 1) - text = heading_data.get("text", "") + # Extract from nested content structure + content = heading_data.get("content", {}) + if not isinstance(content, dict): + return + text = content.get("text", "") + level = content.get("level", 1) if text: level = max(1, min(6, level)) @@ -495,7 +548,25 @@ class RendererDocx(BaseRenderer): def _renderJsonParagraph(self, doc: Document, paragraph_data: Dict[str, Any], styles: Dict[str, Any]) -> None: """Render a JSON paragraph to DOCX using AI-generated styles.""" try: - text = paragraph_data.get("text", "") + # Extract from nested content structure + content = paragraph_data.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" + + # CRITICAL: Prevent rendering base64 image data as text + # Base64 image data typically starts with /9j/ (JPEG) or iVBORw0KGgo (PNG) + if text and (text.startswith("/9j/") or text.startswith("iVBORw0KGgo") or + (len(text) > 100 and all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" for c in text[:100]))): + # This looks like base64 data - don't render as text + self.logger.warning(f"Skipping rendering of what appears to be base64 data in paragraph (length: {len(text)})") + para = doc.add_paragraph("[Error: Image data found in text content - image embedding may have failed]") + if para.runs: + para.runs[0].font.color.rgb = RGBColor(255, 0, 0) # Red color for error + return if text: para = doc.add_paragraph(text) @@ -506,8 +577,12 @@ class RendererDocx(BaseRenderer): def _renderJsonCodeBlock(self, doc: Document, code_data: Dict[str, Any], styles: Dict[str, Any]) -> None: """Render a JSON code block to DOCX using AI-generated styles.""" try: - code = code_data.get("code", "") - language = code_data.get("language", "") + # Extract from nested content structure + content = code_data.get("content", {}) + if not isinstance(content, dict): + return + code = content.get("code", "") + language = content.get("language", "") if code: if language: @@ -525,20 +600,33 @@ class RendererDocx(BaseRenderer): def _renderJsonImage(self, doc: Document, image_data: Dict[str, Any], styles: Dict[str, Any]) -> None: """Render a JSON image to DOCX.""" try: - base64_data = image_data.get("base64Data", "") - alt_text = image_data.get("altText", "Image") + # Extract from nested content structure + content = image_data.get("content", {}) + if not isinstance(content, dict): + return + base64_data = content.get("base64Data", "") + alt_text = content.get("altText", "Image") if base64_data: - image_bytes = base64.b64decode(base64_data) - doc.add_picture(io.BytesIO(image_bytes), width=Inches(4)) - - if alt_text: - caption_para = doc.add_paragraph(f"Figure: {alt_text}") - caption_para.runs[0].italic = True + try: + image_bytes = base64.b64decode(base64_data) + doc.add_picture(io.BytesIO(image_bytes), width=Inches(4)) + + if alt_text: + caption_para = doc.add_paragraph(f"Figure: {alt_text}") + caption_para.runs[0].italic = True + except Exception as embedError: + # Image decoding or embedding failed + raise Exception(f"Failed to decode or embed image: {str(embedError)}") + else: + raise Exception("No image data provided (base64Data is empty)") except Exception as e: - self.logger.warning(f"Error rendering image: {str(e)}") - doc.add_paragraph(f"[Image: {image_data.get('altText', 'Image')}]") + self.logger.error(f"Error embedding image in DOCX: {str(e)}") + errorMsg = f"[Error: Could not embed image '{image_data.get('altText', 'Image')}'. {str(e)}]" + errorPara = doc.add_paragraph(errorMsg) + if errorPara.runs: + errorPara.runs[0].font.color.rgb = RGBColor(255, 0, 0) # Red color for error def _extractStructureFromPrompt(self, userPrompt: str, title: str) -> Dict[str, Any]: """Extract document structure from user prompt.""" diff --git a/modules/services/serviceGeneration/renderers/rendererHtml.py b/modules/services/serviceGeneration/renderers/rendererHtml.py index 17ac25b3..4d7dafe0 100644 --- a/modules/services/serviceGeneration/renderers/rendererHtml.py +++ b/modules/services/serviceGeneration/renderers/rendererHtml.py @@ -55,12 +55,18 @@ class RendererHtml(BaseRenderer): else: htmlFilename = self._determineFilename(title, "text/html") + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + # Start with HTML document resultDocuments = [ RenderedDocument( documentData=htmlContent.encode('utf-8'), mimeType="text/html", - filename=htmlFilename + filename=htmlFilename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -90,8 +96,8 @@ class RendererHtml(BaseRenderer): async def _generateHtmlFromJson(self, jsonContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None) -> str: """Generate HTML content from structured JSON document using AI-generated styling.""" try: - # Get style set: default styles, enhanced with AI if userPrompt provided - styles = await self._getStyleSet(userPrompt, aiService) + # Get style set: use styles from metadata if available, otherwise enhance with AI + styles = await self._getStyleSet(jsonContent, userPrompt, aiService) # Validate JSON structure if not self._validateJsonStructure(jsonContent): @@ -148,12 +154,17 @@ class RendererHtml(BaseRenderer): self.logger.error(f"Error generating HTML from JSON: {str(e)}") raise Exception(f"HTML generation failed: {str(e)}") - async def _getStyleSet(self, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: - """Get style set - default styles, enhanced with AI if userPrompt provided. + async def _getStyleSet(self, extractedContent: Dict[str, Any] = None, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: + """Get style set - use styles from document generation metadata if available, + otherwise enhance default styles with AI if userPrompt provided. + + WICHTIG: In a dynamic scalable AI system, styling should come from document generation, + not be generated separately by renderers. Only fall back to AI if styles not provided. Args: + extractedContent: Document content with metadata (may contain styles) userPrompt: User's prompt (AI will detect style instructions in any language) - aiService: AI service (used only if userPrompt provided) + aiService: AI service (used only if styles not in metadata and userPrompt provided) templateName: Name of template style set (None = default) Returns: @@ -162,10 +173,18 @@ class RendererHtml(BaseRenderer): # Get default style set defaultStyleSet = self._getDefaultStyleSet() - # Enhance with AI if userPrompt provided (AI handles multilingual style detection) + # FIRST: Check if styles are provided in document generation metadata (preferred approach) + if extractedContent: + metadata = extractedContent.get("metadata", {}) + if isinstance(metadata, dict): + styles = metadata.get("styles") + if styles and isinstance(styles, dict): + self.logger.debug("Using styles from document generation metadata") + return self._validateStylesContrast(styles) + + # FALLBACK: Enhance with AI if userPrompt provided (only if styles not in metadata) if userPrompt and aiService: - # AI will naturally detect style instructions in any language - self.logger.info(f"Enhancing styles with AI based on user prompt...") + self.logger.info(f"Styles not in metadata, enhancing with AI based on user prompt...") enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService) return self._validateStylesContrast(enhancedStyleSet) else: @@ -446,8 +465,12 @@ class RendererHtml(BaseRenderer): def _renderJsonTable(self, tableData: Dict[str, Any], styles: Dict[str, Any]) -> str: """Render a JSON table to HTML using AI-generated styles.""" try: - headers = tableData.get("headers", []) - rows = tableData.get("rows", []) + # Extract from nested content structure + content = tableData.get("content", {}) + if not isinstance(content, dict): + return "" + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers or not rows: return "" @@ -477,9 +500,13 @@ class RendererHtml(BaseRenderer): return "" def _renderJsonBulletList(self, listData: Dict[str, Any], styles: Dict[str, Any]) -> str: - """Render a JSON bullet list to HTML using AI-generated styles.""" + """Render a JSON bullet list to HTML using AI-generated styles. Expects nested content structure.""" try: - items = listData.get("items", []) + # Extract from nested content structure + content = listData.get("content", {}) + if not isinstance(content, dict): + return "" + items = content.get("items", []) if not items: return "" @@ -513,8 +540,12 @@ class RendererHtml(BaseRenderer): elif not isinstance(headingData, dict): return "" - level = headingData.get("level", 1) - text = headingData.get("text", "") + # Extract from nested content structure + content = headingData.get("content", {}) + if not isinstance(content, dict): + return "" + text = content.get("text", "") + level = content.get("level", 1) if text: level = max(1, min(6, level)) @@ -531,11 +562,19 @@ class RendererHtml(BaseRenderer): try: # Normalize inputs - paragraphData is typically a list of elements from _getSectionData if isinstance(paragraphData, list): - # Extract text from all paragraph elements + # Extract text from all paragraph elements (expects nested content structure) texts = [] for el in paragraphData: - if isinstance(el, dict) and "text" in el: - texts.append(el["text"]) + if isinstance(el, dict): + content = el.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" + if text: + texts.append(text) elif isinstance(el, str): texts.append(el) if texts: @@ -545,7 +584,15 @@ class RendererHtml(BaseRenderer): elif isinstance(paragraphData, str): return f'

{paragraphData}

' elif isinstance(paragraphData, dict): - text = paragraphData.get("text", "") + # Handle nested content structure: element.content vs element.text + # Extract from nested content structure + content = paragraphData.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" if text: return f'

{text}

' return "" @@ -557,10 +604,14 @@ class RendererHtml(BaseRenderer): return "" def _renderJsonCodeBlock(self, codeData: Dict[str, Any], styles: Dict[str, Any]) -> str: - """Render a JSON code block to HTML using AI-generated styles.""" + """Render a JSON code block to HTML using AI-generated styles. Expects nested content structure.""" try: - code = codeData.get("code", "") - language = codeData.get("language", "") + # Extract from nested content structure + content = codeData.get("content", {}) + if not isinstance(content, dict): + return "" + code = content.get("code", "") + language = content.get("language", "") if code: if language: @@ -575,12 +626,16 @@ class RendererHtml(BaseRenderer): return "" def _renderJsonImage(self, imageData: Dict[str, Any], styles: Dict[str, Any]) -> str: - """Render a JSON image to HTML with placeholder for later replacement.""" + """Render a JSON image to HTML with placeholder for later replacement. Expects nested content structure.""" try: import html - base64Data = imageData.get("base64Data", "") - altText = imageData.get("altText", "Image") - caption = imageData.get("caption", "") + # Extract from nested content structure + content = imageData.get("content", {}) + if not isinstance(content, dict): + return "" + base64Data = content.get("base64Data", "") + altText = content.get("altText", "Image") + caption = content.get("caption", "") # Escape HTML in altText and caption to prevent injection altTextEscaped = html.escape(str(altText)) @@ -600,8 +655,10 @@ class RendererHtml(BaseRenderer): return "" except Exception as e: - self.logger.warning(f"Error rendering image: {str(e)}") - return f'
[Image: {imageData.get("altText", "Image")}]
' + self.logger.error(f"Error embedding image in HTML: {str(e)}") + altText = imageData.get("altText", "Image") + errorMsg = html.escape(f"[Error: Could not embed image '{altText}'. {str(e)}]") + return f'
{errorMsg}
' def _extractImages(self, jsonContent: Dict[str, Any]) -> List[Dict[str, Any]]: """ @@ -626,12 +683,24 @@ class RendererHtml(BaseRenderer): if section.get("content_type") == "image": elements = section.get("elements", []) for element in elements: - base64Data = element.get("base64Data", "") + # Extract from nested content structure + content = element.get("content", {}) + base64Data = "" - # If base64Data not found, try extracting from url data URI + if isinstance(content, dict): + base64Data = content.get("base64Data", "") + elif isinstance(content, str): + # Content might be base64 string directly (shouldn't happen) + pass + + # If base64Data not found in content, try direct element fields (fallback) if not base64Data: - url = element.get("url", "") - if url.startswith("data:image/"): + base64Data = element.get("base64Data", "") + + # If base64Data still not found, try extracting from url data URI + if not base64Data: + url = element.get("url", "") or (content.get("url", "") if isinstance(content, dict) else "") + if url and isinstance(url, str) and url.startswith("data:image/"): # Extract base64 from data URI: data:image/png;base64, import re match = re.match(r'data:image/[^;]+;base64,(.+)', url) @@ -642,7 +711,8 @@ class RendererHtml(BaseRenderer): sectionId = section.get("id", "unknown") # Bestimme MIME-Type und Extension - mimeType = element.get("mimeType", "image/png") + mimeType = element.get("mimeType", "") or (content.get("mimeType", "") if isinstance(content, dict) else "") + if not mimeType or mimeType == "unknown": if not mimeType or mimeType == "unknown": # Versuche MIME-Type aus base64 zu erkennen if base64Data.startswith("/9j/"): diff --git a/modules/services/serviceGeneration/renderers/rendererImage.py b/modules/services/serviceGeneration/renderers/rendererImage.py index 7d317131..479881df 100644 --- a/modules/services/serviceGeneration/renderers/rendererImage.py +++ b/modules/services/serviceGeneration/renderers/rendererImage.py @@ -54,11 +54,17 @@ class RendererImage(BaseRenderer): else: imageBytes = imageContent + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + return [ RenderedDocument( documentData=imageBytes, mimeType="image/png", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] diff --git a/modules/services/serviceGeneration/renderers/rendererJson.py b/modules/services/serviceGeneration/renderers/rendererJson.py index 04196cf4..91e8342d 100644 --- a/modules/services/serviceGeneration/renderers/rendererJson.py +++ b/modules/services/serviceGeneration/renderers/rendererJson.py @@ -43,11 +43,17 @@ class RendererJson(BaseRenderer): else: filename = self._determineFilename(title, "application/json") + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + return [ RenderedDocument( documentData=jsonContent.encode('utf-8'), mimeType="application/json", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -60,11 +66,15 @@ class RendererJson(BaseRenderer): "metadata": {"error": str(e)} } fallbackContent = json.dumps(fallbackData, indent=2) + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None return [ RenderedDocument( documentData=fallbackContent.encode('utf-8'), mimeType="application/json", - filename=self._determineFilename(title, "application/json") + filename=self._determineFilename(title, "application/json"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] diff --git a/modules/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/services/serviceGeneration/renderers/rendererMarkdown.py index 7b23eb25..d491c8c2 100644 --- a/modules/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/services/serviceGeneration/renderers/rendererMarkdown.py @@ -41,11 +41,17 @@ class RendererMarkdown(BaseRenderer): else: filename = self._determineFilename(title, "text/markdown") + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + return [ RenderedDocument( documentData=markdownContent.encode('utf-8'), mimeType="text/markdown", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -53,11 +59,15 @@ class RendererMarkdown(BaseRenderer): self.logger.error(f"Error rendering markdown: {str(e)}") # Return minimal markdown fallback fallbackContent = f"# {title}\n\nError rendering report: {str(e)}" + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None return [ RenderedDocument( documentData=fallbackContent.encode('utf-8'), mimeType="text/markdown", - filename=self._determineFilename(title, "text/markdown") + filename=self._determineFilename(title, "text/markdown"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -164,8 +174,12 @@ class RendererMarkdown(BaseRenderer): def _renderJsonTable(self, tableData: Dict[str, Any]) -> str: """Render a JSON table to markdown.""" try: - headers = tableData.get("headers", []) - rows = tableData.get("rows", []) + # Extract from nested content structure + content = tableData.get("content", {}) + if not isinstance(content, dict): + return "" + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers or not rows: return "" @@ -194,7 +208,11 @@ class RendererMarkdown(BaseRenderer): def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str: """Render a JSON bullet list to markdown.""" try: - items = listData.get("items", []) + # Extract from nested content structure + content = listData.get("content", {}) + if not isinstance(content, dict): + return "" + items = content.get("items", []) if not items: return "" @@ -215,8 +233,12 @@ class RendererMarkdown(BaseRenderer): def _renderJsonHeading(self, headingData: Dict[str, Any]) -> str: """Render a JSON heading to markdown.""" try: - level = headingData.get("level", 1) - text = headingData.get("text", "") + # Extract from nested content structure + content = headingData.get("content", {}) + if not isinstance(content, dict): + return "" + text = content.get("text", "") + level = content.get("level", 1) if text: level = max(1, min(6, level)) @@ -231,7 +253,14 @@ class RendererMarkdown(BaseRenderer): def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str: """Render a JSON paragraph to markdown.""" try: - text = paragraphData.get("text", "") + # Extract from nested content structure + content = paragraphData.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" return text if text else "" except Exception as e: @@ -241,8 +270,12 @@ class RendererMarkdown(BaseRenderer): def _renderJsonCodeBlock(self, codeData: Dict[str, Any]) -> str: """Render a JSON code block to markdown.""" try: - code = codeData.get("code", "") - language = codeData.get("language", "") + # Extract from nested content structure + content = codeData.get("content", {}) + if not isinstance(content, dict): + return "" + code = content.get("code", "") + language = content.get("language", "") if code: if language: @@ -259,8 +292,12 @@ class RendererMarkdown(BaseRenderer): def _renderJsonImage(self, imageData: Dict[str, Any]) -> str: """Render a JSON image to markdown.""" try: - altText = imageData.get("altText", "Image") - base64Data = imageData.get("base64Data", "") + # Extract from nested content structure + content = imageData.get("content", {}) + if not isinstance(content, dict): + return "" + altText = content.get("altText", "Image") + base64Data = content.get("base64Data", "") if base64Data: # For base64 images, we can't embed them directly in markdown diff --git a/modules/services/serviceGeneration/renderers/rendererPdf.py b/modules/services/serviceGeneration/renderers/rendererPdf.py index 9767449e..a6583a33 100644 --- a/modules/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/services/serviceGeneration/renderers/rendererPdf.py @@ -51,6 +51,10 @@ class RendererPdf(BaseRenderer): # Generate PDF using AI-analyzed styling pdf_content = await self._generatePdfFromJson(extractedContent, title, userPrompt, aiService) + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + # Determine filename from document or title documents = extractedContent.get("documents", []) if documents and isinstance(documents[0], dict): @@ -74,7 +78,9 @@ class RendererPdf(BaseRenderer): RenderedDocument( documentData=pdf_bytes, mimeType="application/pdf", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -93,8 +99,8 @@ class RendererPdf(BaseRenderer): async def _generatePdfFromJson(self, json_content: Dict[str, Any], title: str, userPrompt: str = None, aiService=None) -> str: """Generate PDF content from structured JSON document using AI-generated styling.""" try: - # Get style set: default styles, enhanced with AI if userPrompt provided - styles = await self._getStyleSet(userPrompt, aiService) + # Get style set: use styles from metadata if available, otherwise enhance with AI + styles = await self._getStyleSet(json_content, userPrompt, aiService) # Validate JSON structure if not self._validateJsonStructure(json_content): @@ -157,12 +163,17 @@ class RendererPdf(BaseRenderer): self.logger.error(f"Error generating PDF from JSON: {str(e)}") raise Exception(f"PDF generation failed: {str(e)}") - async def _getStyleSet(self, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: - """Get style set - default styles, enhanced with AI if userPrompt provided. + async def _getStyleSet(self, extractedContent: Dict[str, Any] = None, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: + """Get style set - use styles from document generation metadata if available, + otherwise enhance default styles with AI if userPrompt provided. + + WICHTIG: In a dynamic scalable AI system, styling should come from document generation, + not be generated separately by renderers. Only fall back to AI if styles not provided. Args: + extractedContent: Document content with metadata (may contain styles) userPrompt: User's prompt (AI will detect style instructions in any language) - aiService: AI service (used only if userPrompt provided) + aiService: AI service (used only if styles not in metadata and userPrompt provided) templateName: Name of template style set (None = default) Returns: @@ -171,10 +182,19 @@ class RendererPdf(BaseRenderer): # Get default style set defaultStyleSet = self._getDefaultStyleSet() - # Enhance with AI if userPrompt provided (AI handles multilingual style detection) + # FIRST: Check if styles are provided in document generation metadata (preferred approach) + if extractedContent: + metadata = extractedContent.get("metadata", {}) + if isinstance(metadata, dict): + styles = metadata.get("styles") + if styles and isinstance(styles, dict): + self.logger.debug("Using styles from document generation metadata") + enhancedStyleSet = self._convertColorsFormat(styles) + return self._validateStylesContrast(enhancedStyleSet) + + # FALLBACK: Enhance with AI if userPrompt provided (only if styles not in metadata) if userPrompt and aiService: - # AI will naturally detect style instructions in any language - self.logger.info(f"Enhancing styles with AI based on user prompt...") + self.logger.info(f"Styles not in metadata, enhancing with AI based on user prompt...") enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService) # Convert colors to PDF format after getting styles enhancedStyleSet = self._convertColorsFormat(enhancedStyleSet) @@ -545,22 +565,36 @@ class RendererPdf(BaseRenderer): all_elements.append(Spacer(1, 6)) continue - # Standard section types - if section_type == "table": + # Check element type, not section type (elements can have different types than section) + if element_type == "table": all_elements.extend(self._renderJsonTable(element, styles)) - elif section_type == "bullet_list": + elif element_type == "bullet_list": all_elements.extend(self._renderJsonBulletList(element, styles)) - elif section_type == "heading": + elif element_type == "heading": all_elements.extend(self._renderJsonHeading(element, styles)) - elif section_type == "paragraph": + elif element_type == "paragraph": all_elements.extend(self._renderJsonParagraph(element, styles)) - elif section_type == "code_block": + elif element_type == "code_block": all_elements.extend(self._renderJsonCodeBlock(element, styles)) - elif section_type == "image": + elif element_type == "image": all_elements.extend(self._renderJsonImage(element, styles)) else: - # Fallback to paragraph for unknown types - all_elements.extend(self._renderJsonParagraph(element, styles)) + # Fallback: if element_type not set, use section_type as fallback + if section_type == "table": + all_elements.extend(self._renderJsonTable(element, styles)) + elif section_type == "bullet_list": + all_elements.extend(self._renderJsonBulletList(element, styles)) + elif section_type == "heading": + all_elements.extend(self._renderJsonHeading(element, styles)) + elif section_type == "paragraph": + all_elements.extend(self._renderJsonParagraph(element, styles)) + elif section_type == "code_block": + all_elements.extend(self._renderJsonCodeBlock(element, styles)) + elif section_type == "image": + all_elements.extend(self._renderJsonImage(element, styles)) + else: + # Final fallback to paragraph for unknown types + all_elements.extend(self._renderJsonParagraph(element, styles)) return all_elements @@ -571,8 +605,13 @@ class RendererPdf(BaseRenderer): def _renderJsonTable(self, table_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON table to PDF elements using AI-generated styles.""" try: - headers = table_data.get("headers", []) - rows = table_data.get("rows", []) + # Handle nested content structure: element.content.headers vs element.headers + # Extract from nested content structure + content = table_data.get("content", {}) + if not isinstance(content, dict): + return [] + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers or not rows: return [] @@ -588,13 +627,13 @@ class RendererPdf(BaseRenderer): table_cell_style = styles.get("table_cell", {}) table_style = [ - ('BACKGROUND', (0, 0), (-1, 0), self._hex_to_color(table_header_style.get("background", "#4F4F4F"))), - ('TEXTCOLOR', (0, 0), (-1, 0), self._hex_to_color(table_header_style.get("text_color", "#FFFFFF"))), + ('BACKGROUND', (0, 0), (-1, 0), self._hexToColor(table_header_style.get("background", "#4F4F4F"))), + ('TEXTCOLOR', (0, 0), (-1, 0), self._hexToColor(table_header_style.get("text_color", "#FFFFFF"))), ('ALIGN', (0, 0), (-1, -1), self._getTableAlignment(table_cell_style.get("align", "left"))), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold' if table_header_style.get("bold", True) else 'Helvetica'), ('FONTSIZE', (0, 0), (-1, 0), table_header_style.get("font_size", 12)), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - ('BACKGROUND', (0, 1), (-1, -1), self._hex_to_color(table_cell_style.get("background", "#FFFFFF"))), + ('BACKGROUND', (0, 1), (-1, -1), self._hexToColor(table_cell_style.get("background", "#FFFFFF"))), ('FONTSIZE', (0, 1), (-1, -1), table_cell_style.get("font_size", 10)), ('GRID', (0, 0), (-1, -1), 1, colors.black) ] @@ -610,7 +649,11 @@ class RendererPdf(BaseRenderer): def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON bullet list to PDF elements using AI-generated styles.""" try: - items = list_data.get("items", []) + # Extract from nested content structure + content = list_data.get("content", {}) + if not isinstance(content, dict): + return [] + items = content.get("items", []) bullet_style_def = styles.get("bullet_list", {}) elements = [] @@ -632,8 +675,12 @@ class RendererPdf(BaseRenderer): def _renderJsonHeading(self, heading_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON heading to PDF elements using AI-generated styles.""" try: - level = heading_data.get("level", 1) - text = heading_data.get("text", "") + # Extract from nested content structure + content = heading_data.get("content", {}) + if not isinstance(content, dict): + return [] + text = content.get("text", "") + level = content.get("level", 1) if text: level = max(1, min(6, level)) @@ -649,7 +696,14 @@ class RendererPdf(BaseRenderer): def _renderJsonParagraph(self, paragraph_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON paragraph to PDF elements using AI-generated styles.""" try: - text = paragraph_data.get("text", "") + # Extract from nested content structure + content = paragraph_data.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" if text: return [Paragraph(text, self._createNormalStyle(styles))] @@ -663,8 +717,12 @@ class RendererPdf(BaseRenderer): def _renderJsonCodeBlock(self, code_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON code block to PDF elements using AI-generated styles.""" try: - code = code_data.get("code", "") - language = code_data.get("language", "") + # Extract from nested content structure + content = code_data.get("content", {}) + if not isinstance(content, dict): + return [] + code = content.get("code", "") + language = content.get("language", "") code_style_def = styles.get("code_block", {}) if code: @@ -700,14 +758,34 @@ class RendererPdf(BaseRenderer): def _renderJsonImage(self, image_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: """Render a JSON image to PDF elements using reportlab.""" try: - base64_data = image_data.get("base64Data", "") - alt_text = image_data.get("altText", "Image") - caption = image_data.get("caption", "") + # Extract from nested content structure + content = image_data.get("content", {}) + base64_data = "" + alt_text = "Image" + caption = "" - # If base64Data not found, try extracting from url data URI + if isinstance(content, dict): + # Nested content structure + base64_data = content.get("base64Data", "") + alt_text = content.get("altText", "Image") + caption = content.get("caption", "") + elif isinstance(content, str): + # Content might be base64 string directly (shouldn't happen, but handle it) + self.logger.warning("Image content is a string, not a dict. This should not happen.") + return [Paragraph(f"[Image: Invalid format]", self._createNormalStyle(styles))] + + # If base64Data not found in content, try direct element fields (fallback) if not base64_data: - url = image_data.get("url", "") - if url.startswith("data:image/"): + base64_data = image_data.get("base64Data", "") + if not alt_text or alt_text == "Image": + alt_text = image_data.get("altText", "Image") + if not caption: + caption = image_data.get("caption", "") + + # If base64Data still not found, try extracting from url data URI + if not base64_data: + url = image_data.get("url", "") or (content.get("url", "") if isinstance(content, dict) else "") + if url and isinstance(url, str) and url.startswith("data:image/"): # Extract base64 from data URI: data:image/png;base64, import re match = re.match(r'data:image/[^;]+;base64,(.+)', url) @@ -715,8 +793,18 @@ class RendererPdf(BaseRenderer): base64_data = match.group(1) if not base64_data: + self.logger.warning(f"No base64 data found for image. Alt text: {alt_text}") return [Paragraph(f"[Image: {alt_text}]", self._createNormalStyle(styles))] + # Validate that base64_data is actually base64 (not the entire element rendered as text) + if len(base64_data) > 10000: # Very long string might be entire element JSON + self.logger.warning(f"Base64 data seems too long ({len(base64_data)} chars), might be incorrectly extracted") + + # Ensure base64_data is a string, not bytes or other type + if not isinstance(base64_data, str): + self.logger.warning(f"Base64 data is not a string: {type(base64_data)}") + return [Paragraph(f"[Image: {alt_text} - Invalid data type]", self._createNormalStyle(styles))] + try: from reportlab.platypus import Image as ReportLabImage from reportlab.lib.units import inch @@ -731,25 +819,61 @@ class RendererPdf(BaseRenderer): # Try to get image dimensions from PIL try: from PIL import Image as PILImage - pilImage = PILImage.open(imageStream) - imgWidth, imgHeight = pilImage.size + from reportlab.lib.pagesizes import A4 - # Scale to fit page (max width 6 inches, maintain aspect ratio) - maxWidth = 6 * inch - if imgWidth > maxWidth: - scale = maxWidth / imgWidth - imgWidth = maxWidth + pilImage = PILImage.open(imageStream) + originalWidth, originalHeight = pilImage.size + + # Calculate available page dimensions (A4 with margins: 72pt left/right, 72pt top, 18pt bottom) + pageWidth = A4[0] # 595.27 points + pageHeight = A4[1] # 841.89 points + leftMargin = 72 + rightMargin = 72 + topMargin = 72 + bottomMargin = 18 + + # Use actual frame dimensions from SimpleDocTemplate + # Frame is smaller than page minus margins due to internal spacing + # From error message: frame is 439.27559055118115 x 739.8897637795277 + # Use conservative values with safety margin + availableWidth = 430.0 # Slightly smaller than frame width for safety + availableHeight = 730.0 # Slightly smaller than frame height for safety + + # Convert original image size from pixels to points (assuming 72 DPI) + # If image DPI is different, PIL will provide correct size + # For safety, use a conservative conversion + imgWidthPoints = originalWidth * (inch / 72) # Convert to inches, then to points + imgHeightPoints = originalHeight * (inch / 72) + + # Scale to fit within available page dimensions while maintaining aspect ratio + widthScale = availableWidth / imgWidthPoints if imgWidthPoints > 0 else 1.0 + heightScale = availableHeight / imgHeightPoints if imgHeightPoints > 0 else 1.0 + + # Use the smaller scale to ensure image fits both width and height + scale = min(widthScale, heightScale, 1.0) # Don't scale up, only down + + imgWidth = imgWidthPoints * scale + imgHeight = imgHeightPoints * scale + + # Additional safety check: ensure dimensions don't exceed available space + if imgWidth > availableWidth: + scale = availableWidth / imgWidth + imgWidth = availableWidth imgHeight = imgHeight * scale - else: - imgWidth = imgWidth * (inch / 72) # Convert pixels to inches (assuming 72 DPI) - imgHeight = imgHeight * (inch / 72) + + if imgHeight > availableHeight: + scale = availableHeight / imgHeight + imgHeight = availableHeight + imgWidth = imgWidth * scale # Reset stream for reportlab imageStream.seek(0) - except Exception: - # Fallback: use default size - imgWidth = 4 * inch - imgHeight = 3 * inch + except Exception as e: + # Fallback: use default size that fits page + self.logger.warning(f"Error calculating image size: {str(e)}, using safe default") + # Use 80% of available width as safe default + imgWidth = 4 * inch # ~288 points, safe for ~451pt available width + imgHeight = 3 * inch # ~216 points, safe for ~751pt available height imageStream.seek(0) # Create reportlab Image @@ -773,10 +897,16 @@ class RendererPdf(BaseRenderer): return elements except Exception as imgError: - self.logger.warning(f"Error embedding image in PDF: {str(imgError)}") - # Fallback to placeholder - return [Paragraph(f"[Image: {alt_text}]", self._createNormalStyle(styles))] + self.logger.error(f"Error embedding image in PDF: {str(imgError)}") + # Return error message instead of placeholder + errorStyle = self._createNormalStyle(styles) + errorStyle.textColor = self._hexToColor("#FF0000") # Red color for error + errorMsg = f"[Error: Could not embed image '{alt_text}'. {str(imgError)}]" + return [Paragraph(errorMsg, errorStyle)] except Exception as e: - self.logger.warning(f"Error rendering image: {str(e)}") - return [Paragraph(f"[Image: {image_data.get('altText', 'Image')}]", self._createNormalStyle(styles))] \ No newline at end of file + self.logger.error(f"Error rendering image: {str(e)}") + errorStyle = self._createNormalStyle(styles) + errorStyle.textColor = self._hexToColor("#FF0000") # Red color for error + errorMsg = f"[Error: Could not render image '{image_data.get('altText', 'Image')}'. {str(e)}]" + return [Paragraph(errorMsg, errorStyle)] \ No newline at end of file diff --git a/modules/services/serviceGeneration/renderers/rendererPptx.py b/modules/services/serviceGeneration/renderers/rendererPptx.py index d12048c7..850a59a4 100644 --- a/modules/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/services/serviceGeneration/renderers/rendererPptx.py @@ -48,8 +48,8 @@ class RendererPptx(BaseRenderer): from pptx.dml.color import RGBColor import re - # Get style set: default styles, enhanced with AI if userPrompt provided - styles = await self._getStyleSet(userPrompt, aiService) + # Get style set: use styles from metadata if available, otherwise enhance with AI + styles = await self._getStyleSet(extractedContent, userPrompt, aiService) # Create new presentation prs = Presentation() @@ -99,7 +99,7 @@ class RendererPptx(BaseRenderer): if title_shape.text_frame.paragraphs[0].font: title_shape.text_frame.paragraphs[0].font.size = Pt(title_style.get("font_size", 44)) title_shape.text_frame.paragraphs[0].font.bold = title_style.get("bold", True) - title_color = self._get_safe_color(title_style.get("color", (31, 78, 121))) + title_color = self._getSafeColor(title_style.get("color", (31, 78, 121))) title_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*title_color) # Handle images first (if present) @@ -133,7 +133,7 @@ class RendererPptx(BaseRenderer): heading_style = styles.get("heading", {}) p.font.size = Pt(heading_style.get("font_size", 32)) p.font.bold = heading_style.get("bold", True) - heading_color = self._get_safe_color(heading_style.get("color", (47, 47, 47))) + heading_color = self._getSafeColor(heading_style.get("color", (47, 47, 47))) p.font.color.rgb = RGBColor(*heading_color) elif paragraph.startswith('##'): # Subheader @@ -141,7 +141,7 @@ class RendererPptx(BaseRenderer): subheading_style = styles.get("subheading", {}) p.font.size = Pt(subheading_style.get("font_size", 24)) p.font.bold = subheading_style.get("bold", True) - subheading_color = self._get_safe_color(subheading_style.get("color", (79, 79, 79))) + subheading_color = self._getSafeColor(subheading_style.get("color", (79, 79, 79))) p.font.color.rgb = RGBColor(*subheading_color) elif paragraph.startswith('*') and paragraph.endswith('*'): # Bold text @@ -149,14 +149,14 @@ class RendererPptx(BaseRenderer): paragraph_style = styles.get("paragraph", {}) p.font.size = Pt(paragraph_style.get("font_size", 18)) p.font.bold = True - paragraph_color = self._get_safe_color(paragraph_style.get("color", (47, 47, 47))) + paragraph_color = self._getSafeColor(paragraph_style.get("color", (47, 47, 47))) p.font.color.rgb = RGBColor(*paragraph_color) else: # Regular text paragraph_style = styles.get("paragraph", {}) p.font.size = Pt(paragraph_style.get("font_size", 18)) p.font.bold = paragraph_style.get("bold", False) - paragraph_color = self._get_safe_color(paragraph_style.get("color", (47, 47, 47))) + paragraph_color = self._getSafeColor(paragraph_style.get("color", (47, 47, 47))) p.font.color.rgb = RGBColor(*paragraph_color) # Apply alignment @@ -181,7 +181,7 @@ class RendererPptx(BaseRenderer): if title_shape.text_frame.paragraphs[0].font: title_shape.text_frame.paragraphs[0].font.size = Pt(title_style.get("font_size", 48)) title_shape.text_frame.paragraphs[0].font.bold = title_style.get("bold", True) - title_color = self._get_safe_color(title_style.get("color", (31, 78, 121))) + title_color = self._getSafeColor(title_style.get("color", (31, 78, 121))) title_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*title_color) subtitle_shape = slide.placeholders[1] @@ -215,32 +215,46 @@ class RendererPptx(BaseRenderer): else: filename = self._determineFilename(title, "application/vnd.openxmlformats-officedocument.presentationml.presentation") + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + return [ RenderedDocument( documentData=pptx_bytes, mimeType="application/vnd.openxmlformats-officedocument.presentationml.presentation", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] except ImportError: logger.error("python-pptx library not installed. Install with: pip install python-pptx") fallbackContent = "python-pptx library not installed" + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None return [ RenderedDocument( documentData=fallbackContent.encode('utf-8'), mimeType="text/plain", - filename=self._determineFilename(title, "text/plain") + filename=self._determineFilename(title, "text/plain"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] except Exception as e: logger.error(f"Error rendering PowerPoint presentation: {str(e)}") fallbackContent = f"Error rendering PowerPoint presentation: {str(e)}" + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None return [ RenderedDocument( documentData=fallbackContent.encode('utf-8'), mimeType="text/plain", - filename=self._determineFilename(title, "text/plain") + filename=self._determineFilename(title, "text/plain"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -349,12 +363,17 @@ class RendererPptx(BaseRenderer): """Get MIME type for rendered output.""" return self.outputMimeType - async def _getStyleSet(self, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: - """Get style set - default styles, enhanced with AI if userPrompt provided. + async def _getStyleSet(self, extractedContent: Dict[str, Any] = None, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: + """Get style set - use styles from document generation metadata if available, + otherwise enhance default styles with AI if userPrompt provided. + + WICHTIG: In a dynamic scalable AI system, styling should come from document generation, + not be generated separately by renderers. Only fall back to AI if styles not provided. Args: + extractedContent: Document content with metadata (may contain styles) userPrompt: User's prompt (AI will detect style instructions in any language) - aiService: AI service (used only if userPrompt provided) + aiService: AI service (used only if styles not in metadata and userPrompt provided) templateName: Name of template style set (None = default) Returns: @@ -363,10 +382,19 @@ class RendererPptx(BaseRenderer): # Get default style set defaultStyleSet = self._getDefaultStyleSet() - # Enhance with AI if userPrompt provided (AI handles multilingual style detection) + # FIRST: Check if styles are provided in document generation metadata (preferred approach) + if extractedContent: + metadata = extractedContent.get("metadata", {}) + if isinstance(metadata, dict): + styles = metadata.get("styles") + if styles and isinstance(styles, dict): + self.logger.debug("Using styles from document generation metadata") + enhancedStyleSet = self._convertColorsFormat(styles) + return self._validateStylesReadability(enhancedStyleSet) + + # FALLBACK: Enhance with AI if userPrompt provided (only if styles not in metadata) if userPrompt and aiService: - # AI will naturally detect style instructions in any language - self.logger.info(f"Enhancing styles with AI based on user prompt...") + self.logger.info(f"Styles not in metadata, enhancing with AI based on user prompt...") enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService) # Convert colors to PPTX format after getting styles enhancedStyleSet = self._convertColorsFormat(enhancedStyleSet) @@ -690,15 +718,28 @@ JSON ONLY. NO OTHER TEXT.""" # Handle image sections specially if content_type == "image": - # Extract image data + # Extract image data from nested content structure images = [] for element in elements: - if element.get("base64Data"): - images.append({ - "base64Data": element.get("base64Data"), - "altText": element.get("altText", "Image"), - "caption": element.get("caption") - }) + if isinstance(element, dict): + # Extract from nested content structure + content = element.get("content", {}) + if isinstance(content, dict): + base64Data = content.get("base64Data") + altText = content.get("altText", "Image") + caption = content.get("caption", "") + else: + # Fallback to direct element fields + base64Data = element.get("base64Data") + altText = element.get("altText", "Image") + caption = element.get("caption", "") + + if base64Data: + images.append({ + "base64Data": base64Data, + "altText": altText, + "caption": caption + }) return { "title": section_title or (elements[0].get("altText", "Image") if elements else "Image"), @@ -719,7 +760,7 @@ JSON ONLY. NO OTHER TEXT.""" elif content_type == "code": content_parts.append(self._formatCodeForSlide(elements)) else: - content_parts.append(self._format_paragraph_for_slide(elements)) + content_parts.append(self._formatParagraphForSlide(elements)) # Combine content parts slide_content = "\n\n".join(filter(None, content_parts)) @@ -734,17 +775,20 @@ JSON ONLY. NO OTHER TEXT.""" logger.warning(f"Error creating slide from section: {str(e)}") return None - def _formatTableForSlide(self, elements: List[Dict[str, Any]]) -> str: + def _formatTableForSlide(self, element: Dict[str, Any]) -> str: """Format table data for slide presentation.""" try: - # Extract table data from elements array - headers = [] - rows = [] - for element in elements: - if isinstance(element, dict) and "headers" in element and "rows" in element: - headers = element.get("headers", []) - rows = element.get("rows", []) - break + # Extract table data from element - handle nested content structure + if not isinstance(element, dict): + return "" + + # Extract from nested content structure + content = element.get("content", {}) + if not isinstance(content, dict): + return "" + + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers: return "" @@ -778,7 +822,11 @@ JSON ONLY. NO OTHER TEXT.""" def _formatListForSlide(self, list_data: Dict[str, Any]) -> str: """Format list data for slide presentation.""" try: - items = list_data.get("items", []) + # Extract from nested content structure + content = list_data.get("content", {}) + if not isinstance(content, dict): + return "" + items = content.get("items", []) if not items: return "" @@ -810,8 +858,12 @@ JSON ONLY. NO OTHER TEXT.""" def _formatHeadingForSlide(self, heading_data: Dict[str, Any]) -> str: """Format heading data for slide presentation.""" try: - text = heading_data.get("text", "") - level = heading_data.get("level", 1) + # Extract from nested content structure + content = heading_data.get("content", {}) + if not isinstance(content, dict): + return "" + text = content.get("text", "") + level = content.get("level", 1) if text: return f"{'#' * level} {text}" @@ -825,7 +877,14 @@ JSON ONLY. NO OTHER TEXT.""" def _formatParagraphForSlide(self, paragraph_data: Dict[str, Any]) -> str: """Format paragraph data for slide presentation.""" try: - text = paragraph_data.get("text", "") + # Extract from nested content structure + content = paragraph_data.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" if text: # Limit paragraph length based on content density @@ -844,8 +903,12 @@ JSON ONLY. NO OTHER TEXT.""" def _formatCodeForSlide(self, code_data: Dict[str, Any]) -> str: """Format code data for slide presentation.""" try: - code = code_data.get("code", "") - language = code_data.get("language", "") + # Extract from nested content structure + content = code_data.get("content", {}) + if not isinstance(content, dict): + return "" + code = content.get("code", "") + language = content.get("language", "") if code: # Limit code length based on content density @@ -912,6 +975,10 @@ JSON ONLY. NO OTHER TEXT.""" section_type = section.get("content_type", "paragraph") elements = section.get("elements", []) + # Skip sections with no elements (unless they're headings that should create new slides) + if not elements and section_type != "heading": + continue + if section_type == "heading": # If we have accumulated content, create a slide if current_slide_content: @@ -923,10 +990,26 @@ JSON ONLY. NO OTHER TEXT.""" current_slide_content = [] # Start new slide with heading as title + heading_found = False for element in elements: - if isinstance(element, dict) and "text" in element: - current_slide_title = element.get("text", "Untitled Section") - break + if isinstance(element, dict): + # Extract from nested content structure + content = element.get("content", {}) + if isinstance(content, dict): + heading_text = content.get("text", "") + elif isinstance(content, str): + heading_text = content + else: + heading_text = "" + + if heading_text: + current_slide_title = heading_text + heading_found = True + break + + # If no heading text found but this is a heading section, use section ID or default + if not heading_found: + current_slide_title = section.get("id", "Untitled Section") elif section_type == "image": # Create separate slide for image if current_slide_content: @@ -940,12 +1023,25 @@ JSON ONLY. NO OTHER TEXT.""" # Extract image data imageData = [] for element in elements: - if element.get("base64Data"): - imageData.append({ - "base64Data": element.get("base64Data"), - "altText": element.get("altText", "Image"), - "caption": element.get("caption") - }) + if isinstance(element, dict): + # Extract from nested content structure + content = element.get("content", {}) + if isinstance(content, dict): + base64Data = content.get("base64Data") + altText = content.get("altText", "Image") + caption = content.get("caption", "") + else: + # Fallback to direct element fields + base64Data = element.get("base64Data") + altText = element.get("altText", "Image") + caption = element.get("caption", "") + + if base64Data: + imageData.append({ + "base64Data": base64Data, + "altText": altText, + "caption": caption + }) slides.append({ "title": section.get("title") or (imageData[0].get("altText", "Image") if imageData else "Image"), @@ -986,17 +1082,17 @@ JSON ONLY. NO OTHER TEXT.""" content_parts = [] for element in elements: if content_type == "table": - content_parts.append(self._formatTableForSlide([element])) - elif content_type == "list": - content_parts.append(self._formatListForSlide([element])) + content_parts.append(self._formatTableForSlide(element)) + elif content_type == "bullet_list" or content_type == "list": + content_parts.append(self._formatListForSlide(element)) elif content_type == "heading": - content_parts.append(self._formatHeadingForSlide([element])) + content_parts.append(self._formatHeadingForSlide(element)) elif content_type == "paragraph": - content_parts.append(self._formatParagraphForSlide([element])) - elif content_type == "code": - content_parts.append(self._formatCodeForSlide([element])) + content_parts.append(self._formatParagraphForSlide(element)) + elif content_type == "code_block" or content_type == "code": + content_parts.append(self._formatCodeForSlide(element)) else: - content_parts.append(self._format_paragraph_for_slide([element])) + content_parts.append(self._formatParagraphForSlide(element)) return "\n\n".join(filter(None, content_parts)) @@ -1009,6 +1105,7 @@ JSON ONLY. NO OTHER TEXT.""" try: from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN + from pptx.dml.color import RGBColor import base64 import io @@ -1106,7 +1203,25 @@ JSON ONLY. NO OTHER TEXT.""" slide.shapes.add_picture(imageStream, left, top, width=imgWidth, height=imgHeight) except Exception as e: - logger.warning(f"Error adding images to slide: {str(e)}") + logger.error(f"Error embedding images in PPTX slide: {str(e)}") + # Add error message text box to slide + try: + from pptx.util import Inches, Pt + from pptx.enum.text import PP_ALIGN + errorMsg = f"[Error: Could not embed image(s). {str(e)}]" + errorBox = slide.shapes.add_textbox( + Inches(0.5), + Inches(2), + slideWidth - Inches(1), + Inches(0.5) + ) + errorFrame = errorBox.text_frame + errorFrame.text = errorMsg + errorFrame.paragraphs[0].font.size = Pt(12) + errorFrame.paragraphs[0].font.color.rgb = RGBColor(255, 0, 0) # Red color + errorFrame.paragraphs[0].alignment = PP_ALIGN.LEFT + except Exception as errorBoxError: + logger.error(f"Could not add error message to slide: {str(errorBoxError)}") def _formatTimestamp(self) -> str: """Format current timestamp for presentation generation.""" diff --git a/modules/services/serviceGeneration/renderers/rendererText.py b/modules/services/serviceGeneration/renderers/rendererText.py index 1948b29f..340e55e4 100644 --- a/modules/services/serviceGeneration/renderers/rendererText.py +++ b/modules/services/serviceGeneration/renderers/rendererText.py @@ -63,11 +63,17 @@ class RendererText(BaseRenderer): else: filename = self._determineFilename(title, "text/plain") + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + return [ RenderedDocument( documentData=textContent.encode('utf-8'), mimeType="text/plain", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -75,11 +81,15 @@ class RendererText(BaseRenderer): self.logger.error(f"Error rendering text: {str(e)}") # Return minimal text fallback fallbackContent = f"{title}\n\nError rendering report: {str(e)}" + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None return [ RenderedDocument( documentData=fallbackContent.encode('utf-8'), mimeType="text/plain", - filename=self._determineFilename(title, "text/plain") + filename=self._determineFilename(title, "text/plain"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] @@ -201,8 +211,12 @@ class RendererText(BaseRenderer): def _renderJsonTable(self, tableData: Dict[str, Any]) -> str: """Render a JSON table to text.""" try: - headers = tableData.get("headers", []) - rows = tableData.get("rows", []) + # Extract from nested content structure + content = tableData.get("content", {}) + if not isinstance(content, dict): + return "" + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers or not rows: return "" @@ -231,7 +245,11 @@ class RendererText(BaseRenderer): def _renderJsonBulletList(self, listData: Dict[str, Any]) -> str: """Render a JSON bullet list to text.""" try: - items = listData.get("items", []) + # Extract from nested content structure + content = listData.get("content", {}) + if not isinstance(content, dict): + return "" + items = content.get("items", []) if not items: return "" @@ -252,8 +270,12 @@ class RendererText(BaseRenderer): def _renderJsonHeading(self, headingData: Dict[str, Any]) -> str: """Render a JSON heading to text.""" try: - level = headingData.get("level", 1) - text = headingData.get("text", "") + # Extract from nested content structure + content = headingData.get("content", {}) + if not isinstance(content, dict): + return "" + text = content.get("text", "") + level = content.get("level", 1) if text: level = max(1, min(6, level)) @@ -273,7 +295,14 @@ class RendererText(BaseRenderer): def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str: """Render a JSON paragraph to text.""" try: - text = paragraphData.get("text", "") + # Extract from nested content structure + content = paragraphData.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" return text if text else "" except Exception as e: @@ -283,8 +312,12 @@ class RendererText(BaseRenderer): def _renderJsonCodeBlock(self, codeData: Dict[str, Any]) -> str: """Render a JSON code block to text.""" try: - code = codeData.get("code", "") - language = codeData.get("language", "") + # Extract from nested content structure + content = codeData.get("content", {}) + if not isinstance(content, dict): + return "" + code = content.get("code", "") + language = content.get("language", "") if code: if language: @@ -301,9 +334,14 @@ class RendererText(BaseRenderer): def _renderJsonImage(self, imageData: Dict[str, Any]) -> str: """Render a JSON image to text.""" try: - altText = imageData.get("altText", "Image") + # Extract from nested content structure + content = imageData.get("content", {}) + if isinstance(content, dict): + altText = content.get("altText", "Image") + else: + altText = imageData.get("altText", "Image") return f"[Image: {altText}]" except Exception as e: self.logger.warning(f"Error rendering image: {str(e)}") - return f"[Image: {imageData.get('altText', 'Image')}]" + return f"[Image: Image]" diff --git a/modules/services/serviceGeneration/renderers/rendererXlsx.py b/modules/services/serviceGeneration/renderers/rendererXlsx.py index d8d23065..3ff49788 100644 --- a/modules/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/services/serviceGeneration/renderers/rendererXlsx.py @@ -50,6 +50,10 @@ class RendererXlsx(BaseRenderer): # Generate Excel using AI-analyzed styling excelContent = await self._generateExcelFromJson(extractedContent, title, userPrompt, aiService) + # Extract metadata for document type and other info + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + # Determine filename from document or title documents = extractedContent.get("documents", []) if documents and isinstance(documents[0], dict): @@ -72,14 +76,27 @@ class RendererXlsx(BaseRenderer): RenderedDocument( documentData=excel_bytes, mimeType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - filename=filename + filename=filename, + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None ) ] except Exception as e: self.logger.error(f"Error rendering Excel: {str(e)}") - # Return CSV fallback - return f"Title,Content\n{title},Error rendering Excel report: {str(e)}", "text/csv" + # Return CSV fallback with metadata + metadata = extractedContent.get("metadata", {}) if extractedContent else {} + documentType = metadata.get("documentType") if isinstance(metadata, dict) else None + fallbackContent = f"Title,Content\n{title},Error rendering Excel report: {str(e)}" + return [ + RenderedDocument( + documentData=fallbackContent.encode('utf-8'), + mimeType="text/csv", + filename=self._determineFilename(title, "text/csv"), + documentType=documentType, + metadata=metadata if isinstance(metadata, dict) else None + ) + ] def _generateExcel(self, content: str, title: str) -> str: """Generate Excel content using openpyxl.""" @@ -231,8 +248,8 @@ class RendererXlsx(BaseRenderer): self.services.utils.debugLogToFile(f"EXCEL JSON CONTENT TYPE: {type(jsonContent)}", "EXCEL_RENDERER") self.services.utils.debugLogToFile(f"EXCEL JSON CONTENT KEYS: {list(jsonContent.keys()) if isinstance(jsonContent, dict) else 'Not a dict'}", "EXCEL_RENDERER") - # Get style set: default styles, enhanced with AI if userPrompt provided - styles = await self._getStyleSet(userPrompt, aiService) + # Get style set: use styles from metadata if available, otherwise enhance with AI + styles = await self._getStyleSet(jsonContent, userPrompt, aiService) # Validate JSON structure (standardized schema: {metadata: {...}, documents: [{sections: [...]}]}) if not self._validateJsonStructure(jsonContent): @@ -275,12 +292,17 @@ class RendererXlsx(BaseRenderer): self.logger.error(f"Error generating Excel from JSON: {str(e)}") raise Exception(f"Excel generation failed: {str(e)}") - async def _getStyleSet(self, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: - """Get style set - default styles, enhanced with AI if userPrompt provided. + async def _getStyleSet(self, extractedContent: Dict[str, Any] = None, userPrompt: str = None, aiService=None, templateName: str = None) -> Dict[str, Any]: + """Get style set - use styles from document generation metadata if available, + otherwise enhance default styles with AI if userPrompt provided. + + WICHTIG: In a dynamic scalable AI system, styling should come from document generation, + not be generated separately by renderers. Only fall back to AI if styles not provided. Args: + extractedContent: Document content with metadata (may contain styles) userPrompt: User's prompt (AI will detect style instructions in any language) - aiService: AI service (used only if userPrompt provided) + aiService: AI service (used only if styles not in metadata and userPrompt provided) templateName: Name of template style set (None = default) Returns: @@ -289,10 +311,19 @@ class RendererXlsx(BaseRenderer): # Get default style set defaultStyleSet = self._getDefaultStyleSet() - # Enhance with AI if userPrompt provided (AI handles multilingual style detection) + # FIRST: Check if styles are provided in document generation metadata (preferred approach) + if extractedContent: + metadata = extractedContent.get("metadata", {}) + if isinstance(metadata, dict): + styles = metadata.get("styles") + if styles and isinstance(styles, dict): + self.logger.debug("Using styles from document generation metadata") + enhancedStyleSet = self._convertColorsFormat(styles) + return self._validateStylesContrast(enhancedStyleSet) + + # FALLBACK: Enhance with AI if userPrompt provided (only if styles not in metadata) if userPrompt and aiService: - # AI will naturally detect style instructions in any language - self.logger.info(f"Enhancing styles with AI based on user prompt...") + self.logger.info(f"Styles not in metadata, enhancing with AI based on user prompt...") enhancedStyleSet = await self._enhanceStylesWithAI(userPrompt, defaultStyleSet, aiService) # Convert colors to Excel format after getting styles enhancedStyleSet = self._convertColorsFormat(enhancedStyleSet) @@ -462,86 +493,119 @@ class RendererXlsx(BaseRenderer): # Create sheets for i, sheetName in enumerate(sheetNames): + # Sanitize sheet name before creating + sanitized_name = self._sanitizeSheetName(sheetName) if i == 0: # Use the default sheet for the first sheet sheet = wb.active - sheet.title = sheetName + sheet.title = sanitized_name else: # Create additional sheets - sheet = wb.create_sheet(sheetName, i) - sheets[sheetName.lower()] = sheet + sheet = wb.create_sheet(sanitized_name, i) + # Use sanitized name as key (lowercase for lookup) + sheets[sanitized_name.lower()] = sheet return sheets + def _sanitizeSheetName(self, name: str) -> str: + """Sanitize sheet name: remove invalid characters and ensure valid length.""" + if not name: + return "Sheet" + # Remove invalid characters: [ ] : * ? / \ + invalid_chars = ['[', ']', ':', '*', '?', '/', '\\'] + sanitized = name + for char in invalid_chars: + sanitized = sanitized.replace(char, '') + # Remove leading/trailing spaces and apostrophes + sanitized = sanitized.strip().strip("'") + # Ensure not empty + if not sanitized: + sanitized = "Sheet" + # Excel sheet name limit is 31 characters + return sanitized[:31] + def _generateSheetNamesFromContent(self, jsonContent: Dict[str, Any]) -> List[str]: - """Generate sheet names based on actual content structure.""" + """Generate sheet names: each heading section creates a new tab.""" sections = self._extractSections(jsonContent) # If no sections, create a single sheet if not sections: return ["Content"] - # Generate sheet names based on content structure + # Simple logic: each heading section creates a new tab sheetNames = [] - - # Check if we have multiple table sections - tableSections = [s for s in sections if s.get("content_type") == "table"] - - if len(tableSections) > 1: - # Create separate sheets for each table - for i, section in enumerate(tableSections, 1): - # Try to get caption from table element first, then section title, then fallback - sectionTitle = None + for section in sections: + if section.get("content_type") == "heading": + # Extract heading text from elements elements = section.get("elements", []) if elements and isinstance(elements, list) and len(elements) > 0: - tableElement = elements[0] - sectionTitle = tableElement.get("caption") - - if not sectionTitle: - sectionTitle = section.get("title") - - if not sectionTitle: - sectionTitle = f"Table {i}" - - sheetNames.append(sectionTitle[:31]) # Excel sheet name limit - else: - # Single table or mixed content - create only main sheet + headingElement = elements[0] + content = headingElement.get("content", {}) + if isinstance(content, dict): + headingText = content.get("text", "") + elif isinstance(content, str): + headingText = content + else: + headingText = "" + + if headingText: + sanitized_name = self._sanitizeSheetName(headingText) + # Ensure unique sheet names + if sanitized_name not in sheetNames: + sheetNames.append(sanitized_name) + else: + # Add number suffix for duplicates + counter = 1 + base_name = sanitized_name[:28] # Leave room for " (1)" + while f"{base_name} ({counter})" in sheetNames: + counter += 1 + sheetNames.append(f"{base_name} ({counter})"[:31]) + + # If no headings found, use document title + if not sheetNames: documentTitle = jsonContent.get("metadata", {}).get("title", "Document") - sheetNames.append(documentTitle[:31]) # Excel sheet name limit + sheetNames.append(self._sanitizeSheetName(documentTitle)) return sheetNames def _populateExcelSheets(self, sheets: Dict[str, Any], jsonContent: Dict[str, Any], styles: Dict[str, Any]) -> None: - """Populate Excel sheets with content from JSON based on actual sheet names.""" + """Populate Excel sheets: each heading creates a new tab, all following content goes in that tab.""" try: - # Get the actual sheet names that were created + # Get the actual sheet names that were created (keys are lowercase) sheetNames = list(sheets.keys()) if not sheetNames: return sections = self._extractSections(jsonContent) - tableSections = [s for s in sections if s.get("content_type") == "table"] - if len(tableSections) > 1: - # Multiple tables - populate each sheet with its corresponding table - for i, section in enumerate(tableSections): - if i < len(sheetNames): - sheetName = sheetNames[i] - sheet = sheets[sheetName] - # Use the caption from table element as sheet title, or fallback to sheet name - sheetTitle = sheetName - elements = section.get("elements", []) - if elements and isinstance(elements, list) and len(elements) > 0: - tableElement = elements[0] - caption = tableElement.get("caption") - if caption: - sheetTitle = caption - self._populateTableSheet(sheet, section, styles, sheetTitle) - else: - # Single table or mixed content - populate only main sheet - firstSheetName = sheetNames[0] - self._populateMainSheet(sheets[firstSheetName], jsonContent, styles) + # Simple logic: iterate through sections, each heading creates a new tab + currentSheetIndex = 0 + currentSheet = None + currentRow = 1 + + for section in sections: + contentType = section.get("content_type", "paragraph") + + # Heading section: switch to next sheet + if contentType == "heading": + if currentSheetIndex < len(sheetNames): + sheetName = sheetNames[currentSheetIndex] + currentSheet = sheets[sheetName] # sheets dict uses lowercase keys + currentSheetIndex += 1 + currentRow = 1 # Start at row 1 for new sheet + else: + # More headings than sheets - use last sheet + if sheetNames: + currentSheet = sheets[sheetNames[-1]] + + # Render content in current sheet (or first sheet if no headings yet) + if currentSheet is None and sheetNames: + currentSheet = sheets[sheetNames[0]] + + if currentSheet: + currentRow = self._addSectionToSheet(currentSheet, section, styles, currentRow) + currentRow += 1 # Add spacing between sections except Exception as e: self.logger.warning(f"Could not populate Excel sheets: {str(e)}") @@ -558,9 +622,15 @@ class RendererXlsx(BaseRenderer): # Get table data from elements (canonical JSON format) elements = section.get("elements", []) if elements and isinstance(elements, list) and len(elements) > 0: - table_data = elements[0] - headers = table_data.get("headers", []) - rows = table_data.get("rows", []) + table_element = elements[0] + # Extract from nested content structure + content = table_element.get("content", {}) + if not isinstance(content, dict): + headers = [] + rows = [] + else: + headers = content.get("headers", []) + rows = content.get("rows", []) else: headers = [] rows = [] @@ -578,11 +648,28 @@ class RendererXlsx(BaseRenderer): if header_style.get("background"): cell.fill = PatternFill(start_color=self._getSafeColor(header_style["background"]), end_color=self._getSafeColor(header_style["background"]), fill_type="solid") - # Add rows + # Add rows - handle both array format and cells object format cell_style = styles.get("table_cell", {}) for row_idx, row_data in enumerate(rows, 4): - for col_idx, cell_value in enumerate(row_data, 1): - cell = sheet.cell(row=row_idx, column=col_idx, value=cell_value) + # Handle different row formats + if isinstance(row_data, list): + # Array format: [value1, value2, ...] + cell_values = row_data + elif isinstance(row_data, dict) and "cells" in row_data: + # Cells object format: {"cells": [{"value": ...}, ...]} + cell_values = [cell_obj.get("value", "") for cell_obj in row_data.get("cells", [])] + else: + # Unknown format, skip + continue + + for col_idx, cell_value in enumerate(cell_values, 1): + # Extract value if it's a dict with "value" key + if isinstance(cell_value, dict): + actual_value = cell_value.get("value", "") + else: + actual_value = cell_value + + cell = sheet.cell(row=row_idx, column=col_idx, value=actual_value) if cell_style.get("text_color"): cell.font = Font(color=self._getSafeColor(cell_style["text_color"])) @@ -714,18 +801,33 @@ class RendererXlsx(BaseRenderer): # Handle all section types using elements array elements = section.get("elements", []) for element in elements: - if section_type == "table": + # Check element type, not section type (elements can have different types than section) + element_type = element.get("type", "") if isinstance(element, dict) else "" + + if element_type == "table": startRow = self._addTableToExcel(sheet, element, styles, startRow) - elif section_type == "bullet_list" or section_type == "list": + elif element_type == "bullet_list" or element_type == "list": startRow = self._addListToExcel(sheet, element, styles, startRow) - elif section_type == "paragraph": + elif element_type == "paragraph": startRow = self._addParagraphToExcel(sheet, element, styles, startRow) - elif section_type == "heading": + elif element_type == "heading": startRow = self._addHeadingToExcel(sheet, element, styles, startRow) - elif section_type == "image": + elif element_type == "image": startRow = self._addImageToExcel(sheet, element, styles, startRow) else: - startRow = self._addParagraphToExcel(sheet, element, styles, startRow) + # Fallback: if element_type not set, use section_type + if section_type == "table": + startRow = self._addTableToExcel(sheet, element, styles, startRow) + elif section_type == "bullet_list" or section_type == "list": + startRow = self._addListToExcel(sheet, element, styles, startRow) + elif section_type == "paragraph": + startRow = self._addParagraphToExcel(sheet, element, styles, startRow) + elif section_type == "heading": + startRow = self._addHeadingToExcel(sheet, element, styles, startRow) + elif section_type == "image": + startRow = self._addImageToExcel(sheet, element, styles, startRow) + else: + startRow = self._addParagraphToExcel(sheet, element, styles, startRow) return startRow @@ -733,36 +835,114 @@ class RendererXlsx(BaseRenderer): self.logger.warning(f"Could not add section to sheet: {str(e)}") return startRow + 1 + def _sanitizeCellValue(self, value: Any) -> str: + """Sanitize cell value: remove markdown, convert to string, handle None.""" + if value is None: + return "" + if isinstance(value, dict): + # Extract value from dict if present + return str(value.get("value", "")) + if isinstance(value, (int, float)): + return value # Keep numbers as-is + # Convert to string and remove markdown formatting + text = str(value) + # Remove markdown bold (**text**) + text = text.replace("**", "") + # Remove markdown italic (*text*) + text = text.replace("*", "") + # Remove other markdown + text = text.replace("__", "").replace("_", "") + return text.strip() + def _addTableToExcel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], startRow: int) -> int: - """Add a table element to Excel sheet.""" + """Add a table element to Excel sheet with proper formatting and borders.""" try: - # In canonical JSON format, table elements have headers and rows directly - headers = element.get("headers", []) - rows = element.get("rows", []) + # Extract from nested content structure + content = element.get("content", {}) + if not isinstance(content, dict): + return startRow + headers = content.get("headers", []) + rows = content.get("rows", []) if not headers and not rows: return startRow - # Add headers + # Define border style + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + headerRow = startRow header_style = styles.get("table_header", {}) + + # Add headers with formatting for col, header in enumerate(headers, 1): - cell = sheet.cell(row=startRow, column=col, value=header) - if header_style.get("bold"): - cell.font = Font(bold=True, color=self._getSafeColor(header_style.get("text_color", "FF000000"))) + sanitized_header = self._sanitizeCellValue(header) + cell = sheet.cell(row=headerRow, column=col, value=sanitized_header) + + # Font styling + cell.font = Font( + bold=header_style.get("bold", True), + color=self._getSafeColor(header_style.get("text_color", "FF000000")) + ) + + # Background color if header_style.get("background"): - cell.fill = PatternFill(start_color=self._getSafeColor(header_style["background"]), end_color=self._getSafeColor(header_style["background"]), fill_type="solid") + cell.fill = PatternFill( + start_color=self._getSafeColor(header_style["background"]), + end_color=self._getSafeColor(header_style["background"]), + fill_type="solid" + ) + + # Alignment + cell.alignment = Alignment( + horizontal=header_style.get("align", "left"), + vertical="center" + ) + + # Border + cell.border = thin_border startRow += 1 - # Add rows + # Add rows with formatting cell_style = styles.get("table_cell", {}) for row_data in rows: - for col, cell_value in enumerate(row_data, 1): - cell = sheet.cell(row=startRow, column=col, value=cell_value) + # Handle different row formats + if isinstance(row_data, list): + cell_values = row_data + elif isinstance(row_data, dict) and "cells" in row_data: + cell_values = [cell_obj.get("value", "") for cell_obj in row_data.get("cells", [])] + else: + continue + + for col, cell_value in enumerate(cell_values, 1): + sanitized_value = self._sanitizeCellValue(cell_value) + cell = sheet.cell(row=startRow, column=col, value=sanitized_value) + + # Font styling if cell_style.get("text_color"): cell.font = Font(color=self._getSafeColor(cell_style["text_color"])) + + # Alignment + cell.alignment = Alignment( + horizontal=cell_style.get("align", "left"), + vertical="center" + ) + + # Border + cell.border = thin_border + startRow += 1 + # Auto-adjust column widths + for col in range(1, len(headers) + 1): + column_letter = get_column_letter(col) + sheet.column_dimensions[column_letter].width = 20 + return startRow except Exception as e: @@ -770,9 +950,13 @@ class RendererXlsx(BaseRenderer): return startRow + 1 def _addListToExcel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], startRow: int) -> int: - """Add a list element to Excel sheet.""" + """Add a list element to Excel sheet. Expects nested content structure.""" try: - list_items = element.get("items", []) + # Extract from nested content structure + content = element.get("content", {}) + if not isinstance(content, dict): + return startRow + list_items = content.get("items", []) list_style = styles.get("bullet_list", {}) for item in list_items: @@ -788,9 +972,16 @@ class RendererXlsx(BaseRenderer): return startRow + 1 def _addParagraphToExcel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], startRow: int) -> int: - """Add a paragraph element to Excel sheet.""" + """Add a paragraph element to Excel sheet. Expects nested content structure.""" try: - text = element.get("text", "") + # Extract from nested content structure + content = element.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" if text: sheet.cell(row=startRow, column=1, value=text) @@ -807,10 +998,14 @@ class RendererXlsx(BaseRenderer): return startRow + 1 def _addHeadingToExcel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], startRow: int) -> int: - """Add a heading element to Excel sheet.""" + """Add a heading element to Excel sheet. Expects nested content structure.""" try: - text = element.get("text", "") - level = element.get("level", 1) + # Extract from nested content structure + content = element.get("content", {}) + if not isinstance(content, dict): + return startRow + text = content.get("text", "") + level = content.get("level", 1) if text: sheet.cell(row=startRow, column=1, value=text) @@ -835,11 +1030,15 @@ class RendererXlsx(BaseRenderer): return startRow + 1 def _addImageToExcel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], startRow: int) -> int: - """Add an image element to Excel sheet using openpyxl.""" + """Add an image element to Excel sheet using openpyxl. Expects nested content structure.""" try: - base64Data = element.get("base64Data", "") - altText = element.get("altText", "Image") - caption = element.get("caption", "") + # Extract from nested content structure + content = element.get("content", {}) + if not isinstance(content, dict): + return startRow + base64Data = content.get("base64Data", "") + altText = content.get("altText", "Image") + caption = content.get("caption", "") if not base64Data: # No image data - add placeholder text @@ -891,16 +1090,23 @@ class RendererXlsx(BaseRenderer): return startRow + 1 except ImportError: - self.logger.warning("openpyxl.drawing.image not available, using placeholder") - sheet.cell(row=startRow, column=1, value=f"[Image: {altText}]") + self.logger.error("openpyxl.drawing.image not available, cannot embed image") + errorMsg = f"[Error: Image embedding not available. Image: {altText}]" + errorCell = sheet.cell(row=startRow, column=1, value=errorMsg) + errorCell.font = Font(color="FFFF0000", italic=True) # Red color return startRow + 1 except Exception as imgError: - self.logger.warning(f"Error embedding image in Excel: {str(imgError)}") - sheet.cell(row=startRow, column=1, value=f"[Image: {altText}]") + self.logger.error(f"Error embedding image in Excel: {str(imgError)}") + errorMsg = f"[Error: Could not embed image '{altText}'. {str(imgError)}]" + errorCell = sheet.cell(row=startRow, column=1, value=errorMsg) + errorCell.font = Font(color="FFFF0000", italic=True) # Red color return startRow + 1 except Exception as e: - self.logger.warning(f"Could not add image to Excel: {str(e)}") + self.logger.error(f"Error adding image to Excel: {str(e)}") + errorMsg = f"[Error: Could not process image. {str(e)}]" + errorCell = sheet.cell(row=startRow, column=1, value=errorMsg) + errorCell.font = Font(color="FFFF0000", italic=True) # Red color return startRow + 1 def _formatTimestamp(self) -> str: diff --git a/modules/workflows/processing/adaptive/contentValidator.py b/modules/workflows/processing/adaptive/contentValidator.py index 4e405630..1eb453ee 100644 --- a/modules/workflows/processing/adaptive/contentValidator.py +++ b/modules/workflows/processing/adaptive/contentValidator.py @@ -213,10 +213,21 @@ class ContentValidator: sourceJson = getattr(doc, 'sourceJson', None) data = getattr(doc, 'documentData', None) + # WICHTIG: For rendered documents (HTML, PDF, DOCX, etc.), jsonStructure is METADATA about the structure, + # NOT the actual rendered content. The actual content is in documentData. + # Include both: jsonStructure for structure metadata, and contentPreview for actual content check if sourceJson and isinstance(sourceJson, dict): # Use source JSON for structure analysis (for rendered documents like xlsx/docx/pdf) jsonSummary = self._summarizeJsonStructure(sourceJson) summary["jsonStructure"] = jsonSummary + # Add note that this is metadata, not actual content + summary["note"] = "jsonStructure contains metadata about document structure. Actual rendered content is in documentData." + + # For rendered documents, also check actual content + if data is not None: + contentPreview = self._getContentPreview(data, formatExt, mimeType) + if contentPreview: + summary["contentPreview"] = contentPreview elif data is not None: # Fallback: try to parse documentData as JSON (for non-rendered documents) if isinstance(data, dict): @@ -227,6 +238,11 @@ class ContentValidator: # Handle list of documents jsonSummary = self._summarizeJsonStructure(data[0]) summary["jsonStructure"] = jsonSummary + else: + # For non-JSON data (e.g., rendered HTML), get content preview + contentPreview = self._getContentPreview(data, formatExt, mimeType) + if contentPreview: + summary["contentPreview"] = contentPreview summaries.append(summary) except Exception as e: @@ -295,6 +311,73 @@ class ContentValidator: bytes /= 1024.0 return f"{bytes:.1f} TB" + def _getContentPreview(self, data: Any, formatExt: str, mimeType: str) -> Optional[Dict[str, Any]]: + """Get structural validation info for rendered documents (generic, NO content preview for security/privacy) + + Returns metadata about document structure to help validation distinguish between: + - Structure metadata (jsonStructure) - describes what should be rendered + - Actual rendered content (documentData) - the actual document file + + Does NOT expose actual content, only structural indicators. + """ + try: + if data is None: + return None + + preview = {} + + # Generic content type detection + if isinstance(data, bytes): + preview["dataType"] = "bytes" + preview["contentLength"] = len(data) + # Check if it's likely text-based (for text formats like HTML, TXT, etc.) + try: + # Try to decode as UTF-8 to check if it's text-based + decoded = data.decode('utf-8', errors='strict') + preview["isTextBased"] = True + preview["contentLength"] = len(decoded) + + # For text-based formats, check if it looks like rendered content vs JSON metadata + # JSON metadata typically starts with { or [ and contains structure keywords + trimmed = decoded.strip() + looksLikeJson = (trimmed.startswith('{') or trimmed.startswith('[')) and \ + ('"sections"' in trimmed or '"contentPartIds"' in trimmed or '"generationHint"' in trimmed) + preview["looksLikeRenderedContent"] = not looksLikeJson + + except UnicodeDecodeError: + # Not valid UTF-8, likely binary (PDF, DOCX, images, etc.) + preview["isTextBased"] = False + preview["isBinary"] = True + # Binary files with content are rendered (not metadata) + preview["looksLikeRenderedContent"] = True + + elif isinstance(data, str): + preview["dataType"] = "string" + preview["isTextBased"] = True + preview["contentLength"] = len(data) + + # Check if it looks like rendered content vs JSON metadata + trimmed = data.strip() + looksLikeJson = (trimmed.startswith('{') or trimmed.startswith('[')) and \ + ('"sections"' in trimmed or '"contentPartIds"' in trimmed or '"generationHint"' in trimmed) + preview["looksLikeRenderedContent"] = not looksLikeJson + + elif isinstance(data, (dict, list)): + # If documentData is still a dict/list, it's likely structure metadata, not rendered content + preview["dataType"] = "json" + preview["isTextBased"] = True + preview["looksLikeRenderedContent"] = False + preview["note"] = "documentData is JSON structure, not rendered document file" + else: + preview["dataType"] = type(data).__name__ + preview["contentLength"] = len(str(data)) if hasattr(data, '__len__') else 0 + + return preview if preview else None + + except Exception as e: + logger.warning(f"Error getting content structure info: {str(e)}") + return None + def _isFormatCompatible(self, deliveredFormat: str, expectedFormat: str) -> bool: """ @@ -445,31 +528,23 @@ EXPECTED FORMATS: {expectedFormats if expectedFormats else ['any']}{actionContex === VALIDATION INSTRUCTIONS === -IMPORTANT: Different formats can represent the same data structure. Do not reject a format just because it differs from expected - check the structure summary for actual content. +CRITICAL: Validate ONLY metadata/structure. Documents may be binary (PDF, DOCX, images) or very large (200MB+). NEVER try to read or validate actual content values. VALIDATION RULES: -1. Use structure summary (sections, statistics, counts) as PRIMARY evidence for DATA-ORIENTED criteria. Trust structure over format claims. -2. Use ACTION HISTORY as PRIMARY evidence for PROCESS-ORIENTED criteria (e.g., "internet search performed", "sources cited"). Document metadata may only reflect the last action, not the entire workflow. -3. For each criterion in criteriaMapping: evaluate ONLY that criterion. Do not mention other criteria. -4. Priority: Data completeness > Format compatibility. Missing data is more critical than format mismatch. -5. Format understanding: Different formats can represent equivalent data structures. Focus on content, not format name. -6. Multi-step workflow awareness: If ACTION HISTORY is present, consider the workflow as a whole. Document metadata (e.g., extraction_method) describes how data was EXTRACTED in the last step, not necessarily how it was OBTAINED in the workflow. -7. Data availability assessment: If delivered documents do not contain required data, clearly indicate this in findings. Re-reading the same documents might not help. -8. CRITICAL - Data vs Data Description: When criteria require specific data types (e.g., images, tables, charts, files), distinguish between: - - ACTUAL DATA: The actual data itself (binary data, structured data, embedded content) - - DATA DESCRIPTIONS: Text fields that describe or specify what data should be created (e.g., "image_description", "table_description", "chart_specification") - these are TEXT METADATA, NOT the actual data - - If only descriptions/specifications exist but no actual data, the criterion is NOT met. Descriptions are instructions for creating data, not the data itself. - - Check content types in sections/elements: if content_type matches the required data type (e.g., "image" for images, "table" for tables), actual data exists. If only text fields describing the data exist, the data is missing. - - Check document statistics: if counts for the required data type are 0, the data is missing even if descriptions exist. +1. METADATA ONLY: Use jsonStructure (sections, contentPartIds, content_type, statistics) and contentPreview (dataType, contentLength, looksLikeRenderedContent) for validation. These are METADATA indicators, NOT actual content. +2. FORMAT VALIDATION: Check mimeType/format metadata only. Do NOT inspect content to determine format. Format mismatch = wrong_format gap. +3. CONTENT EXISTENCE: Use contentPreview.looksLikeRenderedContent=true to confirm content exists. Use jsonStructure.content_type to confirm data types exist (e.g., "image" section = image exists). Do NOT validate content quality, accuracy, or completeness of actual data values. +4. STRUCTURE VALIDATION: Use jsonStructure.sections, statistics (counts, rowCount, columnCount) as evidence. Trust structure metadata over format claims. +5. PROCESS VALIDATION: Use ACTION HISTORY for process-oriented criteria (e.g., "search performed", "extraction done"). +6. ONE CRITERION PER EVALUATION: Evaluate each criterion independently. Do not mention other criteria. VALIDATION STEPS: -- Check ACTION HISTORY first (if present) for PROCESS-ORIENTED criteria (e.g., "search performed", "sources used", "verification done") -- Check ACTION VALIDATION METADATA (if present) - this contains action-specific context for the LAST action only -- Check structure summary for quantities, counts, statistics (for DATA-ORIENTED criteria) -- Compare found values with required values from criteria -- If structure unavailable, use metadata only (format, filename, size) -- Classify gaps: missing_data (less than required), incomplete_data (partial), wrong_structure (wrong organization), wrong_format (format mismatch but data present) -- Assess if documents contain the required data: If structure shows documents lack the data, note this in findings - data must be generated or obtained elsewhere, not re-extracted from same documents +- Check ACTION HISTORY for process-oriented criteria +- Check jsonStructure metadata (sections, content_type, statistics) for structure validation +- Check contentPreview.looksLikeRenderedContent for content existence (not quality) +- Check mimeType/format for format validation +- NEVER try to read actual content values (binary files, large files, data accuracy) +- Classify gaps: missing_data, incomplete_data, wrong_structure, wrong_format SCORING: - Data complete + structure matches → qualityScore: 0.9-1.0 diff --git a/modules/workflows/processing/shared/placeholderFactory.py b/modules/workflows/processing/shared/placeholderFactory.py index 797352ab..c8920247 100644 --- a/modules/workflows/processing/shared/placeholderFactory.py +++ b/modules/workflows/processing/shared/placeholderFactory.py @@ -379,8 +379,34 @@ def extractLearningsAndImprovements(context: Any) -> str: return "No learnings available yet" def extractLatestRefinementFeedback(context: Any) -> str: - """Extract the latest refinement feedback. Maps to {{KEY:LATEST_REFINEMENT_FEEDBACK}}""" + """Extract the latest refinement feedback. Maps to {{KEY:LATEST_REFINEMENT_FEEDBACK}} + + CRITICAL: If ERROR level logs are found, refinement should stop processing. + """ try: + # First check for ERROR level logs in workflow + if hasattr(context, 'workflow') and context.workflow: + try: + import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects + from modules.interfaces.interfaceDbAppObjects import getRootInterface + rootInterface = getRootInterface() + interfaceDbChat = interfaceDbChatObjects.getInterface(rootInterface.currentUser) + + # Get workflow logs + chatData = interfaceDbChat.getUnifiedChatData(context.workflow.id, None) + logs = chatData.get("logs", []) + + # Check for ERROR level logs + for log in logs: + if isinstance(log, dict): + log_level = log.get("level", "").upper() + log_message = str(log.get("message", "")) + if log_level == "ERROR" or "ERROR" in log_message.upper(): + return f"CRITICAL: Processing stopped due to ERROR in logs: {log_message[:200]}" + except Exception as log_check_error: + # If we can't check logs, continue with normal feedback extraction + logger.warning(f"Could not check for ERROR logs: {str(log_check_error)}") + if not hasattr(context, 'previousReviewResult') or not context.previousReviewResult or not isinstance(context.previousReviewResult, list): return "No previous refinement feedback available" diff --git a/tests/functional/test10_document_generation_formats.py b/tests/functional/test10_document_generation_formats.py new file mode 100644 index 00000000..941034ba --- /dev/null +++ b/tests/functional/test10_document_generation_formats.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Document Generation Formats Test 10 - Tests document generation in DOCX, XLSX, PPTX, and PDF formats +Tests professional document formats with various content types including tables, images, and structured data. +""" + +import asyncio +import json +import sys +import os +import time +import base64 +from typing import Dict, Any, List, Optional + +# Add the gateway to path (go up 2 levels from tests/functional/) +_gateway_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +if _gateway_path not in sys.path: + sys.path.insert(0, _gateway_path) + +# Import the service initialization +from modules.services import getInterface as getServices +from modules.datamodels.datamodelChat import UserInputRequest, WorkflowModeEnum +from modules.datamodels.datamodelUam import User +from modules.features.workflow import chatStart +import modules.interfaces.interfaceDbChatObjects as interfaceDbChatObjects + + +class DocumentGenerationFormatsTester10: + def __init__(self): + # Use root user for testing (has full access to everything) + from modules.interfaces.interfaceDbAppObjects import getRootInterface + rootInterface = getRootInterface() + self.testUser = rootInterface.currentUser + + # Initialize services using the existing system + self.services = getServices(self.testUser, None) # Test user, no workflow + self.workflow = None + self.testResults = {} + self.generatedDocuments = {} + self.pdfFileId = None # Store PDF file ID for reuse + + async def initialize(self): + """Initialize the test environment.""" + # Enable debug file logging for tests + from modules.shared.configuration import APP_CONFIG + APP_CONFIG.set("APP_DEBUG_CHAT_WORKFLOW_ENABLED", True) + + # Set logging level to INFO to see workflow progress + import logging + logging.getLogger().setLevel(logging.INFO) + + print(f"Initialized test with user: {self.testUser.id}") + print(f"Mandate ID: {self.testUser.mandateId}") + print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}") + + # Upload PDF file for testing + await self.uploadPdfFile() + + async def uploadPdfFile(self): + """Upload the PDF file and store its file ID.""" + pdfPath = os.path.join(os.path.dirname(__file__), "..", "..", "..", "local", "temp", "B2025-02c.pdf") + pdfPath = os.path.abspath(pdfPath) + + if not os.path.exists(pdfPath): + print(f"⚠️ Warning: PDF file not found at {pdfPath}") + print(" Test will continue without PDF attachment") + return + + try: + # Read PDF file + with open(pdfPath, "rb") as f: + pdfContent = f.read() + + # Create file using services.interfaceDbComponent + if not hasattr(self.services, 'interfaceDbComponent') or not self.services.interfaceDbComponent: + print("⚠️ Warning: interfaceDbComponent not available in services") + print(" Test will continue without PDF attachment") + return + + interfaceDbComponent = self.services.interfaceDbComponent + + fileItem = interfaceDbComponent.createFile( + name="B2025-02c.pdf", + mimeType="application/pdf", + content=pdfContent + ) + + # Store file data + interfaceDbComponent.createFileData(fileItem.id, pdfContent) + + self.pdfFileId = fileItem.id + print(f"✅ Uploaded PDF file: {fileItem.fileName} (ID: {self.pdfFileId}, Size: {len(pdfContent)} bytes)") + + except Exception as e: + import traceback + print(f"⚠️ Warning: Failed to upload PDF file: {str(e)}") + print(f" Traceback: {traceback.format_exc()}") + print(" Test will continue without PDF attachment") + + def createTestPrompt(self, format: str) -> str: + """Create a test prompt for document generation in the specified format. + + The prompt requests: + - Professional document structure with title, sections, tables, and images + - Extraction of content from attached PDF + - Structured data presentation appropriate for the format + """ + formatPrompts = { + "docx": ( + "Create a professional Word document about 'Fuel Station Receipt Analysis' with:\n" + "1) A main title\n" + "2) An executive summary paragraph\n" + "3) Extract and include the image from the attached PDF document (B2025-02c.pdf)\n" + "4) A detailed analysis section with:\n" + " - Bullet points of key findings\n" + " - A table summarizing transaction details\n" + "5) A conclusion section with recommendations\n\n" + "Format as a professional DOCX document with proper headings and structure." + ), + "xlsx": ( + "Create an Excel spreadsheet analyzing the fuel station receipt from the attached PDF (B2025-02c.pdf).\n" + "Include:\n" + "1) A summary sheet with key metrics\n" + "2) A detailed data sheet with:\n" + " - Transaction details in rows\n" + " - Columns for: Date, Item, Quantity, Price, Total\n" + " - Proper formatting and headers\n" + "3) A calculations sheet with:\n" + " - VAT calculations\n" + " - Net and gross totals\n\n" + "Format as a professional XLSX spreadsheet with formulas and formatting." + ), + "pptx": ( + "Create a PowerPoint presentation about 'Fuel Station Receipt Analysis' with:\n" + "1) Title slide with main title\n" + "2) Overview slide explaining the receipt analysis\n" + "3) Extract and include the image from the attached PDF document (B2025-02c.pdf)\n" + "4) Analysis slides with:\n" + " - Bullet points of key findings\n" + " - Visual representation of data\n" + "5) Conclusion slide with recommendations\n\n" + "Format as a professional PPTX presentation with consistent styling." + ), + "pdf": ( + "Create a professional PDF document about 'Fuel Station Receipt Analysis' with:\n" + "1) A main title\n" + "2) An introduction paragraph explaining the receipt analysis\n" + "3) Extract and include the image from the attached PDF document (B2025-02c.pdf)\n" + "4) A section analyzing the receipt data with:\n" + " - Bullet points of key findings\n" + " - A table summarizing transaction details\n" + "5) A conclusion paragraph with recommendations\n\n" + "Format as a professional PDF document suitable for printing." + ) + } + + return formatPrompts.get(format.lower(), formatPrompts["docx"]) + + async def generateDocumentInFormat(self, format: str) -> Dict[str, Any]: + """Generate a document in the specified format using workflow.""" + print("\n" + "="*80) + print(f"GENERATING DOCUMENT IN {format.upper()} FORMAT") + print("="*80) + + prompt = self.createTestPrompt(format) + print(f"Prompt: {prompt[:200]}...") + + # Create user input request with PDF file attachment + listFileId = [] + if self.pdfFileId: + listFileId = [self.pdfFileId] + print(f"Attaching PDF file (ID: {self.pdfFileId})") + else: + print("⚠️ No PDF file attached (file upload may have failed)") + + # Create user input request + userInput = UserInputRequest( + prompt=prompt, + listFileId=listFileId, + userLanguage="en" + ) + + # Start workflow + print(f"\nStarting workflow for {format.upper()} generation...") + workflow = await chatStart( + currentUser=self.testUser, + userInput=userInput, + workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC, + workflowId=None + ) + + if not workflow: + return { + "success": False, + "error": "Failed to start workflow" + } + + self.workflow = workflow + print(f"Workflow started: {workflow.id}") + + # Wait for workflow completion (no timeout - wait indefinitely) + print(f"Waiting for workflow completion...") + completed = await self.waitForWorkflowCompletion(timeout=None) + + if not completed: + return { + "success": False, + "error": "Workflow did not complete", + "workflowId": workflow.id, + "status": workflow.status if workflow else "unknown" + } + + # Analyze results + results = self.analyzeWorkflowResults() + + # Extract documents for this format + documents = results.get("documents", []) + formatDocuments = [d for d in documents if d.get("fileName", "").endswith(f".{format.lower()}")] + + return { + "success": True, + "format": format, + "workflowId": workflow.id, + "status": results.get("status"), + "documentCount": len(formatDocuments), + "documents": formatDocuments, + "results": results + } + + async def waitForWorkflowCompletion(self, timeout: Optional[int] = None, checkInterval: int = 2) -> bool: + """Wait for workflow to complete.""" + if not self.workflow: + return False + + startTime = time.time() + lastStatus = None + + interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) + + if timeout is None: + print("Waiting indefinitely (no timeout)") + + while True: + # Check timeout only if specified + if timeout is not None and time.time() - startTime > timeout: + print(f"\n⏱️ Timeout after {timeout} seconds") + return False + + # Get current workflow status + try: + currentWorkflow = interfaceDbChat.getWorkflow(self.workflow.id) + if not currentWorkflow: + print("\n❌ Workflow not found") + return False + + currentStatus = currentWorkflow.status + elapsed = int(time.time() - startTime) + + # Print status if it changed + if currentStatus != lastStatus: + print(f"Workflow status: {currentStatus} (elapsed: {elapsed}s)") + lastStatus = currentStatus + + # Check if workflow is complete + if currentStatus in ["completed", "stopped", "failed"]: + self.workflow = currentWorkflow + statusIcon = "✅" if currentStatus == "completed" else "❌" + print(f"\n{statusIcon} Workflow finished with status: {currentStatus} (elapsed: {elapsed}s)") + return currentStatus == "completed" + + # Wait before next check + await asyncio.sleep(checkInterval) + + except Exception as e: + print(f"\n⚠️ Error checking workflow status: {str(e)}") + await asyncio.sleep(checkInterval) + + def analyzeWorkflowResults(self) -> Dict[str, Any]: + """Analyze workflow results and extract information.""" + if not self.workflow: + return {"error": "No workflow to analyze"} + + interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) + workflow = interfaceDbChat.getWorkflow(self.workflow.id) + + if not workflow: + return {"error": "Workflow not found"} + + # Get unified chat data + chatData = interfaceDbChat.getUnifiedChatData(workflow.id, None) + + # Count messages + messages = chatData.get("messages", []) + userMessages = [m for m in messages if m.get("role") == "user"] + assistantMessages = [m for m in messages if m.get("role") == "assistant"] + + # Count documents + documents = chatData.get("documents", []) + + # Get logs + logs = chatData.get("logs", []) + + results = { + "workflowId": workflow.id, + "status": workflow.status, + "workflowMode": str(workflow.workflowMode) if hasattr(workflow, 'workflowMode') else None, + "currentRound": workflow.currentRound, + "totalTasks": workflow.totalTasks, + "totalActions": workflow.totalActions, + "messageCount": len(messages), + "userMessageCount": len(userMessages), + "assistantMessageCount": len(assistantMessages), + "documentCount": len(documents), + "logCount": len(logs), + "documents": documents, + "logs": logs + } + + print(f"\nWorkflow Results:") + print(f" Status: {results['status']}") + print(f" Tasks: {results['totalTasks']}") + print(f" Actions: {results['totalActions']}") + print(f" Messages: {results['messageCount']}") + print(f" Documents: {results['documentCount']}") + + # Print document details + if documents: + print(f"\nGenerated Documents:") + for doc in documents: + fileName = doc.get("fileName", "unknown") + fileSize = doc.get("fileSize", 0) + mimeType = doc.get("mimeType", "unknown") + documentType = doc.get("documentType", "N/A") + print(f" - {fileName} ({fileSize} bytes, {mimeType}, type: {documentType})") + + return results + + def verifyDocumentFormat(self, document: Dict[str, Any], expectedFormat: str) -> Dict[str, Any]: + """Verify that a document matches the expected format and contains expected metadata.""" + fileName = document.get("fileName", "") + mimeType = document.get("mimeType", "") + fileSize = document.get("fileSize", 0) + documentType = document.get("documentType") + metadata = document.get("metadata") + + # Expected MIME types + expectedMimeTypes = { + "pdf": ["application/pdf"], + "docx": ["application/vnd.openxmlformats-officedocument.wordprocessingml.document"], + "xlsx": ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + "pptx": ["application/vnd.openxmlformats-officedocument.presentationml.presentation"] + } + + # Expected file extensions + expectedExtensions = { + "pdf": [".pdf"], + "docx": [".docx"], + "xlsx": [".xlsx"], + "pptx": [".pptx"] + } + + formatLower = expectedFormat.lower() + expectedMimes = expectedMimeTypes.get(formatLower, []) + expectedExts = expectedExtensions.get(formatLower, []) + + # Check file extension + hasCorrectExtension = any(fileName.lower().endswith(ext) for ext in expectedExts) + + # Check MIME type + hasCorrectMimeType = any(mimeType.lower() == mime.lower() for mime in expectedMimes) + + # Check file size (should be > 0) + hasValidSize = fileSize > 0 + + # Check document type (should be present) + hasDocumentType = documentType is not None + + # Check metadata (should be present) + hasMetadata = metadata is not None and isinstance(metadata, dict) + + verification = { + "format": expectedFormat, + "fileName": fileName, + "mimeType": mimeType, + "fileSize": fileSize, + "documentType": documentType, + "hasMetadata": hasMetadata, + "hasCorrectExtension": hasCorrectExtension, + "hasCorrectMimeType": hasCorrectMimeType, + "hasValidSize": hasValidSize, + "hasDocumentType": hasDocumentType, + "isValid": hasCorrectExtension and hasValidSize and hasCorrectMimeType, + "isComplete": hasCorrectExtension and hasValidSize and hasCorrectMimeType and hasDocumentType and hasMetadata + } + + return verification + + async def testAllFormats(self) -> Dict[str, Any]: + """Test document generation in DOCX, XLSX, PPTX, and PDF formats.""" + print("\n" + "="*80) + print("TESTING DOCUMENT GENERATION IN DOCX, XLSX, PPTX, AND PDF FORMATS") + print("="*80) + + formats = ["docx", "xlsx", "pptx", "pdf"] + results = {} + + for format in formats: + try: + print(f"\n{'='*80}") + print(f"Testing {format.upper()} format...") + print(f"{'='*80}") + + result = await self.generateDocumentInFormat(format) + results[format] = result + + if result.get("success"): + documents = result.get("documents", []) + if documents: + # Verify first document + verification = self.verifyDocumentFormat(documents[0], format) + result["verification"] = verification + + print(f"\n✅ {format.upper()} generation successful!") + print(f" Documents: {len(documents)}") + print(f" Verification: {'✅ PASS' if verification['isValid'] else '❌ FAIL'}") + print(f" Complete (with metadata): {'✅ YES' if verification['isComplete'] else '❌ NO'}") + if verification.get("fileName"): + print(f" File: {verification['fileName']}") + print(f" Size: {verification['fileSize']} bytes") + print(f" MIME: {verification['mimeType']}") + print(f" Document Type: {verification.get('documentType', 'N/A')}") + print(f" Has Metadata: {'✅' if verification.get('hasMetadata') else '❌'}") + else: + print(f"\n⚠️ {format.upper()} generation completed but no documents found") + else: + error = result.get("error", "Unknown error") + print(f"\n❌ {format.upper()} generation failed: {error}") + + # Small delay between tests + await asyncio.sleep(2) + + except Exception as e: + import traceback + print(f"\n❌ Error testing {format.upper()}: {str(e)}") + print(traceback.format_exc()) + results[format] = { + "success": False, + "error": str(e), + "traceback": traceback.format_exc() + } + + return results + + async def runTest(self): + """Run the complete test.""" + print("\n" + "="*80) + print("DOCUMENT GENERATION FORMATS TEST 10 - DOCX, XLSX, PPTX, PDF") + print("="*80) + + try: + # Initialize + await self.initialize() + + # Test all formats + formatResults = await self.testAllFormats() + + # Summary + print("\n" + "="*80) + print("TEST SUMMARY") + print("="*80) + + # Format tests summary + print("\nFormat Tests:") + successCount = 0 + failCount = 0 + completeCount = 0 # Documents with metadata + + for format, result in formatResults.items(): + if result.get("success"): + successCount += 1 + verification = result.get("verification", {}) + isValid = verification.get("isValid", False) + isComplete = verification.get("isComplete", False) + if isComplete: + completeCount += 1 + statusIcon = "✅" if isValid else "⚠️" + completeIcon = "✅" if isComplete else "❌" + docCount = result.get("documentCount", 0) + print(f"{statusIcon} {format.upper():6s}: {'PASS' if isValid else 'FAIL'} - {docCount} document(s) - Metadata: {completeIcon}") + else: + failCount += 1 + error = result.get("error", "Unknown error") + print(f"❌ {format.upper():6s}: FAIL - {error}") + + print(f"\nFormat Tests: {successCount} passed, {failCount} failed out of {len(formatResults)} formats") + print(f"Complete Documents (with metadata): {completeCount} out of {successCount} successful generations") + + self.testResults = { + "success": failCount == 0, + "formatTests": { + "successCount": successCount, + "failCount": failCount, + "completeCount": completeCount, + "totalFormats": len(formatResults), + "results": formatResults + }, + "totalSuccess": successCount, + "totalFail": failCount + } + + return self.testResults + + except Exception as e: + import traceback + print(f"\n❌ Test failed with error: {type(e).__name__}: {str(e)}") + print(f"Traceback:\n{traceback.format_exc()}") + self.testResults = { + "success": False, + "error": str(e), + "traceback": traceback.format_exc() + } + return self.testResults + + +async def main(): + """Run document generation formats test 10.""" + tester = DocumentGenerationFormatsTester10() + results = await tester.runTest() + + # Print final results as JSON for easy parsing + print("\n" + "="*80) + print("FINAL RESULTS (JSON)") + print("="*80) + print(json.dumps(results, indent=2, default=str)) + + +if __name__ == "__main__": + asyncio.run(main()) +