tags + return '\n'.join(f'
{text}
' for text in texts) + return "" + elif isinstance(paragraphData, str): + return f'{paragraphData}
' + elif isinstance(paragraphData, dict): + text = paragraphData.get("text", "") + if text: + return f'{text}
' + return "" + else: return "" - - text = paragraphData.get("text", "") - - if text: - return f'{text}
' - - return "" except Exception as e: self.logger.warning(f"Error rendering paragraph: {str(e)}") @@ -441,16 +462,145 @@ class RendererHtml(BaseRenderer): return "" def _renderJsonImage(self, imageData: Dict[str, Any], styles: Dict[str, Any]) -> str: - """Render a JSON image to HTML.""" + """Render a JSON image to HTML with placeholder for later replacement.""" try: base64Data = imageData.get("base64Data", "") altText = imageData.get("altText", "Image") + caption = imageData.get("caption", "") if base64Data: - return f'
`
+ - Return multiple files: HTML file + image files
+
+4. **PDF Renderer** (`rendererPdf.py`): ⚠️ **NEEDS UPDATE**
+ - Currently: Shows placeholder `[Image: altText]`
+ - **Required Change**: Embed images directly in PDF using reportlab
+ - Implementation: Use `reportlab.platypus.Image()` with base64 decoded bytes
+
+5. **DOCX Renderer** (`rendererDocx.py`): ✅ **READY**
+ - Embeds images directly using `doc.add_picture()`
+ - Adds captions below images
+ - No changes needed
+
+6. **XLSX Renderer** (`rendererXlsx.py`): ⚠️ **NEEDS IMPLEMENTATION**
+ - Currently: No image handling found
+ - **Required Change**: Add image support using openpyxl
+ - Implementation: Use `openpyxl.drawing.image.Image()` to embed images in cells
+ - Store images in worksheet cells or as floating images
+
+7. **PPTX Renderer** (`rendererPptx.py`): ⚠️ **NEEDS IMPLEMENTATION**
+ - Currently: No image handling found
+ - **Required Change**: Add image support using python-pptx
+ - Implementation: Use `slide.shapes.add_picture()` to add images to slides
+
+### Renderer Update Requirements:
+
+**Priority 1 (Critical for HTML output)**:
+- HTML Renderer: Create separate image files and link them
+
+**Priority 2 (Important for document formats)**:
+- PDF Renderer: Embed images using reportlab
+- XLSX Renderer: Add image embedding support
+- PPTX Renderer: Add image embedding support
+
+## Answers to Open Questions
+
+### 1. Performance: How to handle very large documents (100+ sections)?
+
+**Answer**: Use parallel processing where possible, with progress ChatLog messages.
+
+**Implementation Strategy**:
+- **Parallel Section Generation**: Generate independent sections in parallel using asyncio
+- **Batch Processing**: Process sections in batches (e.g., 10 sections at a time)
+- **Progress Tracking**: Send ChatLog progress updates:
+ - "Generating structure..." (Phase 1)
+ - "Generating content for section X/Y..." (Phase 2)
+ - "Generating image for section X..." (Phase 2 - images)
+ - "Merging content..." (Phase 3)
+ - "Rendering final document..." (Phase 3)
+- **Streaming**: For very large documents, consider streaming partial results
+
+**Example Progress Messages**:
+```
+Phase 1: Structure Generation (0% → 33%)
+Phase 2: Content Generation (33% → 90%)
+ - Section 1/10: Heading (34%)
+ - Section 2/10: Paragraph (40%)
+ - Section 3/10: Image generation (50%)
+ - Section 4/10: Chapter (60%)
+ ...
+Phase 3: Integration & Rendering (90% → 100%)
+```
+
+### 2. Error Handling: What if one section fails?
+
+**Answer**: Skip failed sections, keep section title and type, show error message in the section.
+
+**Implementation Strategy**:
+- **Graceful Degradation**: Continue processing remaining sections
+- **Error Section**: Create error placeholder section:
+ ```json
+ {
+ "id": "section_failed_3",
+ "content_type": "paragraph",
+ "elements": [{
+ "text": "[ERROR: Failed to generate content for this section. Error: [Reference: {label}]
') + continue + elif element_type == "extracted_text": + # Extracted text format + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f' (Source: {source})' if source else '' + htmlParts.append(f'{content}{source_text}
') + continue + + # If we processed reference/extracted_text elements, return them + if htmlParts: + return '\n'.join(htmlParts) + if sectionType == "table": # Process the section data to extract table structure processedData = self._processSectionByType(section) diff --git a/modules/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/services/serviceGeneration/renderers/rendererMarkdown.py index 3c9569e9..dfe2bda2 100644 --- a/modules/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/services/serviceGeneration/renderers/rendererMarkdown.py @@ -77,11 +77,39 @@ class RendererMarkdown(BaseRenderer): raise Exception(f"Markdown generation failed: {str(e)}") def _renderJsonSection(self, section: Dict[str, Any]) -> str: - """Render a single JSON section to markdown.""" + """Render a single JSON section to markdown. + Supports three content formats: reference, object (base64), extracted_text. + """ try: sectionType = self._getSectionType(section) sectionData = self._getSectionData(section) + # Check for three content formats from Phase 5D in elements + if isinstance(sectionData, list): + markdownParts = [] + for element in sectionData: + element_type = element.get("type", "") if isinstance(element, dict) else "" + + # Support three content formats from Phase 5D + if element_type == "reference": + # Document reference format + doc_ref = element.get("documentReference", "") + label = element.get("label", "Reference") + markdownParts.append(f"*[Reference: {label}]*") + continue + elif element_type == "extracted_text": + # Extracted text format + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f" *(Source: {source})*" if source else "" + markdownParts.append(f"{content}{source_text}") + continue + + # If we processed reference/extracted_text elements, return them + if markdownParts: + return '\n\n'.join(markdownParts) + if sectionType == "table": # Process the section data to extract table structure processedData = self._processSectionByType(section) diff --git a/modules/services/serviceGeneration/renderers/rendererPdf.py b/modules/services/serviceGeneration/renderers/rendererPdf.py index 1cfcfad7..128e84d3 100644 --- a/modules/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/services/serviceGeneration/renderers/rendererPdf.py @@ -477,7 +477,9 @@ class RendererPdf(BaseRenderer): return colors.black def _renderJsonSection(self, section: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: - """Render a single JSON section to PDF elements using AI-generated styles.""" + """Render a single JSON section to PDF elements using AI-generated styles. + Supports three content formats: reference, object (base64), extracted_text. + """ try: section_type = self._getSectionType(section) elements = self._getSectionData(section) @@ -485,6 +487,33 @@ class RendererPdf(BaseRenderer): # Process each element in the section all_elements = [] for element in elements: + element_type = element.get("type", "") if isinstance(element, dict) else "" + + # Support three content formats from Phase 5D + if element_type == "reference": + # Document reference format + doc_ref = element.get("documentReference", "") + label = element.get("label", "Reference") + ref_style = ParagraphStyle( + 'Reference', + parent=self._createNormalStyle(styles), + fontStyle='italic', + textColor=colors.grey + ) + all_elements.append(Paragraph(f"[Reference: {label}]", ref_style)) + all_elements.append(Spacer(1, 6)) + continue + elif element_type == "extracted_text": + # Extracted text format + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f" (Source: {source})" if source else "" + all_elements.append(Paragraph(f"{content}{source_text}", self._createNormalStyle(styles))) + all_elements.append(Spacer(1, 6)) + continue + + # Standard section types if section_type == "table": all_elements.extend(self._renderJsonTable(element, styles)) elif section_type == "bullet_list": diff --git a/modules/services/serviceGeneration/renderers/rendererPptx.py b/modules/services/serviceGeneration/renderers/rendererPptx.py index 6b1b9e18..e9ad334c 100644 --- a/modules/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/services/serviceGeneration/renderers/rendererPptx.py @@ -3,6 +3,9 @@ import logging import base64 import io +import json +import re +from datetime import datetime, UTC from typing import Dict, Any, Optional, Tuple, List from .rendererBaseTemplate import BaseRenderer @@ -261,7 +264,7 @@ class RendererPptx(BaseRenderer): Returns: List of slide content strings """ - import re + # re is already imported at module level # First, try to split by major headers (# or ##) # This is the most common case for AI-generated content @@ -399,7 +402,7 @@ class RendererPptx(BaseRenderer): def _createProfessionalPptxTemplate(self, userPrompt: str, style_schema: Dict[str, Any]) -> str: """Create a professional PowerPoint-specific AI style template for corporate-quality slides.""" - import json + # json is already imported at module level schema_json = json.dumps(style_schema, indent=4) return f"""Customize the JSON below for professional PowerPoint slides. @@ -443,8 +446,7 @@ JSON ONLY. NO OTHER TEXT.""" self.logger.warning("AI service returned no response, using defaults") return default_styles - import json - import re + # json and re are already imported at module level # Clean and parse JSON result = response.content.strip() if response and response.content else "" @@ -634,6 +636,27 @@ JSON ONLY. NO OTHER TEXT.""" content_type = section.get("content_type", "paragraph") elements = section.get("elements", []) + # Check for three content formats from Phase 5D in elements + content_parts = [] + for element in elements: + element_type = element.get("type", "") if isinstance(element, dict) else "" + + # Support three content formats from Phase 5D + if element_type == "reference": + # Document reference format + doc_ref = element.get("documentReference", "") + label = element.get("label", "Reference") + content_parts.append(f"[Reference: {label}]") + continue + elif element_type == "extracted_text": + # Extracted text format + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f" (Source: {source})" if source else "" + content_parts.append(f"{content}{source_text}") + continue + # Handle image sections specially if content_type == "image": # Extract image data @@ -647,26 +670,25 @@ JSON ONLY. NO OTHER TEXT.""" }) return { - "title": section_title or element.get("altText", "Image"), - "content": "", # No text content for image slides + "title": section_title or (elements[0].get("altText", "Image") if elements else "Image"), + "content": "\n\n".join(content_parts) if content_parts else "", # Include reference/extracted_text if present "images": images } # Build slide content based on section type - content_parts = [] - - if content_type == "table": - content_parts.append(self._formatTableForSlide(elements)) - elif content_type == "list": - content_parts.append(self._formatListForSlide(elements)) - elif content_type == "heading": - content_parts.append(self._formatHeadingForSlide(elements)) - elif content_type == "paragraph": - content_parts.append(self._formatParagraphForSlide(elements)) - elif content_type == "code": - content_parts.append(self._formatCodeForSlide(elements)) - else: - content_parts.append(self._format_paragraph_for_slide(elements)) + if not content_parts: # Only if we didn't process reference/extracted_text above + if content_type == "table": + content_parts.append(self._formatTableForSlide(elements)) + elif content_type == "list": + content_parts.append(self._formatListForSlide(elements)) + elif content_type == "heading": + content_parts.append(self._formatHeadingForSlide(elements)) + elif content_type == "paragraph": + content_parts.append(self._formatParagraphForSlide(elements)) + elif content_type == "code": + content_parts.append(self._formatCodeForSlide(elements)) + else: + content_parts.append(self._format_paragraph_for_slide(elements)) # Combine content parts slide_content = "\n\n".join(filter(None, content_parts)) @@ -1057,5 +1079,5 @@ JSON ONLY. NO OTHER TEXT.""" def _formatTimestamp(self) -> str: """Format current timestamp for presentation generation.""" - from datetime import datetime, UTC + # datetime and UTC are already imported at module level return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") diff --git a/modules/services/serviceGeneration/renderers/rendererText.py b/modules/services/serviceGeneration/renderers/rendererText.py index 56d4af61..acbeaaf9 100644 --- a/modules/services/serviceGeneration/renderers/rendererText.py +++ b/modules/services/serviceGeneration/renderers/rendererText.py @@ -100,11 +100,39 @@ class RendererText(BaseRenderer): raise Exception(f"Text generation failed: {str(e)}") def _renderJsonSection(self, section: Dict[str, Any]) -> str: - """Render a single JSON section to text.""" + """Render a single JSON section to text. + Supports three content formats: reference, object (base64), extracted_text. + """ try: sectionType = self._getSectionType(section) sectionData = self._getSectionData(section) + # Check for three content formats from Phase 5D in elements + if isinstance(sectionData, list): + textParts = [] + for element in sectionData: + element_type = element.get("type", "") if isinstance(element, dict) else "" + + # Support three content formats from Phase 5D + if element_type == "reference": + # Document reference format + doc_ref = element.get("documentReference", "") + label = element.get("label", "Reference") + textParts.append(f"[Reference: {label}]") + continue + elif element_type == "extracted_text": + # Extracted text format + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f" (Source: {source})" if source else "" + textParts.append(f"{content}{source_text}") + continue + + # If we processed reference/extracted_text elements, return them + if textParts: + return '\n\n'.join(textParts) + if sectionType == "table": # Process the section data to extract table structure processedData = self._processSectionByType(section) diff --git a/modules/services/serviceGeneration/subContentGenerator.py b/modules/services/serviceGeneration/subContentGenerator.py index 0f75f595..681a5923 100644 --- a/modules/services/serviceGeneration/subContentGenerator.py +++ b/modules/services/serviceGeneration/subContentGenerator.py @@ -7,6 +7,10 @@ Generates content for each section in the document structure. import logging import asyncio +import json +import base64 +import re +import traceback from typing import Dict, Any, Optional, List, Callable from modules.services.serviceGeneration.subContentIntegrator import ContentIntegrator @@ -25,6 +29,7 @@ class ContentGenerator: structure: Dict[str, Any], cachedContent: Optional[Dict[str, Any]] = None, userPrompt: str = "", + contentParts: Optional[List[Any]] = None, progressCallback: Optional[Callable] = None, parallelGeneration: bool = True, batchSize: int = 10 @@ -33,9 +38,10 @@ class ContentGenerator: Generate content for all sections in structure. Args: - structure: Document structure from Phase 1 + structure: Document structure from Phase 1 (with contentPartIds per section) cachedContent: Extracted content cache userPrompt: Original user prompt + contentParts: List of all available ContentParts (for mapping by contentPartIds) progressCallback: Function to call for progress updates parallelGeneration: Enable parallel section generation batchSize: Number of sections to process in parallel @@ -89,6 +95,7 @@ class ContentGenerator: sections=sections, cachedContent=cachedContent, userPrompt=userPrompt, + contentParts=contentParts, # Pass ContentParts for section generation documentMetadata=structure.get("metadata", {}), progressCallback=lambda idx, total, msg: progressCallback( currentSectionIndex + idx, @@ -103,6 +110,7 @@ class ContentGenerator: sections=sections, cachedContent=cachedContent, userPrompt=userPrompt, + contentParts=contentParts, # Pass ContentParts for section generation documentMetadata=structure.get("metadata", {}), progressCallback=lambda idx, total, msg: progressCallback( currentSectionIndex + idx, @@ -138,7 +146,8 @@ class ContentGenerator: sections: List[Dict[str, Any]], cachedContent: Optional[Dict[str, Any]], userPrompt: str, - documentMetadata: Dict[str, Any], + contentParts: Optional[List[Any]] = None, + documentMetadata: Dict[str, Any] = {}, progressCallback: Optional[Callable] = None ) -> List[Dict[str, Any]]: """ @@ -149,6 +158,14 @@ class ContentGenerator: previousSections = [] totalSections = len(sections) + # Create ContentParts lookup map by ID + contentPartsMap = {} + if contentParts: + for part in contentParts: + partId = part.id if hasattr(part, 'id') else part.get('id', '') + if partId: + contentPartsMap[partId] = part + for idx, section in enumerate(sections): try: contentType = section.get("content_type", "content") @@ -171,11 +188,20 @@ class ContentGenerator: message ) + # Get ContentParts for this section + sectionContentPartIds = section.get("contentPartIds", []) + sectionContentParts = [] + if sectionContentPartIds and contentPartsMap: + for partId in sectionContentPartIds: + if partId in contentPartsMap: + sectionContentParts.append(contentPartsMap[partId]) + context = { "userPrompt": userPrompt, "cachedContent": cachedContent, "previousSections": previousSections.copy(), "targetSection": section, + "sectionContentParts": sectionContentParts, # ContentParts for this section "documentMetadata": documentMetadata, "operationId": None } @@ -272,11 +298,20 @@ class ContentGenerator: message ) + # Get ContentParts for this section + sectionContentPartIds = section.get("contentPartIds", []) + sectionContentParts = [] + if sectionContentPartIds and contentPartsMap: + for partId in sectionContentPartIds: + if partId in contentPartsMap: + sectionContentParts.append(contentPartsMap[partId]) + context = { "userPrompt": userPrompt, "cachedContent": cachedContent, "previousSections": batchPreviousSections.copy(), # Include sections from previous batches "targetSection": section, + "sectionContentParts": sectionContentParts, # ContentParts for this section "documentMetadata": documentMetadata, "operationId": None # Can be set if needed for nested progress } @@ -371,17 +406,13 @@ class ContentGenerator: # Create section-specific prompt sectionPrompt = self._createSectionPrompt(section, context) - # Debug: Log section generation prompt - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - sectionId = section.get('id', 'unknown') - contentType = section.get('content_type', 'unknown') - try: - self.services.utils.writeDebugFile( - sectionPrompt, - f"document_generation_section_{sectionId}_{contentType}_prompt" - ) - except Exception as e: - logger.debug(f"Could not write debug file for section prompt: {e}") + # Debug: Log section generation prompt (harmonisiert - keine Checks nötig) + sectionId = section.get('id', 'unknown') + contentType = section.get('content_type', 'unknown') + self.services.utils.writeDebugFile( + sectionPrompt, + f"document_generation_section_{sectionId}_{contentType}_prompt" + ) # Call AI to generate content from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum @@ -397,32 +428,27 @@ class ContentGenerator: outputFormat="json" ) - # Debug: Log section generation response (always log, even if empty) + # Debug: Log section generation response (harmonisiert - keine Checks nötig) sectionId = section.get('id', 'unknown') contentType = section.get('content_type', 'unknown') - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - try: - responseContent = '' - if aiResponse: - if hasattr(aiResponse, 'content') and aiResponse.content: - responseContent = aiResponse.content - elif hasattr(aiResponse, 'documents') and aiResponse.documents: - responseContent = f"[Response has {len(aiResponse.documents)} documents]" - else: - responseContent = f"[Response object: {type(aiResponse).__name__}, attributes: {dir(aiResponse)}]" - else: - responseContent = '[No response object]' - - self.services.utils.writeDebugFile( - responseContent, - f"document_generation_section_{sectionId}_{contentType}_response" - ) - logger.debug(f"Logged section response for {sectionId} ({len(responseContent)} chars)") - except Exception as e: - logger.warning(f"Could not write debug file for section response: {e}") - import traceback - logger.debug(traceback.format_exc()) + responseContent = '' + if aiResponse: + if hasattr(aiResponse, 'content') and aiResponse.content: + responseContent = aiResponse.content + elif hasattr(aiResponse, 'documents') and aiResponse.documents: + responseContent = f"[Response has {len(aiResponse.documents)} documents]" + else: + responseContent = f"[Response object: {type(aiResponse).__name__}, attributes: {dir(aiResponse)}]" + else: + responseContent = '[No response object]' + + # Debug: Log section generation response (harmonisiert - keine Checks nötig) + self.services.utils.writeDebugFile( + responseContent, + f"document_generation_section_{sectionId}_{contentType}_response" + ) + logger.debug(f"Logged section response for {sectionId} ({len(responseContent)} chars)") if not aiResponse or not aiResponse.content: logger.error(f"AI section generation returned empty response for section {sectionId}") @@ -443,7 +469,7 @@ class ContentGenerator: logger.error(f"Extracted JSON (first 500 chars): {extractedJson[:500] if extractedJson else 'None'}") raise ValueError("No JSON found in AI section response") - import json + # json is already imported at module level try: elementsData = json.loads(extractedJson) logger.debug(f"Parsed JSON for section {section.get('id')}: type={type(elementsData)}, keys={list(elementsData.keys()) if isinstance(elementsData, dict) else 'N/A'}") @@ -480,7 +506,7 @@ class ContentGenerator: # Last resort: try to extract partial content and create minimal valid JSON try: # Try to extract text content before the truncation point - import re + # re is already imported at module level # Look for text field that might be partially complete textMatch = re.search(r'"text"\s*:\s*"([^"]*)', extractedJson) if textMatch: @@ -577,14 +603,14 @@ class ContentGenerator: ) -> Dict[str, Any]: """Generate image for image section or include existing image""" try: - # Check if this is an existing image to include + # Check if this is an existing image to include or render imageSource = section.get("image_source", "generate") - if imageSource == "existing": - # Include existing image from cachedContent + if imageSource == "existing" or imageSource == "render": + # Phase 4: Include existing image or render image from cachedContent imageRefId = section.get("image_reference_id") if not imageRefId: - raise ValueError(f"Image section {section.get('id')} has image_source='existing' but no image_reference_id") + raise ValueError(f"Image section {section.get('id')} has image_source='{imageSource}' but no image_reference_id") cachedContent = context.get("cachedContent", {}) imageDocuments = cachedContent.get("imageDocuments", []) @@ -594,7 +620,7 @@ class ContentGenerator: if not imageDoc: raise ValueError(f"Image document {imageRefId} not found in cachedContent.imageDocuments") - # Create image element from existing image + # Create image element from existing/render image altText = imageDoc.get("altText", section.get("generation_hint", "Image")) mimeType = imageDoc.get("mimeType", "image/png") @@ -605,7 +631,7 @@ class ContentGenerator: "caption": section.get("metadata", {}).get("caption") }] - logger.info(f"Successfully included existing image {imageRefId} for section {section.get('id')}") + logger.info(f"Successfully integrated image {imageRefId} for section {section.get('id')} (source={imageSource})") return section # Generate new image (existing logic) @@ -620,7 +646,7 @@ class ContentGenerator: # Call AI service for image generation from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, AiCallPromptImage - import json + # json is already imported at module level # Create image generation prompt promptModel = AiCallPromptImage( @@ -664,7 +690,7 @@ class ContentGenerator: # Validate base64 data try: - import base64 + # base64 is already imported at module level base64.b64decode(base64Data[:100], validate=True) # Validate first 100 chars except Exception as e: logger.warning(f"Image data may not be valid base64: {str(e)}") @@ -710,9 +736,11 @@ class ContentGenerator: """Create sub-prompt for section content generation""" contentType = section.get("content_type", "") generationHint = section.get("generation_hint", "") + extractionPrompt = section.get("extractionPrompt") # Optional extraction prompt for ContentParts userPrompt = context.get("userPrompt", "") cachedContent = context.get("cachedContent") previousSections = context.get("previousSections", []) + sectionContentParts = context.get("sectionContentParts", []) # ContentParts for this section documentMetadata = context.get("documentMetadata", {}) # Get user language @@ -723,6 +751,51 @@ class ContentGenerator: if cachedContent and cachedContent.get("extractedContent"): cachedContentText = self._formatCachedContent(cachedContent) + # Format ContentParts for this section + contentPartsText = "" + imagePartReferences = [] # Track image parts for text reference + + if sectionContentParts: + try: + partsList = [] + imageIndex = 1 + for part in sectionContentParts: + partTypeGroup = part.typeGroup if hasattr(part, 'typeGroup') else part.get('typeGroup', '') + partMimeType = part.mimeType if hasattr(part, 'mimeType') else part.get('mimeType', '') + partId = part.id if hasattr(part, 'id') else part.get('id', '') + partData = part.data if hasattr(part, 'data') else part.get('data', '') + + # Check if this is an image part + isImage = partTypeGroup == "image" or (partMimeType and partMimeType.startswith("image/")) + + if contentType == "image" and isImage: + # For image sections: include image data for integration + partsList.append(f"- ContentPart {partId} (image): [Image data available for integration]") + elif isImage: + # For non-image sections: track for text reference + imagePartReferences.append({ + "id": partId, + "index": imageIndex + }) + imageIndex += 1 + # Don't include image data in prompt for non-image sections + else: + # For text/table/etc parts: include data preview + dataPreview = str(partData)[:200] if partData else "[No data]" + partsList.append(f"- ContentPart {partId} ({partTypeGroup}): {dataPreview}{'...' if partData and len(str(partData)) > 200 else ''}") + + if partsList: + contentPartsText = "\n".join(partsList) + + # Add image reference instructions for non-image sections + if imagePartReferences and contentType != "image": + refText = ", ".join([f"Bild {ref['index']}" if userLanguage == "de" else f"Image {ref['index']}" for ref in imagePartReferences]) + contentPartsText += f"\n\nNOTE: Reference images as text in the document language: {refText}" + + except Exception as e: + logger.warning(f"Could not format ContentParts for section prompt: {str(e)}") + contentPartsText = "" + # Format previous sections for context previousSectionsText = "" if previousSections: @@ -787,14 +860,22 @@ EXTRACTED CONTENT (if available): {cachedContentText if cachedContentText else "None"} {'='*80} +{'='*80} +CONTENT PARTS FOR THIS SECTION: +{'='*80} +{contentPartsText if contentPartsText else "No ContentParts assigned to this section."} +{'='*80} + TASK: Generate content for this section ONLY. INSTRUCTIONS: 1. Generate content appropriate for section type: {contentType} 2. Use the generation hint: {generationHint} -3. Consider previous sections for continuity -4. Use extracted content if relevant -5. All content must be in the language '{userLanguage}' +{f"3. Use extractionPrompt for ContentParts: {extractionPrompt}" if extractionPrompt else "3. Use ContentParts data if provided"} +4. Consider previous sections for continuity +5. Use extracted content if relevant +6. All content must be in the language '{userLanguage}' +7. {'For image sections: Integrate image ContentParts as visual elements' if contentType == "image" else 'For non-image sections: Reference image ContentParts as text (e.g., "siehe Bild 1" in German, "see Image 1" in English)'} 6. CRITICAL: Return ONLY a JSON object with an "elements" array. DO NOT return a full document structure. diff --git a/modules/services/serviceGeneration/subContentIntegrator.py b/modules/services/serviceGeneration/subContentIntegrator.py index 7bee437e..1a83eb6e 100644 --- a/modules/services/serviceGeneration/subContentIntegrator.py +++ b/modules/services/serviceGeneration/subContentIntegrator.py @@ -65,18 +65,14 @@ class ContentIntegrator: ) sections[idx] = section - # Debug: Write final merged structure to debug file - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - try: - import json - structureJson = json.dumps(structure, indent=2, ensure_ascii=False) - self.services.utils.writeDebugFile( - structureJson, - "document_generation_final_merged_json" - ) - logger.debug(f"Logged final merged JSON structure ({len(structureJson)} chars)") - except Exception as e: - logger.debug(f"Could not write debug file for final merged JSON: {e}") + # Debug: Write final merged structure to debug file (harmonisiert - keine Checks nötig) + import json + structureJson = json.dumps(structure, indent=2, ensure_ascii=False) + self.services.utils.writeDebugFile( + structureJson, + "document_generation_final_merged_json" + ) + logger.debug(f"Logged final merged JSON structure ({len(structureJson)} chars)") return structure diff --git a/modules/services/serviceGeneration/subDocumentPurposeAnalyzer.py b/modules/services/serviceGeneration/subDocumentPurposeAnalyzer.py deleted file mode 100644 index d6620d3d..00000000 --- a/modules/services/serviceGeneration/subDocumentPurposeAnalyzer.py +++ /dev/null @@ -1,316 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Document Purpose Analyzer for hierarchical document generation. -Uses AI to analyze user prompt and determine purpose for each document. -""" - -import logging -import json -from typing import Dict, Any, List, Optional -from modules.datamodels.datamodelChat import ChatDocument -from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum - -logger = logging.getLogger(__name__) - - -class DocumentPurposeAnalyzer: - """Analyzes user prompt and documents to determine document purposes""" - - def __init__(self, services: Any): - self.services = services - - async def analyzeDocumentPurposes( - self, - userPrompt: str, - chatDocuments: List[ChatDocument], - actionContext: str = "generateDocument" - ) -> Dict[str, Any]: - """ - Use AI to analyze user prompt and determine purpose for each document. - - Args: - userPrompt: User's original prompt - chatDocuments: List of ChatDocument objects to analyze - actionContext: Action name (e.g., "generateDocument", "extractData") - - Returns: - { - "document_purposes": [ - { - "document_id": "...", - "purpose": "extract_text_content" | "include_image" | ..., - "reasoning": "...", - "extractionPrompt": "..." (if purpose requires extraction), - "processingNotes": "..." - } - ], - "overall_intent": "..." - } - """ - try: - if not chatDocuments: - return { - "document_purposes": [], - "overall_intent": "No documents provided" - } - - # Create document metadata list for AI analysis - documentMetadata = [] - for doc in chatDocuments: - docInfo = { - "document_id": doc.id, - "fileName": doc.fileName, - "mimeType": doc.mimeType, - "fileSize": doc.fileSize - } - documentMetadata.append(docInfo) - - # Create analysis prompt - analysisPrompt = self._createAnalysisPrompt( - userPrompt=userPrompt, - actionContext=actionContext, - documentMetadata=documentMetadata - ) - - # Debug: Log purpose analysis prompt - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - try: - self.services.utils.writeDebugFile( - analysisPrompt, - "document_purpose_analysis_prompt" - ) - except Exception as e: - logger.debug(f"Could not write debug file for purpose analysis prompt: {e}") - - # Call AI for analysis - options = AiCallOptions( - operationType=OperationTypeEnum.DATA_GENERATE, - resultFormat="json" - ) - - aiResponse = await self.services.ai.callAiContent( - prompt=analysisPrompt, - options=options, - outputFormat="json" - ) - - # Debug: Log purpose analysis response - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - try: - responseContent = aiResponse.content if aiResponse and aiResponse.content else '' - responseMetadata = { - "status": aiResponse.status if aiResponse else "N/A", - "error": aiResponse.error if aiResponse else "N/A", - "documents_count": len(aiResponse.documents) if aiResponse and aiResponse.documents else 0 - } - self.services.utils.writeDebugFile( - f"Response Content:\n{responseContent}\n\nResponse Metadata:\n{json.dumps(responseMetadata, indent=2)}", - "document_purpose_analysis_response" - ) - except Exception as e: - logger.debug(f"Could not write debug file for purpose analysis response: {e}") - - if not aiResponse or not aiResponse.content: - logger.warning("AI purpose analysis returned empty response, using defaults") - return self._createDefaultPurposes(chatDocuments, actionContext) - - # Extract and parse JSON - extractedJson = self.services.utils.jsonExtractString(aiResponse.content) - if not extractedJson: - logger.warning("No JSON found in purpose analysis response, using defaults") - return self._createDefaultPurposes(chatDocuments, actionContext) - - try: - analysisResult = json.loads(extractedJson) - - # Validate structure - if "document_purposes" not in analysisResult: - logger.warning("Invalid analysis result structure, using defaults") - return self._createDefaultPurposes(chatDocuments, actionContext) - - # Ensure all documents have purposes - analyzedIds = {dp.get("document_id") for dp in analysisResult.get("document_purposes", [])} - for doc in chatDocuments: - if doc.id not in analyzedIds: - logger.warning(f"Document {doc.id} not in analysis result, adding default purpose") - defaultPurpose = self._determineDefaultPurpose(doc, actionContext) - analysisResult["document_purposes"].append({ - "document_id": doc.id, - "purpose": defaultPurpose, - "reasoning": f"Default purpose based on document type and action context", - "extractionPrompt": None, - "processingNotes": None - }) - - return analysisResult - - except json.JSONDecodeError as e: - logger.error(f"Failed to parse purpose analysis JSON: {str(e)}") - logger.error(f"Extracted JSON (first 500 chars): {extractedJson[:500]}") - return self._createDefaultPurposes(chatDocuments, actionContext) - - except Exception as e: - logger.error(f"Error analyzing document purposes: {str(e)}") - return self._createDefaultPurposes(chatDocuments, actionContext) - - def _createAnalysisPrompt( - self, - userPrompt: str, - actionContext: str, - documentMetadata: List[Dict[str, Any]] - ) -> str: - """Create AI prompt for document purpose analysis""" - - # Format document list - docListText = "" - for i, docInfo in enumerate(documentMetadata, 1): - docListText += f"\n{i}. Document ID: {docInfo['document_id']}\n" - docListText += f" File Name: {docInfo['fileName']}\n" - docListText += f" MIME Type: {docInfo['mimeType']}\n" - docListText += f" File Size: {docInfo['fileSize']} bytes\n" - - # Get user language - userLanguage = self._getUserLanguage() - - prompt = f"""{'='*80} -DOCUMENT PURPOSE ANALYSIS -{'='*80} - -USER PROMPT: -{userPrompt} - -ACTION CONTEXT: {actionContext} - -DOCUMENTS PROVIDED: -{docListText} -{'='*80} - -TASK: For each document, determine its purpose based on: -1. User prompt intent (what the user wants to do) -2. Action context (what action is being performed) -3. Document type (mimeType - is it text, image, etc.) -4. Document metadata (fileName, size) - -AVAILABLE PURPOSES: -- "extract_text_content": Extract text content for use in document generation -- "include_image": Include the image directly in the generated document (for images) -- "analyze_image_vision": Analyze image with vision AI to extract text/information (for images with text/charts) -- "use_as_template": Use document structure/layout as template for generation -- "use_as_reference": Use as background context/reference without detailed extraction -- "extract_data": Extract structured data (key-value pairs, entities, fields) -- "attach": Document is an attachment - don't process, just attach to output -- "convert_format": Convert document format (for convert actions) -- "translate": Translate document content (for translate actions) -- "summarize": Create summary of document (for summarize actions) -- "compare": Compare documents (for comparison actions) -- "merge": Merge documents (for merge actions) -- "extract_tables_charts": Extract tables and charts specifically -- "use_for_styling": Use document for styling/formatting reference only -- "extract_metadata": Extract only document metadata - -CRITICAL RULES: -1. For images (mimeType starts with "image/"): - - If user wants to "include" or "show" images → "include_image" - - If user wants to "analyze", "read text", or "extract text" from images → "analyze_image_vision" - - Default for images in generateDocument → "include_image" - -2. For text documents in generateDocument: - - If user mentions "template" or "structure" → "use_as_template" - - If user mentions "reference" or "context" → "use_as_reference" - - Default → "extract_text_content" - -3. Consider action context: - - generateDocument: Usually "extract_text_content" or "include_image" - - extractData: Usually "extract_data" - - translateDocument: Usually "translate" - - summarizeDocument: Usually "summarize" - -4. Return ONLY valid JSON following this structure: -{{ - "document_purposes": [ - {{ - "document_id": "document_id_here", - "purpose": "extract_text_content", - "reasoning": "Brief explanation in language '{userLanguage}'", - "extractionPrompt": "Specific extraction prompt if purpose requires extraction, otherwise null", - "processingNotes": "Any special processing requirements or null" - }} - ], - "overall_intent": "Summary of how documents should be used together in language '{userLanguage}'" -}} - -5. All content must be in the language '{userLanguage}' -6. Return ONLY the JSON structure. No explanations before or after. - -Return ONLY the JSON structure. -""" - return prompt - - def _createDefaultPurposes( - self, - chatDocuments: List[ChatDocument], - actionContext: str - ) -> Dict[str, Any]: - """Create default purposes when AI analysis fails""" - purposes = [] - - for doc in chatDocuments: - purpose = self._determineDefaultPurpose(doc, actionContext) - purposes.append({ - "document_id": doc.id, - "purpose": purpose, - "reasoning": f"Default purpose based on document type ({doc.mimeType}) and action context ({actionContext})", - "extractionPrompt": None, - "processingNotes": None - }) - - return { - "document_purposes": purposes, - "overall_intent": f"Default processing for {len(chatDocuments)} document(s) in {actionContext} action" - } - - def _determineDefaultPurpose( - self, - doc: ChatDocument, - actionContext: str - ) -> str: - """Determine default purpose based on document type and action context""" - mimeType = doc.mimeType or "" - - # Image documents - if mimeType.startswith("image/"): - if actionContext == "generateDocument": - return "include_image" - elif actionContext in ["extractData", "process"]: - return "analyze_image_vision" - else: - return "include_image" # Default for images - - # Action-specific defaults - if actionContext == "extractData": - return "extract_data" - elif actionContext == "translateDocument": - return "translate" - elif actionContext == "summarizeDocument": - return "summarize" - elif actionContext == "convertDocument" or actionContext == "convert": - return "convert_format" - elif actionContext == "generateDocument": - return "extract_text_content" - else: - # Default for other actions - return "extract_text_content" - - def _getUserLanguage(self) -> str: - """Get user language for document generation""" - try: - if self.services: - if hasattr(self.services, 'currentUserLanguage') and self.services.currentUserLanguage: - return self.services.currentUserLanguage - elif hasattr(self.services, 'user') and self.services.user and hasattr(self.services.user, 'language'): - return self.services.user.language - except Exception: - pass - return 'en' # Default fallback - diff --git a/modules/services/serviceGeneration/subPromptBuilderGeneration.py b/modules/services/serviceGeneration/subPromptBuilderGeneration.py index 9a78b9f4..0ee6fa5e 100644 --- a/modules/services/serviceGeneration/subPromptBuilderGeneration.py +++ b/modules/services/serviceGeneration/subPromptBuilderGeneration.py @@ -19,7 +19,8 @@ async def buildGenerationPrompt( title: str, extracted_content: str = None, continuationContext: Dict[str, Any] = None, - services: Any = None + services: Any = None, + useContentParts: bool = False # ARCHITECTURE: If True, don't include full content in prompt (ContentParts will be used directly) ) -> str: """ Build the unified generation prompt using a single JSON template. @@ -120,7 +121,9 @@ Continue generating the remaining content now. # PROMPT FOR FIRST CALL # Structure: User request + Extracted content FIRST (if available), then JSON template, then instructions - if extracted_content: + # ARCHITECTURE: If useContentParts=True, don't include full content in prompt + # ContentParts will be passed directly to callAi for model-aware chunking + if extracted_content and not useContentParts: # If we have extracted content, put it FIRST and make it very clear it's the source data generationPrompt = f"""{'='*80} USER REQUEST / USER PROMPT: diff --git a/modules/services/serviceGeneration/subStructureGenerator.py b/modules/services/serviceGeneration/subStructureGenerator.py index d2ef1aeb..62e72c69 100644 --- a/modules/services/serviceGeneration/subStructureGenerator.py +++ b/modules/services/serviceGeneration/subStructureGenerator.py @@ -24,6 +24,7 @@ class StructureGenerator: userPrompt: str, documentList: Optional[Any] = None, cachedContent: Optional[Dict[str, Any]] = None, + contentParts: Optional[List[Any]] = None, maxSectionLength: int = 500, existingImages: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: @@ -34,30 +35,28 @@ class StructureGenerator: userPrompt: User's original prompt documentList: Optional document references cachedContent: Optional extracted content cache + contentParts: Optional list of ContentParts to analyze for structure generation maxSectionLength: Maximum words for simple sections existingImages: Optional list of existing images to include Returns: - Document structure with empty elements arrays + Document structure with empty elements arrays and contentPartIds per section """ try: # Create structure generation prompt structurePrompt = self._createStructurePrompt( userPrompt=userPrompt, cachedContent=cachedContent, + contentParts=contentParts, maxSectionLength=maxSectionLength, existingImages=existingImages or [] ) - # Debug: Log structure generation prompt - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - try: - self.services.utils.writeDebugFile( - structurePrompt, - "document_generation_structure_prompt" - ) - except Exception as e: - logger.debug(f"Could not write debug file for structure prompt: {e}") + # Debug: Log structure generation prompt (harmonisiert - keine Checks nötig) + self.services.utils.writeDebugFile( + structurePrompt, + "document_generation_structure_prompt" + ) # Call AI to generate structure from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum @@ -73,15 +72,11 @@ class StructureGenerator: outputFormat="json" ) - # Debug: Log structure generation response - if self.services and hasattr(self.services, 'utils') and hasattr(self.services.utils, 'writeDebugFile'): - try: - self.services.utils.writeDebugFile( - aiResponse.content if aiResponse and aiResponse.content else '', - "document_generation_structure_response" - ) - except Exception as e: - logger.debug(f"Could not write debug file for structure response: {e}") + # Debug: Log structure generation response (harmonisiert - keine Checks nötig) + self.services.utils.writeDebugFile( + aiResponse.content if aiResponse and aiResponse.content else '', + "document_generation_structure_response" + ) if not aiResponse or not aiResponse.content: raise ValueError("AI structure generation returned empty response") @@ -106,6 +101,7 @@ class StructureGenerator: self, userPrompt: str, cachedContent: Optional[Dict[str, Any]] = None, + contentParts: Optional[List[Any]] = None, maxSectionLength: int = 500, existingImages: Optional[List[Dict[str, Any]]] = None ) -> str: @@ -126,6 +122,41 @@ class StructureGenerator: if cachedContent and cachedContent.get("imageDocuments"): existingImages = cachedContent.get("imageDocuments", []) + # Format ContentParts as JSON for structure generation + contentPartsJson = "" + if contentParts: + try: + import json + # Convert ContentParts to dict format for JSON serialization + contentPartsList = [] + for part in contentParts: + if hasattr(part, 'dict'): + partDict = part.dict() + elif isinstance(part, dict): + partDict = part + else: + # Try to convert to dict + partDict = { + "id": getattr(part, 'id', ''), + "typeGroup": getattr(part, 'typeGroup', ''), + "mimeType": getattr(part, 'mimeType', ''), + "label": getattr(part, 'label', ''), + "metadata": getattr(part, 'metadata', {}) + } + # Only include essential fields for structure generation (not full data) + contentPartsList.append({ + "id": partDict.get("id", ""), + "typeGroup": partDict.get("typeGroup", ""), + "mimeType": partDict.get("mimeType", ""), + "label": partDict.get("label", ""), + "metadata": partDict.get("metadata", {}) + }) + + contentPartsJson = json.dumps(contentPartsList, indent=2, ensure_ascii=False) + except Exception as e: + logger.warning(f"Could not format ContentParts as JSON: {str(e)}") + contentPartsJson = "" + # Create structure template structureTemplate = jsonTemplateDocument.replace("{{DOCUMENT_TITLE}}", "Document Title") @@ -145,13 +176,15 @@ EXTRACTED CONTENT (if available): {'='*80} INSTRUCTIONS: -1. Analyze the user request and extracted content +1. Analyze the user request, extracted content, and available ContentParts 2. Create a document structure with CONTENT sections only 3. For each section, specify: - id: Unique identifier (e.g., "section_title_1", "section_image_1") - content_type: "heading" | "paragraph" | "image" | "table" | "bullet_list" | "code_block" - complexity: "simple" (can generate directly) or "complex" (needs sub-prompt) - generation_hint: Brief description of what content should be generated + - contentPartIds: Array of ContentPart IDs that should be used for this section (e.g., ["part_1", "part_2"]) - can be empty [] + - extractionPrompt: (optional) Specific prompt for extracting/processing ContentParts for this section - image_prompt: (only for image sections) Detailed prompt for image generation - order: Section order number (starting from 1) - elements: [] (empty array - will be populated later) @@ -160,10 +193,12 @@ INSTRUCTIONS: - If user requests illustrations/images, create image sections - If existing images are provided in documentList (check EXISTING IMAGES section below), create image sections that reference them - Add image_prompt field with detailed description for image generation (only for new images) - - Set complexity to "complex" + - Set complexity to "complex" for new images, "simple" for existing/render images - For existing images: Set image_source to "existing" and image_reference_id to the image document ID + - For images to render (from input documents): Set image_source to "render" and image_reference_id to the image document ID - Example for new image: {{"id": "section_image_1", "content_type": "image", "complexity": "complex", "generation_hint": "Illustration for chapter 1", "image_prompt": "A detailed description for image generation", "order": 2, "elements": []}} - Example for existing image: {{"id": "section_image_1", "content_type": "image", "complexity": "simple", "generation_hint": "Include provided image", "image_source": "existing", "image_reference_id": "doc_id_here", "order": 2, "elements": []}} + - Example for render image: {{"id": "section_image_1", "content_type": "image", "complexity": "simple", "generation_hint": "Render input image", "image_source": "render", "image_reference_id": "doc_id_here", "order": 2, "elements": []}} {'='*80} EXISTING IMAGES (to include in document): @@ -178,12 +213,21 @@ EXISTING IMAGES (to include in document): 7. Return ONLY valid JSON following this structure: {structureTemplate} -5. CRITICAL RULES: +5. CRITICAL RULES FOR CONTENT PARTS: + - Analyze available ContentParts and determine which ones are needed for each section + - For image sections (content_type == "image"): Include image ContentParts in contentPartIds - images will be integrated as visual elements + - For other sections (heading, paragraph, etc.): If image ContentParts are referenced, they will be referenced as text in the document language (not integrated as images) + - Each section can reference multiple ContentParts via contentPartIds array + - If specific extraction/processing is needed for ContentParts, provide extractionPrompt + - Image references in non-image sections should be automatically derived in the document language (e.g., "siehe Bild 1" in German, "see Image 1" in English) + +6. CRITICAL RULES: - Return ONLY valid JSON (no comments, no trailing commas, double quotes only) - Follow the exact JSON schema structure provided - IMPORTANT: All sections MUST have empty elements arrays: "elements": [] (the template shows examples with content, but you must use empty arrays) - ALL sections MUST include "generation_hint" field with a brief description of what content should be generated - ALL sections MUST include "complexity" field: "simple" for short content, "complex" for long chapters/images + - ALL sections MUST include "contentPartIds" field (can be empty array [] if no ContentParts needed) - Image sections MUST include "image_prompt" field with detailed description for image generation - Order numbers MUST start from 1 (not 0) - All content must be in the language '{userLanguage}' @@ -235,6 +279,14 @@ Return ONLY the JSON structure. No explanations. if "elements" not in section: section["elements"] = [] + # Ensure contentPartIds field exists (can be empty array) + if "contentPartIds" not in section: + section["contentPartIds"] = [] + + # Ensure extractionPrompt field exists (optional) + if "extractionPrompt" not in section: + section["extractionPrompt"] = None + # Identify complexity if not set if "complexity" not in section: section["complexity"] = self._identifySectionComplexity( @@ -255,11 +307,11 @@ Return ONLY the JSON structure. No explanations. if section.get("content_type") == "image": imageSource = section.get("image_source", "generate") - if imageSource == "existing": - # Existing image - ensure image_reference_id is set + if imageSource == "existing" or imageSource == "render": + # Existing or render image - ensure image_reference_id is set if "image_reference_id" not in section: - logger.warning(f"Image section {sectionId} has image_source='existing' but no image_reference_id") - # Existing images are simple (no generation needed) + logger.warning(f"Image section {sectionId} has image_source='{imageSource}' but no image_reference_id") + # Existing/render images are simple (no generation needed, code integration) section["complexity"] = "simple" else: # New image generation - ensure image_prompt diff --git a/modules/shared/jsonUtils.py b/modules/shared/jsonUtils.py index f2678b63..9a7cffab 100644 --- a/modules/shared/jsonUtils.py +++ b/modules/shared/jsonUtils.py @@ -2,6 +2,7 @@ # All rights reserved. import json import logging +import re from typing import Any, Dict, List, Optional, Tuple, Union, Type, TypeVar from pydantic import BaseModel, ValidationError @@ -11,10 +12,32 @@ T = TypeVar('T', bound=BaseModel) def stripCodeFences(text: str) -> str: - """Remove ```json / ``` fences and surrounding whitespace if present.""" + """Remove ```json / ``` fences and surrounding whitespace if present. + Also removes [SOURCE: ...] and [END SOURCE] tags that may wrap the JSON.""" if not text: return text s = text.strip() + + # Remove [SOURCE: ...] tags at the beginning + if s.startswith("[SOURCE:"): + # Find the end of the SOURCE tag (newline or end of string) + end_pos = s.find("\n") + if end_pos != -1: + s = s[end_pos+1:] + else: + # No newline, entire string is SOURCE tag + return "" + + # Remove [END SOURCE] tags at the end + if s.endswith("[END SOURCE]"): + # Find the start of END SOURCE tag (newline before it) + start_pos = s.rfind("\n[END SOURCE]") + if start_pos != -1: + s = s[:start_pos] + else: + # No newline, entire string is END SOURCE tag + return "" + # Handle opening fence (may or may not have closing fence) if s.startswith("```"): # Remove first triple backticks @@ -201,7 +224,7 @@ def closeJsonStructures(text: str) -> str: # Look for patterns like: "value" or "value\n (unterminated) # Check if we're in the middle of a string value when text ends if result.strip(): - import re + # re is already imported at module level # Count quotes - if odd number, we have an unterminated string quoteCount = result.count('"') if quoteCount % 2 == 1: @@ -367,7 +390,7 @@ def _removeLastIncompleteItem(items: List[str], original_text: str) -> List[str] Remove the last item if it appears to be incomplete/corrupted. This prevents corrupted data from being included in the final result. """ - import re + # re is already imported at module level if not items: return items @@ -418,7 +441,7 @@ def _extractGenericContent(text: str) -> List[Dict[str, Any]]: CRITICAL: Must preserve original content_type and id from the JSON structure! """ - import re + # re is already imported at module level sections = [] @@ -1025,7 +1048,7 @@ def _extractCutOffElements(incomplete_section: Dict[str, Any], raw_json: str) -> if not cut_off_element: # Extract the last incomplete part from raw JSON # Find the last incomplete string/number/array - import re + # re is already imported at module level # Look for incomplete string at the end incomplete_match = re.search(r'"([^"]*?)(?:"|$)', raw_json[-500:], re.DOTALL) if incomplete_match: @@ -1045,7 +1068,7 @@ def _extractCutOffFromElement(element: Dict[str, Any], raw_json: str) -> Optiona This helps identify where exactly to continue within nested structures. """ - import re + # re is already imported at module level # Check for code_block with nested JSON if "code" in element: diff --git a/modules/workflows/methods/methodAi/actions/__init__.py b/modules/workflows/methods/methodAi/actions/__init__.py index f0ba9d4d..8ebe6679 100644 --- a/modules/workflows/methods/methodAi/actions/__init__.py +++ b/modules/workflows/methods/methodAi/actions/__init__.py @@ -8,9 +8,7 @@ from .process import process from .webResearch import webResearch from .summarizeDocument import summarizeDocument from .translateDocument import translateDocument -from .convert import convert from .convertDocument import convertDocument -from .extractData import extractData from .generateDocument import generateDocument __all__ = [ @@ -18,9 +16,7 @@ __all__ = [ 'webResearch', 'summarizeDocument', 'translateDocument', - 'convert', 'convertDocument', - 'extractData', 'generateDocument', ] diff --git a/modules/workflows/methods/methodAi/actions/convert.py b/modules/workflows/methods/methodAi/actions/convert.py deleted file mode 100644 index 788fadea..00000000 --- a/modules/workflows/methods/methodAi/actions/convert.py +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. - -""" -Convert action for AI operations. -Converts documents/data between different formats with specific formatting options. -""" - -import logging -import json -from typing import Dict, Any -from modules.workflows.methods.methodBase import action -from modules.datamodels.datamodelChat import ActionResult, ActionDocument -from modules.datamodels.datamodelDocref import DocumentReferenceList - -logger = logging.getLogger(__name__) - -@action -async def convert(self, parameters: Dict[str, Any]) -> ActionResult: - """ - GENERAL: - - Purpose: Convert documents/data between different formats with specific formatting options (e.g., JSON→CSV with custom columns, delimiters). - - Input requirements: documentList (required); inputFormat and outputFormat (required). - - Output format: Document in target format with specified formatting options. - - CRITICAL: If input is already in standardized JSON format, uses automatic rendering system (no AI call needed). - - Parameters: - - documentList (list, required): Document reference(s) to convert. - - inputFormat (str, required): Source format (json, csv, xlsx, txt, etc.). - - outputFormat (str, required): Target format (csv, json, xlsx, txt, etc.). - - columnsPerRow (int, optional): For CSV output, number of columns per row. Default: auto-detect. - - delimiter (str, optional): For CSV output, delimiter character. Default: comma (,). - - includeHeader (bool, optional): For CSV output, whether to include header row. Default: True. - - language (str, optional): Language for output (e.g., 'de', 'en', 'fr'). Default: 'en'. - """ - documentList = parameters.get("documentList", []) - if not documentList: - return ActionResult.isFailure(error="documentList is required") - - inputFormat = parameters.get("inputFormat") - outputFormat = parameters.get("outputFormat") - if not inputFormat or not outputFormat: - return ActionResult.isFailure(error="inputFormat and outputFormat are required") - - # Normalize formats (remove leading dot if present) - normalizedInputFormat = inputFormat.strip().lstrip('.').lower() - normalizedOutputFormat = outputFormat.strip().lstrip('.').lower() - - # Get documents - if isinstance(documentList, DocumentReferenceList): - docRefList = documentList - elif isinstance(documentList, list): - docRefList = DocumentReferenceList.from_string_list(documentList) - else: - docRefList = DocumentReferenceList.from_string_list([documentList]) - - chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docRefList) - if not chatDocuments: - return ActionResult.isFailure(error="No documents found in documentList") - - # Check if input is standardized JSON format - if so, use direct rendering - if normalizedInputFormat == "json" and len(chatDocuments) == 1: - try: - doc = chatDocuments[0] - # ChatDocument doesn't have documentData - need to load file content using fileId - docBytes = self.services.chat.getFileData(doc.fileId) - if not docBytes: - raise ValueError(f"No file data found for fileId={doc.fileId}") - - # Decode bytes to string - docData = docBytes.decode('utf-8') - - # Try to parse as JSON - if isinstance(docData, str): - jsonData = json.loads(docData) - elif isinstance(docData, dict): - jsonData = docData - else: - jsonData = None - - # Check if it's standardized JSON format (has "documents" or "sections") - if jsonData and (isinstance(jsonData, dict) and ("documents" in jsonData or "sections" in jsonData)): - # Use direct rendering - no AI call needed! - from modules.services.serviceGeneration.mainServiceGeneration import GenerationService - generationService = GenerationService(self.services) - - # Ensure format is "documents" array - if "documents" not in jsonData: - jsonData = {"documents": [{"sections": jsonData.get("sections", []), "metadata": jsonData.get("metadata", {})}]} - - # Get title - title = jsonData.get("metadata", {}).get("title", doc.documentName or "Converted Document") - - # Render with options - renderOptions = {} - if normalizedOutputFormat == "csv": - renderOptions["delimiter"] = parameters.get("delimiter", ",") - renderOptions["columnsPerRow"] = parameters.get("columnsPerRow") - renderOptions["includeHeader"] = parameters.get("includeHeader", True) - - rendered_content, mime_type, _images = await generationService.renderReport( - jsonData, normalizedOutputFormat, title, None, None - ) - - # Apply CSV options if needed (renderer will handle them) - if normalizedOutputFormat == "csv" and renderOptions: - rendered_content = self.csvProcessing.applyCsvOptions(rendered_content, renderOptions) - - validationMetadata = { - "actionType": "ai.convert", - "inputFormat": normalizedInputFormat, - "outputFormat": normalizedOutputFormat, - "hasSourceJson": True, - "conversionType": "direct_rendering" - } - actionDoc = ActionDocument( - documentName=f"{doc.documentName.rsplit('.', 1)[0] if '.' in doc.documentName else doc.documentName}.{normalizedOutputFormat}", - documentData=rendered_content, - mimeType=mime_type, - sourceJson=jsonData, # Preserve source JSON for structure validation - validationMetadata=validationMetadata - ) - - return ActionResult.isSuccess(documents=[actionDoc]) - - except Exception as e: - logger.warning(f"Direct rendering failed, falling back to AI conversion: {str(e)}") - # Fall through to AI-based conversion - - # Fallback: Use AI for conversion (for non-JSON inputs or complex conversions) - columnsPerRow = parameters.get("columnsPerRow") - delimiter = parameters.get("delimiter", ",") - includeHeader = parameters.get("includeHeader", True) - language = parameters.get("language", "en") - - aiPrompt = f"Convert the provided document(s) from {normalizedInputFormat.upper()} format to {normalizedOutputFormat.upper()} format." - - if normalizedOutputFormat == "csv": - aiPrompt += f" Use '{delimiter}' as the delimiter character." - if columnsPerRow: - aiPrompt += f" Format the output with {columnsPerRow} columns per row." - if not includeHeader: - aiPrompt += " Do not include a header row." - else: - aiPrompt += " Include a header row with column names." - - if language and language != "en": - aiPrompt += f" Use language: {language}." - - aiPrompt += " Preserve all data and ensure accurate conversion. Maintain data integrity and structure." - - return await self.process({ - "aiPrompt": aiPrompt, - "documentList": documentList, - "resultType": normalizedOutputFormat - }) - diff --git a/modules/workflows/methods/methodAi/actions/extractData.py b/modules/workflows/methods/methodAi/actions/extractData.py deleted file mode 100644 index 723914bd..00000000 --- a/modules/workflows/methods/methodAi/actions/extractData.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. - -""" -Extract Data action for AI operations. -Extracts structured data from documents (key-value pairs, entities, facts, etc.). -""" - -import logging -from typing import Dict, Any -from modules.workflows.methods.methodBase import action -from modules.datamodels.datamodelChat import ActionResult - -logger = logging.getLogger(__name__) - -@action -async def extractData(self, parameters: Dict[str, Any]) -> ActionResult: - """ - GENERAL: - - Purpose: Extract structured data from documents (key-value pairs, entities, facts, etc.). - - Input requirements: documentList (required); optional dataStructure, fields. - - Output format: JSON by default, or specified resultType. - - Parameters: - - documentList (list, required): Document reference(s) to extract data from. - - dataStructure (str, optional): Desired data structure - flat, nested, or list. Default: nested. - - fields (list, optional): Specific fields/properties to extract (e.g., ["name", "date", "amount"]). - - resultType (str, optional): Output format (json, csv, xlsx, etc.). Default: json. - """ - documentList = parameters.get("documentList", []) - if not documentList: - return ActionResult.isFailure(error="documentList is required") - - dataStructure = parameters.get("dataStructure", "nested") - fields = parameters.get("fields", []) - resultType = parameters.get("resultType", "json") - - aiPrompt = "Extract structured data from the provided document(s)." - if fields: - fieldsStr = ", ".join(fields) - aiPrompt += f" Extract the following specific fields: {fieldsStr}." - else: - aiPrompt += " Extract all relevant data including names, dates, amounts, entities, and key information." - - structureInstructions = { - "flat": "Use a flat key-value structure with simple properties.", - "nested": "Use a nested JSON structure with logical grouping of related data.", - "list": "Structure the data as a list/array of objects, one per entity or record." - } - aiPrompt += f" {structureInstructions.get(dataStructure.lower(), structureInstructions['nested'])}" - - aiPrompt += " Ensure all extracted data is accurate and complete." - - return await self.process({ - "aiPrompt": aiPrompt, - "documentList": documentList, - "resultType": resultType - }) - diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 5b5db12f..6569ddab 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -3,18 +3,17 @@ """ Generate Document action for AI operations. -Generates documents from scratch or based on templates/inputs using hierarchical approach. +Wrapper around AI service callAiContent method. """ import logging import time -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List from modules.workflows.methods.methodBase import action from modules.datamodels.datamodelChat import ActionResult, ActionDocument -from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy -from modules.services.serviceGeneration.subStructureGenerator import StructureGenerator -from modules.services.serviceGeneration.subContentGenerator import ContentGenerator -from modules.services.serviceGeneration.subDocumentPurposeAnalyzer import DocumentPurposeAnalyzer +from modules.datamodels.datamodelExtraction import ContentPart +from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum +from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData logger = logging.getLogger(__name__) @@ -59,38 +58,15 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: resultType = "txt" logger.info(f"Auto-detected Text format from prompt") - maxSectionLength = parameters.get("maxSectionLength", 500) - parallelGeneration = parameters.get("parallelGeneration", True) - progressLogging = parameters.get("progressLogging", True) - # Create operation ID for progress tracking workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" operationId = f"doc_gen_{workflowId}_{int(time.time())}" parentOperationId = parameters.get('parentOperationId') try: - # Phase 1: Structure Generation - if progressLogging: - self.services.chat.progressLogStart( - operationId, - "Document", - "Structure Generation", - "Generating document structure...", - parentOperationId=parentOperationId - ) - - structureGenerator = StructureGenerator(self.services) - - # Analyze document purposes and process documents accordingly - cachedContent = None - imageDocuments = [] - documentPurposes = {} - + # Convert documentList to DocumentReferenceList if needed + docRefList = None if documentList: - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.1, "Analyzing document purposes...") - - # Convert documentList to DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList if isinstance(documentList, DocumentReferenceList): @@ -101,301 +77,78 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: docRefList = DocumentReferenceList.from_string_list(documentList) else: docRefList = DocumentReferenceList(references=[]) - - # Get ChatDocuments - chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docRefList) - if chatDocuments: - logger.info(f"Analyzing purposes for {len(chatDocuments)} documents") - - # Analyze document purposes using AI - purposeAnalyzer = DocumentPurposeAnalyzer(self.services) - purposeAnalysis = await purposeAnalyzer.analyzeDocumentPurposes( - userPrompt=prompt, - chatDocuments=chatDocuments, - actionContext="generateDocument" - ) - - documentPurposes = {dp["document_id"]: dp for dp in purposeAnalysis.get("document_purposes", [])} - logger.info(f"Purpose analysis complete: {purposeAnalysis.get('overall_intent', 'N/A')}") - - # Separate documents by purpose - textDocs = [] - imageDocsToInclude = [] - imageDocsToAnalyze = [] - - for doc in chatDocuments: - docPurpose = documentPurposes.get(doc.id, {}) - purpose = docPurpose.get("purpose", "extract_text_content") - - if purpose == "include_image": - imageDocsToInclude.append(doc) - elif purpose == "analyze_image_vision": - imageDocsToAnalyze.append(doc) - elif purpose in ["extract_text_content", "use_as_template", "use_as_reference", "extract_data"]: - textDocs.append(doc) - # Skip "attach" purpose - don't process - - # Process text documents (extract content) - extractedResults = [] - if textDocs: - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.15, f"Extracting content from {len(textDocs)} text document(s)...") - - # Prepare extraction options with purpose-specific prompts - extractionOptionsList = [] - for doc in textDocs: - docPurpose = documentPurposes.get(doc.id, {}) - extractionPrompt = docPurpose.get("extractionPrompt") or "Extract all content from the document" - - extractionOptions = ExtractionOptions( - prompt=extractionPrompt, - mergeStrategy=MergeStrategy( - mergeType="concatenate", - groupBy="typeGroup", - orderBy="id" - ), - processDocumentsIndividually=True - ) - extractionOptionsList.append((doc, extractionOptions)) - - # Extract content from text documents - for doc, extractionOptions in extractionOptionsList: - try: - docResults = self.services.extraction.extractContent( - [doc], - extractionOptions, - parentOperationId=operationId - ) - extractedResults.extend(docResults) - except Exception as e: - logger.error(f"Error extracting content from {doc.fileName}: {str(e)}") - - logger.info(f"Extracted content from {len(extractedResults)} text document(s)") - - # Process images to analyze (vision call) - if imageDocsToAnalyze: - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.2, f"Analyzing {len(imageDocsToAnalyze)} image(s) with vision AI...") - - # Extract content from images using vision analysis - for doc in imageDocsToAnalyze: - try: - docPurpose = documentPurposes.get(doc.id, {}) - extractionPrompt = docPurpose.get("extractionPrompt") or "Extract all text and information from this image" - - extractionOptions = ExtractionOptions( - prompt=extractionPrompt, - mergeStrategy=MergeStrategy( - mergeType="concatenate", - groupBy="typeGroup", - orderBy="id" - ), - processDocumentsIndividually=True - ) - - docResults = self.services.extraction.extractContent( - [doc], - extractionOptions, - parentOperationId=operationId - ) - extractedResults.extend(docResults) - except Exception as e: - logger.error(f"Error analyzing image {doc.fileName}: {str(e)}") - - logger.info(f"Analyzed {len(imageDocsToAnalyze)} image(s) with vision AI") - - # Process images to include (store image data) - if imageDocsToInclude: - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.25, f"Preparing {len(imageDocsToInclude)} image(s) for inclusion...") - - # Get image data for inclusion - from modules.interfaces.interfaceDbComponentObjects import getInterface - dbInterface = getInterface() - - for doc in imageDocsToInclude: - try: - # Get image bytes - imageBytes = dbInterface.getFileData(doc.fileId) - if imageBytes: - # Encode to base64 - import base64 - base64Data = base64.b64encode(imageBytes).decode('utf-8') - - # Create image document entry - imageDoc = { - "id": doc.id, - "fileName": doc.fileName, - "mimeType": doc.mimeType, - "base64Data": base64Data, - "altText": doc.fileName or "Image", - "fileSize": doc.fileSize - } - imageDocuments.append(imageDoc) - logger.debug(f"Prepared image {doc.fileName} for inclusion ({len(base64Data)} chars base64)") - else: - logger.warning(f"Could not retrieve image data for {doc.fileName}") - except Exception as e: - logger.error(f"Error preparing image {doc.fileName} for inclusion: {str(e)}") - - logger.info(f"Prepared {len(imageDocuments)} image(s) for inclusion") - - # Build cachedContent with all information - cachedContent = { - "extractedContent": extractedResults, - "imageDocuments": imageDocuments, - "documentPurposes": documentPurposes, - "extractionTimestamp": time.time(), - "sourceDocuments": [doc.id for doc in chatDocuments] - } - - logger.info(f"Document processing complete: {len(extractedResults)} extracted, {len(imageDocuments)} images to include") - # Generate structure - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.2, "Generating document structure...") + # Prepare title + title = parameters.get("documentType") or "Generated Document" - structure = await structureGenerator.generateStructure( - userPrompt=prompt, - documentList=documentList if documentList else None, - cachedContent=cachedContent, - maxSectionLength=maxSectionLength, - existingImages=imageDocuments # Pass existing images for structure generation + # Call AI service for document generation + # callAiContent handles documentList internally via Phases 5A-5E + options = AiCallOptions( + operationType=OperationTypeEnum.DATA_GENERATE, + priority=PriorityEnum.BALANCED, + processingMode=ProcessingModeEnum.DETAILED, + compressPrompt=False, + compressContext=False ) - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.33, "Structure generated") - - # Phase 2: Content Generation - if progressLogging: - self.services.chat.progressLogUpdate( - operationId, - 0.34, - "Starting content generation..." - ) - - contentGenerator = ContentGenerator(self.services) - - # Create enhanced progress callback - def progressCallback(sectionIndex: int, totalSections: int, message: str): - if progressLogging: - # Calculate progress: 34% to 90% for content generation phase - if totalSections > 0: - progress = 0.34 + (0.56 * (sectionIndex / totalSections)) - else: - progress = 0.34 - - # Format message - if sectionIndex > 0 and totalSections > 0: - progressMessage = f"Section {sectionIndex}/{totalSections}: {message}" - else: - progressMessage = message - - self.services.chat.progressLogUpdate( - operationId, - progress, - progressMessage - ) - - completeStructure = await contentGenerator.generateContent( - structure=structure, - cachedContent=cachedContent, - userPrompt=prompt, - progressCallback=progressCallback, - parallelGeneration=parallelGeneration - ) - - if progressLogging: - self.services.chat.progressLogUpdate(operationId, 0.90, "Content generated") - - # Phase 3: Integration & Rendering - if progressLogging: - self.services.chat.progressLogUpdate( - operationId, - 0.91, - "Rendering final document..." - ) - - # Use existing renderReport method - title = structure.get("metadata", {}).get("title", "Generated Document") - if documentType: - title = f"{title} ({documentType})" - - renderedContent, mimeType, images = await self.services.generation.renderReport( - extractedContent=completeStructure, + aiResponse: AiResponse = await self.services.ai.callAiContent( + prompt=prompt, + options=options, + documentList=docRefList, # Übergebe documentList direkt - callAiContent macht Phasen 5A-5E outputFormat=resultType, title=title, - userPrompt=prompt, - aiService=self.services.ai + parentOperationId=parentOperationId ) - # Build list of documents to return - documents = [ - ActionDocument( - documentName=f"document.{resultType}", - documentData=renderedContent, - mimeType=mimeType - ) - ] + # Convert AiResponse to ActionResult + documents = [] - # Add images as separate documents - if images: - logger.info(f"Processing {len(images)} image(s) from renderer") - import base64 - for idx, imageData in enumerate(images): - try: - base64Data = imageData.get("base64Data", "") - altText = imageData.get("altText", f"image_{idx + 1}") - caption = imageData.get("caption", "") - sectionId = imageData.get("sectionId", f"section_{idx + 1}") - - if base64Data: - # Decode base64 to bytes - imageBytes = base64.b64decode(base64Data) - - # Determine filename and mime type - filename = imageData.get("filename", f"image_{idx + 1}.png") - if not filename.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')): - filename = f"image_{idx + 1}.png" - - # Determine mime type from filename - if filename.lower().endswith('.png'): - imageMimeType = "image/png" - elif filename.lower().endswith(('.jpg', '.jpeg')): - imageMimeType = "image/jpeg" - elif filename.lower().endswith('.gif'): - imageMimeType = "image/gif" - elif filename.lower().endswith('.webp'): - imageMimeType = "image/webp" - else: - imageMimeType = "image/png" # Default - - # Add image document - documents.append(ActionDocument( - documentName=filename, - documentData=imageBytes, - mimeType=imageMimeType - )) - logger.info(f"Added image document: {filename} (section: {sectionId}, {len(imageBytes)} bytes, alt: {altText})") + # Convert DocumentData to ActionDocument + if aiResponse.documents: + for docData in aiResponse.documents: + documents.append(ActionDocument( + documentName=docData.documentName, + documentData=docData.documentData, + mimeType=docData.mimeType, + sourceJson=docData.sourceJson if hasattr(docData, 'sourceJson') else None + )) + + # If no documents but content exists, create a document from content + if not documents and aiResponse.content: + # Determine document name from metadata + docName = f"document.{resultType}" + if aiResponse.metadata and aiResponse.metadata.filename: + docName = aiResponse.metadata.filename + elif aiResponse.metadata and aiResponse.metadata.title: + import re + sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", aiResponse.metadata.title) + sanitized = re.sub(r"_+", "_", sanitized).strip("_") + if sanitized: + if not sanitized.lower().endswith(f".{resultType}"): + docName = f"{sanitized}.{resultType}" else: - logger.warning(f"Image {idx + 1} (section: {sectionId}) has no base64Data, skipping") - except Exception as e: - logger.error(f"Error adding image document {idx + 1}: {str(e)}", exc_info=True) - continue - else: - logger.debug("No images returned from renderer") - - # Note: Document creation is handled by the workflow system - # We just return the rendered content and images in ActionResult - - if progressLogging: - self.services.chat.progressLogFinish(operationId, True) + docName = sanitized + + # Determine mime type + mimeType = "text/plain" + if resultType == "html": + mimeType = "text/html" + elif resultType == "json": + mimeType = "application/json" + elif resultType == "pdf": + mimeType = "application/pdf" + elif resultType == "md": + mimeType = "text/markdown" + + documents.append(ActionDocument( + documentName=docName, + documentData=aiResponse.content.encode('utf-8') if isinstance(aiResponse.content, str) else aiResponse.content, + mimeType=mimeType + )) return ActionResult.isSuccess(documents=documents) except Exception as e: - logger.error(f"Error in hierarchical document generation: {str(e)}") - if progressLogging: - self.services.chat.progressLogFinish(operationId, False) + logger.error(f"Error in document generation: {str(e)}") return ActionResult.isFailure(error=str(e)) diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 2468d949..5abc57cd 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -8,11 +8,12 @@ Universal AI document processing action. import logging import time +import json from typing import Dict, Any, List, Optional from modules.workflows.methods.methodBase import action from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelAi import AiCallOptions -from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy, ContentPart +from modules.datamodels.datamodelExtraction import ContentPart logger = logging.getLogger(__name__) @@ -82,8 +83,7 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: output_mime_type = "application/octet-stream" # Prefer service-provided mimeType when available logger.info(f"Using result type: {resultType} -> {output_extension}") - # Phase 7.3: Extract content first if documents provided, then use contentParts - # Check if contentParts are already provided (preferred path) + # Check if contentParts are already provided (from context.extractContent or other sources) contentParts: Optional[List[ContentPart]] = None if "contentParts" in parameters: contentParts = parameters.get("contentParts") @@ -95,63 +95,42 @@ async def process(self, parameters: Dict[str, Any]) -> ActionResult: logger.warning(f"Invalid contentParts type: {type(contentParts)}, treating as empty") contentParts = None - # If contentParts not provided but documentList is, extract content first - if not contentParts and documentList.references: - self.services.chat.progressLogUpdate(operationId, 0.3, "Extracting content from documents") - - # Get ChatDocuments - chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList) - if not chatDocuments: - logger.warning("No documents found in documentList") - else: - logger.info(f"Extracting content from {len(chatDocuments)} documents") - - # Prepare extraction options (use defaults if not provided) - extractionOptions = parameters.get("extractionOptions") - if not extractionOptions: - extractionOptions = ExtractionOptions( - prompt="Extract all content from the document", - mergeStrategy=MergeStrategy( - mergeType="concatenate", - groupBy="typeGroup", - orderBy="id" - ), - processDocumentsIndividually=True - ) - - # Extract content using extraction service with hierarchical progress logging - # Pass operationId for per-document progress tracking - extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions, operationId=operationId) - - # Combine all ContentParts from all extracted results - contentParts = [] - for extracted in extractedResults: - if extracted.parts: - contentParts.extend(extracted.parts) - - logger.info(f"Extracted {len(contentParts)} content parts from {len(extractedResults)} documents") - # Update progress - preparing AI call self.services.chat.progressLogUpdate(operationId, 0.4, "Preparing AI call") - # Build options with only resultFormat - let service layer handle all other parameters + # Build options output_format = output_extension.replace('.', '') or 'txt' options = AiCallOptions( resultFormat=output_format - # Removed all model parameters - service layer will analyze prompt and determine optimal parameters ) # Update progress - calling AI self.services.chat.progressLogUpdate(operationId, 0.6, "Calling AI") - # Use unified callAiContent method with contentParts (extraction is now separate) - aiResponse = await self.services.ai.callAiContent( - prompt=aiPrompt, - options=options, - contentParts=contentParts, # Already extracted (or None if no documents) - outputFormat=output_format, - parentOperationId=operationId - ) + # Use unified callAiContent method + # If contentParts provided (pre-extracted), use them directly + # Otherwise, pass documentList and let callAiContent handle Phases 5A-5E internally + # Note: ContentExtracted documents (from context.extractContent) are now handled + # automatically in _extractAndPrepareContent() (Phase 5B) + if contentParts: + # Pre-extracted ContentParts - use them directly + aiResponse = await self.services.ai.callAiContent( + prompt=aiPrompt, + options=options, + contentParts=contentParts, # Pre-extracted ContentParts + outputFormat=output_format, + parentOperationId=operationId + ) + else: + # Pass documentList - callAiContent handles Phases 5A-5E internally + # This includes automatic detection of ContentExtracted documents + aiResponse = await self.services.ai.callAiContent( + prompt=aiPrompt, + options=options, + documentList=documentList, # callAiContent macht Phasen 5A-5E + outputFormat=output_format, + parentOperationId=operationId + ) # Update progress - processing result self.services.chat.progressLogUpdate(operationId, 0.8, "Processing result") diff --git a/modules/workflows/methods/methodAi/methodAi.py b/modules/workflows/methods/methodAi/methodAi.py index 7595c2eb..881b007d 100644 --- a/modules/workflows/methods/methodAi/methodAi.py +++ b/modules/workflows/methods/methodAi/methodAi.py @@ -15,9 +15,7 @@ from .actions.process import process from .actions.webResearch import webResearch from .actions.summarizeDocument import summarizeDocument from .actions.translateDocument import translateDocument -from .actions.convert import convert from .actions.convertDocument import convertDocument -from .actions.extractData import extractData from .actions.generateDocument import generateDocument logger = logging.getLogger(__name__) @@ -192,69 +190,6 @@ class MethodAi(MethodBase): }, execute=translateDocument.__get__(self, self.__class__) ), - "convert": WorkflowActionDefinition( - actionId="ai.convert", - description="Convert documents/data between different formats with specific formatting options", - parameters={ - "documentList": WorkflowActionParameter( - name="documentList", - type="List[str]", - frontendType=FrontendType.DOCUMENT_REFERENCE, - required=True, - description="Document reference(s) to convert" - ), - "inputFormat": WorkflowActionParameter( - name="inputFormat", - type="str", - frontendType=FrontendType.SELECT, - frontendOptions=["json", "csv", "xlsx", "txt"], - required=True, - description="Source format" - ), - "outputFormat": WorkflowActionParameter( - name="outputFormat", - type="str", - frontendType=FrontendType.SELECT, - frontendOptions=["csv", "json", "xlsx", "txt"], - required=True, - description="Target format" - ), - "columnsPerRow": WorkflowActionParameter( - name="columnsPerRow", - type="int", - frontendType=FrontendType.NUMBER, - required=False, - description="For CSV output, number of columns per row. Default: auto-detect", - validation={"min": 1, "max": 100} - ), - "delimiter": WorkflowActionParameter( - name="delimiter", - type="str", - frontendType=FrontendType.TEXT, - required=False, - default=",", - description="For CSV output, delimiter character" - ), - "includeHeader": WorkflowActionParameter( - name="includeHeader", - type="bool", - frontendType=FrontendType.CHECKBOX, - required=False, - default=True, - description="For CSV output, whether to include header row" - ), - "language": WorkflowActionParameter( - name="language", - type="str", - frontendType=FrontendType.SELECT, - frontendOptions=["de", "en", "fr"], - required=False, - default="en", - description="Language for output" - ) - }, - execute=convert.__get__(self, self.__class__) - ), "convertDocument": WorkflowActionDefinition( actionId="ai.convertDocument", description="Convert documents between different formats (PDF→Word, Excel→CSV, etc.)", @@ -285,45 +220,6 @@ class MethodAi(MethodBase): }, execute=convertDocument.__get__(self, self.__class__) ), - "extractData": WorkflowActionDefinition( - actionId="ai.extractData", - description="Extract structured data from documents (key-value pairs, entities, facts, etc.)", - parameters={ - "documentList": WorkflowActionParameter( - name="documentList", - type="List[str]", - frontendType=FrontendType.DOCUMENT_REFERENCE, - required=True, - description="Document reference(s) to extract data from" - ), - "dataStructure": WorkflowActionParameter( - name="dataStructure", - type="str", - frontendType=FrontendType.SELECT, - frontendOptions=["flat", "nested", "list"], - required=False, - default="nested", - description="Desired data structure" - ), - "fields": WorkflowActionParameter( - name="fields", - type="List[str]", - frontendType=FrontendType.MULTISELECT, - required=False, - description="Specific fields/properties to extract (e.g., [name, date, amount])" - ), - "resultType": WorkflowActionParameter( - name="resultType", - type="str", - frontendType=FrontendType.SELECT, - frontendOptions=["json", "csv", "xlsx"], - required=False, - default="json", - description="Output format" - ) - }, - execute=extractData.__get__(self, self.__class__) - ), "generateDocument": WorkflowActionDefinition( actionId="ai.generateDocument", description="Generate documents from scratch or based on templates/inputs", @@ -371,9 +267,7 @@ class MethodAi(MethodBase): self.webResearch = webResearch.__get__(self, self.__class__) self.summarizeDocument = summarizeDocument.__get__(self, self.__class__) self.translateDocument = translateDocument.__get__(self, self.__class__) - self.convert = convert.__get__(self, self.__class__) self.convertDocument = convertDocument.__get__(self, self.__class__) - self.extractData = extractData.__get__(self, self.__class__) self.generateDocument = generateDocument.__get__(self, self.__class__) def _format_timestamp_for_filename(self) -> str: diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index 8c5fd5fb..949ac63d 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -19,10 +19,21 @@ logger = logging.getLogger(__name__) @action async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult: """ - Extract content from documents (separate from AI calls). + Extract raw content parts from documents without AI processing. - This action performs pure content extraction without AI processing. - The extracted ContentParts can then be used by subsequent AI processing actions. + This action performs pure content extraction WITHOUT AI/OCR processing. + It returns ContentParts with different typeGroups: + - "text": Extracted text from text-based formats (PDF text layers, Word docs, etc.) + - "image": Images as base64-encoded data (NOT converted to text, no OCR) + - "table": Tables as structured data + - "structure": Structured content (JSON, etc.) + - "container": Container elements (PDF pages, etc.) + + IMPORTANT: + - Images are returned as base64 data, NOT as extracted text + - No OCR is performed - images are preserved as visual elements + - Text extraction only works for text-based formats (not images) + - The extracted ContentParts can then be used by subsequent AI processing actions Parameters: - documentList (list, required): Document reference(s) to extract content from. @@ -30,7 +41,8 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult: Returns: - ActionResult with ActionDocument containing ContentExtracted objects - - ContentExtracted.parts contains List[ContentPart] (already chunked if needed) + - ContentExtracted.parts contains List[ContentPart] with various typeGroups + - Each ContentPart has a typeGroup indicating its type (text, image, table, etc.) """ try: # Init progress logger @@ -79,12 +91,26 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult: # Convert dict to ExtractionOptions object if needed, or create defaults if extractionOptionsParam: if isinstance(extractionOptionsParam, dict): + # Ensure required fields are present + if "prompt" not in extractionOptionsParam: + extractionOptionsParam["prompt"] = "Extract all content from the document" + if "mergeStrategy" not in extractionOptionsParam: + extractionOptionsParam["mergeStrategy"] = MergeStrategy( + mergeType="concatenate", + groupBy="typeGroup", + orderBy="id" + ) # Convert dict to ExtractionOptions object - extractionOptions = ExtractionOptions(**extractionOptionsParam) + try: + extractionOptions = ExtractionOptions(**extractionOptionsParam) + except Exception as e: + logger.warning(f"Failed to create ExtractionOptions from dict: {str(e)}, using defaults") + extractionOptions = None elif isinstance(extractionOptionsParam, ExtractionOptions): extractionOptions = extractionOptionsParam else: # Invalid type, use defaults + logger.warning(f"Invalid extractionOptions type: {type(extractionOptionsParam)}, using defaults") extractionOptions = None else: extractionOptions = None diff --git a/modules/workflows/methods/methodContext/methodContext.py b/modules/workflows/methods/methodContext/methodContext.py index a635764f..942f3f85 100644 --- a/modules/workflows/methods/methodContext/methodContext.py +++ b/modules/workflows/methods/methodContext/methodContext.py @@ -50,7 +50,7 @@ class MethodContext(MethodBase): ), "extractContent": WorkflowActionDefinition( actionId="context.extractContent", - description="Extract content from documents (separate from AI calls)", + description="Extract raw content parts from documents without AI processing. Returns ContentParts with different typeGroups (text, image, table, structure, container). Images are returned as base64 data, not as extracted text. Text content is extracted from text-based formats (PDF text layers, Word docs, etc.) but NOT from images (no OCR). Use this action to prepare documents for subsequent AI processing actions.", parameters={ "documentList": WorkflowActionParameter( name="documentList", @@ -64,7 +64,7 @@ class MethodContext(MethodBase): type="dict", frontendType=FrontendType.JSON, required=False, - description="Extraction options (if not provided, defaults are used)" + description="Extraction options (if not provided, defaults are used). Note: This action does NOT use AI - it performs pure content extraction. Images are preserved as base64 data, not converted to text." ) }, execute=extractContent.__get__(self, self.__class__) diff --git a/modules/workflows/processing/shared/ARCHITECTURE_IMPLEMENTATION_ANALYSIS.md b/modules/workflows/processing/shared/ARCHITECTURE_IMPLEMENTATION_ANALYSIS.md deleted file mode 100644 index 39c649ce..00000000 --- a/modules/workflows/processing/shared/ARCHITECTURE_IMPLEMENTATION_ANALYSIS.md +++ /dev/null @@ -1,354 +0,0 @@ -# Architecture & Implementation Analysis -## Deep Review of Hierarchical Document Generation - -**Date**: 2025-12-22 -**Status**: Critical Issues Found - ---- - -## Executive Summary - -The hierarchical document generation system is **partially implemented** but has **critical architectural mismatches** and **implementation gaps** that prevent it from working correctly. While core components exist, several fundamental issues need to be addressed. - ---- - -## ✅ What's Correctly Implemented - -### Phase 1: Core Infrastructure ✅ -- ✅ `StructureGenerator` class exists with `generateStructure()` method -- ✅ `ContentGenerator` class exists with `generateContent()` method -- ✅ `ContentIntegrator` class exists with `integrateContent()` method -- ✅ `generateDocument` action uses hierarchical approach -- ✅ Basic progress logging implemented -- ✅ Error handling with `createErrorSection()` implemented - -### Phase 2: Image Generation ✅ -- ✅ `_generateImageSection()` method implemented -- ✅ Image prompt extraction from structure -- ✅ Base64 image data storage -- ✅ Error handling for image failures - -### Phase 3: Parallel Processing ✅ -- ✅ `_generateSectionsParallel()` method implemented -- ✅ `_generateSectionsSequential()` method implemented -- ✅ Batch processing for large documents -- ✅ Progress callback system -- ✅ Exception handling in parallel execution - ---- - -## ❌ Critical Issues Found - -### Issue 1: Previous Sections Context Not Working in Parallel Mode ⚠️ **PARTIALLY FIXED** - -**Problem**: -- In parallel mode, sections within the same batch cannot see each other (correct) -- BUT: Sections in later batches should see sections from earlier batches -- **Current Status**: Code was fixed to accumulate previous sections, but needs verification - -**Location**: `subContentGenerator.py` lines 240-319 - -**Fix Applied**: -- Added `accumulatedPreviousSections` to track sections across batches -- Pass accumulated sections to each batch -- **VERIFICATION NEEDED**: Test that prompts actually show previous sections - -**Risk**: Medium - May cause continuity issues in generated content - ---- - -### Issue 2: Variable Shadowing Bug ✅ **FIXED** - -**Problem**: -- `contentType` variable was shadowed in loop, causing wrong section type in prompts - -**Location**: `subContentGenerator.py` line 676 - -**Fix Applied**: -- Renamed loop variable to `prevContentType` - -**Status**: ✅ Fixed - ---- - -### Issue 3: Missing `generation_hint` in Structure Response ✅ **FIXED** - -**Problem**: -- Structure generator creates generic hints like "Section heading" instead of meaningful hints -- AI generates same content for all headings because hints are identical - -**Location**: `subStructureGenerator.py` lines 242-269 - -**Fix Applied**: -- Added `_extractMeaningfulHint()` method to extract meaningful hints from section IDs -- Example: `section_heading_current_state` → "Current State" - -**Status**: ✅ Fixed - ---- - -### Issue 4: JSON Template Architecture Mismatch ✅ **FIXED** - -**Problem**: -- `jsonTemplateDocument` showed filled `elements` arrays, but structure generation requires empty arrays -- Template missing `generation_hint` and `complexity` fields -- Template showed `order: 0` but should start from 1 - -**Location**: `datamodelJson.py` - -**Fix Applied**: -- Updated template to show empty `elements: []` -- Added `generation_hint` to all sections -- Added `complexity` to all sections -- Changed `order` to start from 1 -- Added `title` to metadata - -**Status**: ✅ Fixed - ---- - -### Issue 5: Structure Prompt Instructions Mismatch ✅ **FIXED** - -**Problem**: -- Prompt said "All sections must have empty elements arrays" but template showed filled arrays -- Prompt didn't explicitly require `generation_hint` and `complexity` fields - -**Location**: `subStructureGenerator.py` lines 181-190 - -**Fix Applied**: -- Enhanced prompt to explicitly require `generation_hint` and `complexity` -- Clarified that template examples show structure, but elements must be empty - -**Status**: ✅ Fixed - ---- - -## ⚠️ Remaining Issues & Gaps - -### Issue 6: Missing Validation Before Content Generation ⚠️ **NOT IMPLEMENTED** - -**Problem**: -- No validation that structure has required fields before content generation -- No check that all sections have `generation_hint` before generating content - -**Expected** (from Phase 6): -```python -# Validate structure before content generation -if not validateStructure(structure): - raise ValueError("Invalid structure") -``` - -**Current**: Validation happens in `_validateAndEnhanceStructure()` but only adds missing fields, doesn't validate - -**Impact**: Low - Enhancement adds missing fields, but explicit validation would be better - -**Recommendation**: Add explicit validation method - ---- - -### Issue 7: Previous Sections Formatting Missing Content ⚠️ **PARTIALLY IMPLEMENTED** - -**Problem**: -- Previous sections formatting extracts content from `elements`, but if sections don't have elements yet (in parallel mode), it shows nothing -- Should show `generation_hint` as fallback when elements not available - -**Location**: `subContentGenerator.py` lines 671-709 - -**Current Behavior**: -- Shows content preview if elements exist -- Shows nothing if elements don't exist - -**Expected Behavior**: -- Show content preview if elements exist -- Show `generation_hint` as fallback if elements don't exist - -**Impact**: Medium - Reduces context quality in parallel generation - -**Recommendation**: Add fallback to show `generation_hint` when elements not available - ---- - -### Issue 8: Debug File Shows Raw Response, Not Validated Structure ⚠️ **NOT FIXED** - -**Problem**: -- Debug file writes `aiResponse.content` (raw AI response) before validation -- Can't verify if `generation_hint` was added by validation - -**Location**: `subStructureGenerator.py` lines 77-84 - -**Impact**: Low - Makes debugging harder but doesn't affect functionality - -**Recommendation**: Write validated structure to separate debug file - ---- - -### Issue 9: Missing Unit Tests ⚠️ **NOT IMPLEMENTED** - -**Problem**: -- No unit tests for any components (Phase 7 requirement) -- No tests for structure generation -- No tests for content generation -- No tests for integration - -**Impact**: High - No way to verify correctness or catch regressions - -**Recommendation**: Add comprehensive unit tests - ---- - -### Issue 10: Missing Integration Tests ⚠️ **NOT IMPLEMENTED** - -**Problem**: -- No end-to-end tests -- No tests with images -- No tests with long documents -- No error scenario tests - -**Impact**: High - No verification of complete flow - -**Recommendation**: Add integration tests - ---- - -### Issue 11: Content Caching Not Optimized ⚠️ **PARTIALLY IMPLEMENTED** - -**Problem**: -- Content is extracted and cached, but: - - No cache validation (check if documents changed) - - No cache reuse verification - - Content is passed to prompts but may not be formatted efficiently - -**Expected** (from Phase 5): -- Cache validation -- Efficient formatting -- Performance testing - -**Current**: Basic caching exists but not optimized - -**Impact**: Medium - Works but could be more efficient - -**Recommendation**: Add cache validation and optimization - ---- - -### Issue 12: Renderer Updates Not Verified ⚠️ **UNKNOWN** - -**Problem**: -- Implementation plan requires renderer updates for images -- HTML renderer should create separate image files -- PDF/XLSX/PPTX renderers should embed images -- **Status unknown** - need to verify renderers handle images correctly - -**Impact**: High - Images may not render correctly - -**Recommendation**: Verify all renderers handle images correctly - ---- - -## 📋 Architecture Compliance Check - -### Data Structure Compliance ✅ - -| Field | Required | Implemented | Status | -|-------|----------|-------------|--------| -| `metadata.title` | Yes | ✅ | ✅ | -| `metadata.split_strategy` | Yes | ✅ | ✅ | -| `sections[].id` | Yes | ✅ | ✅ | -| `sections[].content_type` | Yes | ✅ | ✅ | -| `sections[].complexity` | Yes | ✅ | ✅ | -| `sections[].generation_hint` | Yes | ✅ | ✅ | -| `sections[].order` | Yes | ✅ | ✅ | -| `sections[].elements` | Yes | ✅ | ✅ | -| `sections[].image_prompt` | Image only | ✅ | ✅ | - -### Component Method Compliance ✅ - -| Component | Method | Required | Implemented | Status | -|-----------|--------|----------|-------------|--------| -| StructureGenerator | `generateStructure()` | Yes | ✅ | ✅ | -| StructureGenerator | `_createStructurePrompt()` | Yes | ✅ | ✅ | -| StructureGenerator | `_identifySectionComplexity()` | Yes | ✅ | ✅ | -| StructureGenerator | `_extractImagePrompts()` | Yes | ✅ | ✅ | -| StructureGenerator | `_validateAndEnhanceStructure()` | Yes | ✅ | ✅ | -| StructureGenerator | `_extractMeaningfulHint()` | Yes | ✅ | ✅ | -| ContentGenerator | `generateContent()` | Yes | ✅ | ✅ | -| ContentGenerator | `_generateSectionContent()` | Yes | ✅ | ✅ | -| ContentGenerator | `_generateSimpleSection()` | Yes | ✅ | ✅ | -| ContentGenerator | `_generateComplexTextSection()` | Yes | ✅ | ✅ | -| ContentGenerator | `_generateImageSection()` | Yes | ✅ | ✅ | -| ContentGenerator | `_generateSectionsParallel()` | Yes | ✅ | ✅ | -| ContentGenerator | `_generateSectionsSequential()` | Yes | ✅ | ✅ | -| ContentGenerator | `_createSectionPrompt()` | Yes | ✅ | ✅ | -| ContentIntegrator | `integrateContent()` | Yes | ✅ | ✅ | -| ContentIntegrator | `validateCompleteness()` | Yes | ✅ | ✅ | -| ContentIntegrator | `createErrorSection()` | Yes | ✅ | ✅ | - ---- - -## 🎯 Priority Fixes Needed - -### Critical (Must Fix) -1. ✅ **Issue 2**: Variable shadowing bug - **FIXED** -2. ✅ **Issue 3**: Missing generation_hint - **FIXED** -3. ✅ **Issue 4**: JSON template mismatch - **FIXED** -4. ✅ **Issue 5**: Prompt instructions mismatch - **FIXED** -5. ⚠️ **Issue 1**: Previous sections context - **NEEDS VERIFICATION** - -### High Priority (Should Fix) -6. ⚠️ **Issue 12**: Renderer image handling - **NEEDS VERIFICATION** -7. ⚠️ **Issue 9**: Missing unit tests - **NOT IMPLEMENTED** -8. ⚠️ **Issue 10**: Missing integration tests - **NOT IMPLEMENTED** - -### Medium Priority (Nice to Have) -9. ⚠️ **Issue 7**: Previous sections formatting fallback - **PARTIALLY IMPLEMENTED** -10. ⚠️ **Issue 11**: Content caching optimization - **PARTIALLY IMPLEMENTED** -11. ⚠️ **Issue 6**: Structure validation - **NOT IMPLEMENTED** -12. ⚠️ **Issue 8**: Debug file improvements - **NOT IMPLEMENTED** - ---- - -## ✅ Summary - -### What Works -- Core infrastructure is implemented -- Image generation is integrated -- Parallel processing is implemented -- Error handling is in place -- Progress logging works - -### What's Fixed (This Session) -- Variable shadowing bug -- Missing generation_hint extraction -- JSON template architecture mismatch -- Prompt instructions clarity -- Previous sections tracking (needs verification) - -### What Needs Work -- Unit and integration tests -- Renderer verification -- Previous sections formatting fallback -- Cache optimization -- Structure validation - -### Overall Status -**Architecture**: ✅ **85% Compliant** -**Implementation**: ✅ **80% Complete** -**Testing**: ❌ **0% Complete** -**Production Ready**: ⚠️ **Not Yet** (needs testing and verification) - ---- - -## Next Steps - -1. **Verify Issue 1 Fix**: Test that previous sections are correctly tracked in parallel mode -2. **Verify Issue 12**: Test that all renderers handle images correctly -3. **Add Unit Tests**: Start with critical components (StructureGenerator, ContentGenerator) -4. **Add Integration Tests**: Test end-to-end flow with various scenarios -5. **Improve Previous Sections Formatting**: Add fallback to show generation_hint when elements not available -6. **Add Structure Validation**: Explicit validation before content generation -7. **Optimize Content Caching**: Add cache validation and efficient formatting - ---- - -**Analysis Complete**: 2025-12-22 - diff --git a/modules/workflows/processing/shared/CONCEPT_HIERARCHICAL_DOCUMENT_GENERATION.md b/modules/workflows/processing/shared/CONCEPT_HIERARCHICAL_DOCUMENT_GENERATION.md deleted file mode 100644 index d0a59e80..00000000 --- a/modules/workflows/processing/shared/CONCEPT_HIERARCHICAL_DOCUMENT_GENERATION.md +++ /dev/null @@ -1,459 +0,0 @@ -# Concept: Hierarchical Document Generation with Image Integration - -## Executive Summary - -This concept proposes a **three-phase hierarchical approach** to document generation that enables proper image integration and handles complex documents efficiently. - -**Key Decisions**: -- ✅ **Performance**: Parallel processing with ChatLog progress messages -- ✅ **Error Handling**: Skip failed sections, show error messages -- ✅ **Image Storage**: Store as base64 in JSON (renderers need direct access) -- ✅ **Backward Compatibility**: Not needed - implement as new default - -**Renderer Status**: -- ✅ **Ready**: Text, Markdown, DOCX renderers -- ⚠️ **Needs Update**: HTML (create separate image files), PDF (embed images) -- ⚠️ **Needs Implementation**: XLSX, PPTX (add image support) - -## Problem Statement - -Currently, the document generation system has the following limitations: - -1. **No Image Integration**: Images are generated separately but cannot be embedded into document structures -2. **Single-Pass Generation**: Documents are generated in one AI call, making it difficult to handle complex sections (long text, images, chapters) -3. **Repeated Extraction**: Content extraction may happen multiple times unnecessarily -4. **No Structured Approach**: No mechanism to first define document structure, then populate sections - -## Current Architecture Analysis - -### Current Flow: -``` -User Request → ai.generateDocument → ai.process → AI JSON Generation → Renderer → Final Document -``` - -### Issues: -- AI generates complete JSON structure in one pass -- Images are generated separately via `ai.generate` action -- No mechanism to integrate generated images into document structure -- JSON schema supports `image` content_type, but AI rarely generates it -- Content extraction happens per action, not cached/reused - -### Current Image Handling: -- Images can be rendered IF they exist in JSON structure (`content_type: "image"`) -- Image data expected as `base64Data` in elements -- Renderers support image rendering (Docx, PDF, HTML, etc.) -- But images are never generated WITHIN document generation - -## Proposed Solution: Hierarchical Document Generation - -### Core Concept - -**Three-Phase Approach:** -1. **Structure Generation Phase**: Generate document skeleton with section placeholders -2. **Content Generation Phase**: Generate content for each section (text or image) via sub-prompts -3. **Integration Phase**: Merge all generated content into final document structure - -### Architecture Overview - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Phase 1: Structure Generation │ -│ - Generate document skeleton │ -│ - Identify sections (text, image, complex) │ -│ - Create section placeholders with metadata │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ Phase 2: Content Generation (Tree-like) │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Section 1: Heading (simple) │ │ -│ │ → Generate directly │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Section 2: Paragraph (simple) │ │ -│ │ → Generate directly │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Section 3: Image (complex) │ │ -│ │ → Sub-prompt: Generate image │ │ -│ │ → Store image data │ │ -│ │ → Create image section with base64Data │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ Section 4: Long Chapter (complex) │ │ -│ │ → Sub-prompt: Generate chapter content │ │ -│ │ → Split into subsections if needed │ │ -│ └──────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - ↓ -┌─────────────────────────────────────────────────────────────┐ -│ Phase 3: Integration │ -│ - Merge all generated content │ -│ - Replace placeholders with actual data │ -│ - Validate structure completeness │ -│ - Render to final format │ -└─────────────────────────────────────────────────────────────┘ -``` - -## Detailed Design - -### Phase 1: Structure Generation - -**Purpose**: Create document skeleton with section metadata - -**Process**: -1. AI generates document structure with sections -2. Each section includes: - - `id`: Unique identifier - - `content_type`: Type (heading, paragraph, image, table, etc.) - - `complexity`: "simple" or "complex" - - `generation_hint`: Instructions for content generation - - `order`: Section order - - `elements`: Empty or placeholder - -**Example Structure**: -```json -{ - "metadata": { - "title": "Children's Bedtime Story", - "split_strategy": "single_document" - }, - "documents": [{ - "id": "doc_1", - "sections": [ - { - "id": "section_title", - "content_type": "heading", - "complexity": "simple", - "generation_hint": "Story title", - "order": 1, - "elements": [] - }, - { - "id": "section_intro", - "content_type": "paragraph", - "complexity": "simple", - "generation_hint": "Introduction paragraph", - "order": 2, - "elements": [] - }, - { - "id": "section_image_1", - "content_type": "image", - "complexity": "complex", - "generation_hint": "Illustration: Rabbit meeting owl in moonlit forest", - "image_prompt": "A small brown rabbit sitting in a peaceful forest clearing under moonlight with stars, meeting a wise owl perched on a branch", - "order": 3, - "elements": [] - }, - { - "id": "section_chapter_1", - "content_type": "paragraph", - "complexity": "complex", - "generation_hint": "First chapter: Rabbit's adventure begins", - "order": 4, - "elements": [] - } - ] - }] -} -``` - -### Phase 2: Content Generation - -**Purpose**: Generate actual content for each section - -**Process**: -1. Iterate through sections in order -2. For each section: - - **Simple sections** (heading, short paragraph): - - Generate content directly via AI - - Populate `elements` array - - **Complex sections** (image, long chapter): - - Create sub-prompt based on `generation_hint` and `image_prompt` - - Generate content via specialized action: - - Images: `ai.generate` with image generation - - Long text: `ai.process` with focused prompt - - Store generated content - - Populate `elements` array - -**Content Caching**: -- Extract content from source documents ONCE at the start -- Cache extracted content for reuse across all sections -- Pass cached content to sub-prompts to avoid re-extraction - -**Image Generation**: -- For `content_type: "image"` sections: - - Use `image_prompt` from structure - - Call `ai.generate` action with image generation - - Receive base64 image data - - Create image element: - ```json - { - "url": "data:image/png;base64,
`
- - Return multiple files: HTML file + image files
-
-4. **PDF Renderer** (`rendererPdf.py`): ⚠️ **NEEDS UPDATE**
- - Currently: Shows placeholder `[Image: altText]`
- - **Required Change**: Embed images directly in PDF using reportlab
- - Implementation: Use `reportlab.platypus.Image()` with base64 decoded bytes
-
-5. **DOCX Renderer** (`rendererDocx.py`): ✅ **READY**
- - Embeds images directly using `doc.add_picture()`
- - Adds captions below images
- - No changes needed
-
-6. **XLSX Renderer** (`rendererXlsx.py`): ⚠️ **NEEDS IMPLEMENTATION**
- - Currently: No image handling found
- - **Required Change**: Add image support using openpyxl
- - Implementation: Use `openpyxl.drawing.image.Image()` to embed images in cells
- - Store images in worksheet cells or as floating images
-
-7. **PPTX Renderer** (`rendererPptx.py`): ⚠️ **NEEDS IMPLEMENTATION**
- - Currently: No image handling found
- - **Required Change**: Add image support using python-pptx
- - Implementation: Use `slide.shapes.add_picture()` to add images to slides
-
-### Renderer Update Requirements:
-
-**Priority 1 (Critical for HTML output)**:
-- HTML Renderer: Create separate image files and link them
-
-**Priority 2 (Important for document formats)**:
-- PDF Renderer: Embed images using reportlab
-- XLSX Renderer: Add image embedding support
-- PPTX Renderer: Add image embedding support
-
-## Answers to Open Questions
-
-### 1. Performance: How to handle very large documents (100+ sections)?
-
-**Answer**: Use parallel processing where possible, with progress ChatLog messages.
-
-**Implementation Strategy**:
-- **Parallel Section Generation**: Generate independent sections in parallel using asyncio
-- **Batch Processing**: Process sections in batches (e.g., 10 sections at a time)
-- **Progress Tracking**: Send ChatLog progress updates:
- - "Generating structure..." (Phase 1)
- - "Generating content for section X/Y..." (Phase 2)
- - "Generating image for section X..." (Phase 2 - images)
- - "Merging content..." (Phase 3)
- - "Rendering final document..." (Phase 3)
-- **Streaming**: For very large documents, consider streaming partial results
-
-**Example Progress Messages**:
-```
-Phase 1: Structure Generation (0% → 33%)
-Phase 2: Content Generation (33% → 90%)
- - Section 1/10: Heading (34%)
- - Section 2/10: Paragraph (40%)
- - Section 3/10: Image generation (50%)
- - Section 4/10: Chapter (60%)
- ...
-Phase 3: Integration & Rendering (90% → 100%)
-```
-
-### 2. Error Handling: What if one section fails?
-
-**Answer**: Skip failed sections, keep section title and type, show error message in the section.
-
-**Implementation Strategy**:
-- **Graceful Degradation**: Continue processing remaining sections
-- **Error Section**: Create error placeholder section:
- ```json
- {
- "id": "section_failed_3",
- "content_type": "paragraph",
- "elements": [{
- "text": "[ERROR: Failed to generate content for this section. Error: [Reference: {label}]
') - continue - elif element_type == "extracted_text": - # Extracted text format - content = element.get("content", "") - source = element.get("source", "") - if content: - source_text = f' (Source: {source})' if source else '' - htmlParts.append(f'{content}{source_text}
') - continue - - # If we processed reference/extracted_text elements, return them - if htmlParts: - return '\n'.join(htmlParts) + # WICHTIG: Respektiere sectionType (content_type) ZUERST, dann process elements entsprechend + # Process elements according to section's content_type, not just element types if sectionType == "table": # Process the section data to extract table structure @@ -339,8 +317,58 @@ class RendererHtml(BaseRenderer): processedData = self._processSectionByType(section) return self._renderJsonBulletList(processedData, styles) elif sectionType == "heading": + # Extract text from elements for heading rendering + if isinstance(sectionData, list): + # Extract text from heading elements + headingText = "" + for element in sectionData: + if isinstance(element, dict): + element_type = element.get("type", "") + if element_type == "heading": + headingText = element.get("content", element.get("text", "")) + break + elif element_type == "extracted_text": + # Use extracted text as heading if no heading element found + content = element.get("content", "") + if content and not headingText: + # Extract first line or title from extracted text + headingText = content.split('\n')[0].strip() + # Remove markdown formatting + headingText = headingText.replace('#', '').replace('**', '').strip() + break + elif "text" in element: + headingText = element.get("text", "") + break + if headingText: + return self._renderJsonHeading({"text": headingText, "level": 2}, styles) return self._renderJsonHeading(sectionData, styles) elif sectionType == "paragraph": + # Process paragraph elements, including extracted_text + if isinstance(sectionData, list): + htmlParts = [] + for element in sectionData: + element_type = element.get("type", "") if isinstance(element, dict) else "" + + if element_type == "reference": + doc_ref = element.get("documentReference", "") + label = element.get("label", "Reference") + htmlParts.append(f'[Reference: {label}]
') + elif element_type == "extracted_text": + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f' (Source: {source})' if source else '' + htmlParts.append(f'{content}{source_text}
') + elif isinstance(element, dict): + # Regular paragraph element + text = element.get("text", element.get("content", "")) + if text: + htmlParts.append(f'{text}
') + elif isinstance(element, str): + htmlParts.append(f'{element}
') + + if htmlParts: + return '\n'.join(htmlParts) return self._renderJsonParagraph(sectionData, styles) elif sectionType == "code_block": # Process the section data to extract code block structure @@ -351,6 +379,25 @@ class RendererHtml(BaseRenderer): processedData = self._processSectionByType(section) return self._renderJsonImage(processedData, styles) else: + # Fallback: Check for special element types first + if isinstance(sectionData, list): + htmlParts = [] + for element in sectionData: + element_type = element.get("type", "") if isinstance(element, dict) else "" + + if element_type == "reference": + doc_ref = element.get("documentReference", "") + label = element.get("label", "Reference") + htmlParts.append(f'[Reference: {label}]
') + elif element_type == "extracted_text": + content = element.get("content", "") + source = element.get("source", "") + if content: + source_text = f' (Source: {source})' if source else '' + htmlParts.append(f'{content}{source_text}
') + + if htmlParts: + return '\n'.join(htmlParts) # Fallback to paragraph for unknown types return self._renderJsonParagraph(sectionData, styles) diff --git a/tests/functional/test09_document_generation_formats.py b/tests/functional/test09_document_generation_formats.py index 49860665..3e33c996 100644 --- a/tests/functional/test09_document_generation_formats.py +++ b/tests/functional/test09_document_generation_formats.py @@ -214,14 +214,14 @@ class DocumentGenerationFormatsTester: self.workflow = workflow print(f"Workflow started: {workflow.id}") - # Wait for workflow completion + # Wait for workflow completion (no timeout - wait indefinitely) print(f"Waiting for workflow completion...") - completed = await self.waitForWorkflowCompletion(timeout=300) # 5 minute timeout + completed = await self.waitForWorkflowCompletion(timeout=None) if not completed: return { "success": False, - "error": "Workflow did not complete within timeout", + "error": "Workflow did not complete", "workflowId": workflow.id, "status": workflow.status if workflow else "unknown" } @@ -243,7 +243,7 @@ class DocumentGenerationFormatsTester: "results": results } - async def waitForWorkflowCompletion(self, timeout: int = 300, checkInterval: int = 2) -> bool: + async def waitForWorkflowCompletion(self, timeout: Optional[int] = None, checkInterval: int = 2) -> bool: """Wait for workflow to complete.""" if not self.workflow: return False @@ -253,9 +253,12 @@ class DocumentGenerationFormatsTester: interfaceDbChat = interfaceDbChatObjects.getInterface(self.testUser) + if timeout is None: + print("Waiting indefinitely (no timeout)") + while True: - # Check timeout - if time.time() - startTime > timeout: + # Check timeout only if specified + if timeout is not None and time.time() - startTime > timeout: print(f"\n⏱️ Timeout after {timeout} seconds") return False @@ -455,13 +458,13 @@ class DocumentGenerationFormatsTester: self.workflow = workflow print(f"Workflow started: {workflow.id}") - # Wait for workflow completion - completed = await self.waitForWorkflowCompletion(timeout=300) + # Wait for workflow completion (no timeout - wait indefinitely) + completed = await self.waitForWorkflowCompletion(timeout=None) if not completed: results[testType] = { "success": False, - "error": "Workflow did not complete within timeout", + "error": "Workflow did not complete", "workflowId": workflow.id } continue From 9d4bd8ceef948b3891eb643b46caca706a116b6a Mon Sep 17 00:00:00 2001 From: ValueOn AG{title}
Error rendering report: {str(e)}
", "text/html" + if base64Data: + try: + # Decode base64 to bytes + imageBytes = base64.b64decode(base64Data) + resultDocuments.append( + RenderedDocument( + documentData=imageBytes, + mimeType=mimeType, + filename=filename + ) + ) + self.logger.debug(f"Added image file: {filename} ({len(imageBytes)} bytes)") + except Exception as e: + self.logger.warning(f"Error creating image file {filename}: {str(e)}") + + return resultDocuments 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.""" @@ -597,8 +635,31 @@ class RendererHtml(BaseRenderer): if base64Data: sectionId = section.get("id", "unknown") + + # Bestimme MIME-Type und Extension + mimeType = element.get("mimeType", "image/png") + if not mimeType or mimeType == "unknown": + # Versuche MIME-Type aus base64 zu erkennen + if base64Data.startswith("/9j/"): + mimeType = "image/jpeg" + elif base64Data.startswith("iVBORw0KGgo"): + mimeType = "image/png" + else: + mimeType = "image/png" # Default + + # Bestimme Extension basierend auf MIME-Type + extension = "png" + if mimeType == "image/jpeg" or mimeType == "image/jpg": + extension = "jpg" + elif mimeType == "image/png": + extension = "png" + elif mimeType == "image/gif": + extension = "gif" + elif mimeType == "image/webp": + extension = "webp" + # Generate filename from section ID - filename = f"{sectionId}.png" + filename = f"{sectionId}.{extension}" # Clean filename (remove invalid characters) filename = "".join(c if c.isalnum() or c in "._-" else "_" for c in filename) @@ -607,7 +668,8 @@ class RendererHtml(BaseRenderer): "altText": element.get("altText", "Image"), "caption": element.get("caption"), "sectionId": sectionId, - "filename": filename + "filename": filename, + "mimeType": mimeType }) self.logger.debug(f"Extracted image from section {sectionId}: {filename}") @@ -633,8 +695,9 @@ class RendererHtml(BaseRenderer): import base64 import re - # Find all image data URIs in HTML - dataUriPattern = r'data:image/png;base64,([A-Za-z0-9+/=]+)' + # Find all image data URIs in HTML (verschiedene MIME-Types unterstützen) + # Pattern: data:image/[type];base64,{content}{source_text}
') + htmlParts.append(f'{content}{source_text}
') elif isinstance(element, dict): # Regular paragraph element text = element.get("text", element.get("content", "")) @@ -432,7 +432,7 @@ class RendererHtml(BaseRenderer): source = element.get("source", "") if content: source_text = f' (Source: {source})' if source else '' - htmlParts.append(f'{content}{source_text}
') + htmlParts.append(f'{content}{source_text}
') if htmlParts: return '\n'.join(htmlParts) @@ -577,18 +577,23 @@ class RendererHtml(BaseRenderer): def _renderJsonImage(self, imageData: Dict[str, Any], styles: Dict[str, Any]) -> str: """Render a JSON image to HTML with placeholder for later replacement.""" try: + import html base64Data = imageData.get("base64Data", "") altText = imageData.get("altText", "Image") caption = imageData.get("caption", "") + # Escape HTML in altText and caption to prevent injection + altTextEscaped = html.escape(str(altText)) + captionEscaped = html.escape(str(caption)) if caption else "" + if base64Data: # Use data URI as placeholder - will be replaced with file path in _replaceImageDataUris # Include a marker so we can find and replace it - imageMarker = f"" - imgTag = 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'{content}{source_text}
') elif isinstance(element, dict): - # Regular paragraph element - text = element.get("text", element.get("content", "")) + # Regular paragraph element - extract from nested content structure (standard JSON format) + content = element.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" + if text: htmlParts.append(f'{text}
') elif isinstance(element, str): @@ -629,10 +636,11 @@ class RendererHtml(BaseRenderer): """Render a JSON image to HTML with placeholder for later replacement. Expects nested content structure.""" try: import html - # Extract from nested content structure + # Extract from nested content structure (standard JSON format) content = imageData.get("content", {}) if not isinstance(content, dict): return "" + base64Data = content.get("base64Data", "") altText = content.get("altText", "Image") caption = content.get("caption", "") @@ -645,7 +653,9 @@ class RendererHtml(BaseRenderer): # Use data URI as placeholder - will be replaced with file path in _replaceImageDataUris # Include a marker so we can find and replace it imageMarker = f"" - imgTag = f', or style attributes +11. For headings: Return plain text only, no HTML tags or styling +12. For images: Do NOT include base64 data in JSON - images are handled separately ## OUTPUT FORMAT Return a JSON object with this structure: @@ -1020,7 +1346,16 @@ Return a JSON object with this structure: ] }} -CRITICAL: "content" MUST always be an object (never a string). Return ONLY valid JSON. Do not include any explanatory text outside the JSON. +CRITICAL: +- "content" MUST always be an object (never a string) +- For text content: Return plain text only, NO HTML tags, NO CSS styles, NO formatting markup +- Return ONLY valid JSON. Do not include any explanatory text outside the JSON. + +## CONTEXT (for reference only) +{contextText if contextText else ""} +``` +{userPrompt} +``` """ else: prompt = f"""# TASK: Generate Section Content @@ -1029,30 +1364,21 @@ CRITICAL: "content" MUST always be an object (never a string). Return ONLY valid - Section ID: {sectionId} - Content Type: {contentType} - Generation Hint: {generationHint} -{contextText} - -## USER REQUEST (for context) -``` -{userPrompt} -``` ## AVAILABLE CONTENT FOR THIS SECTION {contentPartsText if contentPartsText else "(No content parts specified for this section)"} -## IMPORTANT - SECTION INDEPENDENCE: -- This section is independent and self-contained -- You do NOT have information about other sections' content -- Provide all necessary context within this section -- Context above is for logical flow only, NOT for content dependencies - ## INSTRUCTIONS 1. Generate content for section "{sectionId}" based on the generation hint above 2. Use the available content parts to populate this section -3. For images: Use data URI format (data:image/[type];base64,[data]) when embedding base64 image data -4. For extracted text: Format appropriately based on content_type ({contentType}) -5. Ensure the generated content is self-contained and understandable independently -6. Return ONLY a JSON object with an "elements" array -7. Each element should match the content_type: {contentType} +3. For extracted text: Format appropriately based on content_type ({contentType}) +4. Ensure the generated content is self-contained and understandable independently +5. Return ONLY a JSON object with an "elements" array +6. Each element should match the content_type: {contentType} +7. CRITICAL - NO HTML/STYLING: Do NOT include HTML tags, CSS styles, or any formatting markup in text content. Return plain text only. Formatting is handled automatically by the renderer. +8. For paragraphs: Return plain text only, no HTML tags like
, or style attributes +9. For headings: Return plain text only, no HTML tags or styling +10. For images: If you need to reference an image, describe it in altText. Do NOT include base64 data - images are handled separately ## OUTPUT FORMAT Return a JSON object with this structure: @@ -1066,7 +1392,16 @@ Return a JSON object with this structure: ] }} -CRITICAL: "content" MUST always be an object (never a string). Return ONLY valid JSON. Do not include any explanatory text outside the JSON. +CRITICAL: +- "content" MUST always be an object (never a string) +- For text content: Return plain text only, NO HTML tags, NO CSS styles, NO formatting markup +- Return ONLY valid JSON. Do not include any explanatory text outside the JSON + +## CONTEXT (for reference only) +{contextText if contextText else ""} +``` +{userPrompt} +``` """ return prompt diff --git a/modules/services/serviceAi/subStructureGeneration.py b/modules/services/serviceAi/subStructureGeneration.py index 84e659a4..d3b46e0e 100644 --- a/modules/services/serviceAi/subStructureGeneration.py +++ b/modules/services/serviceAi/subStructureGeneration.py @@ -160,18 +160,30 @@ IMPORTANT - CHAPTER INDEPENDENCE: - One chapter does NOT have information about another chapter - Each chapter must provide its own context and be understandable alone +CRITICAL - CONTENT ASSIGNMENT TO CHAPTERS: +- You MUST assign available ContentParts to chapters using contentPartIds +- Based on the user request, determine which content should be used in which chapter +- If the user request mentions specific content, assign the corresponding ContentPart to the appropriate chapter +- Chapters WITHOUT contentPartIds can only generate generic content, NOT document-specific analysis +- To include document content analysis, chapters MUST have contentPartIds assigned +- Review the user request carefully to match ContentParts to chapters based on context and purpose + CRITICAL - CHAPTERS WITHOUT CONTENT PARTS: - If contentPartIds is EMPTY, generationHint MUST be VERY DETAILED with all context needed to generate content from scratch - Include: what to generate, what information to include, purpose, specific details -- Without content parts, AI relies ENTIRELY on generationHint -- GOOD: "Create [specific content] with [details]. Include [information]. Purpose: [explanation]." -- BAD: "Create title" or "Add section" (too vague) +- Without content parts, AI relies ENTIRELY on generationHint and CANNOT analyze document content + +IMPORTANT - FORMATTING: +- Formatting (fonts, colors, layouts, styles) is handled AUTOMATICALLY by the renderer +- Do NOT specify formatting details in generationHint unless it's content-specific (e.g., "pie chart with 3 segments") +- Focus on CONTENT and STRUCTURE, not visual formatting +- The renderer will apply appropriate styling based on the output format ({outputFormat}) For each chapter: - chapter id - level (1, 2, 3, etc.) - title -- contentPartIds: [List of ContentPart IDs] +- contentPartIds: [List of ContentPart IDs] - ASSIGN content based on user request and chapter purpose - contentPartInstructions: {{ "partId": {{ "instruction": "How content should be structured" @@ -179,6 +191,7 @@ For each chapter: }} - generationHint: Description of the content (must be self-contained with all necessary context) * If contentPartIds is EMPTY, generationHint MUST be VERY DETAILED with all context needed to generate content from scratch + * Focus on content and structure, NOT formatting details OUTPUT FORMAT: {outputFormat} diff --git a/modules/services/serviceGeneration/renderers/rendererPptx.py b/modules/services/serviceGeneration/renderers/rendererPptx.py index 9e6f41c9..5525ae89 100644 --- a/modules/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/services/serviceGeneration/renderers/rendererPptx.py @@ -82,205 +82,119 @@ class RendererPptx(BaseRenderer): logger.info(f"Slide {i+1}: '{slide_data.get('title', 'No title')}' - sections: {len(slide_sections)}, images: {len(slide_images)}, content: {len(slide_content)} chars") - # Determine layout: first slide (i==0) uses title slide layout - # For image-only slides, use blank layout to avoid placeholder interference - # Otherwise use title+content layout - if i == 0: - slideLayoutIndex = 0 # Title slide layout - elif hasImages and not hasSections and not slide_content: - # Image-only slide: use blank layout (typically index 6, fallback to 5 if not available) - try: - slideLayoutIndex = 6 # Blank layout - # Verify layout exists, fallback if not - if slideLayoutIndex >= len(prs.slide_layouts): - slideLayoutIndex = 5 # Alternative blank layout - except (AttributeError, IndexError): - slideLayoutIndex = 1 # Fallback to title+content - else: - slideLayoutIndex = 1 # Title and content layout + # Use blank layout for all slides to avoid placeholder interference + # Find blank layout (typically index 6, fallback to 5) + slideLayoutIndex = None + for idx in [6, 5]: + if idx < len(prs.slide_layouts): + try: + layout = prs.slide_layouts[idx] + # Check if it's a blank layout (no placeholders) + if len(layout.placeholders) == 0: + slideLayoutIndex = idx + break + except (AttributeError, IndexError): + continue + + # If no blank layout found, use layout with fewest placeholders + if slideLayoutIndex is None: + min_placeholders = float('inf') + for idx in range(len(prs.slide_layouts)): + try: + layout = prs.slide_layouts[idx] + placeholder_count = len(layout.placeholders) if hasattr(layout, 'placeholders') else 0 + if placeholder_count < min_placeholders: + min_placeholders = placeholder_count + slideLayoutIndex = idx + except: + continue + + # Fallback to first layout if still None + if slideLayoutIndex is None: + slideLayoutIndex = 0 slide_layout = prs.slide_layouts[slideLayoutIndex] slide = prs.slides.add_slide(slide_layout) - # Set title with AI-generated styling - # For blank layouts, add title as textbox since there's no title placeholder + # Clear placeholder text instead of removing placeholders (safer approach) + # This avoids corrupting the PPTX file structure try: - title_shape = slide.shapes.title - title_shape.text = slide_data.get("title", "Slide") - - # Apply title styling - LEFT ALIGNED by default - title_style = styles.get("title", {}) - 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._getSafeColor(title_style.get("color", (31, 78, 121))) - title_shape.text_frame.paragraphs[0].font.color.rgb = RGBColor(*title_color) - # Set left alignment for title - title_shape.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT - except AttributeError: - # Blank layout has no title placeholder - add title as textbox - from pptx.util import Inches - titleBox = slide.shapes.add_textbox(Inches(0.5), Inches(0.3), prs.slide_width - Inches(1), Inches(0.8)) - titleFrame = titleBox.text_frame - titleFrame.text = slide_data.get("title", "Slide") - title_style = styles.get("title", {}) - titleFrame.paragraphs[0].font.size = Pt(title_style.get("font_size", 44)) - titleFrame.paragraphs[0].font.bold = title_style.get("bold", True) - title_color = self._getSafeColor(title_style.get("color", (31, 78, 121))) - titleFrame.paragraphs[0].font.color.rgb = RGBColor(*title_color) - titleFrame.paragraphs[0].alignment = PP_ALIGN.LEFT + for shape in slide.shapes: + if hasattr(shape, 'is_placeholder') and shape.is_placeholder: + try: + if hasattr(shape, 'text_frame'): + shape.text_frame.clear() + # Set text to empty string to remove "Click to add text" + if len(shape.text_frame.paragraphs) > 0: + shape.text_frame.paragraphs[0].text = "" + except: + pass + except Exception as placeholder_error: + logger.warning(f"Could not clear placeholders: {str(placeholder_error)}") + + # Add title as textbox (smaller size for slides) + from pptx.util import Inches + titleBox = slide.shapes.add_textbox(Inches(0.5), Inches(0.2), prs.slide_width - Inches(1), Inches(0.6)) + titleFrame = titleBox.text_frame + titleFrame.text = slide_data.get("title", "Slide") + title_style = styles.get("title", {}) + # Smaller title size for slides (default 32 instead of 44) + title_font_size = title_style.get("font_size", 32) + # Reduce further for slides (max 32pt, min 10pt for readability) + title_font_size = max(10, min(title_font_size, 32)) + titleFrame.paragraphs[0].font.size = Pt(title_font_size) + titleFrame.paragraphs[0].font.bold = title_style.get("bold", True) + title_color = self._getSafeColor(title_style.get("color", (31, 78, 121))) + titleFrame.paragraphs[0].font.color.rgb = RGBColor(*title_color) + titleFrame.paragraphs[0].alignment = PP_ALIGN.LEFT + titleFrame.word_wrap = True # Render sections with proper PowerPoint objects (tables, lists, etc.) + # Organize content into frames for better layout if hasSections: - # Use content placeholder for structured content (only if layout has placeholder[1]) - try: - content_shape = slide.placeholders[1] - text_frame = content_shape.text_frame - text_frame.clear() - except (AttributeError, IndexError): - # Layout might not have placeholder[1], create textbox instead - from pptx.util import Inches - left = Inches(0.5) - top = Inches(1.5) - width = prs.slide_width - Inches(1) - height = prs.slide_height - top - Inches(0.5) - textbox = slide.shapes.add_textbox(left, top, width, height) - text_frame = textbox.text_frame - text_frame.word_wrap = True - - # Track vertical position for multiple content types - current_y = Inches(1.5) # Start below title - - for section in slide_sections: - section_type = section.get("content_type", "paragraph") - elements = section.get("elements", []) - - # Check if section has image content_type - if section_type == "image": - # Extract images from this section - for element in elements: - if isinstance(element, dict) and element.get("type") == "image": - content = element.get("content", {}) - if isinstance(content, dict): - base64Data = content.get("base64Data") - if base64Data: - slide_images.append({ - "base64Data": base64Data, - "altText": content.get("altText", "Image"), - "caption": content.get("caption", "") - }) - continue # Skip rendering image sections as text - - # Handle sections without elements (e.g., headings that create slides) - if not elements: - continue - - for element in elements: - if not isinstance(element, dict): - continue - - # Check element type first, fall back to section type - element_type = element.get("type", "") - if not element_type: - element_type = section_type - - # Skip image elements - they're handled separately - if element_type == "image": - content = element.get("content", {}) - if isinstance(content, dict): - base64Data = content.get("base64Data") - if base64Data: - slide_images.append({ - "base64Data": base64Data, - "altText": content.get("altText", "Image"), - "caption": content.get("caption", "") - }) - continue - - if element_type == "table": - # Render as actual PowerPoint table - self._addTableToSlide(slide, element, styles, current_y) - current_y += Inches(2) # Space for table - elif element_type == "bullet_list" or element_type == "list": - # Render as actual PowerPoint bullet list - if text_frame: - self._addBulletListToSlide(slide, element, styles, text_frame) - elif element_type == "heading": - # Render as heading in text frame - if text_frame: - self._addHeadingToSlide(slide, element, styles, text_frame) - elif element_type == "paragraph": - # Render as paragraph in text frame - if text_frame: - self._addParagraphToSlide(slide, element, styles, text_frame) - elif element_type == "code_block" or element_type == "code": - # Render as formatted code block - if text_frame: - self._addCodeBlockToSlide(slide, element, styles, text_frame) - elif element_type == "extracted_text": - # Render extracted text as paragraph with styling - if text_frame: - content = element.get("content", "") - source = element.get("source", "") - if content: - paragraph_style = styles.get("paragraph", {}) - p = text_frame.add_paragraph() - p.text = content - p.font.size = Pt(paragraph_style.get("font_size", 18)) - p.font.bold = paragraph_style.get("bold", False) - p.font.color.rgb = RGBColor(*self._getSafeColor(paragraph_style.get("color", (47, 47, 47)))) - p.alignment = PP_ALIGN.LEFT # Left align by default - if source: - p.add_run(f" (Source: {source})").font.italic = True - elif element_type == "reference": - # Render reference - if text_frame: - label = element.get("label", "Reference") - p = text_frame.add_paragraph() - p.text = f"[Reference: {label}]" - p.font.italic = True - p.alignment = PP_ALIGN.LEFT - else: - # Fallback: try to render as paragraph - if text_frame: - content = element.get("content", "") - if isinstance(content, dict): - text = content.get("text", "") - elif isinstance(content, str): - text = content - else: - text = "" - - if text: - self._addParagraphToSlide(slide, element, styles, text_frame) + # Organize sections into content groups for frame-based layout + # Images are handled within the frame rendering method + self._renderSlideContentWithFrames(slide, slide_sections, slide_images, styles, prs) - # Handle images after processing sections (images may have been extracted from sections) - # Update hasImages in case images were added during section processing - hasImages = len(slide_images) > 0 - if hasImages: - self._addImagesToSlide(slide, slide_images, styles) - - # Fallback: if no sections but has content text, render as before + # Fallback: if no sections but has content text, render in textbox elif slide_content and not hasImages: - content_shape = slide.placeholders[1] - text_frame = content_shape.text_frame - text_frame.clear() + # Create textbox for content (no placeholders in blank layout) + from pptx.util import Inches + title_height_used = Inches(1.0) # Title height for blank slides + content_left = Inches(0.5) + content_top = title_height_used + Inches(0.3) + content_width = prs.slide_width - Inches(1) + content_height = prs.slide_height - content_top - Inches(0.5) + content_textbox = slide.shapes.add_textbox(content_left, content_top, content_width, content_height) + text_frame = content_textbox.text_frame + text_frame.word_wrap = True + text_frame.auto_size = None # Split content into paragraphs paragraphs = slide_content.split('\n\n') - for paraIdx, paragraph in enumerate(paragraphs): + for paragraph in paragraphs: if paragraph.strip(): - if paraIdx == 0: - p = text_frame.paragraphs[0] - else: - p = text_frame.add_paragraph() - + p = text_frame.add_paragraph() p.text = paragraph.strip() - # Apply AI-generated styling + # Apply AI-generated styling with adaptive sizing paragraph_style = styles.get("paragraph", {}) - p.font.size = Pt(paragraph_style.get("font_size", 18)) + base_font_size = paragraph_style.get("font_size", 18) + # Calculate adaptive font size based on content length + try: + total_chars = len(slide_content) + chars_per_line = max(1, int(content_width / Pt(10))) + lines_needed = total_chars / chars_per_line + available_lines = max(1, int(content_height / Pt(14))) + font_multiplier = 1.0 + if available_lines > 0 and lines_needed > available_lines: + font_multiplier = max(0.6, min(1.0, (available_lines / lines_needed) * 1.1)) + calculated_size = max(6, int(base_font_size * font_multiplier)) # Minimum 6pt + except (ZeroDivisionError, ValueError, TypeError): + calculated_size = max(6, base_font_size) # Fallback to base size with minimum + + p.font.size = Pt(calculated_size) p.font.bold = paragraph_style.get("bold", False) paragraph_color = self._getSafeColor(paragraph_style.get("color", (47, 47, 47))) p.font.color.rgb = RGBColor(*paragraph_color) @@ -567,11 +481,11 @@ class RendererPptx(BaseRenderer): def _getDefaultStyleSet(self) -> Dict[str, Any]: """Default PowerPoint style set - used when no style instructions present.""" return { - "title": {"font_size": 52, "color": "#1B365D", "bold": True, "align": "center"}, - "heading": {"font_size": 36, "color": "#2C5F2D", "bold": True, "align": "left"}, - "subheading": {"font_size": 28, "color": "#4A90E2", "bold": True, "align": "left"}, - "paragraph": {"font_size": 20, "color": "#2F2F2F", "bold": False, "align": "left"}, - "bullet_list": {"font_size": 20, "color": "#2F2F2F", "indent": 20}, + "title": {"font_size": 32, "color": "#1B365D", "bold": True, "align": "left"}, + "heading": {"font_size": 24, "color": "#1B365D", "bold": True, "align": "left"}, + "subheading": {"font_size": 20, "color": "#4A90E2", "bold": True, "align": "left"}, + "paragraph": {"font_size": 14, "color": "#2F2F2F", "bold": False, "align": "left"}, + "bullet_list": {"font_size": 14, "color": "#2F2F2F", "indent": 20}, "table_header": {"font_size": 18, "color": "#FFFFFF", "bold": True, "background": "#1B365D"}, "table_cell": {"font_size": 16, "color": "#2F2F2F", "bold": False, "background": "#F8F9FA"}, "slide_size": "16:9", @@ -724,11 +638,15 @@ JSON ONLY. NO OTHER TEXT.""" # Get section title from data or use default section_title = "Untitled Section" if section.get("content_type") == "heading": - # Extract text from elements array + # Extract text from elements array - use nested content structure for element in section.get("elements", []): - if isinstance(element, dict) and "text" in element: - section_title = element.get("text", "Untitled Section") - break + if isinstance(element, dict): + content = element.get("content", {}) + if isinstance(content, dict): + text = content.get("text", "") + if text: + section_title = text + break elif section.get("title"): section_title = section.get("title") @@ -738,7 +656,10 @@ JSON ONLY. NO OTHER TEXT.""" # Check for three content formats from Phase 5D in elements content_parts = [] for element in elements: - element_type = element.get("type", "") if isinstance(element, dict) else "" + if not isinstance(element, dict): + continue + + element_type = element.get("type", "") # Support three content formats from Phase 5D if element_type == "reference": @@ -782,25 +703,47 @@ JSON ONLY. NO OTHER TEXT.""" }) return { - "title": section_title or (elements[0].get("altText", "Image") if elements else "Image"), + "title": section_title or (elements[0].get("content", {}).get("altText", "Image") if elements and isinstance(elements[0], dict) else "Image"), "content": "\n\n".join(content_parts) if content_parts else "", # Include reference/extracted_text if present "images": images } - # Build slide content based on section type + # Build slide content based on section type - iterate over elements and format each if not content_parts: # Only if we didn't process reference/extracted_text above - if content_type == "table": - content_parts.append(self._formatTableForSlide(elements)) - elif content_type == "list": - content_parts.append(self._formatListForSlide(elements)) - elif content_type == "heading": - content_parts.append(self._formatHeadingForSlide(elements)) - elif content_type == "paragraph": - content_parts.append(self._formatParagraphForSlide(elements)) - elif content_type == "code": - content_parts.append(self._formatCodeForSlide(elements)) - else: - content_parts.append(self._formatParagraphForSlide(elements)) + for element in elements: + if not isinstance(element, dict): + continue + + element_type = element.get("type", "") + # Use element type if available, otherwise fall back to section content_type + if not element_type: + element_type = content_type + + if element_type == "table": + formatted = self._formatTableForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "bullet_list" or element_type == "list": + formatted = self._formatListForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "heading": + formatted = self._formatHeadingForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "paragraph": + formatted = self._formatParagraphForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "code_block" or element_type == "code": + formatted = self._formatCodeForSlide(element) + if formatted: + content_parts.append(formatted) + else: + # Fallback to paragraph formatting + formatted = self._formatParagraphForSlide(element) + if formatted: + content_parts.append(formatted) # Combine content parts slide_content = "\n\n".join(filter(None, content_parts)) @@ -1002,7 +945,7 @@ JSON ONLY. NO OTHER TEXT.""" return 1 # Default to title and content layout def _createSlidesFromSections(self, sections: List[Dict[str, Any]], styles: Dict[str, Any]) -> List[Dict[str, Any]]: - """Create slides from sections: each heading creates a new slide, content accumulates until next heading.""" + """Create slides from sections: each heading level 1 (chapter) creates a new slide, content accumulates until next level 1 heading.""" try: slides = [] current_slide_sections = [] # Store sections (not formatted text) for proper rendering @@ -1017,74 +960,43 @@ JSON ONLY. NO OTHER TEXT.""" continue if section_type == "heading": - # If we have accumulated content, create a slide - if current_slide_sections: - slides.append({ - "title": current_slide_title, - "sections": current_slide_sections.copy(), # Store sections for proper rendering - "images": [] - }) - current_slide_sections = [] - - # Start new slide with heading as title - heading_found = False + # Extract heading level + level = 1 # Default + heading_text = "" for element in elements: if isinstance(element, dict): # Extract from nested content structure content = element.get("content", {}) if isinstance(content, dict): heading_text = content.get("text", "") + level = content.get("level", 1) elif isinstance(content, str): heading_text = content - else: - heading_text = "" - - if heading_text: - current_slide_title = heading_text - heading_found = True - break + level = 1 - # 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") + # Only level 1 headings (chapters) create new slides + if level == 1: + # If we have accumulated content, create a slide + if current_slide_sections: + slides.append({ + "title": current_slide_title, + "sections": current_slide_sections.copy(), # Store sections for proper rendering + "images": [] + }) + current_slide_sections = [] + + # Start new slide with heading as title + if heading_text: + current_slide_title = heading_text + else: + # If no heading text found but this is a heading section, use section ID or default + current_slide_title = section.get("id", "Untitled Section") + else: + # Level 2+ headings are added as sections to current slide + current_slide_sections.append(section) elif section_type == "image": - # Create separate slide for image - if current_slide_sections: - slides.append({ - "title": current_slide_title, - "sections": current_slide_sections.copy(), - "images": [] - }) - current_slide_sections = [] - - # Extract image data - imageData = [] - for element in elements: - 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"), - "sections": [], - "images": imageData - }) + # Images are added to current slide (will be organized in frames) + current_slide_sections.append(section) else: # Add section to current slide (will be rendered properly) current_slide_sections.append(section) @@ -1113,21 +1025,42 @@ JSON ONLY. NO OTHER TEXT.""" if content_type == "image": return "" - # Process each element in the section + # Process each element in the section - use element type, not section type content_parts = [] for element in elements: - if content_type == "table": - 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)) - elif content_type == "paragraph": - content_parts.append(self._formatParagraphForSlide(element)) - elif content_type == "code_block" or content_type == "code": - content_parts.append(self._formatCodeForSlide(element)) + if not isinstance(element, dict): + continue + + element_type = element.get("type", "") + # Use element type if available, otherwise fall back to section content_type + if not element_type: + element_type = content_type + + if element_type == "table": + formatted = self._formatTableForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "bullet_list" or element_type == "list": + formatted = self._formatListForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "heading": + formatted = self._formatHeadingForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "paragraph": + formatted = self._formatParagraphForSlide(element) + if formatted: + content_parts.append(formatted) + elif element_type == "code_block" or element_type == "code": + formatted = self._formatCodeForSlide(element) + if formatted: + content_parts.append(formatted) else: - content_parts.append(self._formatParagraphForSlide(element)) + # Fallback to paragraph formatting + formatted = self._formatParagraphForSlide(element) + if formatted: + content_parts.append(formatted) return "\n\n".join(filter(None, content_parts)) @@ -1166,80 +1099,80 @@ JSON ONLY. NO OTHER TEXT.""" img = images[0] base64Data = img.get("base64Data") # Validate base64Data is present and not empty - if base64Data and isinstance(base64Data, str) and len(base64Data.strip()) > 0: - try: - imageBytes = base64.b64decode(base64Data) - if len(imageBytes) == 0: - logger.error("Decoded image bytes are empty") - return - imageStream = io.BytesIO(imageBytes) - except Exception as decode_error: - logger.error(f"Failed to decode base64 image data: {str(decode_error)}") - return - else: + if not base64Data or not isinstance(base64Data, str) or len(base64Data.strip()) == 0: logger.error(f"Invalid base64Data: present={bool(base64Data)}, type={type(base64Data)}, length={len(base64Data) if base64Data else 0}") return + + try: + imageBytes = base64.b64decode(base64Data) + if len(imageBytes) == 0: + logger.error("Decoded image bytes are empty") + return + imageStream = io.BytesIO(imageBytes) + except Exception as decode_error: + logger.error(f"Failed to decode base64 image data: {str(decode_error)}") + return + + # Get image dimensions + try: + from PIL import Image as PILImage + pilImage = PILImage.open(imageStream) + imgWidth, imgHeight = pilImage.size - # Get image dimensions - try: - from PIL import Image as PILImage - pilImage = PILImage.open(imageStream) - imgWidth, imgHeight = pilImage.size - - # Scale to fit available space (max 90% of slide for better visibility) - # Convert PIL pixels to PowerPoint points (1 inch = 72 points, typical screen DPI = 96) - # Conversion: pixels * (72/96) = points - imgWidthPoints = imgWidth * (72.0 / 96.0) - imgHeightPoints = imgHeight * (72.0 / 96.0) - - maxWidth = availableWidth * 0.9 - maxHeight = availableHeight * 0.9 - - scale = min(maxWidth / imgWidthPoints, maxHeight / imgHeightPoints, 1.0) - finalWidth = imgWidthPoints * scale - finalHeight = imgHeightPoints * scale - - # Center image - left = (slideWidth - finalWidth) / 2 - top = titleHeight + (availableHeight - finalHeight) / 2 - - imageStream.seek(0) - except Exception: - # Fallback: use default size - finalWidth = Inches(6) - finalHeight = Inches(4.5) - left = (slideWidth - finalWidth) / 2 - top = titleHeight + Inches(1) - imageStream.seek(0) + # Scale to fit available space (max 90% of slide for better visibility) + # Convert PIL pixels to PowerPoint points (1 inch = 72 points, typical screen DPI = 96) + # Conversion: pixels * (72/96) = points + imgWidthPoints = imgWidth * (72.0 / 96.0) + imgHeightPoints = imgHeight * (72.0 / 96.0) - # Add image to slide - try: + maxWidth = availableWidth * 0.9 + maxHeight = availableHeight * 0.9 + + scale = min(maxWidth / imgWidthPoints, maxHeight / imgHeightPoints, 1.0) + finalWidth = imgWidthPoints * scale + finalHeight = imgHeightPoints * scale + + # Center image + left = (slideWidth - finalWidth) / 2 + top = titleHeight + (availableHeight - finalHeight) / 2 + + imageStream.seek(0) + except Exception: + # Fallback: use default size + finalWidth = Inches(6) + finalHeight = Inches(4.5) + left = (slideWidth - finalWidth) / 2 + top = titleHeight + Inches(1) + imageStream.seek(0) + + # Add image to slide + try: + slide.shapes.add_picture(imageStream, left, top, width=finalWidth, height=finalHeight) + except Exception as add_error: + # If add_picture fails, try with explicit format + imageStream.seek(0) + # Ensure we have valid image data + if len(imageBytes) > 0: slide.shapes.add_picture(imageStream, left, top, width=finalWidth, height=finalHeight) - except Exception as add_error: - # If add_picture fails, try with explicit format - imageStream.seek(0) - # Ensure we have valid image data - if len(imageBytes) > 0: - slide.shapes.add_picture(imageStream, left, top, width=finalWidth, height=finalHeight) - else: - raise Exception(f"Empty image data: {add_error}") - - # Add caption if available - caption = img.get("caption") or img.get("altText") - if caption and caption != "Image": - # Add text box below image - captionTop = top + finalHeight + Inches(0.2) - captionBox = slide.shapes.add_textbox( - Inches(1), - captionTop, - slideWidth - Inches(2), - Inches(0.5) - ) - captionFrame = captionBox.text_frame - captionFrame.text = caption - captionFrame.paragraphs[0].font.size = Pt(12) - captionFrame.paragraphs[0].font.italic = True - captionFrame.paragraphs[0].alignment = PP_ALIGN.CENTER + else: + raise Exception(f"Empty image data: {add_error}") + + # Add caption if available + caption = img.get("caption") or img.get("altText") + if caption and caption != "Image": + # Add text box below image + captionTop = top + finalHeight + Inches(0.2) + captionBox = slide.shapes.add_textbox( + Inches(1), + captionTop, + slideWidth - Inches(2), + Inches(0.5) + ) + captionFrame = captionBox.text_frame + captionFrame.text = caption + captionFrame.paragraphs[0].font.size = Pt(12) + captionFrame.paragraphs[0].font.italic = True + captionFrame.paragraphs[0].alignment = PP_ALIGN.CENTER else: # Multiple images: arrange in grid cols = 2 if len(images) <= 4 else 3 @@ -1267,7 +1200,7 @@ JSON ONLY. NO OTHER TEXT.""" import traceback logger.error(f"Traceback: {traceback.format_exc()}") - def _addTableToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], top: float) -> None: + def _addTableToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], top: float, max_width: float = None) -> None: """Add a PowerPoint table to slide.""" try: from pptx.util import Inches, Pt @@ -1286,25 +1219,27 @@ JSON ONLY. NO OTHER TEXT.""" return # Calculate table dimensions - num_cols = len(headers) - num_rows = len(rows) + 1 # +1 for header row + num_cols = int(len(headers)) # Ensure integer + num_rows = int(len(rows) + 1) # +1 for header row, ensure integer left = Inches(0.5) # Get presentation from stored reference or slide if hasattr(self, '_currentPresentation'): prs = self._currentPresentation else: prs = slide.presentation - width = prs.slide_width - Inches(1) + width = max_width if max_width is not None else (prs.slide_width - Inches(1)) row_height = Inches(0.4) - # Create table - table_shape = slide.shapes.add_table(num_rows, num_cols, left, top, width, row_height * num_rows) + # Create table - ensure all parameters are proper types + table_height = row_height * num_rows + table_shape = slide.shapes.add_table(num_rows, num_cols, left, top, width, table_height) table = table_shape.table - # Set column widths - col_width = width / num_cols + # Set column widths - width is in EMU, divide evenly + # python-pptx expects EMU values (914400 EMU = 1 inch) + col_width_emu = int(width) // num_cols # Ensure integer division for EMU for col_idx in range(num_cols): - table.columns[col_idx].width = col_width + table.columns[col_idx].width = col_width_emu # Add headers with styling header_style = styles.get("table_header", {}) @@ -1314,20 +1249,33 @@ JSON ONLY. NO OTHER TEXT.""" for col_idx, header in enumerate(headers): cell = table.cell(0, col_idx) - cell.text = str(header) + # Clear existing text and set new text + cell.text_frame.clear() + cell.text = str(header) if header else "" + + # Ensure paragraph exists + if len(cell.text_frame.paragraphs) == 0: + cell.text_frame.add_paragraph() + + # Apply styling cell.fill.solid() cell.fill.fore_color.rgb = RGBColor(*header_bg_color) - cell.text_frame.paragraphs[0].font.bold = header_style.get("bold", True) - cell.text_frame.paragraphs[0].font.size = Pt(header_font_size) - cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*header_text_color) + para = cell.text_frame.paragraphs[0] + para.font.bold = header_style.get("bold", True) + para.font.size = Pt(header_font_size) + para.font.color.rgb = RGBColor(*header_text_color) align = header_style.get("align", "center") if align == "left": - cell.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT + para.alignment = PP_ALIGN.LEFT elif align == "right": - cell.text_frame.paragraphs[0].alignment = PP_ALIGN.RIGHT + para.alignment = PP_ALIGN.RIGHT else: - cell.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER + para.alignment = PP_ALIGN.CENTER + + # Ensure text is set on paragraph + if not para.text: + para.text = str(header) if header else "" # Add data rows with styling cell_style = styles.get("table_cell", {}) @@ -1338,25 +1286,38 @@ JSON ONLY. NO OTHER TEXT.""" for row_idx, row_data in enumerate(rows, 1): for col_idx, cell_data in enumerate(row_data[:num_cols]): cell = table.cell(row_idx, col_idx) - cell.text = str(cell_data) + # Clear existing text and set new text + cell.text_frame.clear() + cell.text = str(cell_data) if cell_data is not None else "" + + # Ensure paragraph exists + if len(cell.text_frame.paragraphs) == 0: + cell.text_frame.add_paragraph() + + # Apply styling cell.fill.solid() cell.fill.fore_color.rgb = RGBColor(*cell_bg_color) - cell.text_frame.paragraphs[0].font.size = Pt(cell_font_size) - cell.text_frame.paragraphs[0].font.bold = cell_style.get("bold", False) - cell.text_frame.paragraphs[0].font.color.rgb = RGBColor(*cell_text_color) + para = cell.text_frame.paragraphs[0] + para.font.size = Pt(cell_font_size) + para.font.bold = cell_style.get("bold", False) + para.font.color.rgb = RGBColor(*cell_text_color) align = cell_style.get("align", "left") if align == "center": - cell.text_frame.paragraphs[0].alignment = PP_ALIGN.CENTER + para.alignment = PP_ALIGN.CENTER elif align == "right": - cell.text_frame.paragraphs[0].alignment = PP_ALIGN.RIGHT + para.alignment = PP_ALIGN.RIGHT else: - cell.text_frame.paragraphs[0].alignment = PP_ALIGN.LEFT + para.alignment = PP_ALIGN.LEFT + + # Ensure text is set on paragraph + if not para.text: + para.text = str(cell_data) if cell_data is not None else "" except Exception as e: logger.warning(f"Error adding table to slide: {str(e)}") - def _addBulletListToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None: + def _addBulletListToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame, font_size_multiplier: float = 1.0) -> None: """Add bullet list to slide text frame.""" try: from pptx.util import Pt @@ -1373,31 +1334,91 @@ JSON ONLY. NO OTHER TEXT.""" return list_style = styles.get("bullet_list", {}) - for item in items: - p = text_frame.add_paragraph() - if isinstance(item, dict): - p.text = item.get("text", "") - else: - p.text = str(item) - - p.level = 0 - p.font.size = Pt(list_style.get("font_size", 18)) - p.font.color.rgb = RGBColor(*self._getSafeColor(list_style.get("color", (47, 47, 47)))) - p.alignment = PP_ALIGN.LEFT # Left align bullet lists - p.space_before = Pt(6) - # Enable bullet points - set bullet type to enable bullets + base_font_size = list_style.get("font_size", 14) + calculated_size = max(10, int(base_font_size * font_size_multiplier)) # Minimum 10pt for readability + + logger.debug(f"Rendering bullet list with {len(items)} items") + + for idx, item in enumerate(items): try: - from pptx.enum.text import MSO_AUTO_NUMBER - p.paragraph_format.bullet.type = MSO_AUTO_NUMBER.BULLET - except (ImportError, AttributeError): - # Fallback: bullets are usually enabled by default when level is set - # Just ensure level is set (already done above) - pass + # Get text content first + if isinstance(item, dict): + item_text = item.get("text", "") + else: + item_text = str(item) + + # Skip empty items + if not item_text or len(item_text.strip()) == 0: + logger.debug(f"Skipping empty bullet item {idx}") + continue + + # Create new paragraph for each bullet item + p = text_frame.add_paragraph() + + # Set level to 1 for bullet points BEFORE setting text + # In python-pptx, setting level > 0 should automatically enable bullets + p.level = 1 + + # Set text content + p.text = item_text + + # Apply formatting first + p.font.size = Pt(calculated_size) + p.font.color.rgb = RGBColor(*self._getSafeColor(list_style.get("color", (47, 47, 47)))) + p.alignment = PP_ALIGN.LEFT # Left align bullet lists + p.space_before = Pt(2) # Small spacing before + p.space_after = Pt(2) # Small spacing after + + # In python-pptx, setting level > 0 should enable bullets automatically + # However, some versions may not support paragraph_format, so we'll use manual bullets as fallback + # Always add manual bullet character to ensure visibility + if not (p.text.startswith('•') or p.text.startswith('-') or p.text.startswith('*') or p.text.startswith('◦')): + p.text = '• ' + p.text + logger.debug(f"Added manual bullet character to item {idx}") + + # Set proper indentation for multiline bullets (hanging indent) + # For multiline bullets: bullet at left margin, text indented, wrapped lines align with text + try: + # Try accessing paragraph_format - it may not exist in all python-pptx versions + if hasattr(p, 'paragraph_format'): + pf = p.paragraph_format + # Left indent: indents the entire paragraph (bullet + text) + pf.left_indent = Pt(18) + # First line indent: negative value creates hanging indent + # This brings the bullet back to the left while keeping text indented + pf.first_line_indent = Pt(-18) # Negative to create hanging indent + logger.debug(f"Set hanging indent for bullet item {idx}") + else: + # Try via _element if paragraph_format not available + try: + from pptx.util import Pt as PtUtil + pPr = p._element.get_or_add_pPr() + # Set left margin (indents entire paragraph) + pPr.left_margin = PtUtil(18) + # Set first line indent (negative for hanging indent) + pPr.first_line_indent = PtUtil(-18) + logger.debug(f"Set hanging indent via XML for bullet item {idx}") + except Exception as xml_error: + logger.debug(f"Could not set hanging indent via XML: {str(xml_error)}") + # Indentation is optional, continue without it + pass + except Exception as indent_error: + logger.debug(f"Could not set indent for item {idx}: {str(indent_error)}") + # Continue without indent - bullets will still show, but multiline won't be properly indented + + logger.debug(f"Successfully added bullet item {idx}: '{item_text[:50]}...'") + + except Exception as item_error: + logger.error(f"Error adding bullet item {idx}: {str(item_error)}", exc_info=True) + # Continue with next item even if one fails + continue + + logger.debug(f"Completed rendering bullet list, added {len(text_frame.paragraphs)} paragraphs") except Exception as e: logger.warning(f"Error adding bullet list to slide: {str(e)}") - def _addHeadingToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None: + def _addHeadingToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame, font_size_multiplier: float = 1.0) -> None: """Add heading to slide text frame.""" try: from pptx.util import Pt @@ -1414,17 +1435,32 @@ JSON ONLY. NO OTHER TEXT.""" if text: p = text_frame.add_paragraph() p.text = text - p.level = min(level - 1, 2) # PowerPoint supports 0-2 levels + # Headings should be level 0 (no indentation) regardless of heading level + p.level = 0 heading_style = styles.get("heading", {}) - p.font.size = Pt(heading_style.get("font_size", 32)) + # Different font sizes for different heading levels + if level == 1: + base_font_size = heading_style.get("font_size", 28) # Largest for H1 + elif level == 2: + base_font_size = heading_style.get("font_size", 22) # Medium for H2 + elif level == 3: + base_font_size = heading_style.get("font_size", 18) # Smaller for H3 + else: + base_font_size = heading_style.get("font_size", 16) # Default for H4+ + + calculated_size = max(12, int(base_font_size * font_size_multiplier)) # Minimum 12pt for headings + p.font.size = Pt(calculated_size) p.font.bold = heading_style.get("bold", True) - p.font.color.rgb = RGBColor(*self._getSafeColor(heading_style.get("color", (47, 47, 47)))) + p.font.color.rgb = RGBColor(*self._getSafeColor(heading_style.get("color", (31, 78, 121)))) + # Add spacing before and after headings + p.space_before = Pt(12 if level == 1 else 8) # More space before H1 + p.space_after = Pt(6) # Space after heading except Exception as e: logger.warning(f"Error adding heading to slide: {str(e)}") - def _addParagraphToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None: + def _addParagraphToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame, font_size_multiplier: float = 1.0) -> None: """Add paragraph to slide text frame.""" try: from pptx.util import Pt @@ -1443,12 +1479,28 @@ JSON ONLY. NO OTHER TEXT.""" if text: p = text_frame.add_paragraph() p.text = text + # Explicitly set level to 0 for regular paragraphs (not bullets) + p.level = 0 + + # Ensure no bullet formatting + try: + if hasattr(p, 'paragraph_format'): + p.paragraph_format.bullet.type = None + except (AttributeError, TypeError): + pass paragraph_style = styles.get("paragraph", {}) - p.font.size = Pt(paragraph_style.get("font_size", 18)) + base_font_size = paragraph_style.get("font_size", 14) # Smaller default for better readability + calculated_size = max(10, int(base_font_size * font_size_multiplier)) # Minimum 10pt for readability + p.font.size = Pt(calculated_size) p.font.bold = paragraph_style.get("bold", False) p.font.color.rgb = RGBColor(*self._getSafeColor(paragraph_style.get("color", (47, 47, 47)))) + # Add proper spacing + p.space_before = Pt(6) # Space before paragraph + p.space_after = Pt(6) # Space after paragraph + p.line_spacing = 1.2 # Line spacing for readability + align = paragraph_style.get("align", "left") if align == "center": p.alignment = PP_ALIGN.CENTER @@ -1460,7 +1512,7 @@ JSON ONLY. NO OTHER TEXT.""" except Exception as e: logger.warning(f"Error adding paragraph to slide: {str(e)}") - def _addCodeBlockToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame) -> None: + def _addCodeBlockToSlide(self, slide, element: Dict[str, Any], styles: Dict[str, Any], text_frame, font_size_multiplier: float = 1.0) -> None: """Add code block to slide text frame.""" try: from pptx.util import Pt @@ -1477,13 +1529,15 @@ JSON ONLY. NO OTHER TEXT.""" if code: code_style = styles.get("code_block", {}) code_font = code_style.get("font", "Courier New") - code_font_size = code_style.get("font_size", 9) + base_code_font_size = code_style.get("font_size", 9) + code_font_size = max(6, int(base_code_font_size * font_size_multiplier)) # Minimum 6pt for code code_color = self._getSafeColor(code_style.get("color", (47, 47, 47))) p = text_frame.add_paragraph() if language: p.text = f"Code ({language}):" p.font.bold = True + p.font.size = Pt(code_font_size) p = text_frame.add_paragraph() p.text = code @@ -1498,3 +1552,593 @@ JSON ONLY. NO OTHER TEXT.""" """Format current timestamp for presentation generation.""" # datetime and UTC are already imported at module level return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + + def _renderSlideContentWithFrames(self, slide, slide_sections: List[Dict[str, Any]], slide_images: List[Dict[str, Any]], styles: Dict[str, Any], prs) -> None: + """ + Organize slide content into frames for better layout. + Groups content by type (images, bullet lists, paragraphs, tables) and renders each in appropriately sized frames. + """ + try: + from pptx.util import Inches, Pt + from pptx.enum.text import PP_ALIGN + from pptx.dml.color import RGBColor + + # Extract images from sections first + images_to_render = list(slide_images) if slide_images else [] + text_sections = [] + table_sections = [] + + for section in slide_sections: + section_type = section.get("content_type", "paragraph") + elements = section.get("elements", []) + + if not elements: + # Skip empty sections + continue + + # Extract images from all sections + section_has_images = False + for element in elements: + if isinstance(element, dict) and element.get("type") == "image": + content = element.get("content", {}) + base64Data = None + + # Handle different content formats + if isinstance(content, dict): + base64Data = content.get("base64Data") + altText = content.get("altText", "Image") + caption = content.get("caption", "") + elif isinstance(content, str): + # If content is a string, it might be base64 data directly + # Check if it looks like base64 + if len(content) > 100 and all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=" for c in content[:100]): + base64Data = content + altText = "Image" + caption = "" + else: + # Not base64, skip + continue + else: + # Try to get base64Data directly from element + base64Data = element.get("base64Data") + altText = element.get("altText", "Image") + caption = element.get("caption", "") + + if base64Data: + images_to_render.append({ + "base64Data": base64Data, + "altText": altText, + "caption": caption + }) + section_has_images = True + + # Skip image-only sections (they're already added to images_to_render) + if section_type == "image" and section_has_images: + continue + + # Categorize sections (excluding image elements) + has_table = False + non_image_elements = [] + + for element in elements: + if isinstance(element, dict): + element_type = element.get("type", "") + # Skip image elements when categorizing + if element_type == "image": + continue + if element_type == "table" or section_type == "table": + has_table = True + non_image_elements.append(element) + + # Only add sections that have non-image content + if non_image_elements: + if has_table: + # Create a copy of section without image elements for table rendering + table_section = { + **section, + "elements": non_image_elements + } + table_sections.append(table_section) + else: + # Create a copy of section without image elements for text rendering + text_section = { + **section, + "elements": non_image_elements + } + text_sections.append(text_section) + + # Calculate layout dimensions + title_height = Inches(1.5) + available_height = prs.slide_height - title_height - Inches(0.5) # Title + margin + available_width = prs.slide_width - Inches(1) # Margins + margin = Inches(0.5) + + current_y = title_height + Inches(0.3) + + # Determine layout strategy based on content types + has_images = len(images_to_render) > 0 + has_tables = len(table_sections) > 0 + has_text = len(text_sections) > 0 + + # Layout 1: Images + Text (horizontal split for landscape) + if has_images and has_text and not has_tables: + # Horizontal split: images on left, text on right (landscape format) + img_width = available_width * 0.48 + text_width = available_width * 0.48 + img_left = margin + text_left = margin + img_width + Inches(0.2) + + # Render images in left column (full height) + if images_to_render: + img_height = available_height - Inches(0.2) + self._addImagesToSlideInFrame(slide, images_to_render, styles, img_left, current_y, img_width, img_height) + + # Render text in right column (full height, adaptive font size) + if text_sections: + text_height = available_height - Inches(0.2) + self._renderTextSectionsInFrame(slide, text_sections, styles, text_left, current_y, text_width, text_height, adaptiveFontSize=True) + + # Layout 2: Tables + Text (horizontal split for landscape) + elif has_tables and has_text: + # Horizontal split: tables on left, text on right (landscape format) + table_width = available_width * 0.48 + text_width = available_width * 0.48 + table_left = margin + text_left = margin + table_width + Inches(0.2) + + # Render tables in left column (full height) + table_y = current_y + for table_section in table_sections: + elements = table_section.get("elements", []) + for element in elements: + if isinstance(element, dict) and element.get("type") == "table": + try: + self._addTableToSlide(slide, element, styles, table_y, max_width=table_width) + # Calculate actual table height + content = element.get("content", {}) + if isinstance(content, dict): + rows = content.get("rows", []) + num_rows = len(rows) + 1 # +1 for header + actual_height = Inches(0.4) * num_rows + table_y += actual_height + Inches(0.15) + else: + table_y += Inches(2) + except Exception as table_error: + logger.error(f"Error rendering table: {str(table_error)}") + # Continue with next table + break + + # Render text in right column (full height, adaptive font size) + if text_sections: + text_height = available_height - Inches(0.2) + self._renderTextSectionsInFrame(slide, text_sections, styles, text_left, current_y, text_width, text_height, adaptiveFontSize=True) + + # Layout 3: Images + Tables + Text (horizontal split for landscape) + elif has_images and has_tables and has_text: + # Horizontal split: Images (left), Tables (middle), Text (right) + img_width = available_width * 0.31 + table_width = available_width * 0.31 + text_width = available_width * 0.31 + img_left = margin + table_left = margin + img_width + Inches(0.15) + text_left = margin + img_width + table_width + Inches(0.3) + + # Render images in left column (full height) + if images_to_render: + img_height = available_height - Inches(0.2) + self._addImagesToSlideInFrame(slide, images_to_render, styles, img_left, current_y, img_width, img_height) + + # Render tables in middle column (full height) + table_y = current_y + for table_section in table_sections: + elements = table_section.get("elements", []) + for element in elements: + if isinstance(element, dict) and element.get("type") == "table": + try: + self._addTableToSlide(slide, element, styles, table_y, max_width=table_width) + content = element.get("content", {}) + if isinstance(content, dict): + rows = content.get("rows", []) + num_rows = len(rows) + 1 + actual_height = Inches(0.4) * num_rows + table_y += actual_height + Inches(0.15) + else: + table_y += Inches(2) + except Exception as table_error: + logger.error(f"Error rendering table: {str(table_error)}") + break + + # Render text in right column (full height, adaptive font size) + if text_sections: + text_height = available_height - Inches(0.2) + self._renderTextSectionsInFrame(slide, text_sections, styles, text_left, current_y, text_width, text_height, adaptiveFontSize=True) + + # Layout 4: Images only + elif has_images and not has_text and not has_tables: + img_width = available_width * 0.8 + img_height = available_height * 0.8 + img_left = (available_width - img_width) / 2 + margin + self._addImagesToSlideInFrame(slide, images_to_render, styles, img_left, current_y, img_width, img_height) + + # Layout 5: Text only (default, adaptive font size) + elif has_text and not has_images and not has_tables: + text_height = available_height - Inches(0.2) + self._renderTextSectionsInFrame(slide, text_sections, styles, margin, current_y, available_width, text_height, adaptiveFontSize=True) + + # Layout 6: Tables only + elif has_tables and not has_images and not has_text: + table_height = available_height / max(len(table_sections), 1) + table_width = available_width + for table_section in table_sections: + elements = table_section.get("elements", []) + for element in elements: + if isinstance(element, dict) and element.get("type") == "table": + try: + self._addTableToSlide(slide, element, styles, current_y, max_width=table_width) + # Calculate actual table height + content = element.get("content", {}) + if isinstance(content, dict): + rows = content.get("rows", []) + num_rows = len(rows) + 1 # +1 for header + actual_height = min(Inches(0.4) * num_rows, table_height) + current_y += actual_height + Inches(0.2) + else: + current_y += table_height + Inches(0.2) + except Exception as table_error: + logger.error(f"Error rendering table: {str(table_error)}") + # Continue with next table + break + + except Exception as e: + logger.error(f"Error rendering slide content with frames: {str(e)}") + # Fallback to simple rendering + try: + content_shape = slide.placeholders[1] + text_frame = content_shape.text_frame + text_frame.clear() + except (AttributeError, IndexError): + from pptx.util import Inches + left = Inches(0.5) + top = Inches(1.5) + width = prs.slide_width - Inches(1) + height = prs.slide_height - top - Inches(0.5) + textbox = slide.shapes.add_textbox(left, top, width, height) + text_frame = textbox.text_frame + text_frame.word_wrap = True + + # Simple fallback rendering + for section in slide_sections: + self._renderSectionToTextFrame(slide, section, styles, text_frame, font_size_multiplier=1.0) + + def _renderTextSectionsInFrame(self, slide, text_sections: List[Dict[str, Any]], styles: Dict[str, Any], left: float, top: float, width: float, height: float, adaptiveFontSize: bool = False) -> None: + """Render text sections (paragraphs, lists, headings) in a text frame.""" + try: + from pptx.util import Inches, Pt + from pptx.enum.text import PP_ALIGN + from pptx.dml.color import RGBColor + + # Calculate total text length for adaptive font sizing + total_text_length = 0 + if adaptiveFontSize: + for section in text_sections: + elements = section.get("elements", []) + for element in elements: + if isinstance(element, dict): + element_type = element.get("type", "") + if element_type in ["paragraph", "bullet_list", "list", "heading"]: + content = element.get("content", "") + if isinstance(content, dict): + if "text" in content: + total_text_length += len(str(content["text"])) + elif "items" in content: + for item in content.get("items", []): + total_text_length += len(str(item)) + elif isinstance(content, str): + total_text_length += len(content) + + # Calculate adaptive font size multiplier based on text length and frame size + font_size_multiplier = 1.0 + if adaptiveFontSize and total_text_length > 0: + try: + # More accurate calculation: estimate characters per line based on average character width + # Average character width is approximately 0.6 * font_size in points + # For 14pt font, average char width ≈ 8.4pt + avg_char_width_pt = 8.4 # Approximate for 14pt font + chars_per_line = max(1, int(float(width) / avg_char_width_pt)) + + # Estimate lines needed + lines_needed = total_text_length / max(chars_per_line, 1) + + # Available lines based on height (line height ≈ 1.2 * font_size) + line_height_pt = 16.8 # Approximate for 14pt font with 1.2 spacing + available_lines = max(1, int(float(height) / line_height_pt)) + + if available_lines > 0 and lines_needed > available_lines: + # More aggressive scaling for long texts + # Calculate exact scale needed, then add 10% buffer + scale_needed = available_lines / lines_needed + font_size_multiplier = scale_needed * 0.9 # 10% buffer + # Allow scaling down to 50% for very long texts (minimum readable) + font_size_multiplier = max(0.5, min(1.0, font_size_multiplier)) + elif lines_needed <= available_lines * 0.7: + # If text is much shorter than available space, can use slightly larger font + font_size_multiplier = min(1.1, (available_lines / lines_needed) * 0.8) + except (ZeroDivisionError, ValueError, TypeError) as calc_error: + logger.debug(f"Font size calculation error: {str(calc_error)}") + # Fallback to default if calculation fails + font_size_multiplier = 1.0 + + textbox = slide.shapes.add_textbox(left, top, width, height) + text_frame = textbox.text_frame + text_frame.word_wrap = True + text_frame.auto_size = None # Disable auto-size for fixed frame + # Ensure text frame can display bullets + text_frame.margin_left = Pt(0) + text_frame.margin_right = Pt(0) + text_frame.margin_top = Pt(0) + text_frame.margin_bottom = Pt(0) + + # Pass font size multiplier to rendering methods + for section in text_sections: + self._renderSectionToTextFrame(slide, section, styles, text_frame, font_size_multiplier) + + except Exception as e: + logger.warning(f"Error rendering text sections in frame: {str(e)}") + + def _renderSectionToTextFrame(self, slide, section: Dict[str, Any], styles: Dict[str, Any], text_frame, font_size_multiplier: float = 1.0) -> None: + """Render a single section to a text frame.""" + try: + from pptx.util import Pt + from pptx.enum.text import PP_ALIGN + from pptx.dml.color import RGBColor + + section_type = section.get("content_type", "paragraph") + elements = section.get("elements", []) + + if not elements: + return + + for element in elements: + if not isinstance(element, dict): + continue + + element_type = element.get("type", "") + if not element_type: + element_type = section_type + + # Skip images - handled separately + if element_type == "image": + continue + + if element_type == "bullet_list" or element_type == "list": + self._addBulletListToSlide(slide, element, styles, text_frame, font_size_multiplier) + elif element_type == "heading": + self._addHeadingToSlide(slide, element, styles, text_frame, font_size_multiplier) + elif element_type == "paragraph": + self._addParagraphToSlide(slide, element, styles, text_frame, font_size_multiplier) + elif element_type == "code_block" or element_type == "code": + self._addCodeBlockToSlide(slide, element, styles, text_frame, font_size_multiplier) + elif element_type == "extracted_text": + content = element.get("content", "") + source = element.get("source", "") + if content: + paragraph_style = styles.get("paragraph", {}) + p = text_frame.add_paragraph() + p.text = content + base_font_size = paragraph_style.get("font_size", 18) + p.font.size = Pt(int(base_font_size * font_size_multiplier)) + p.font.bold = paragraph_style.get("bold", False) + p.font.color.rgb = RGBColor(*self._getSafeColor(paragraph_style.get("color", (47, 47, 47)))) + p.alignment = PP_ALIGN.LEFT + if source: + p.add_run(f" (Source: {source})").font.italic = True + elif element_type == "reference": + label = element.get("label", "Reference") + p = text_frame.add_paragraph() + p.text = f"[Reference: {label}]" + p.font.italic = True + p.alignment = PP_ALIGN.LEFT + else: + # Fallback to paragraph + content = element.get("content", "") + if isinstance(content, dict): + text = content.get("text", "") + elif isinstance(content, str): + text = content + else: + text = "" + + if text: + self._addParagraphToSlide(slide, element, styles, text_frame, font_size_multiplier=1.0) + + except Exception as e: + logger.warning(f"Error rendering section to text frame: {str(e)}") + + def _addImagesToSlideInFrame(self, slide, images: List[Dict[str, Any]], styles: Dict[str, Any], left: float, top: float, width: float, height: float) -> None: + """Add images to slide within a specific frame area.""" + try: + from pptx.util import Inches, Pt + from pptx.enum.text import PP_ALIGN + import base64 + import io + + if not images: + logger.debug("No images to render in frame") + return + + logger.info(f"Rendering {len(images)} image(s) in frame at ({left}, {top}), size ({width}, {height})") + + # Calculate image dimensions within frame + if len(images) == 1: + # Single image: fit to frame + img = images[0] + base64Data = img.get("base64Data") + + if not base64Data: + logger.warning("Image has no base64Data") + return + + # Clean base64 data (remove data URI prefix if present) + if isinstance(base64Data, str): + if base64Data.startswith("data:image/"): + # Extract base64 from data URI + base64Data = base64Data.split(",", 1)[1] + # Remove any whitespace + base64Data = base64Data.strip() + + try: + # Decode base64 + imageBytes = base64.b64decode(base64Data, validate=True) + if len(imageBytes) == 0: + logger.error("Decoded image bytes are empty") + return + + imageStream = io.BytesIO(imageBytes) + + # Get image dimensions using PIL + imgWidth, imgHeight = None, None + try: + from PIL import Image as PILImage + pilImage = PILImage.open(imageStream) + imgWidth, imgHeight = pilImage.size + imageStream.seek(0) # Reset stream for PowerPoint + + # Validate image dimensions - ensure they're reasonable + if imgWidth <= 1 or imgHeight <= 1: + logger.warning(f"Image has invalid dimensions: {imgWidth}x{imgHeight}, using default size") + imgWidth, imgHeight = 800, 600 + imageStream.seek(0) + elif imgWidth < 100 or imgHeight < 100: + logger.warning(f"Image dimensions very small: {imgWidth}x{imgHeight}, may appear tiny") + except ImportError: + logger.warning("PIL not available, using default image size") + imgWidth, imgHeight = 800, 600 # Default dimensions + except Exception as pil_error: + logger.warning(f"Error getting image dimensions with PIL: {str(pil_error)}, using default size") + imgWidth, imgHeight = 800, 600 + imageStream.seek(0) + + # Ensure we have valid dimensions + if not imgWidth or not imgHeight or imgWidth <= 1 or imgHeight <= 1: + logger.warning("Invalid image dimensions, using default 800x600") + imgWidth, imgHeight = 800, 600 + + # Scale to fit frame while maintaining aspect ratio + # width and height parameters are already in Inches (from pptx.util.Inches) + # Convert PIL pixel dimensions to Inches (assuming 96 DPI for PIL images) + imgWidthInches = Inches(imgWidth / 96.0) + imgHeightInches = Inches(imgHeight / 96.0) + + # Calculate scale to fit within frame + # Inches objects support division, result is a float + try: + scale_width = width / imgWidthInches if imgWidthInches > 0 else 1.0 + scale_height = height / imgHeightInches if imgHeightInches > 0 else 1.0 + scale = min(scale_width, scale_height, 1.0) # Don't scale up, only down + + finalWidth = imgWidthInches * scale + finalHeight = imgHeightInches * scale + + # Ensure minimum size (at least 1 inch) to prevent tiny rendering + minSize = Inches(1) + if finalWidth < minSize or finalHeight < minSize: + # Use minimum size while maintaining aspect ratio + min_scale = max(minSize / imgWidthInches if imgWidthInches > 0 else 1.0, + minSize / imgHeightInches if imgHeightInches > 0 else 1.0) + finalWidth = max(minSize, imgWidthInches * min_scale) + finalHeight = max(minSize, imgHeightInches * min_scale) + + # Ensure we don't exceed frame bounds + if finalWidth > width: + finalWidth = width + finalHeight = imgHeightInches * (width / imgWidthInches) if imgWidthInches > 0 else finalHeight + if finalHeight > height: + finalHeight = height + finalWidth = imgWidthInches * (height / imgHeightInches) if imgHeightInches > 0 else finalWidth + except (ZeroDivisionError, TypeError, AttributeError) as calc_error: + logger.warning(f"Error calculating image size: {str(calc_error)}, using frame size") + finalWidth = width * 0.9 # Use 90% of frame width + finalHeight = height * 0.9 # Use 90% of frame height + + # Center in frame + frame_left = left + (width - finalWidth) / 2 + frame_top = top + (height - finalHeight) / 2 + + # Add image to slide + imageStream.seek(0) + slide.shapes.add_picture(imageStream, frame_left, frame_top, width=finalWidth, height=finalHeight) + logger.info(f"Successfully added image to slide at ({frame_left}, {frame_top}), size ({finalWidth}, {finalHeight})") + + # Add caption if available + caption = img.get("caption") or img.get("altText") + if caption and caption != "Image": + captionTop = frame_top + finalHeight + Inches(0.1) + captionBox = slide.shapes.add_textbox(left, captionTop, width, Inches(0.4)) + captionFrame = captionBox.text_frame + captionFrame.text = caption + captionFrame.paragraphs[0].font.size = Pt(10) + captionFrame.paragraphs[0].font.italic = True + captionFrame.paragraphs[0].alignment = PP_ALIGN.CENTER + except base64.binascii.Error as b64_error: + logger.error(f"Invalid base64 data: {str(b64_error)}") + except Exception as img_error: + logger.error(f"Error adding image to frame: {str(img_error)}", exc_info=True) + else: + # Multiple images: grid layout + cols = 2 if len(images) <= 4 else 3 + rows = (len(images) + cols - 1) // cols + imgWidth = (width - Inches(0.2) * (cols - 1)) / cols + imgHeight = (height - Inches(0.2) * (rows - 1)) / rows + + for idx, img in enumerate(images): + base64Data = img.get("base64Data") + if not base64Data: + logger.warning(f"Image {idx} has no base64Data") + continue + + # Clean base64 data + if isinstance(base64Data, str): + if base64Data.startswith("data:image/"): + base64Data = base64Data.split(",", 1)[1] + base64Data = base64Data.strip().replace("\n", "").replace("\r", "").replace("\t", "").replace(" ", "") + + row = idx // cols + col = idx % cols + img_left = left + col * (imgWidth + Inches(0.2)) + img_top = top + row * (imgHeight + Inches(0.2)) + + try: + imageBytes = base64.b64decode(base64Data, validate=True) + if len(imageBytes) == 0: + logger.error(f"Decoded image {idx} bytes are empty") + continue + + imageStream = io.BytesIO(imageBytes) + + # Try to get dimensions for better scaling + try: + from PIL import Image as PILImage + pilImage = PILImage.open(imageStream) + imgW, imgH = pilImage.size + # Scale to fit grid cell while maintaining aspect ratio + scale = min(imgWidth / (imgW * (72.0 / 96.0)), imgHeight / (imgH * (72.0 / 96.0)), 1.0) + finalW = (imgW * (72.0 / 96.0)) * scale + finalH = (imgH * (72.0 / 96.0)) * scale + # Center in grid cell + cell_left = img_left + (imgWidth - finalW) / 2 + cell_top = img_top + (imgHeight - finalH) / 2 + imageStream.seek(0) + slide.shapes.add_picture(imageStream, cell_left, cell_top, width=finalW, height=finalH) + except (ImportError, Exception): + # Fallback: use grid cell size directly + imageStream.seek(0) + slide.shapes.add_picture(imageStream, img_left, img_top, width=imgWidth, height=imgHeight) + + logger.info(f"Successfully added image {idx+1}/{len(images)} to slide grid") + except base64.binascii.Error as b64_error: + logger.error(f"Invalid base64 data for image {idx}: {str(b64_error)}") + except Exception as img_error: + logger.error(f"Error adding image {idx} to frame: {str(img_error)}", exc_info=True) + + except Exception as e: + logger.error(f"Error adding images to slide frame: {str(e)}", exc_info=True) diff --git a/modules/services/serviceGeneration/renderers/rendererXlsx.py b/modules/services/serviceGeneration/renderers/rendererXlsx.py index c1992f94..24c620d2 100644 --- a/modules/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/services/serviceGeneration/renderers/rendererXlsx.py @@ -535,6 +535,45 @@ class RendererXlsx(BaseRenderer): self.logger.warning(f"AI styling failed: {str(e)}, using defaults") return defaultStyles + def _getSafeAlignment(self, alignValue: Any) -> str: + """Get safe alignment value for openpyxl. Valid values: 'left', 'general', 'distributed', 'fill', 'justify', 'center', 'right', 'centerContinuous'.""" + if not alignValue: + return "left" + + alignStr = str(alignValue).lower().strip() + + # Map common alignment values to openpyxl values + alignmentMap = { + "left": "left", + "right": "right", + "center": "center", + "centre": "center", + "general": "general", + "distributed": "distributed", + "fill": "fill", + "justify": "justify", + "centercontinuous": "centerContinuous", + "center-continuous": "centerContinuous", + "start": "left", + "end": "right", + "middle": "center" + } + + # Check direct mapping + if alignStr in alignmentMap: + return alignmentMap[alignStr] + + # Check if it contains alignment keywords + if "left" in alignStr or "start" in alignStr: + return "left" + elif "right" in alignStr or "end" in alignStr: + return "right" + elif "center" in alignStr or "centre" in alignStr or "middle" in alignStr: + return "center" + + # Default to left if unknown + return "left" + def _getSafeColor(self, colorValue: str, default: str = "FF000000") -> str: """Get a safe aRGB color value for Excel (without # prefix).""" if not isinstance(colorValue, str): @@ -603,30 +642,34 @@ class RendererXlsx(BaseRenderer): return sanitized[:31] def _generateSheetNamesFromContent(self, jsonContent: Dict[str, Any]) -> List[str]: - """Generate sheet names: each heading section creates a new tab.""" + """Generate sheet names: each heading level 1 (chapter) creates a new tab.""" sections = self._extractSections(jsonContent) # If no sections, create a single sheet if not sections: return ["Content"] - # Simple logic: each heading section creates a new tab + # Only heading level 1 (chapters) create new tabs sheetNames = [] for section in sections: if section.get("content_type") == "heading": - # Extract heading text from elements + # Extract heading text and level from elements elements = section.get("elements", []) if elements and isinstance(elements, list) and len(elements) > 0: headingElement = elements[0] content = headingElement.get("content", {}) if isinstance(content, dict): headingText = content.get("text", "") + level = content.get("level", 1) elif isinstance(content, str): headingText = content + level = 1 else: headingText = "" + level = 1 - if headingText: + # Only level 1 headings (chapters) create tabs + if headingText and level == 1: sanitized_name = self._sanitizeSheetName(headingText) # Ensure unique sheet names if sanitized_name not in sheetNames: @@ -639,7 +682,7 @@ class RendererXlsx(BaseRenderer): counter += 1 sheetNames.append(f"{base_name} ({counter})"[:31]) - # If no headings found, use document title + # If no level 1 headings found, use document title if not sheetNames: documentTitle = jsonContent.get("metadata", {}).get("title", "Document") sheetNames.append(self._sanitizeSheetName(documentTitle)) @@ -647,7 +690,7 @@ class RendererXlsx(BaseRenderer): return sheetNames def _populateExcelSheets(self, sheets: Dict[str, Any], jsonContent: Dict[str, Any], styles: Dict[str, Any]) -> None: - """Populate Excel sheets: each heading creates a new tab, all following content goes in that tab.""" + """Populate Excel sheets: each heading level 1 (chapter) creates a new tab, all following content goes in that tab.""" try: # Get the actual sheet names that were created (keys are lowercase) sheetNames = list(sheets.keys()) @@ -657,7 +700,7 @@ class RendererXlsx(BaseRenderer): sections = self._extractSections(jsonContent) - # Simple logic: iterate through sections, each heading creates a new tab + # Only heading level 1 (chapters) create new tabs currentSheetIndex = 0 currentSheet = None currentRow = 1 @@ -665,17 +708,28 @@ class RendererXlsx(BaseRenderer): for section in sections: contentType = section.get("content_type", "paragraph") - # Heading section: switch to next sheet + # Heading section: check if it's level 1 (chapter) to 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]] + # Extract level from heading element + elements = section.get("elements", []) + level = 1 # Default + if elements and isinstance(elements, list) and len(elements) > 0: + headingElement = elements[0] + content = headingElement.get("content", {}) + if isinstance(content, dict): + level = content.get("level", 1) + + # Only level 1 headings (chapters) create new tabs + if level == 1: + 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: @@ -695,7 +749,7 @@ class RendererXlsx(BaseRenderer): sheet['A1'] = sheetTitle title_style = styles.get("title", {}) sheet['A1'].font = Font(size=16, bold=True, color=self._getSafeColor(title_style.get("color", "FF1F4E79"))) - sheet['A1'].alignment = Alignment(horizontal=title_style.get("align", "left")) + sheet['A1'].alignment = Alignment(horizontal=self._getSafeAlignment(title_style.get("align", "left"))) # Get table data from elements (canonical JSON format) elements = section.get("elements", []) @@ -707,8 +761,13 @@ class RendererXlsx(BaseRenderer): headers = [] rows = [] else: - headers = content.get("headers", []) - rows = content.get("rows", []) + headers = content.get("headers") or [] + rows = content.get("rows") or [] + # Ensure headers and rows are lists + if not isinstance(headers, list): + headers = [] + if not isinstance(rows, list): + rows = [] else: headers = [] rows = [] @@ -770,11 +829,11 @@ class RendererXlsx(BaseRenderer): try: safe_color = self._getSafeColor(title_style["color"]) sheet['A1'].font = Font(size=title_style["font_size"], bold=title_style["bold"], color=safe_color) - sheet['A1'].alignment = Alignment(horizontal=title_style["align"]) + sheet['A1'].alignment = Alignment(horizontal=self._getSafeAlignment(title_style["align"])) except Exception as font_error: # Try with a safe color sheet['A1'].font = Font(size=title_style["font_size"], bold=title_style["bold"], color="FF000000") - sheet['A1'].alignment = Alignment(horizontal=title_style["align"]) + sheet['A1'].alignment = Alignment(horizontal=self._getSafeAlignment(title_style["align"])) # Generation info sheet['A3'] = "Generated:" @@ -892,6 +951,8 @@ class RendererXlsx(BaseRenderer): startRow = self._addHeadingToExcel(sheet, element, styles, startRow) elif element_type == "image": startRow = self._addImageToExcel(sheet, element, styles, startRow) + elif element_type == "code_block" or element_type == "code": + startRow = self._addCodeBlockToExcel(sheet, element, styles, startRow) else: # Fallback: if element_type not set, use section_type if section_type == "table": @@ -904,6 +965,8 @@ class RendererXlsx(BaseRenderer): startRow = self._addHeadingToExcel(sheet, element, styles, startRow) elif section_type == "image": startRow = self._addImageToExcel(sheet, element, styles, startRow) + elif section_type == "code_block" or section_type == "code": + startRow = self._addCodeBlockToExcel(sheet, element, styles, startRow) else: startRow = self._addParagraphToExcel(sheet, element, styles, startRow) @@ -943,9 +1006,16 @@ class RendererXlsx(BaseRenderer): content = element.get("content", {}) if not isinstance(content, dict): return startRow + headers = content.get("headers", []) rows = content.get("rows", []) + # Ensure headers and rows are lists + if not isinstance(headers, list): + headers = [] + if not isinstance(rows, list): + rows = [] + if not headers and not rows: return startRow @@ -965,60 +1035,95 @@ class RendererXlsx(BaseRenderer): 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" + # Apply styling with fallbacks - don't let styling errors prevent data rendering + try: + # Font styling + cell.font = Font( + bold=header_style.get("bold", True), + color=self._getSafeColor(header_style.get("text_color", "FF000000")) ) + except Exception: + # Fallback to default font if styling fails + try: + cell.font = Font(bold=True, color=self._getSafeColor("FF000000")) + except Exception: + pass # Continue even if font fails - # Alignment - cell.alignment = Alignment( - horizontal=header_style.get("align", "left"), - vertical="center" - ) + try: + # 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" + ) + except Exception: + pass # Continue without background color if it fails - # Border - cell.border = thin_border + try: + # Alignment + cell.alignment = Alignment( + horizontal=self._getSafeAlignment(header_style.get("align", "left")), + vertical="center" + ) + except Exception: + # Fallback to default alignment if it fails + try: + cell.alignment = Alignment(horizontal="left", vertical="center") + except Exception: + pass # Continue even if alignment fails + + try: + # Border + cell.border = thin_border + except Exception: + pass # Continue without border if it fails startRow += 1 # Add rows with formatting cell_style = styles.get("table_cell", {}) for row_data in rows: - # 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) + # 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 - # Font styling - if cell_style.get("text_color"): - cell.font = Font(color=self._getSafeColor(cell_style["text_color"])) + 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) + + # Apply styling with fallbacks - don't let styling errors prevent data rendering + try: + # Font styling + if cell_style.get("text_color"): + cell.font = Font(color=self._getSafeColor(cell_style["text_color"])) + except Exception: + pass # Continue without font color if it fails + + try: + # Alignment + cell.alignment = Alignment( + horizontal=self._getSafeAlignment(cell_style.get("align", "left")), + vertical="center" + ) + except Exception: + # Fallback to default alignment if it fails + try: + cell.alignment = Alignment(horizontal="left", vertical="center") + except Exception: + pass # Continue even if alignment fails + + try: + # Border + cell.border = thin_border + except Exception: + pass # Continue without border if it fails - # Alignment - cell.alignment = Alignment( - horizontal=cell_style.get("align", "left"), - vertical="center" - ) - - # Border - cell.border = thin_border - - startRow += 1 + startRow += 1 # Auto-adjust column widths for col in range(1, len(headers) + 1): @@ -1038,7 +1143,10 @@ class RendererXlsx(BaseRenderer): content = element.get("content", {}) if not isinstance(content, dict): return startRow - list_items = content.get("items", []) + list_items = content.get("items") or [] + # Ensure list_items is a list + if not isinstance(list_items, list): + list_items = [] list_style = styles.get("bullet_list", {}) for item in list_items: @@ -1199,6 +1307,52 @@ class RendererXlsx(BaseRenderer): errorCell = sheet.cell(row=startRow, column=1, value=errorMsg) errorCell.font = Font(color="FFFF0000", italic=True) # Red color return startRow + 1 + + def _addCodeBlockToExcel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], startRow: int) -> int: + """Add a code block element to Excel sheet. Expects nested content structure.""" + try: + # Extract from nested content structure + content = element.get("content", {}) + if not isinstance(content, dict): + return startRow + code = content.get("code", "") + language = content.get("language", "") + + if code: + code_style = styles.get("code_block", {}) + + # Add language label if present + if language: + langCell = sheet.cell(row=startRow, column=1, value=f"Code ({language}):") + langCell.font = Font(bold=True, color=self._getSafeColor(code_style.get("color", "FF000000"))) + startRow += 1 + + # Split code into lines and add each line + code_lines = code.split('\n') + for line in code_lines: + codeCell = sheet.cell(row=startRow, column=1, value=line) + codeCell.font = Font( + name=code_style.get("font", "Courier New"), + size=code_style.get("font_size", 10), + color=self._getSafeColor(code_style.get("color", "FF2F2F2F")) + ) + # Set background color if specified + if code_style.get("background"): + codeCell.fill = PatternFill( + start_color=self._getSafeColor(code_style["background"]), + end_color=self._getSafeColor(code_style["background"]), + fill_type="solid" + ) + startRow += 1 + + # Add spacing after code block + startRow += 1 + + return startRow + + except Exception as e: + self.logger.warning(f"Could not add code block to Excel: {str(e)}") + return startRow + 1 def _formatTimestamp(self) -> str: """Format current timestamp for document generation.""" diff --git a/tests/functional/test10_document_generation_formats.py b/tests/functional/test10_document_generation_formats.py index 05532313..8d963643 100644 --- a/tests/functional/test10_document_generation_formats.py +++ b/tests/functional/test10_document_generation_formats.py @@ -413,10 +413,12 @@ class DocumentGenerationFormatsTester10: async def testAllFormats(self) -> Dict[str, Any]: """Test document generation in DOCX, XLSX, PPTX, PDF, and HTML formats.""" print("\n" + "="*80) - print("TESTING DOCUMENT GENERATION IN DOCX, XLSX, PPTX, PDF, AND HTML FORMATS") + print("TESTING DOCUMENT GENERATION IN HTML FORMAT") print("="*80) - formats = ["docx", "xlsx", "pptx", "pdf", "html"] + # Only test HTML format + formats = ["html"] + # formats = ["docx", "xlsx", "pptx", "pdf", "html"] # Commented out other formats results = {} for format in formats: @@ -469,7 +471,7 @@ class DocumentGenerationFormatsTester10: async def runTest(self): """Run the complete test.""" print("\n" + "="*80) - print("DOCUMENT GENERATION FORMATS TEST 10 - DOCX, XLSX, PPTX, PDF, HTML") + print("DOCUMENT GENERATION FORMATS TEST 10 - HTML ONLY") print("="*80) try: