From 99215e27febf4e66a693eb361b90c59aead9013a Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 12 Oct 2025 00:51:23 +0200 Subject: [PATCH] all renderers active and using json objects --- modules/services/serviceAi/mainServiceAi.py | 48 +- .../mainServiceGeneration.py | 7 +- .../renderers/base_renderer.py | 86 ---- .../renderers/html_renderer.py | 69 --- .../renderers/json_renderer.py | 74 --- .../renderers/markdown_renderer.py | 65 --- .../renderers/pdf_renderer.py | 225 --------- .../serviceGeneration/renderers/registry.py | 4 +- .../renderers/rendererBaseTemplate.py | 285 +++++++++++ .../{csv_renderer.py => rendererCsv.py} | 18 +- .../{docx_renderer.py => rendererDocx.py} | 243 +-------- .../{excel_renderer.py => rendererExcel.py} | 239 ++++++--- .../renderers/rendererHtml.py | 463 ++++++++++++++++++ .../renderers/rendererJson.py | 79 +++ .../renderers/rendererMarkdown.py | 213 ++++++++ .../renderers/rendererPdf.py | 416 ++++++++++++++++ .../{pptx_renderer.py => rendererPptx.py} | 91 +--- .../renderers/rendererText.py | 234 +++++++++ .../renderers/text_renderer.py | 94 ---- .../serviceGeneration/subPromptBuilder.py | 69 ++- rename_renderers.py | 197 -------- test_document_processing.py | 6 +- test_fallback_mechanism.py | 77 --- test_json_to_docx.docx | Bin 37131 -> 0 bytes test_json_to_docx.py | 120 ----- 25 files changed, 2006 insertions(+), 1416 deletions(-) delete mode 100644 modules/services/serviceGeneration/renderers/base_renderer.py delete mode 100644 modules/services/serviceGeneration/renderers/html_renderer.py delete mode 100644 modules/services/serviceGeneration/renderers/json_renderer.py delete mode 100644 modules/services/serviceGeneration/renderers/markdown_renderer.py delete mode 100644 modules/services/serviceGeneration/renderers/pdf_renderer.py create mode 100644 modules/services/serviceGeneration/renderers/rendererBaseTemplate.py rename modules/services/serviceGeneration/renderers/{csv_renderer.py => rendererCsv.py} (91%) rename modules/services/serviceGeneration/renderers/{docx_renderer.py => rendererDocx.py} (80%) rename modules/services/serviceGeneration/renderers/{excel_renderer.py => rendererExcel.py} (72%) create mode 100644 modules/services/serviceGeneration/renderers/rendererHtml.py create mode 100644 modules/services/serviceGeneration/renderers/rendererJson.py create mode 100644 modules/services/serviceGeneration/renderers/rendererMarkdown.py create mode 100644 modules/services/serviceGeneration/renderers/rendererPdf.py rename modules/services/serviceGeneration/renderers/{pptx_renderer.py => rendererPptx.py} (88%) create mode 100644 modules/services/serviceGeneration/renderers/rendererText.py delete mode 100644 modules/services/serviceGeneration/renderers/text_renderer.py delete mode 100644 rename_renderers.py delete mode 100644 test_fallback_mechanism.py delete mode 100644 test_json_to_docx.docx delete mode 100644 test_json_to_docx.py diff --git a/modules/services/serviceAi/mainServiceAi.py b/modules/services/serviceAi/mainServiceAi.py index 6c6f76e2..5f24e158 100644 --- a/modules/services/serviceAi/mainServiceAi.py +++ b/modules/services/serviceAi/mainServiceAi.py @@ -746,8 +746,13 @@ Return only the JSON structure with actual content from the image. Do not includ # Process any document container as text content request_options = options if options is not None else AiCallOptions() request_options.operationType = OperationType.GENERAL - print(f"๐Ÿ” Chunk {chunk_index}: Processing {part.mimeType} container as text with generate_json={generate_json}") + print(f"๐Ÿ” EXTRACTION CONTAINER CHUNK {chunk_index}: Processing {part.mimeType} container as text with generate_json={generate_json}") logger.info(f"Chunk {chunk_index}: Processing {part.mimeType} container as text with generate_json={generate_json}") + + # Log extraction prompt and context + print(f"๐Ÿ” EXTRACTION PROMPT: {prompt}") + print(f"๐Ÿ” EXTRACTION CONTEXT LENGTH: {len(part.data) if part.data else 0} characters") + request = AiCallRequest( prompt=prompt, context=part.data, @@ -756,6 +761,23 @@ Return only the JSON structure with actual content from the image. Do not includ response = await self.aiObjects.call(request) ai_result = response.content + # Log extraction response + print(f"๐Ÿ” EXTRACTION RESPONSE LENGTH: {len(ai_result) if ai_result else 0} characters") + + # Save full extraction prompt and response to debug file + try: + import os + from datetime import datetime, UTC + ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + debug_root = "./test-chat/ai" + os.makedirs(debug_root, exist_ok=True) + with open(os.path.join(debug_root, f"{ts}_extraction_container_chunk_{chunk_index}.txt"), "w", encoding="utf-8") as f: + f.write(f"EXTRACTION PROMPT:\n{prompt}\n\n") + f.write(f"EXTRACTION CONTEXT:\n{part.data if part.data else 'No context'}\n\n") + f.write(f"EXTRACTION RESPONSE:\n{ai_result if ai_result else 'No response'}\n") + except Exception: + pass + # If generating JSON, validate the response if generate_json: try: @@ -798,8 +820,13 @@ Return only the JSON structure with actual content from the image. Do not includ request_options = options if options is not None else AiCallOptions() # FIXED: Set operation type to general for text processing request_options.operationType = OperationType.GENERAL - print(f"๐Ÿ” Chunk {chunk_index}: Calling aiObjects.call with operationType={request_options.operationType}, generate_json={generate_json}") + print(f"๐Ÿ” EXTRACTION CHUNK {chunk_index}: Calling aiObjects.call with operationType={request_options.operationType}, generate_json={generate_json}") logger.info(f"Chunk {chunk_index}: Calling aiObjects.call with operationType={request_options.operationType}, generate_json={generate_json}") + + # Log extraction prompt and context + print(f"๐Ÿ” EXTRACTION PROMPT: {prompt}") + print(f"๐Ÿ” EXTRACTION CONTEXT LENGTH: {len(part.data) if part.data else 0} characters") + request = AiCallRequest( prompt=prompt, context=part.data, @@ -808,6 +835,23 @@ Return only the JSON structure with actual content from the image. Do not includ response = await self.aiObjects.call(request) ai_result = response.content + # Log extraction response + print(f"๐Ÿ” EXTRACTION RESPONSE LENGTH: {len(ai_result) if ai_result else 0} characters") + + # Save full extraction prompt and response to debug file + try: + import os + from datetime import datetime, UTC + ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + debug_root = "./test-chat/ai" + os.makedirs(debug_root, exist_ok=True) + with open(os.path.join(debug_root, f"{ts}_extraction_chunk_{chunk_index}.txt"), "w", encoding="utf-8") as f: + f.write(f"EXTRACTION PROMPT:\n{prompt}\n\n") + f.write(f"EXTRACTION CONTEXT:\n{part.data if part.data else 'No context'}\n\n") + f.write(f"EXTRACTION RESPONSE:\n{ai_result if ai_result else 'No response'}\n") + except Exception: + pass + # If generating JSON, validate the response if generate_json: try: diff --git a/modules/services/serviceGeneration/mainServiceGeneration.py b/modules/services/serviceGeneration/mainServiceGeneration.py index 13c20fad..2d3aa21f 100644 --- a/modules/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/services/serviceGeneration/mainServiceGeneration.py @@ -318,18 +318,17 @@ class GenerationService: if "sections" not in extractedContent: raise ValueError("extractedContent must contain 'sections' field") - # DEBUG: dump renderer input to diagnose JSON structure TODO REMOVE + # DEBUG: Log renderer input metadata only (no verbose JSON) TODO REMOVE try: import os - import json ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") debug_root = "./test-chat/ai" debug_dir = os.path.join(debug_root, f"render_input_{ts}") os.makedirs(debug_dir, exist_ok=True) with open(os.path.join(debug_dir, "meta.txt"), "w", encoding="utf-8") as f: f.write(f"title: {title}\nformat: {outputFormat}\ncontent_type: {type(extractedContent).__name__}\n") - with open(os.path.join(debug_dir, "extracted_content.json"), "w", encoding="utf-8") as f: - json.dump(extractedContent, f, indent=2, ensure_ascii=False) + f.write(f"content_size: {len(str(extractedContent))} characters\n") + f.write(f"sections_count: {len(extractedContent.get('sections', []))}\n") except Exception: pass diff --git a/modules/services/serviceGeneration/renderers/base_renderer.py b/modules/services/serviceGeneration/renderers/base_renderer.py deleted file mode 100644 index dd91be09..00000000 --- a/modules/services/serviceGeneration/renderers/base_renderer.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -Base renderer class for all format renderers. -""" - -from abc import ABC, abstractmethod -from typing import Dict, Any, Tuple, List -import logging - -logger = logging.getLogger(__name__) - -class BaseRenderer(ABC): - """Base class for all format renderers.""" - - def __init__(self): - self.logger = logger - - @classmethod - def get_supported_formats(cls) -> List[str]: - """ - Return list of supported format names for this renderer. - Override this method in subclasses to specify supported formats. - """ - return [] - - @classmethod - def get_format_aliases(cls) -> List[str]: - """ - Return list of format aliases for this renderer. - Override this method in subclasses to specify format aliases. - """ - return [] - - @classmethod - def get_priority(cls) -> int: - """ - Return priority for this renderer (higher number = higher priority). - Used when multiple renderers support the same format. - """ - return 0 - - @abstractmethod - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """ - Get the format-specific extraction prompt for AI content extraction. - - Args: - user_prompt: User's original prompt for report generation - title: Report title - - Returns: - str: Format-specific prompt for AI extraction - """ - pass - - @abstractmethod - async def render(self, extracted_content: str, title: str) -> Tuple[str, str]: - """ - Render extracted content to the target format. - - Args: - extracted_content: Raw content extracted by AI using format-specific prompt - title: Report title - - Returns: - tuple: (rendered_content, mime_type) - """ - pass - - def _extract_sections(self, report_data: Dict[str, Any]) -> list: - """Extract sections from report data.""" - return report_data.get('sections', []) - - def _extract_metadata(self, report_data: Dict[str, Any]) -> Dict[str, Any]: - """Extract metadata from report data.""" - return report_data.get('metadata', {}) - - def _get_title(self, report_data: Dict[str, Any], fallback_title: str) -> str: - """Get title from report data or use fallback.""" - return report_data.get('title', fallback_title) - - def _format_timestamp(self, timestamp: str = None) -> str: - """Format timestamp for display.""" - if timestamp: - return timestamp - from datetime import datetime, UTC - return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") diff --git a/modules/services/serviceGeneration/renderers/html_renderer.py b/modules/services/serviceGeneration/renderers/html_renderer.py deleted file mode 100644 index c2b7e586..00000000 --- a/modules/services/serviceGeneration/renderers/html_renderer.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -HTML renderer for report generation. -""" - -from .base_renderer import BaseRenderer -from typing import Dict, Any, Tuple, List - -class HtmlRenderer(BaseRenderer): - """Renders content to HTML format with format-specific extraction.""" - - @classmethod - def get_supported_formats(cls) -> List[str]: - """Return supported HTML formats.""" - return ['html', 'htm'] - - @classmethod - def get_format_aliases(cls) -> List[str]: - """Return format aliases.""" - return ['web', 'webpage'] - - @classmethod - def get_priority(cls) -> int: - """Return priority for HTML renderer.""" - return 100 - - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only HTML-specific guidelines; global prompt is built centrally.""" - return ( - "HTML FORMAT GUIDELINES:\n" - "- Output a complete HTML5 document starting with .\n" - "- Include , with and , and <body>.\n" - "- Use semantic elements: <header>, <main>, <section>, <article>, <footer>.\n" - "- Provide professional CSS in a <style> block; responsive, clean typography.\n" - "- Use h1/h2/h3 for headings; tables and lists for structure.\n" - "OUTPUT: Return ONLY valid HTML (no markdown, no code fences)." - ) - - async def render(self, extracted_content: str, title: str) -> Tuple[str, str]: - """Render extracted content to HTML format.""" - try: - # The extracted content should already be HTML from the AI - # Just clean it up and ensure it's valid - html_content = self._clean_html_content(extracted_content, title) - - return html_content, "text/html" - - except Exception as e: - self.logger.error(f"Error rendering HTML: {str(e)}") - # Return minimal HTML fallback - return f"<html><head><title>{title}

{title}

Error rendering report: {str(e)}

", "text/html" - - def _clean_html_content(self, content: str, title: str) -> str: - """Clean and validate HTML content from AI.""" - content = content.strip() - - # Remove markdown code blocks if present - if content.startswith("```") and content.endswith("```"): - lines = content.split('\n') - if len(lines) > 2: - content = '\n'.join(lines[1:-1]).strip() - - # Ensure it starts with DOCTYPE - if not content.startswith('\n' + content - else: - content = f'\n\n{title}\n\n{content}\n\n' - - return content diff --git a/modules/services/serviceGeneration/renderers/json_renderer.py b/modules/services/serviceGeneration/renderers/json_renderer.py deleted file mode 100644 index 845d33c2..00000000 --- a/modules/services/serviceGeneration/renderers/json_renderer.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -JSON renderer for report generation. -""" - -from .base_renderer import BaseRenderer -from typing import Dict, Any, Tuple, List -import json - -class JsonRenderer(BaseRenderer): - """Renders content to JSON format with format-specific extraction.""" - - @classmethod - def get_supported_formats(cls) -> List[str]: - """Return supported JSON formats.""" - return ['json'] - - @classmethod - def get_format_aliases(cls) -> List[str]: - """Return format aliases.""" - return ['data'] - - @classmethod - def get_priority(cls) -> int: - """Return priority for JSON renderer.""" - return 80 - - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only JSON-specific guidelines; global prompt is built centrally.""" - return ( - "JSON FORMAT GUIDELINES:\n" - "- Output ONLY a single valid JSON object (no fences, no pre/post text).\n" - "- Choose a structure that best fits the user's intent; include a top-level title and data.\n" - "- Prefer arrays/objects that map cleanly to the extracted facts.\n" - "- Include minimal metadata only if useful (e.g., generatedAt, sources).\n" - "OUTPUT: Return ONLY valid, parseable JSON." - ) - - async def render(self, extracted_content: str, title: str) -> Tuple[str, str]: - """Render extracted content to JSON format.""" - try: - # The extracted content should already be JSON from the AI - # Just validate and format it - json_content = self._clean_json_content(extracted_content, title) - - return json_content, "application/json" - - except Exception as e: - self.logger.error(f"Error rendering JSON: {str(e)}") - # Return minimal JSON fallback - fallback_data = { - "title": title, - "sections": [{"type": "text", "content": f"Error rendering report: {str(e)}"}], - "metadata": {"error": str(e)} - } - return json.dumps(fallback_data, indent=2), "application/json" - - def _clean_json_content(self, content: str, title: str) -> str: - """Clean and validate JSON content from AI.""" - content = content.strip() - - # Remove markdown code blocks if present - if content.startswith("```") and content.endswith("```"): - lines = content.split('\n') - if len(lines) > 2: - content = '\n'.join(lines[1:-1]).strip() - - # Validate JSON - try: - parsed = json.loads(content) - # Re-format with proper indentation - return json.dumps(parsed, indent=2, ensure_ascii=False) - except json.JSONDecodeError: - # If not valid JSON, return as-is - return content diff --git a/modules/services/serviceGeneration/renderers/markdown_renderer.py b/modules/services/serviceGeneration/renderers/markdown_renderer.py deleted file mode 100644 index 8b9b4293..00000000 --- a/modules/services/serviceGeneration/renderers/markdown_renderer.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Markdown renderer for report generation. -""" - -from .base_renderer import BaseRenderer -from typing import Dict, Any, Tuple, List - -class MarkdownRenderer(BaseRenderer): - """Renders content to Markdown format with format-specific extraction.""" - - @classmethod - def get_supported_formats(cls) -> List[str]: - """Return supported Markdown formats.""" - return ['md', 'markdown'] - - @classmethod - def get_format_aliases(cls) -> List[str]: - """Return format aliases.""" - return ['mdown', 'mkd'] - - @classmethod - def get_priority(cls) -> int: - """Return priority for markdown renderer.""" - return 95 - - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only Markdown-specific guidelines; global prompt is built centrally.""" - return ( - "MARKDOWN FORMAT GUIDELINES:\n" - "- Use proper Markdown syntax only (no HTML wrappers).\n" - "- # for main title, ## for sections, ### for subsections.\n" - "- Tables with | separators and a header row.\n" - "- Bullet lists with - or *.\n" - "- Emphasis with **bold** and *italic*.\n" - "- Code blocks with ```language.\n" - "- Horizontal rules (---) to separate major sections when helpful.\n" - "- Include links [text](url) and images ![alt](url) when referenced by sources.\n" - "OUTPUT: Return ONLY raw Markdown content without code fences." - ) - - async def render(self, extracted_content: str, title: str) -> Tuple[str, str]: - """Render extracted content to Markdown format.""" - try: - # The extracted content should already be Markdown from the AI - # Just clean it up - markdown_content = self._clean_markdown_content(extracted_content, title) - - return markdown_content, "text/markdown" - - except Exception as e: - self.logger.error(f"Error rendering markdown: {str(e)}") - # Return minimal markdown fallback - return f"# {title}\n\nError rendering report: {str(e)}", "text/markdown" - - def _clean_markdown_content(self, content: str, title: str) -> str: - """Clean and validate Markdown content from AI.""" - content = content.strip() - - # Remove markdown code blocks if present - if content.startswith("```") and content.endswith("```"): - lines = content.split('\n') - if len(lines) > 2: - content = '\n'.join(lines[1:-1]).strip() - - return content diff --git a/modules/services/serviceGeneration/renderers/pdf_renderer.py b/modules/services/serviceGeneration/renderers/pdf_renderer.py deleted file mode 100644 index 6a8409a1..00000000 --- a/modules/services/serviceGeneration/renderers/pdf_renderer.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -PDF renderer for report generation using reportlab. -""" - -from .base_renderer import BaseRenderer -from typing import Dict, Any, Tuple, List -import io -import base64 -from datetime import datetime, UTC - -try: - from reportlab.lib.pagesizes import letter, A4 - from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak - from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle - from reportlab.lib.units import inch - from reportlab.lib import colors - from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY - REPORTLAB_AVAILABLE = True -except ImportError: - REPORTLAB_AVAILABLE = False - -class PdfRenderer(BaseRenderer): - """Renders content to PDF format using reportlab.""" - - @classmethod - def get_supported_formats(cls) -> List[str]: - """Return supported PDF formats.""" - return ['pdf'] - - @classmethod - def get_format_aliases(cls) -> List[str]: - """Return format aliases.""" - return ['document', 'print'] - - @classmethod - def get_priority(cls) -> int: - """Return priority for PDF renderer.""" - return 120 - - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only PDF-specific guidelines; global prompt is built centrally.""" - return ( - "PDF FORMAT GUIDELINES:\n" - "- Provide structured content suitable for pagination and headings (H1/H2/H3-like).\n" - "- Use bullet lists and tables where useful; separate major sections clearly.\n" - "- Avoid markdown/HTML; produce clean, plain content that can be laid out as PDF.\n" - "OUTPUT: Return ONLY the PDF-ready textual content (no fences)." - ) - - async def render(self, extracted_content: str, title: str) -> Tuple[str, str]: - """Render extracted content to PDF format.""" - try: - if not REPORTLAB_AVAILABLE: - # Fallback to HTML if reportlab not available - from .html_renderer import HtmlRenderer - html_renderer = HtmlRenderer() - html_content, _ = await html_renderer.render(extracted_content, title) - return html_content, "text/html" - - # Generate PDF using reportlab - pdf_content = self._generate_pdf(extracted_content, title) - - return pdf_content, "application/pdf" - - except Exception as e: - self.logger.error(f"Error rendering PDF: {str(e)}") - # Return minimal fallback - return f"PDF Generation Error: {str(e)}", "text/plain" - - def _generate_pdf(self, content: str, title: str) -> str: - """Generate PDF content using reportlab.""" - try: - # Create a buffer to hold the PDF - buffer = io.BytesIO() - - # Create PDF document - doc = SimpleDocTemplate( - buffer, - pagesize=A4, - rightMargin=72, - leftMargin=72, - topMargin=72, - bottomMargin=18 - ) - - # Get styles - styles = getSampleStyleSheet() - - # Create custom styles - title_style = ParagraphStyle( - 'CustomTitle', - parent=styles['Heading1'], - fontSize=24, - spaceAfter=30, - alignment=TA_CENTER, - textColor=colors.darkblue - ) - - heading_style = ParagraphStyle( - 'CustomHeading', - parent=styles['Heading2'], - fontSize=16, - spaceAfter=12, - spaceBefore=12, - textColor=colors.darkblue - ) - - # Build PDF content - story = [] - - # Title page - story.append(Paragraph(title, title_style)) - story.append(Spacer(1, 20)) - story.append(Paragraph(f"Generated: {self._format_timestamp()}", styles['Normal'])) - story.append(PageBreak()) - - # Process content - lines = content.split('\n') - current_section = [] - - for line in lines: - line = line.strip() - if not line: - continue - - # Check for headings - if line.startswith('# '): - # H1 heading - if current_section: - story.extend(self._process_section(current_section, styles)) - current_section = [] - story.append(Paragraph(line[2:], title_style)) - story.append(Spacer(1, 12)) - elif line.startswith('## '): - # H2 heading - if current_section: - story.extend(self._process_section(current_section, styles)) - current_section = [] - story.append(Paragraph(line[3:], heading_style)) - story.append(Spacer(1, 8)) - elif line.startswith('### '): - # H3 heading - if current_section: - story.extend(self._process_section(current_section, styles)) - current_section = [] - story.append(Paragraph(line[4:], styles['Heading3'])) - story.append(Spacer(1, 6)) - else: - current_section.append(line) - - # Process remaining content - if current_section: - story.extend(self._process_section(current_section, styles)) - - # Build PDF - doc.build(story) - - # Get PDF content as base64 - buffer.seek(0) - pdf_bytes = buffer.getvalue() - pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') - - return pdf_base64 - - except Exception as e: - self.logger.error(f"Error generating PDF: {str(e)}") - raise - - def _process_section(self, lines: list, styles) -> list: - """Process a section of content into PDF elements.""" - elements = [] - - for line in lines: - if not line.strip(): - continue - - # Check for tables (lines with |) - if '|' in line and not line.startswith('|'): - # This might be part of a table, process as table - table_data = self._extract_table_data(lines) - if table_data: - table = Table(table_data) - table.setStyle(TableStyle([ - ('BACKGROUND', (0, 0), (-1, 0), colors.grey), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), - ('ALIGN', (0, 0), (-1, -1), 'CENTER'), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, 0), 14), - ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - ('BACKGROUND', (0, 1), (-1, -1), colors.beige), - ('GRID', (0, 0), (-1, -1), 1, colors.black) - ])) - elements.append(table) - elements.append(Spacer(1, 12)) - return elements - - # Check for lists - if line.startswith('- ') or line.startswith('* '): - # This is a list item - elements.append(Paragraph(f"โ€ข {line[2:]}", styles['Normal'])) - else: - # Regular paragraph - elements.append(Paragraph(line, styles['Normal'])) - - elements.append(Spacer(1, 6)) - return elements - - def _extract_table_data(self, lines: list) -> list: - """Extract table data from lines.""" - table_data = [] - in_table = False - - for line in lines: - if '|' in line: - if not in_table: - in_table = True - # Split by | and clean up - cells = [cell.strip() for cell in line.split('|') if cell.strip()] - if cells: - table_data.append(cells) - elif in_table and not line.strip(): - # Empty line, might be end of table - break - - return table_data if len(table_data) > 1 else [] \ No newline at end of file diff --git a/modules/services/serviceGeneration/renderers/registry.py b/modules/services/serviceGeneration/renderers/registry.py index 5c498081..6843c114 100644 --- a/modules/services/serviceGeneration/renderers/registry.py +++ b/modules/services/serviceGeneration/renderers/registry.py @@ -6,7 +6,7 @@ import logging import importlib import pkgutil from typing import Dict, Type, List, Optional -from .base_renderer import BaseRenderer +from .rendererBaseTemplate import BaseRenderer logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ class RendererRegistry: # Scan all Python files in the renderers directory for file_path in renderers_dir.glob("*.py"): - if file_path.name in ['registry.py', 'base_renderer.py', '__init__.py']: + if file_path.name in ['registry.py', 'rendererBaseTemplate.py', '__init__.py']: continue # Extract module name from filename diff --git a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py new file mode 100644 index 00000000..d4b147a7 --- /dev/null +++ b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py @@ -0,0 +1,285 @@ +""" +Base renderer class for all format renderers. +""" + +from abc import ABC, abstractmethod +from typing import Dict, Any, Tuple, List +import logging +import json + +logger = logging.getLogger(__name__) + +class BaseRenderer(ABC): + """Base class for all format renderers.""" + + def __init__(self): + self.logger = logger + + @classmethod + def get_supported_formats(cls) -> List[str]: + """ + Return list of supported format names for this renderer. + Override this method in subclasses to specify supported formats. + """ + return [] + + @classmethod + def get_format_aliases(cls) -> List[str]: + """ + Return list of format aliases for this renderer. + Override this method in subclasses to specify format aliases. + """ + return [] + + @classmethod + def get_priority(cls) -> int: + """ + Return priority for this renderer (higher number = higher priority). + Used when multiple renderers support the same format. + """ + return 0 + + @abstractmethod + async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: + """ + Render extracted JSON content to the target format. + + Args: + extracted_content: Structured JSON content with sections and metadata + title: Report title + user_prompt: Original user prompt for context + ai_service: AI service instance for additional processing + + Returns: + tuple: (rendered_content, mime_type) + """ + pass + + def _extract_sections(self, report_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract sections from report data.""" + return report_data.get('sections', []) + + def _extract_metadata(self, report_data: Dict[str, Any]) -> Dict[str, Any]: + """Extract metadata from report data.""" + return report_data.get('metadata', {}) + + def _get_title(self, report_data: Dict[str, Any], fallback_title: str) -> str: + """Get title from report data or use fallback.""" + metadata = report_data.get('metadata', {}) + return metadata.get('title', fallback_title) + + def _validate_json_structure(self, json_content: Dict[str, Any]) -> bool: + """Validate that JSON content has the expected structure.""" + if not isinstance(json_content, dict): + return False + + if "sections" not in json_content: + return False + + sections = json_content.get("sections", []) + if not isinstance(sections, list): + return False + + # Validate each section has type and data + for section in sections: + if not isinstance(section, dict): + return False + if "type" not in section or "data" not in section: + return False + + return True + + def _get_section_type(self, section: Dict[str, Any]) -> str: + """Get the type of a section.""" + return section.get("type", "paragraph") + + def _get_section_data(self, section: Dict[str, Any]) -> Dict[str, Any]: + """Get the data of a section.""" + return section.get("data", {}) + + def _get_section_id(self, section: Dict[str, Any]) -> str: + """Get the ID of a section (if available).""" + return section.get("id", "unknown") + + def _extract_table_data(self, section_data: Dict[str, Any]) -> Tuple[List[str], List[List[str]]]: + """Extract table headers and rows from section data.""" + headers = section_data.get("headers", []) + rows = section_data.get("rows", []) + return headers, rows + + def _extract_bullet_list_items(self, section_data: Dict[str, Any]) -> List[str]: + """Extract bullet list items from section data.""" + items = section_data.get("items", []) + result = [] + for item in items: + if isinstance(item, str): + result.append(item) + elif isinstance(item, dict) and "text" in item: + result.append(item["text"]) + return result + + def _extract_heading_data(self, section_data: Dict[str, Any]) -> Tuple[int, str]: + """Extract heading level and text from section data.""" + level = section_data.get("level", 1) + text = section_data.get("text", "") + return level, text + + def _extract_paragraph_text(self, section_data: Dict[str, Any]) -> str: + """Extract paragraph text from section data.""" + return section_data.get("text", "") + + def _extract_code_block_data(self, section_data: Dict[str, Any]) -> Tuple[str, str]: + """Extract code and language from section data.""" + code = section_data.get("code", "") + language = section_data.get("language", "") + return code, language + + def _extract_image_data(self, section_data: Dict[str, Any]) -> Tuple[str, str]: + """Extract base64 data and alt text from section data.""" + base64_data = section_data.get("base64Data", "") + alt_text = section_data.get("altText", "Image") + return base64_data, alt_text + + def _get_supported_section_types(self) -> List[str]: + """Return list of supported section types.""" + return ["table", "bullet_list", "heading", "paragraph", "code_block", "image"] + + def _is_valid_section_type(self, section_type: str) -> bool: + """Check if a section type is valid.""" + return section_type in self._get_supported_section_types() + + def _process_section_by_type(self, section: Dict[str, Any]) -> Dict[str, Any]: + """Process a section and return structured data based on its type.""" + section_type = self._get_section_type(section) + section_data = self._get_section_data(section) + + if section_type == "table": + headers, rows = self._extract_table_data(section_data) + return {"type": "table", "headers": headers, "rows": rows} + elif section_type == "bullet_list": + items = self._extract_bullet_list_items(section_data) + return {"type": "bullet_list", "items": items} + elif section_type == "heading": + level, text = self._extract_heading_data(section_data) + return {"type": "heading", "level": level, "text": text} + elif section_type == "paragraph": + text = self._extract_paragraph_text(section_data) + return {"type": "paragraph", "text": text} + elif section_type == "code_block": + code, language = self._extract_code_block_data(section_data) + return {"type": "code_block", "code": code, "language": language} + elif section_type == "image": + base64_data, alt_text = self._extract_image_data(section_data) + return {"type": "image", "base64Data": base64_data, "altText": alt_text} + else: + # Fallback to paragraph + text = self._extract_paragraph_text(section_data) + return {"type": "paragraph", "text": text} + + def _format_timestamp(self, timestamp: str = None) -> str: + """Format timestamp for display.""" + if timestamp: + return timestamp + from datetime import datetime, UTC + return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + + # ===== GENERIC AI STYLING HELPERS ===== + + async def _get_ai_styles(self, ai_service, style_template: str, default_styles: Dict[str, Any]) -> Dict[str, Any]: + """ + Generic AI styling method that can be used by all renderers. + + Args: + ai_service: AI service instance + style_template: Format-specific style template + default_styles: Default styles to fall back to + + Returns: + Dict with styling definitions + """ + if not ai_service: + return default_styles + + try: + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + + request_options = AiCallOptions() + request_options.operationType = OperationType.GENERAL + + request = AiCallRequest(prompt=style_template, context="", options=request_options) + response = await ai_service.aiObjects.call(request) + + import json + import re + + # Debug output + print(f"๐Ÿ” AI STYLING RESPONSE TYPE: {type(response)}") + print(f"๐Ÿ” AI STYLING RESPONSE LENGTH: {len(response.content) if response and hasattr(response, 'content') and response.content else 0}") + + # Clean and parse JSON + result = response.content.strip() if response and response.content else "" + + # Check if result is empty + if not result: + self.logger.warning("AI styling returned empty response, using defaults") + return default_styles + + # Extract JSON from markdown if present + json_match = re.search(r'```json\s*\n(.*?)\n```', result, re.DOTALL) + if json_match: + result = json_match.group(1).strip() + print(f"๐Ÿ” EXTRACTED JSON FROM MARKDOWN: {result[:100]}...") + elif result.startswith('```json'): + result = re.sub(r'^```json\s*', '', result) + result = re.sub(r'\s*```$', '', result) + print(f"๐Ÿ” CLEANED JSON FROM MARKDOWN: {result[:100]}...") + elif result.startswith('```'): + result = re.sub(r'^```\s*', '', result) + result = re.sub(r'\s*```$', '', result) + print(f"๐Ÿ” CLEANED JSON FROM GENERIC MARKDOWN: {result[:100]}...") + + # Try to parse JSON + try: + styles = json.loads(result) + print(f"๐Ÿ” AI STYLING PARSED KEYS: {list(styles.keys()) if isinstance(styles, dict) else 'Not a dict'}") + except json.JSONDecodeError as json_error: + print(f"๐Ÿ” AI STYLING JSON ERROR: {json_error}") + print(f"๐Ÿ” AI STYLING RAW RESULT: {result[:200]}...") + self.logger.warning(f"AI styling returned invalid JSON: {json_error}, using defaults") + return default_styles + + # Convert colors to appropriate format + styles = self._convert_colors_format(styles) + + return styles + + except Exception as e: + self.logger.warning(f"AI styling failed: {str(e)}, using defaults") + return default_styles + + def _convert_colors_format(self, styles: Dict[str, Any]) -> Dict[str, Any]: + """ + Convert colors to appropriate format based on renderer type. + Override this method in subclasses for format-specific color handling. + """ + return styles + + def _create_ai_style_template(self, format_name: str, user_prompt: str, style_schema: Dict[str, Any]) -> str: + """ + Create a standardized AI style template for any format. + + Args: + format_name: Name of the format (e.g., "docx", "xlsx", "pptx") + user_prompt: User's original prompt + style_schema: Format-specific style schema + + Returns: + Formatted prompt string + """ + schema_json = json.dumps(style_schema, indent=4) + + return f"""Return this exact JSON structure with your styling customizations: + +{schema_json} + +NO TEXT. NO EXPLANATIONS. NO MARKDOWN. NO WRAPPER OBJECTS. ONLY THE JSON ABOVE.""" \ No newline at end of file diff --git a/modules/services/serviceGeneration/renderers/csv_renderer.py b/modules/services/serviceGeneration/renderers/rendererCsv.py similarity index 91% rename from modules/services/serviceGeneration/renderers/csv_renderer.py rename to modules/services/serviceGeneration/renderers/rendererCsv.py index 0e35eb30..782e7d4a 100644 --- a/modules/services/serviceGeneration/renderers/csv_renderer.py +++ b/modules/services/serviceGeneration/renderers/rendererCsv.py @@ -2,12 +2,12 @@ CSV renderer for report generation. """ -from .base_renderer import BaseRenderer +from .rendererBaseTemplate import BaseRenderer from typing import Dict, Any, Tuple, List import csv import io -class CsvRenderer(BaseRenderer): +class RendererCsv(BaseRenderer): """Renders content to CSV format with format-specific extraction.""" @classmethod @@ -25,20 +25,6 @@ class CsvRenderer(BaseRenderer): """Return priority for CSV renderer.""" return 70 - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only CSV-specific guidelines; global prompt is built centrally.""" - return ( - "CSV FORMAT GUIDELINES:\n" - "- Extract structured data from source documents into JSON format\n" - "- Focus on tabular data, lists, and structured information\n" - "- For tables: Extract headers and rows as separate arrays\n" - "- For lists: Extract items with optional sub-items\n" - "- Structure content into sections with clear content types\n" - "- Use proper JSON structure with metadata, sections, and elements\n" - "- Ensure data is clean and ready for CSV conversion\n" - "OUTPUT: Return structured JSON that can be converted to CSV format." - ) - async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: """Render extracted JSON content to CSV format.""" try: diff --git a/modules/services/serviceGeneration/renderers/docx_renderer.py b/modules/services/serviceGeneration/renderers/rendererDocx.py similarity index 80% rename from modules/services/serviceGeneration/renderers/docx_renderer.py rename to modules/services/serviceGeneration/renderers/rendererDocx.py index d3781797..b972fc07 100644 --- a/modules/services/serviceGeneration/renderers/docx_renderer.py +++ b/modules/services/serviceGeneration/renderers/rendererDocx.py @@ -2,7 +2,7 @@ DOCX renderer for report generation using python-docx. """ -from .base_renderer import BaseRenderer +from .rendererBaseTemplate import BaseRenderer from typing import Dict, Any, Tuple, List import io import base64 @@ -22,7 +22,7 @@ try: except ImportError: DOCX_AVAILABLE = False -class DocxRenderer(BaseRenderer): +class RendererDocx(BaseRenderer): """Renders content to DOCX format using python-docx.""" @classmethod @@ -40,30 +40,14 @@ class DocxRenderer(BaseRenderer): """Return priority for DOCX renderer.""" return 115 - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only DOCX-specific guidelines; global prompt is built centrally.""" - return ( - "DOCX FORMAT GUIDELINES:\n" - "- Extract the ACTUAL table data, lists, and content from the source documents\n" - "- For tables: Extract all rows and columns in pipe-separated format (Column1 | Column2 | Column3)\n" - "- For lists: Extract the actual list items, not summaries\n" - "- Structure your response with clear headings using numbered format: 1) Heading, 2) Heading, etc.\n" - "- Use bullet points (-) for lists and sub-items\n" - "- Use **bold** for emphasis on key terms\n" - "- Provide clean, structured content that can be directly converted to Word formatting\n" - "- Do NOT include debug information, separators (---), metadata, or FILENAME headers\n" - "- Start directly with your content - no introductory text or separators\n" - "- Extract raw data, not analysis or summaries\n" - "OUTPUT: Return ONLY the structured plain text to be converted into DOCX." - ) - async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: """Render extracted JSON content to DOCX format using AI-analyzed styling.""" + print(f"๐Ÿ” DOCX RENDER CALLED: title={title}, user_prompt={user_prompt[:50] if user_prompt else 'None'}...") try: if not DOCX_AVAILABLE: # Fallback to HTML if python-docx not available - from .html_renderer import HtmlRenderer - html_renderer = HtmlRenderer() + from .rendererHtml import RendererHtml + html_renderer = RendererHtml() html_content, _ = await html_renderer.render(extracted_content, title) return html_content, "text/html" @@ -84,7 +68,10 @@ class DocxRenderer(BaseRenderer): doc = Document() # Get AI-generated styling definitions + print(f"๐Ÿ” ABOUT TO CALL AI STYLING: user_prompt={user_prompt[:50] if user_prompt else 'None'}...") + self.logger.info(f"About to call AI styling with user_prompt: {user_prompt[:100] if user_prompt else 'None'}...") styles = await self._get_docx_styles(user_prompt, ai_service) + print(f"๐Ÿ” AI STYLING RESULT: {type(styles)}") # Apply basic document setup self._setup_basic_document_styles(doc) @@ -125,61 +112,24 @@ class DocxRenderer(BaseRenderer): raise Exception(f"DOCX generation failed: {str(e)}") async def _get_docx_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]: - """Simple AI call to get DOCX styling definitions.""" - if not ai_service: - return self._get_default_styles() + """Get DOCX styling definitions using base template AI styling.""" + style_schema = { + "title": {"font_size": 24, "color": "#1F4E79", "bold": True, "align": "center"}, + "heading1": {"font_size": 18, "color": "#2F2F2F", "bold": True, "align": "left"}, + "heading2": {"font_size": 14, "color": "#4F4F4F", "bold": True, "align": "left"}, + "paragraph": {"font_size": 11, "color": "#2F2F2F", "bold": False, "align": "left"}, + "table_header": {"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": True, "align": "center"}, + "table_cell": {"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": False, "align": "left"}, + "table_border": {"style": "horizontal_only", "color": "#000000", "thickness": "thin"}, + "bullet_list": {"font_size": 11, "color": "#2F2F2F", "indent": 20}, + "code_block": {"font": "Courier New", "font_size": 10, "color": "#2F2F2F", "background": "#F5F5F5"} + } - try: - prompt = f""" -For this DOCX document request: "{user_prompt}" - -Provide styling definitions for DOCX elements. IMPORTANT: Ensure proper contrast - never use white text on white background or dark text on dark background. Respond with ONLY JSON: - -{{ - "title": {{"font_size": 24, "color": "#1F4E79", "bold": true, "align": "center"}}, - "heading1": {{"font_size": 18, "color": "#2F2F2F", "bold": true, "align": "left"}}, - "heading2": {{"font_size": 14, "color": "#4F4F4F", "bold": true, "align": "left"}}, - "paragraph": {{"font_size": 11, "color": "#2F2F2F", "bold": false, "align": "left"}}, - "table_header": {{"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": true, "align": "center"}}, - "table_cell": {{"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": false, "align": "left"}}, - "table_border": {{"style": "horizontal_only", "color": "#000000", "thickness": "thin"}}, - "bullet_list": {{"font_size": 11, "color": "#2F2F2F", "indent": 20}}, - "code_block": {{"font": "Courier New", "font_size": 10, "color": "#2F2F2F", "background": "#F5F5F5"}} -}} - -CRITICAL: Table headers must have dark background with light text, table cells must have light background with dark text for readability. -""" - - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType - - request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL - - request = AiCallRequest(prompt=prompt, context="", options=request_options) - response = await ai_service.aiObjects.call(request) - - import json - import re - - # Clean and parse JSON - result = response.content.strip() - if result.startswith('```json'): - result = re.sub(r'^```json\s*', '', result) - result = re.sub(r'\s*```$', '', result) - elif result.startswith('```'): - result = re.sub(r'^```\s*', '', result) - result = re.sub(r'\s*```$', '', result) - - styles = json.loads(result) - - # Validate and fix contrast issues - styles = self._validate_styles_contrast(styles) - - return styles - - except Exception as e: - self.logger.warning(f"AI styling failed: {str(e)}, using defaults") - return self._get_default_styles() + style_template = self._create_ai_style_template("docx", user_prompt, style_schema) + styles = await self._get_ai_styles(ai_service, style_template, self._get_default_styles()) + + # Validate and fix contrast issues + return self._validate_styles_contrast(styles) def _validate_styles_contrast(self, styles: Dict[str, Any]) -> Dict[str, Any]: """Validate and fix contrast issues in AI-generated styles.""" @@ -1005,145 +955,4 @@ CRITICAL: Table headers must have dark background with light text, table cells m # Bold text if part: run = para.add_run(part) - run.bold = True - - def _add_bullet_point(self, doc, text: str): - """Add a bullet point to the document.""" - if not text.strip(): - return - - # Create paragraph with bullet style - para = doc.add_paragraph(text, style='List Bullet') - - # Check for Markdown formatting in bullet point - if '**' in text or '*' in text: - # Clear the paragraph and rebuild with formatting - para.clear() - self._add_paragraph_to_doc(doc, text) - - def _style_table(self, table): - """Apply styling to the table.""" - try: - # Style header row - if len(table.rows) > 0: - header_cells = table.rows[0].cells - for cell in header_cells: - for paragraph in cell.paragraphs: - for run in paragraph.runs: - run.bold = True - except Exception as e: - self.logger.warning(f"Could not style table: {str(e)}") - - def _format_timestamp(self) -> str: - """Format current timestamp for document generation.""" - from datetime import datetime, UTC - return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") - """Process a table row and add it to the document.""" - if not line.strip(): - return - - # Clean the line - remove bullet point markers and bold markers - clean_line = line.strip() - if clean_line.startswith('โ€ข'): - clean_line = clean_line[1:] # Remove "โ€ข" - elif clean_line.startswith('- **'): - clean_line = clean_line[4:] # Remove "- **" - elif clean_line.startswith('- '): - clean_line = clean_line[2:] # Remove "- " - elif clean_line.startswith('**'): - clean_line = clean_line[2:] # Remove "**" - - # Remove trailing ** if present - if clean_line.endswith('**'): - clean_line = clean_line[:-2] - - # Split by pipe separator - parts = [part.strip() for part in clean_line.split('|')] - - if len(parts) >= 2: - # This is a table row - create a table if it doesn't exist - if not hasattr(self, '_current_table') or self._current_table is None: - # Create new table - self._current_table = doc.add_table(rows=1, cols=len(parts)) - self._current_table.style = 'Table Grid' - - # Check if this looks like a header row (contains common header words) - is_header = any(word.lower() in clean_line.lower() for word in ['name', 'quantity', 'part', 'number', 'description', 'tag', 'item', 'status']) - - # Add header row - for i, part in enumerate(parts): - if i < len(self._current_table.rows[0].cells): - cell = self._current_table.rows[0].cells[i] - cell.text = part - # Make header bold if it looks like a header - if is_header: - for paragraph in cell.paragraphs: - for run in paragraph.runs: - run.bold = True - else: - # Add data row to existing table - row = self._current_table.add_row() - for i, part in enumerate(parts): - if i < len(row.cells): - row.cells[i].text = part - else: - # Not a table row, treat as regular text - doc.add_paragraph(line) - - def _add_bullet_point(self, doc, text: str): - """Add a bullet point to the document.""" - if not text.strip(): - return - - # Create paragraph with bullet style - para = doc.add_paragraph(text, style='List Bullet') - - # Check for bold text in bullet point - if '**' in text: - # Clear the paragraph and rebuild with formatting - para.clear() - parts = text.split('**') - for i, part in enumerate(parts): - if i % 2 == 0: - # Regular text - if part: - para.add_run(part) - else: - # Bold text - if part: - run = para.add_run(part) - run.bold = True - - def _process_table_row(self, doc, line: str): - """Process a table row and add it to the document.""" - if not line.strip(): - return - - # Split by pipe separator - parts = [part.strip() for part in line.split('|')] - - if len(parts) >= 2: - # This is a table row - create a table if it doesn't exist - if not hasattr(self, '_current_table') or self._current_table is None: - # Create new table - self._current_table = doc.add_table(rows=1, cols=len(parts)) - self._current_table.style = 'Table Grid' - - # Add header row - for i, part in enumerate(parts): - if i < len(self._current_table.rows[0].cells): - cell = self._current_table.rows[0].cells[i] - cell.text = part - # Make header bold - for paragraph in cell.paragraphs: - for run in paragraph.runs: - run.bold = True - else: - # Add data row to existing table - row = self._current_table.add_row() - for i, part in enumerate(parts): - if i < len(row.cells): - row.cells[i].text = part - else: - # Not a table row, treat as regular text - doc.add_paragraph(line) \ No newline at end of file + run.bold = True \ No newline at end of file diff --git a/modules/services/serviceGeneration/renderers/excel_renderer.py b/modules/services/serviceGeneration/renderers/rendererExcel.py similarity index 72% rename from modules/services/serviceGeneration/renderers/excel_renderer.py rename to modules/services/serviceGeneration/renderers/rendererExcel.py index a744e981..142892eb 100644 --- a/modules/services/serviceGeneration/renderers/excel_renderer.py +++ b/modules/services/serviceGeneration/renderers/rendererExcel.py @@ -2,7 +2,7 @@ Excel renderer for report generation using openpyxl. """ -from .base_renderer import BaseRenderer +from .rendererBaseTemplate import BaseRenderer from typing import Dict, Any, Tuple, List import io import base64 @@ -17,7 +17,7 @@ try: except ImportError: OPENPYXL_AVAILABLE = False -class ExcelRenderer(BaseRenderer): +class RendererExcel(BaseRenderer): """Renders content to Excel format using openpyxl.""" @classmethod @@ -35,27 +35,13 @@ class ExcelRenderer(BaseRenderer): """Return priority for Excel renderer.""" return 110 - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only Excel-specific guidelines; global prompt is built centrally.""" - return ( - "EXCEL FORMAT GUIDELINES:\n" - "- Extract structured data from source documents into JSON format\n" - "- Focus on tabular data, lists, and structured information suitable for spreadsheets\n" - "- For tables: Extract headers and rows as separate arrays with clear column names\n" - "- For lists: Extract items with optional sub-items and metadata\n" - "- Structure content into sections with clear content types (table, list, paragraph)\n" - "- Use proper JSON structure with metadata, sections, and elements\n" - "- Ensure data is clean and ready for Excel conversion with proper formatting\n" - "OUTPUT: Return structured JSON that can be converted to Excel format." - ) - async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: """Render extracted JSON content to Excel format using AI-analyzed styling.""" try: if not OPENPYXL_AVAILABLE: # Fallback to CSV if openpyxl not available - from .csv_renderer import CsvRenderer - csv_renderer = CsvRenderer() + from .rendererCsv import RendererCsv + csv_renderer = RendererCsv() csv_content, _ = await csv_renderer.render(extracted_content, title, user_prompt, ai_service) return csv_content, "text/csv" @@ -215,6 +201,10 @@ class ExcelRenderer(BaseRenderer): async def _generate_excel_from_json(self, json_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str: """Generate Excel content from structured JSON document using AI-generated styling.""" try: + # Debug output + print(f"๐Ÿ” EXCEL JSON CONTENT TYPE: {type(json_content)}") + print(f"๐Ÿ” EXCEL JSON CONTENT KEYS: {list(json_content.keys()) if isinstance(json_content, dict) else 'Not a dict'}") + # Get AI-generated styling definitions styles = await self._get_excel_styles(user_prompt, ai_service) @@ -231,11 +221,9 @@ class ExcelRenderer(BaseRenderer): # Create workbook wb = Workbook() - # Remove default sheet - wb.remove(wb.active) - # Create sheets based on content sheets = self._create_excel_sheets(wb, json_content, styles) + print(f"๐Ÿ” EXCEL SHEETS CREATED: {list(sheets.keys()) if sheets else 'None'}") # Populate sheets with content self._populate_excel_sheets(sheets, json_content, styles) @@ -247,7 +235,13 @@ class ExcelRenderer(BaseRenderer): # Convert to base64 excel_bytes = buffer.getvalue() - excel_base64 = base64.b64encode(excel_bytes).decode('utf-8') + print(f"๐Ÿ” EXCEL BYTES LENGTH: {len(excel_bytes)}") + try: + excel_base64 = base64.b64encode(excel_bytes).decode('utf-8') + print(f"๐Ÿ” EXCEL BASE64 LENGTH: {len(excel_base64)}") + except Exception as b64_error: + print(f"๐Ÿ” BASE64 ENCODING ERROR: {b64_error}") + raise return excel_base64 @@ -256,59 +250,38 @@ class ExcelRenderer(BaseRenderer): raise Exception(f"Excel generation failed: {str(e)}") async def _get_excel_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]: - """Simple AI call to get Excel styling definitions.""" - if not ai_service: - return self._get_default_excel_styles() + """Get Excel styling definitions using base template AI styling.""" + style_schema = { + "title": {"font_size": 16, "color": "#FF1F4E79", "bold": True, "align": "center"}, + "heading": {"font_size": 14, "color": "#FF2F2F2F", "bold": True, "align": "left"}, + "table_header": {"background": "#FF4F4F4F", "text_color": "#FFFFFFFF", "bold": True, "align": "center"}, + "table_cell": {"background": "#FFFFFFFF", "text_color": "#FF2F2F2F", "bold": False, "align": "left"}, + "bullet_list": {"font_size": 11, "color": "#FF2F2F2F", "indent": 2}, + "paragraph": {"font_size": 11, "color": "#FF2F2F2F", "bold": False, "align": "left"}, + "code_block": {"font": "Courier New", "font_size": 10, "color": "#FF2F2F2F", "background": "#FFF5F5F5"} + } + style_template = self._create_ai_style_template("xlsx", user_prompt, style_schema) + styles = await self._get_ai_styles(ai_service, style_template, self._get_default_excel_styles()) + + # Convert colors to aRGB format and validate + styles = self._convert_colors_format(styles) + return self._validate_excel_styles_contrast(styles) + + def _convert_colors_format(self, styles: Dict[str, Any]) -> Dict[str, Any]: + """Convert hex colors to aRGB format for Excel compatibility.""" try: - prompt = f""" -For this Excel document request: "{user_prompt}" - -Provide styling definitions for Excel elements. Respond with ONLY JSON: - -{{ - "title": {{"font_size": 16, "color": "#1F4E79", "bold": true, "align": "center"}}, - "heading": {{"font_size": 14, "color": "#2F2F2F", "bold": true, "align": "left"}}, - "table_header": {{"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": true, "align": "center"}}, - "table_cell": {{"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": false, "align": "left"}}, - "bullet_list": {{"font_size": 11, "color": "#2F2F2F", "indent": 2}}, - "paragraph": {{"font_size": 11, "color": "#2F2F2F", "bold": false, "align": "left"}}, - "code_block": {{"font": "Courier New", "font_size": 10, "color": "#2F2F2F", "background": "#F5F5F5"}} -}} - -CRITICAL: Table headers must have dark background with light text, table cells must have light background with dark text for readability. -""" - - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType - - request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL - - request = AiCallRequest(prompt=prompt, context="", options=request_options) - response = await ai_service.aiObjects.call(request) - - import json - import re - - # Clean and parse JSON - result = response.content.strip() - if result.startswith('```json'): - result = re.sub(r'^```json\s*', '', result) - result = re.sub(r'\s*```$', '', result) - elif result.startswith('```'): - result = re.sub(r'^```\s*', '', result) - result = re.sub(r'\s*```$', '', result) - - styles = json.loads(result) - - # Validate and fix contrast issues - styles = self._validate_excel_styles_contrast(styles) - + for style_name, style_config in styles.items(): + if isinstance(style_config, dict): + for prop, value in style_config.items(): + if isinstance(value, str) and value.startswith('#') and len(value) == 7: + # Convert #RRGGBB to #AARRGGBB (add FF alpha channel) + styles[style_name][prop] = f"FF{value[1:]}" + print(f"๐Ÿ” CONVERTED COLOR: {value} โ†’ {styles[style_name][prop]}") return styles - except Exception as e: - self.logger.warning(f"AI styling failed: {str(e)}, using defaults") - return self._get_default_excel_styles() + print(f"๐Ÿ” COLOR CONVERSION ERROR: {e}") + return styles def _validate_excel_styles_contrast(self, styles: Dict[str, Any]) -> Dict[str, Any]: """Validate and fix contrast issues in AI-generated styles.""" @@ -348,15 +321,15 @@ CRITICAL: Table headers must have dark background with light text, table cells m return self._get_default_excel_styles() def _get_default_excel_styles(self) -> Dict[str, Any]: - """Default Excel styles.""" + """Default Excel styles with aRGB color format.""" return { - "title": {"font_size": 16, "color": "#1F4E79", "bold": True, "align": "center"}, - "heading": {"font_size": 14, "color": "#2F2F2F", "bold": True, "align": "left"}, - "table_header": {"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": True, "align": "center"}, - "table_cell": {"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": False, "align": "left"}, - "bullet_list": {"font_size": 11, "color": "#2F2F2F", "indent": 2}, - "paragraph": {"font_size": 11, "color": "#2F2F2F", "bold": False, "align": "left"}, - "code_block": {"font": "Courier New", "font_size": 10, "color": "#2F2F2F", "background": "#F5F5F5"} + "title": {"font_size": 16, "color": "#FF1F4E79", "bold": True, "align": "center"}, + "heading": {"font_size": 14, "color": "#FF2F2F2F", "bold": True, "align": "left"}, + "table_header": {"background": "#FF4F4F4F", "text_color": "#FFFFFFFF", "bold": True, "align": "center"}, + "table_cell": {"background": "#FFFFFFFF", "text_color": "#FF2F2F2F", "bold": False, "align": "left"}, + "bullet_list": {"font_size": 11, "color": "#FF2F2F2F", "indent": 2}, + "paragraph": {"font_size": 11, "color": "#FF2F2F2F", "bold": False, "align": "left"}, + "code_block": {"font": "Courier New", "font_size": 10, "color": "#FF2F2F2F", "background": "#FFF5F5F5"} } def _create_excel_sheets(self, wb: Workbook, json_content: Dict[str, Any], styles: Dict[str, Any]) -> Dict[str, Any]: @@ -365,13 +338,16 @@ CRITICAL: Table headers must have dark background with light text, table cells m # Get sheet names from AI styles or generate based on content sheet_names = styles.get("sheet_names", self._generate_sheet_names_from_content(json_content)) + print(f"๐Ÿ” EXCEL SHEET NAMES: {sheet_names}") # Create sheets for i, sheet_name in enumerate(sheet_names): if i == 0: + # Use the default sheet for the first sheet sheet = wb.active sheet.title = sheet_name else: + # Create additional sheets sheet = wb.create_sheet(sheet_name, i) sheets[sheet_name.lower()] = sheet @@ -437,7 +413,9 @@ CRITICAL: Table headers must have dark background with light text, table cells m document_title = json_content.get("metadata", {}).get("title", "Generated Report") sheet['A1'] = document_title - title_style = styles["title"] + # Safety check for title style + title_style = styles.get("title", {"font_size": 16, "bold": True, "color": "#FF1F4E79", "align": "center"}) + print(f"๐Ÿ” EXCEL TITLE STYLE: {title_style}") sheet['A1'].font = Font(size=title_style["font_size"], bold=title_style["bold"], color=title_style["color"]) sheet['A1'].alignment = Alignment(horizontal=title_style["align"]) @@ -560,6 +538,107 @@ CRITICAL: Table headers must have dark background with light text, table cells m self.logger.warning(f"Could not add section to sheet: {str(e)}") return start_row + 1 + def _add_table_to_excel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], start_row: int) -> int: + """Add a table element to Excel sheet.""" + try: + table_data = element.get("data", {}) + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + + if not headers and not rows: + return start_row + + # Add headers + header_style = styles.get("table_header", {}) + for col, header in enumerate(headers, 1): + cell = sheet.cell(row=start_row, column=col, value=header) + if header_style.get("bold"): + cell.font = Font(bold=True, color=header_style.get("text_color", "#FF000000")) + if header_style.get("background"): + cell.fill = PatternFill(start_color=header_style["background"], end_color=header_style["background"], fill_type="solid") + + start_row += 1 + + # Add rows + cell_style = styles.get("table_cell", {}) + for row_data in rows: + for col, cell_value in enumerate(row_data, 1): + cell = sheet.cell(row=start_row, column=col, value=cell_value) + if cell_style.get("text_color"): + cell.font = Font(color=cell_style["text_color"]) + start_row += 1 + + return start_row + + except Exception as e: + self.logger.warning(f"Could not add table to Excel: {str(e)}") + return start_row + 1 + + def _add_list_to_excel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], start_row: int) -> int: + """Add a list element to Excel sheet.""" + try: + list_items = element.get("items", []) + + list_style = styles.get("bullet_list", {}) + for item in list_items: + sheet.cell(row=start_row, column=1, value=f"โ€ข {item}") + if list_style.get("color"): + sheet.cell(row=start_row, column=1).font = Font(color=list_style["color"]) + start_row += 1 + + return start_row + + except Exception as e: + self.logger.warning(f"Could not add list to Excel: {str(e)}") + return start_row + 1 + + def _add_paragraph_to_excel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], start_row: int) -> int: + """Add a paragraph element to Excel sheet.""" + try: + text = element.get("text", "") + if text: + sheet.cell(row=start_row, column=1, value=text) + + paragraph_style = styles.get("paragraph", {}) + if paragraph_style.get("color"): + sheet.cell(row=start_row, column=1).font = Font(color=paragraph_style["color"]) + + start_row += 1 + + return start_row + + except Exception as e: + self.logger.warning(f"Could not add paragraph to Excel: {str(e)}") + return start_row + 1 + + def _add_heading_to_excel(self, sheet, element: Dict[str, Any], styles: Dict[str, Any], start_row: int) -> int: + """Add a heading element to Excel sheet.""" + try: + text = element.get("text", "") + level = element.get("level", 1) + + if text: + sheet.cell(row=start_row, column=1, value=text) + + heading_style = styles.get("heading", {}) + font_size = heading_style.get("font_size", 14) + if level > 1: + font_size = max(10, font_size - (level - 1) * 2) + + sheet.cell(row=start_row, column=1).font = Font( + size=font_size, + bold=True, + color=heading_style.get("color", "#FF000000") + ) + + start_row += 1 + + return start_row + + except Exception as e: + self.logger.warning(f"Could not add heading to Excel: {str(e)}") + return start_row + 1 + def _format_timestamp(self) -> str: """Format current timestamp for document generation.""" return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") diff --git a/modules/services/serviceGeneration/renderers/rendererHtml.py b/modules/services/serviceGeneration/renderers/rendererHtml.py new file mode 100644 index 00000000..6950712e --- /dev/null +++ b/modules/services/serviceGeneration/renderers/rendererHtml.py @@ -0,0 +1,463 @@ +""" +HTML renderer for report generation. +""" + +from .rendererBaseTemplate import BaseRenderer +from typing import Dict, Any, Tuple, List + +class RendererHtml(BaseRenderer): + """Renders content to HTML format with format-specific extraction.""" + + @classmethod + def get_supported_formats(cls) -> List[str]: + """Return supported HTML formats.""" + return ['html', 'htm'] + + @classmethod + def get_format_aliases(cls) -> List[str]: + """Return format aliases.""" + return ['web', 'webpage'] + + @classmethod + def get_priority(cls) -> int: + """Return priority for HTML renderer.""" + return 100 + + async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: + """Render extracted JSON content to HTML format using AI-analyzed styling.""" + try: + # Generate HTML using AI-analyzed styling + html_content = await self._generate_html_from_json(extracted_content, title, user_prompt, ai_service) + + return html_content, "text/html" + + except Exception as e: + self.logger.error(f"Error rendering HTML: {str(e)}") + # Return minimal HTML fallback + return f"{title}

{title}

Error rendering report: {str(e)}

", "text/html" + + async def _generate_html_from_json(self, json_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str: + """Generate HTML content from structured JSON document using AI-generated styling.""" + try: + # Get AI-generated styling definitions + styles = await self._get_html_styles(user_prompt, ai_service) + + # Validate JSON structure + if not isinstance(json_content, dict): + raise ValueError("JSON content must be a dictionary") + + if "sections" not in json_content: + raise ValueError("JSON content must contain 'sections' field") + + # Use title from JSON metadata if available, otherwise use provided title + document_title = json_content.get("metadata", {}).get("title", title) + + # Build HTML document + html_parts = [] + + # HTML document structure + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append('') + html_parts.append(f'{document_title}') + html_parts.append('') + html_parts.append('') + html_parts.append('') + + # Document header + html_parts.append(f'

{document_title}

') + + # Main content + html_parts.append('
') + + # Process each section + sections = json_content.get("sections", []) + for section in sections: + section_html = self._render_json_section(section, styles) + if section_html: + html_parts.append(section_html) + + html_parts.append('
') + + # Footer + html_parts.append('') + + html_parts.append('') + html_parts.append('') + + return '\n'.join(html_parts) + + except Exception as e: + self.logger.error(f"Error generating HTML from JSON: {str(e)}") + raise Exception(f"HTML generation failed: {str(e)}") + + async def _get_html_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]: + """Simple AI call to get HTML styling definitions.""" + if not ai_service: + return self._get_default_html_styles() + + try: + prompt = f"""Return this exact JSON structure with your styling customizations: + +{{ + "title": {{"font_size": "2.5em", "color": "#1F4E79", "font_weight": "bold", "text_align": "center", "margin": "0 0 1em 0"}}, + "heading1": {{"font_size": "2em", "color": "#2F2F2F", "font_weight": "bold", "text_align": "left", "margin": "1.5em 0 0.5em 0"}}, + "heading2": {{"font_size": "1.5em", "color": "#4F4F4F", "font_weight": "bold", "text_align": "left", "margin": "1em 0 0.5em 0"}}, + "paragraph": {{"font_size": "1em", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "margin": "0 0 1em 0", "line_height": "1.6"}}, + "table": {{"border": "1px solid #ddd", "border_collapse": "collapse", "width": "100%", "margin": "1em 0"}}, + "table_header": {{"background": "#4F4F4F", "color": "#FFFFFF", "font_weight": "bold", "text_align": "center", "padding": "12px"}}, + "table_cell": {{"background": "#FFFFFF", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "padding": "8px", "border": "1px solid #ddd"}}, + "bullet_list": {{"font_size": "1em", "color": "#2F2F2F", "margin": "0 0 1em 0", "padding_left": "20px"}}, + "code_block": {{"font_family": "Courier New, monospace", "font_size": "0.9em", "color": "#2F2F2F", "background": "#F5F5F5", "padding": "1em", "border": "1px solid #ddd", "border_radius": "4px", "margin": "1em 0"}}, + "image": {{"max_width": "100%", "height": "auto", "margin": "1em 0", "border_radius": "4px"}}, + "body": {{"font_family": "Arial, sans-serif", "background": "#FFFFFF", "color": "#2F2F2F", "margin": "0", "padding": "20px"}} +}} + +NO TEXT. NO EXPLANATIONS. NO MARKDOWN. NO WRAPPER OBJECTS. ONLY THE JSON ABOVE.""" + + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + + request_options = AiCallOptions() + request_options.operationType = OperationType.GENERAL + + request = AiCallRequest(prompt=prompt, context="", options=request_options) + response = await ai_service.aiObjects.call(request) + + import json + import re + + # Clean and parse JSON + result = response.content.strip() if response and response.content else "" + + # Check if result is empty + if not result: + self.logger.warning("AI styling returned empty response, using defaults") + return self._get_default_html_styles() + + # Extract JSON from markdown code blocks + json_match = re.search(r'```json\s*\n(.*?)\n```', result, re.DOTALL) + if json_match: + result = json_match.group(1).strip() + elif result.startswith('```json'): + result = re.sub(r'^```json\s*', '', result) + result = re.sub(r'\s*```$', '', result) + elif result.startswith('```'): + result = re.sub(r'^```\s*', '', result) + result = re.sub(r'\s*```$', '', result) + + # Try to parse JSON + try: + styles = json.loads(result) + except json.JSONDecodeError as json_error: + self.logger.warning(f"AI styling returned invalid JSON: {json_error}, using defaults") + return self._get_default_html_styles() + + # Validate and fix contrast issues + styles = self._validate_html_styles_contrast(styles) + + return styles + + except Exception as e: + self.logger.warning(f"AI styling failed: {str(e)}, using defaults") + return self._get_default_html_styles() + + def _validate_html_styles_contrast(self, styles: Dict[str, Any]) -> Dict[str, Any]: + """Validate and fix contrast issues in AI-generated styles.""" + try: + # Fix table header contrast + if "table_header" in styles: + header = styles["table_header"] + bg_color = header.get("background", "#FFFFFF") + text_color = header.get("color", "#000000") + + # If both are white or both are dark, fix it + if bg_color.upper() == "#FFFFFF" and text_color.upper() == "#FFFFFF": + header["background"] = "#4F4F4F" + header["color"] = "#FFFFFF" + elif bg_color.upper() == "#000000" and text_color.upper() == "#000000": + header["background"] = "#4F4F4F" + header["color"] = "#FFFFFF" + + # Fix table cell contrast + if "table_cell" in styles: + cell = styles["table_cell"] + bg_color = cell.get("background", "#FFFFFF") + text_color = cell.get("color", "#000000") + + # If both are white or both are dark, fix it + if bg_color.upper() == "#FFFFFF" and text_color.upper() == "#FFFFFF": + cell["background"] = "#FFFFFF" + cell["color"] = "#2F2F2F" + elif bg_color.upper() == "#000000" and text_color.upper() == "#000000": + cell["background"] = "#FFFFFF" + cell["color"] = "#2F2F2F" + + return styles + + except Exception as e: + self.logger.warning(f"Style validation failed: {str(e)}") + return self._get_default_html_styles() + + def _get_default_html_styles(self) -> Dict[str, Any]: + """Default HTML styles.""" + return { + "title": {"font_size": "2.5em", "color": "#1F4E79", "font_weight": "bold", "text_align": "center", "margin": "0 0 1em 0"}, + "heading1": {"font_size": "2em", "color": "#2F2F2F", "font_weight": "bold", "text_align": "left", "margin": "1.5em 0 0.5em 0"}, + "heading2": {"font_size": "1.5em", "color": "#4F4F4F", "font_weight": "bold", "text_align": "left", "margin": "1em 0 0.5em 0"}, + "paragraph": {"font_size": "1em", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "margin": "0 0 1em 0", "line_height": "1.6"}, + "table": {"border": "1px solid #ddd", "border_collapse": "collapse", "width": "100%", "margin": "1em 0"}, + "table_header": {"background": "#4F4F4F", "color": "#FFFFFF", "font_weight": "bold", "text_align": "center", "padding": "12px"}, + "table_cell": {"background": "#FFFFFF", "color": "#2F2F2F", "font_weight": "normal", "text_align": "left", "padding": "8px", "border": "1px solid #ddd"}, + "bullet_list": {"font_size": "1em", "color": "#2F2F2F", "margin": "0 0 1em 0", "padding_left": "20px"}, + "code_block": {"font_family": "Courier New, monospace", "font_size": "0.9em", "color": "#2F2F2F", "background": "#F5F5F5", "padding": "1em", "border": "1px solid #ddd", "border_radius": "4px", "margin": "1em 0"}, + "image": {"max_width": "100%", "height": "auto", "margin": "1em 0", "border_radius": "4px"}, + "body": {"font_family": "Arial, sans-serif", "background": "#FFFFFF", "color": "#2F2F2F", "margin": "0", "padding": "20px"} + } + + def _generate_css_styles(self, styles: Dict[str, Any]) -> str: + """Generate CSS from style definitions.""" + css_parts = [] + + # Body styles + body_style = styles.get("body", {}) + css_parts.append("body {") + for property_name, value in body_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Document title + title_style = styles.get("title", {}) + css_parts.append(".document-title {") + for property_name, value in title_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Headings + for heading_level in ["heading1", "heading2"]: + heading_style = styles.get(heading_level, {}) + css_class = f"h{heading_level[-1]}" + css_parts.append(f"{css_class} {{") + for property_name, value in heading_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Paragraphs + paragraph_style = styles.get("paragraph", {}) + css_parts.append("p {") + for property_name, value in paragraph_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Tables + table_style = styles.get("table", {}) + css_parts.append("table {") + for property_name, value in table_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Table headers + table_header_style = styles.get("table_header", {}) + css_parts.append("th {") + for property_name, value in table_header_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Table cells + table_cell_style = styles.get("table_cell", {}) + css_parts.append("td {") + for property_name, value in table_cell_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Lists + bullet_list_style = styles.get("bullet_list", {}) + css_parts.append("ul {") + for property_name, value in bullet_list_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Code blocks + code_block_style = styles.get("code_block", {}) + css_parts.append("pre {") + for property_name, value in code_block_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Images + image_style = styles.get("image", {}) + css_parts.append("img {") + for property_name, value in image_style.items(): + css_property = property_name.replace("_", "-") + css_parts.append(f" {css_property}: {value};") + css_parts.append("}") + + # Generated info + css_parts.append(".generated-info {") + css_parts.append(" font-size: 0.9em;") + css_parts.append(" color: #666;") + css_parts.append(" text-align: center;") + css_parts.append(" margin-top: 2em;") + css_parts.append(" padding-top: 1em;") + css_parts.append(" border-top: 1px solid #ddd;") + css_parts.append("}") + + return '\n'.join(css_parts) + + def _render_json_section(self, section: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a single JSON section to HTML using AI-generated styles.""" + try: + section_type = self._get_section_type(section) + section_data = self._get_section_data(section) + + if section_type == "table": + return self._render_json_table(section_data, styles) + elif section_type == "bullet_list": + return self._render_json_bullet_list(section_data, styles) + elif section_type == "heading": + return self._render_json_heading(section_data, styles) + elif section_type == "paragraph": + return self._render_json_paragraph(section_data, styles) + elif section_type == "code_block": + return self._render_json_code_block(section_data, styles) + elif section_type == "image": + return self._render_json_image(section_data, styles) + else: + # Fallback to paragraph for unknown types + return self._render_json_paragraph(section_data, styles) + + except Exception as e: + self.logger.warning(f"Error rendering section {self._get_section_id(section)}: {str(e)}") + return f'
[Error rendering section: {str(e)}]
' + + def _render_json_table(self, table_data: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a JSON table to HTML using AI-generated styles.""" + try: + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + + if not headers or not rows: + return "" + + html_parts = [''] + + # Table header + html_parts.append('') + for header in headers: + html_parts.append(f'') + html_parts.append('') + + # Table body + html_parts.append('') + for row in rows: + html_parts.append('') + for cell_data in row: + html_parts.append(f'') + html_parts.append('') + html_parts.append('') + + html_parts.append('
{header}
{cell_data}
') + return '\n'.join(html_parts) + + except Exception as e: + self.logger.warning(f"Error rendering table: {str(e)}") + return "" + + def _render_json_bullet_list(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a JSON bullet list to HTML using AI-generated styles.""" + try: + items = list_data.get("items", []) + + if not items: + return "" + + html_parts = ['') + + return '\n'.join(html_parts) + + except Exception as e: + self.logger.warning(f"Error rendering bullet list: {str(e)}") + return "" + + def _render_json_heading(self, heading_data: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a JSON heading to HTML using AI-generated styles.""" + try: + level = heading_data.get("level", 1) + text = heading_data.get("text", "") + + if text: + level = max(1, min(6, level)) + return f'{text}' + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering heading: {str(e)}") + return "" + + def _render_json_paragraph(self, paragraph_data: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a JSON paragraph to HTML using AI-generated styles.""" + try: + text = paragraph_data.get("text", "") + + if text: + return f'

{text}

' + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering paragraph: {str(e)}") + return "" + + def _render_json_code_block(self, code_data: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a JSON code block to HTML using AI-generated styles.""" + try: + code = code_data.get("code", "") + language = code_data.get("language", "") + + if code: + if language: + return f'
{code}
' + else: + return f'
{code}
' + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering code block: {str(e)}") + return "" + + def _render_json_image(self, image_data: Dict[str, Any], styles: Dict[str, Any]) -> str: + """Render a JSON image to HTML.""" + try: + base64_data = image_data.get("base64Data", "") + alt_text = image_data.get("altText", "Image") + + if base64_data: + return f'{alt_text}' + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering image: {str(e)}") + return f'
[Image: {image_data.get("altText", "Image")}]
' diff --git a/modules/services/serviceGeneration/renderers/rendererJson.py b/modules/services/serviceGeneration/renderers/rendererJson.py new file mode 100644 index 00000000..17555b6f --- /dev/null +++ b/modules/services/serviceGeneration/renderers/rendererJson.py @@ -0,0 +1,79 @@ +""" +JSON renderer for report generation. +""" + +from .rendererBaseTemplate import BaseRenderer +from typing import Dict, Any, Tuple, List +import json + +class RendererJson(BaseRenderer): + """Renders content to JSON format with format-specific extraction.""" + + @classmethod + def get_supported_formats(cls) -> List[str]: + """Return supported JSON formats.""" + return ['json'] + + @classmethod + def get_format_aliases(cls) -> List[str]: + """Return format aliases.""" + return ['data'] + + @classmethod + def get_priority(cls) -> int: + """Return priority for JSON renderer.""" + return 80 + + async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: + """Render extracted JSON content to JSON format.""" + try: + # The extracted content should already be JSON from the AI + # Just validate and format it + json_content = self._clean_json_content(extracted_content, title) + + return json_content, "application/json" + + except Exception as e: + self.logger.error(f"Error rendering JSON: {str(e)}") + # Return minimal JSON fallback + fallback_data = { + "title": title, + "sections": [{"type": "paragraph", "data": {"text": f"Error rendering report: {str(e)}"}}], + "metadata": {"error": str(e)} + } + return json.dumps(fallback_data, indent=2), "application/json" + + def _clean_json_content(self, content: Dict[str, Any], title: str) -> str: + """Clean and validate JSON content from AI.""" + try: + # Validate JSON structure + if not isinstance(content, dict): + raise ValueError("Content must be a dictionary") + + # Ensure it has the expected structure + if "sections" not in content: + # Convert old format to new format + content = { + "sections": [{"type": "paragraph", "data": {"text": str(content)}}], + "metadata": {"title": title} + } + + # Ensure metadata exists + if "metadata" not in content: + content["metadata"] = {} + + # Set title in metadata if not present + if "title" not in content["metadata"]: + content["metadata"]["title"] = title + + # Re-format with proper indentation + return json.dumps(content, indent=2, ensure_ascii=False) + + except Exception as e: + self.logger.warning(f"Error cleaning JSON content: {str(e)}") + # Return minimal valid JSON + fallback_data = { + "sections": [{"type": "paragraph", "data": {"text": str(content)}}], + "metadata": {"title": title, "error": str(e)} + } + return json.dumps(fallback_data, indent=2, ensure_ascii=False) diff --git a/modules/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/services/serviceGeneration/renderers/rendererMarkdown.py new file mode 100644 index 00000000..61f0bebc --- /dev/null +++ b/modules/services/serviceGeneration/renderers/rendererMarkdown.py @@ -0,0 +1,213 @@ +""" +Markdown renderer for report generation. +""" + +from .rendererBaseTemplate import BaseRenderer +from typing import Dict, Any, Tuple, List + +class RendererMarkdown(BaseRenderer): + """Renders content to Markdown format with format-specific extraction.""" + + @classmethod + def get_supported_formats(cls) -> List[str]: + """Return supported Markdown formats.""" + return ['md', 'markdown'] + + @classmethod + def get_format_aliases(cls) -> List[str]: + """Return format aliases.""" + return ['mdown', 'mkd'] + + @classmethod + def get_priority(cls) -> int: + """Return priority for markdown renderer.""" + return 95 + + async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: + """Render extracted JSON content to Markdown format.""" + try: + # Generate markdown from JSON structure + markdown_content = self._generate_markdown_from_json(extracted_content, title) + + return markdown_content, "text/markdown" + + except Exception as e: + self.logger.error(f"Error rendering markdown: {str(e)}") + # Return minimal markdown fallback + return f"# {title}\n\nError rendering report: {str(e)}", "text/markdown" + + def _generate_markdown_from_json(self, json_content: Dict[str, Any], title: str) -> str: + """Generate markdown content from structured JSON document.""" + try: + # Validate JSON structure + if not isinstance(json_content, dict): + raise ValueError("JSON content must be a dictionary") + + if "sections" not in json_content: + raise ValueError("JSON content must contain 'sections' field") + + # Use title from JSON metadata if available, otherwise use provided title + document_title = json_content.get("metadata", {}).get("title", title) + + # Build markdown content + markdown_parts = [] + + # Document title + markdown_parts.append(f"# {document_title}") + markdown_parts.append("") + + # Process each section + sections = json_content.get("sections", []) + for section in sections: + section_markdown = self._render_json_section(section) + if section_markdown: + markdown_parts.append(section_markdown) + markdown_parts.append("") # Add spacing between sections + + # Add generation info + markdown_parts.append("---") + markdown_parts.append(f"*Generated: {self._format_timestamp()}*") + + return '\n'.join(markdown_parts) + + except Exception as e: + self.logger.error(f"Error generating markdown from JSON: {str(e)}") + raise Exception(f"Markdown generation failed: {str(e)}") + + def _render_json_section(self, section: Dict[str, Any]) -> str: + """Render a single JSON section to markdown.""" + try: + section_type = self._get_section_type(section) + section_data = self._get_section_data(section) + + if section_type == "table": + return self._render_json_table(section_data) + elif section_type == "bullet_list": + return self._render_json_bullet_list(section_data) + elif section_type == "heading": + return self._render_json_heading(section_data) + elif section_type == "paragraph": + return self._render_json_paragraph(section_data) + elif section_type == "code_block": + return self._render_json_code_block(section_data) + elif section_type == "image": + return self._render_json_image(section_data) + else: + # Fallback to paragraph for unknown types + return self._render_json_paragraph(section_data) + + except Exception as e: + self.logger.warning(f"Error rendering section {self._get_section_id(section)}: {str(e)}") + return f"*[Error rendering section: {str(e)}]*" + + def _render_json_table(self, table_data: Dict[str, Any]) -> str: + """Render a JSON table to markdown.""" + try: + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + + if not headers or not rows: + return "" + + markdown_parts = [] + + # Create table header + header_line = " | ".join(str(header) for header in headers) + markdown_parts.append(header_line) + + # Add separator line + separator_line = " | ".join("---" for _ in headers) + markdown_parts.append(separator_line) + + # Add data rows + for row in rows: + row_line = " | ".join(str(cell_data) for cell_data in row) + markdown_parts.append(row_line) + + return '\n'.join(markdown_parts) + + except Exception as e: + self.logger.warning(f"Error rendering table: {str(e)}") + return "" + + def _render_json_bullet_list(self, list_data: Dict[str, Any]) -> str: + """Render a JSON bullet list to markdown.""" + try: + items = list_data.get("items", []) + + if not items: + return "" + + markdown_parts = [] + for item in items: + if isinstance(item, str): + markdown_parts.append(f"- {item}") + elif isinstance(item, dict) and "text" in item: + markdown_parts.append(f"- {item['text']}") + + return '\n'.join(markdown_parts) + + except Exception as e: + self.logger.warning(f"Error rendering bullet list: {str(e)}") + return "" + + def _render_json_heading(self, heading_data: Dict[str, Any]) -> str: + """Render a JSON heading to markdown.""" + try: + level = heading_data.get("level", 1) + text = heading_data.get("text", "") + + if text: + level = max(1, min(6, level)) + return f"{'#' * level} {text}" + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering heading: {str(e)}") + return "" + + def _render_json_paragraph(self, paragraph_data: Dict[str, Any]) -> str: + """Render a JSON paragraph to markdown.""" + try: + text = paragraph_data.get("text", "") + return text if text else "" + + except Exception as e: + self.logger.warning(f"Error rendering paragraph: {str(e)}") + return "" + + def _render_json_code_block(self, code_data: Dict[str, Any]) -> str: + """Render a JSON code block to markdown.""" + try: + code = code_data.get("code", "") + language = code_data.get("language", "") + + if code: + if language: + return f"```{language}\n{code}\n```" + else: + return f"```\n{code}\n```" + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering code block: {str(e)}") + return "" + + def _render_json_image(self, image_data: Dict[str, Any]) -> str: + """Render a JSON image to markdown.""" + try: + alt_text = image_data.get("altText", "Image") + base64_data = image_data.get("base64Data", "") + + if base64_data: + # For base64 images, we can't embed them directly in markdown + # So we'll use a placeholder with the alt text + return f"![{alt_text}](data:image/png;base64,{base64_data[:50]}...)" + else: + return f"![{alt_text}](image-placeholder)" + + except Exception as e: + self.logger.warning(f"Error rendering image: {str(e)}") + return f"![{image_data.get('altText', 'Image')}](image-error)" diff --git a/modules/services/serviceGeneration/renderers/rendererPdf.py b/modules/services/serviceGeneration/renderers/rendererPdf.py new file mode 100644 index 00000000..43c0ce6d --- /dev/null +++ b/modules/services/serviceGeneration/renderers/rendererPdf.py @@ -0,0 +1,416 @@ +""" +PDF renderer for report generation using reportlab. +""" + +from .rendererBaseTemplate import BaseRenderer +from typing import Dict, Any, Tuple, List +import io +import base64 +from datetime import datetime, UTC + +try: + from reportlab.lib.pagesizes import letter, A4 + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import inch + from reportlab.lib import colors + from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_JUSTIFY + REPORTLAB_AVAILABLE = True +except ImportError: + REPORTLAB_AVAILABLE = False + +class RendererPdf(BaseRenderer): + """Renders content to PDF format using reportlab.""" + + @classmethod + def get_supported_formats(cls) -> List[str]: + """Return supported PDF formats.""" + return ['pdf'] + + @classmethod + def get_format_aliases(cls) -> List[str]: + """Return format aliases.""" + return ['document', 'print'] + + @classmethod + def get_priority(cls) -> int: + """Return priority for PDF renderer.""" + return 120 + + async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: + """Render extracted JSON content to PDF format using AI-analyzed styling.""" + try: + if not REPORTLAB_AVAILABLE: + # Fallback to HTML if reportlab not available + from .rendererHtml import RendererHtml + html_renderer = RendererHtml() + html_content, _ = await html_renderer.render(extracted_content, title, user_prompt, ai_service) + return html_content, "text/html" + + # Generate PDF using AI-analyzed styling + pdf_content = await self._generate_pdf_from_json(extracted_content, title, user_prompt, ai_service) + + return pdf_content, "application/pdf" + + except Exception as e: + self.logger.error(f"Error rendering PDF: {str(e)}") + # Return minimal fallback + return f"PDF Generation Error: {str(e)}", "text/plain" + + async def _generate_pdf_from_json(self, json_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> str: + """Generate PDF content from structured JSON document using AI-generated styling.""" + try: + # Get AI-generated styling definitions + styles = await self._get_pdf_styles(user_prompt, ai_service) + + # Validate JSON structure + if not isinstance(json_content, dict): + raise ValueError("JSON content must be a dictionary") + + if "sections" not in json_content: + raise ValueError("JSON content must contain 'sections' field") + + # Use title from JSON metadata if available, otherwise use provided title + document_title = json_content.get("metadata", {}).get("title", title) + + # Create a buffer to hold the PDF + buffer = io.BytesIO() + + # Create PDF document + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=72, + leftMargin=72, + topMargin=72, + bottomMargin=18 + ) + + # Build PDF content + story = [] + + # Title page + title_style = self._create_title_style(styles) + story.append(Paragraph(document_title, title_style)) + story.append(Spacer(1, 20)) + story.append(Paragraph(f"Generated: {self._format_timestamp()}", self._create_normal_style(styles))) + story.append(PageBreak()) + + # Process each section + sections = json_content.get("sections", []) + for section in sections: + section_elements = self._render_json_section(section, styles) + story.extend(section_elements) + + # Build PDF + doc.build(story) + + # Get PDF content as base64 + buffer.seek(0) + pdf_bytes = buffer.getvalue() + pdf_base64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return pdf_base64 + + except Exception as e: + self.logger.error(f"Error generating PDF from JSON: {str(e)}") + raise Exception(f"PDF generation failed: {str(e)}") + + async def _get_pdf_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]: + """Get PDF styling definitions using base template AI styling.""" + style_schema = { + "title": {"font_size": 24, "color": "#1F4E79", "bold": True, "align": "center", "space_after": 30}, + "heading1": {"font_size": 18, "color": "#2F2F2F", "bold": True, "align": "left", "space_after": 12, "space_before": 12}, + "heading2": {"font_size": 14, "color": "#4F4F4F", "bold": True, "align": "left", "space_after": 8, "space_before": 8}, + "paragraph": {"font_size": 11, "color": "#2F2F2F", "bold": False, "align": "left", "space_after": 6, "line_height": 1.2}, + "table_header": {"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": True, "align": "center", "font_size": 12}, + "table_cell": {"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": False, "align": "left", "font_size": 10}, + "bullet_list": {"font_size": 11, "color": "#2F2F2F", "space_after": 3}, + "code_block": {"font": "Courier", "font_size": 9, "color": "#2F2F2F", "background": "#F5F5F5", "space_after": 6} + } + + style_template = self._create_ai_style_template("pdf", user_prompt, style_schema) + styles = await self._get_ai_styles(ai_service, style_template, self._get_default_pdf_styles()) + + # Validate and fix contrast issues + return self._validate_pdf_styles_contrast(styles) + + def _validate_pdf_styles_contrast(self, styles: Dict[str, Any]) -> Dict[str, Any]: + """Validate and fix contrast issues in AI-generated styles.""" + try: + # Fix table header contrast + if "table_header" in styles: + header = styles["table_header"] + bg_color = header.get("background", "#FFFFFF") + text_color = header.get("text_color", "#000000") + + # If both are white or both are dark, fix it + if bg_color.upper() == "#FFFFFF" and text_color.upper() == "#FFFFFF": + header["background"] = "#4F4F4F" + header["text_color"] = "#FFFFFF" + elif bg_color.upper() == "#000000" and text_color.upper() == "#000000": + header["background"] = "#4F4F4F" + header["text_color"] = "#FFFFFF" + + # Fix table cell contrast + if "table_cell" in styles: + cell = styles["table_cell"] + bg_color = cell.get("background", "#FFFFFF") + text_color = cell.get("text_color", "#000000") + + # If both are white or both are dark, fix it + if bg_color.upper() == "#FFFFFF" and text_color.upper() == "#FFFFFF": + cell["background"] = "#FFFFFF" + cell["text_color"] = "#2F2F2F" + elif bg_color.upper() == "#000000" and text_color.upper() == "#000000": + cell["background"] = "#FFFFFF" + cell["text_color"] = "#2F2F2F" + + return styles + + except Exception as e: + self.logger.warning(f"Style validation failed: {str(e)}") + return self._get_default_pdf_styles() + + def _get_default_pdf_styles(self) -> Dict[str, Any]: + """Default PDF styles.""" + return { + "title": {"font_size": 24, "color": "#1F4E79", "bold": True, "align": "center", "space_after": 30}, + "heading1": {"font_size": 18, "color": "#2F2F2F", "bold": True, "align": "left", "space_after": 12, "space_before": 12}, + "heading2": {"font_size": 14, "color": "#4F4F4F", "bold": True, "align": "left", "space_after": 8, "space_before": 8}, + "paragraph": {"font_size": 11, "color": "#2F2F2F", "bold": False, "align": "left", "space_after": 6, "line_height": 1.2}, + "table_header": {"background": "#4F4F4F", "text_color": "#FFFFFF", "bold": True, "align": "center", "font_size": 12}, + "table_cell": {"background": "#FFFFFF", "text_color": "#2F2F2F", "bold": False, "align": "left", "font_size": 10}, + "bullet_list": {"font_size": 11, "color": "#2F2F2F", "space_after": 3}, + "code_block": {"font": "Courier", "font_size": 9, "color": "#2F2F2F", "background": "#F5F5F5", "space_after": 6} + } + + def _create_title_style(self, styles: Dict[str, Any]) -> ParagraphStyle: + """Create title style from style definitions.""" + title_style_def = styles.get("title", {}) + + return ParagraphStyle( + 'CustomTitle', + fontSize=title_style_def.get("font_size", 24), + spaceAfter=title_style_def.get("space_after", 30), + alignment=self._get_alignment(title_style_def.get("align", "center")), + textColor=self._hex_to_color(title_style_def.get("color", "#1F4E79")) + ) + + def _create_heading_style(self, styles: Dict[str, Any], level: int) -> ParagraphStyle: + """Create heading style from style definitions.""" + heading_key = f"heading{level}" + heading_style_def = styles.get(heading_key, styles.get("heading1", {})) + + return ParagraphStyle( + f'CustomHeading{level}', + fontSize=heading_style_def.get("font_size", 18 - level * 2), + spaceAfter=heading_style_def.get("space_after", 12), + spaceBefore=heading_style_def.get("space_before", 12), + alignment=self._get_alignment(heading_style_def.get("align", "left")), + textColor=self._hex_to_color(heading_style_def.get("color", "#2F2F2F")) + ) + + def _create_normal_style(self, styles: Dict[str, Any]) -> ParagraphStyle: + """Create normal paragraph style from style definitions.""" + paragraph_style_def = styles.get("paragraph", {}) + + return ParagraphStyle( + 'CustomNormal', + fontSize=paragraph_style_def.get("font_size", 11), + spaceAfter=paragraph_style_def.get("space_after", 6), + alignment=self._get_alignment(paragraph_style_def.get("align", "left")), + textColor=self._hex_to_color(paragraph_style_def.get("color", "#2F2F2F")), + leading=paragraph_style_def.get("line_height", 1.2) * paragraph_style_def.get("font_size", 11) + ) + + def _get_alignment(self, align: str) -> int: + """Convert alignment string to reportlab alignment constant.""" + align_map = { + "center": TA_CENTER, + "left": TA_LEFT, + "justify": TA_JUSTIFY + } + return align_map.get(align.lower(), TA_LEFT) + + def _hex_to_color(self, hex_color: str) -> colors.Color: + """Convert hex color to reportlab color.""" + try: + hex_color = hex_color.lstrip('#') + r = int(hex_color[0:2], 16) / 255.0 + g = int(hex_color[2:4], 16) / 255.0 + b = int(hex_color[4:6], 16) / 255.0 + return colors.Color(r, g, b) + except: + return colors.black + + def _render_json_section(self, section: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a single JSON section to PDF elements using AI-generated styles.""" + try: + section_type = self._get_section_type(section) + section_data = self._get_section_data(section) + + if section_type == "table": + return self._render_json_table(section_data, styles) + elif section_type == "bullet_list": + return self._render_json_bullet_list(section_data, styles) + elif section_type == "heading": + return self._render_json_heading(section_data, styles) + elif section_type == "paragraph": + return self._render_json_paragraph(section_data, styles) + elif section_type == "code_block": + return self._render_json_code_block(section_data, styles) + elif section_type == "image": + return self._render_json_image(section_data, styles) + else: + # Fallback to paragraph for unknown types + return self._render_json_paragraph(section_data, styles) + + except Exception as e: + self.logger.warning(f"Error rendering section {self._get_section_id(section)}: {str(e)}") + return [Paragraph(f"[Error rendering section: {str(e)}]", self._create_normal_style(styles))] + + def _render_json_table(self, table_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a JSON table to PDF elements using AI-generated styles.""" + try: + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + + if not headers or not rows: + return [] + + # Prepare table data + table_data_list = [headers] + rows + + # Create table + table = Table(table_data_list) + + # Apply styling + table_header_style = styles.get("table_header", {}) + table_cell_style = styles.get("table_cell", {}) + + table_style = [ + ('BACKGROUND', (0, 0), (-1, 0), self._hex_to_color(table_header_style.get("background", "#4F4F4F"))), + ('TEXTCOLOR', (0, 0), (-1, 0), self._hex_to_color(table_header_style.get("text_color", "#FFFFFF"))), + ('ALIGN', (0, 0), (-1, -1), self._get_alignment(table_cell_style.get("align", "left"))), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold' if table_header_style.get("bold", True) else 'Helvetica'), + ('FONTSIZE', (0, 0), (-1, 0), table_header_style.get("font_size", 12)), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), self._hex_to_color(table_cell_style.get("background", "#FFFFFF"))), + ('FONTSIZE', (0, 1), (-1, -1), table_cell_style.get("font_size", 10)), + ('GRID', (0, 0), (-1, -1), 1, colors.black) + ] + + table.setStyle(TableStyle(table_style)) + + return [table, Spacer(1, 12)] + + except Exception as e: + self.logger.warning(f"Error rendering table: {str(e)}") + return [] + + def _render_json_bullet_list(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a JSON bullet list to PDF elements using AI-generated styles.""" + try: + items = list_data.get("items", []) + bullet_style_def = styles.get("bullet_list", {}) + + elements = [] + for item in items: + if isinstance(item, str): + elements.append(Paragraph(f"โ€ข {item}", self._create_normal_style(styles))) + elif isinstance(item, dict) and "text" in item: + elements.append(Paragraph(f"โ€ข {item['text']}", self._create_normal_style(styles))) + + if elements: + elements.append(Spacer(1, bullet_style_def.get("space_after", 3))) + + return elements + + except Exception as e: + self.logger.warning(f"Error rendering bullet list: {str(e)}") + return [] + + def _render_json_heading(self, heading_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a JSON heading to PDF elements using AI-generated styles.""" + try: + level = heading_data.get("level", 1) + text = heading_data.get("text", "") + + if text: + level = max(1, min(6, level)) + heading_style = self._create_heading_style(styles, level) + return [Paragraph(text, heading_style)] + + return [] + + except Exception as e: + self.logger.warning(f"Error rendering heading: {str(e)}") + return [] + + def _render_json_paragraph(self, paragraph_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a JSON paragraph to PDF elements using AI-generated styles.""" + try: + text = paragraph_data.get("text", "") + + if text: + return [Paragraph(text, self._create_normal_style(styles))] + + return [] + + except Exception as e: + self.logger.warning(f"Error rendering paragraph: {str(e)}") + return [] + + def _render_json_code_block(self, code_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a JSON code block to PDF elements using AI-generated styles.""" + try: + code = code_data.get("code", "") + language = code_data.get("language", "") + code_style_def = styles.get("code_block", {}) + + if code: + elements = [] + + if language: + lang_style = ParagraphStyle( + 'CodeLanguage', + fontSize=code_style_def.get("font_size", 9), + textColor=self._hex_to_color(code_style_def.get("color", "#2F2F2F")), + fontName='Helvetica-Bold' + ) + elements.append(Paragraph(f"Code ({language}):", lang_style)) + + code_style = ParagraphStyle( + 'CodeBlock', + fontSize=code_style_def.get("font_size", 9), + textColor=self._hex_to_color(code_style_def.get("color", "#2F2F2F")), + fontName=code_style_def.get("font", "Courier"), + backColor=self._hex_to_color(code_style_def.get("background", "#F5F5F5")), + spaceAfter=code_style_def.get("space_after", 6) + ) + elements.append(Paragraph(code, code_style)) + + return elements + + return [] + + except Exception as e: + self.logger.warning(f"Error rendering code block: {str(e)}") + return [] + + def _render_json_image(self, image_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]: + """Render a JSON image to PDF elements.""" + try: + base64_data = image_data.get("base64Data", "") + alt_text = image_data.get("altText", "Image") + + if base64_data: + # For now, just add a placeholder since reportlab image handling is complex + return [Paragraph(f"[Image: {alt_text}]", self._create_normal_style(styles))] + + return [] + + except Exception as e: + self.logger.warning(f"Error rendering image: {str(e)}") + return [Paragraph(f"[Image: {image_data.get('altText', 'Image')}]", self._create_normal_style(styles))] \ No newline at end of file diff --git a/modules/services/serviceGeneration/renderers/pptx_renderer.py b/modules/services/serviceGeneration/renderers/rendererPptx.py similarity index 88% rename from modules/services/serviceGeneration/renderers/pptx_renderer.py rename to modules/services/serviceGeneration/renderers/rendererPptx.py index 73b390b4..4dd0a07d 100644 --- a/modules/services/serviceGeneration/renderers/pptx_renderer.py +++ b/modules/services/serviceGeneration/renderers/rendererPptx.py @@ -1,13 +1,13 @@ import logging import base64 import io -from typing import Dict, Any, Optional, Tuple -from .base_renderer import BaseRenderer +from typing import Dict, Any, Optional, Tuple, List +from .rendererBaseTemplate import BaseRenderer logger = logging.getLogger(__name__) -class PptxRenderer(BaseRenderer): +class RendererPptx(BaseRenderer): """Renderer for PowerPoint (.pptx) files using python-pptx library.""" def __init__(self): @@ -258,76 +258,25 @@ class PptxRenderer(BaseRenderer): """Get MIME type for rendered output.""" return self.output_mime_type - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only PowerPoint-specific guidelines; global prompt is built centrally.""" - return ( - "POWERPOINT FORMAT GUIDELINES:\n" - "- Extract structured data from source documents into JSON format\n" - "- Focus on presentation-ready content with clear sections and visual elements\n" - "- For tables: Extract headers and rows as separate arrays suitable for slides\n" - "- For lists: Extract items with optional sub-items for bullet points\n" - "- Structure content into sections with clear content types (heading, paragraph, table, list)\n" - "- Use proper JSON structure with metadata, sections, and elements\n" - "- Ensure content is concise and suitable for slide presentation\n" - "OUTPUT: Return structured JSON that can be converted to PowerPoint slides." - ) - async def _get_pptx_styles(self, user_prompt: str, ai_service=None) -> Dict[str, Any]: - """Simple AI call to get PowerPoint styling definitions.""" - if not ai_service: - return self._get_default_pptx_styles() + """Get PowerPoint styling definitions using base template AI styling.""" + style_schema = { + "title": {"font_size": 44, "color": "#1F4E79", "bold": True, "align": "center"}, + "heading": {"font_size": 32, "color": "#2F2F2F", "bold": True, "align": "left"}, + "subheading": {"font_size": 24, "color": "#4F4F4F", "bold": True, "align": "left"}, + "paragraph": {"font_size": 18, "color": "#2F2F2F", "bold": False, "align": "left"}, + "bullet_list": {"font_size": 18, "color": "#2F2F2F", "indent": 20}, + "table_header": {"font_size": 16, "color": "#FFFFFF", "bold": True, "background": "#4F4F4F"}, + "table_cell": {"font_size": 14, "color": "#2F2F2F", "bold": False, "background": "#FFFFFF"}, + "slide_size": "16:9", + "content_per_slide": "concise" + } - try: - prompt = f""" -For this PowerPoint presentation request: "{user_prompt}" - -Provide styling definitions for PowerPoint elements. Respond with ONLY JSON: - -{{ - "title": {{"font_size": 44, "color": "#1F4E79", "bold": true, "align": "center"}}, - "heading": {{"font_size": 32, "color": "#2F2F2F", "bold": true, "align": "left"}}, - "subheading": {{"font_size": 24, "color": "#4F4F4F", "bold": true, "align": "left"}}, - "paragraph": {{"font_size": 18, "color": "#2F2F2F", "bold": false, "align": "left"}}, - "bullet_list": {{"font_size": 18, "color": "#2F2F2F", "indent": 20}}, - "table_header": {{"font_size": 16, "color": "#FFFFFF", "bold": true, "background": "#4F4F4F"}}, - "table_cell": {{"font_size": 14, "color": "#2F2F2F", "bold": false, "background": "#FFFFFF"}}, - "slide_size": "16:9", - "content_per_slide": "concise" -}} - -CRITICAL: PowerPoint text must be large enough to read from a distance. Minimum font size should be 14pt for body text. -""" - - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType - - request_options = AiCallOptions() - request_options.operationType = OperationType.GENERAL - - request = AiCallRequest(prompt=prompt, context="", options=request_options) - response = await ai_service.aiObjects.call(request) - - import json - import re - - # Clean and parse JSON - result = response.content.strip() - if result.startswith('```json'): - result = re.sub(r'^```json\s*', '', result) - result = re.sub(r'\s*```$', '', result) - elif result.startswith('```'): - result = re.sub(r'^```\s*', '', result) - result = re.sub(r'\s*```$', '', result) - - styles = json.loads(result) - - # Validate font sizes for PowerPoint readability - styles = self._validate_pptx_styles_readability(styles) - - return styles - - except Exception as e: - logger.warning(f"AI styling failed: {str(e)}, using defaults") - return self._get_default_pptx_styles() + style_template = self._create_ai_style_template("pptx", user_prompt, style_schema) + styles = await self._get_ai_styles(ai_service, style_template, self._get_default_pptx_styles()) + + # Validate PowerPoint-specific requirements + return self._validate_pptx_styles_readability(styles) def _validate_pptx_styles_readability(self, styles: Dict[str, Any]) -> Dict[str, Any]: """Validate and fix readability issues in AI-generated styles.""" diff --git a/modules/services/serviceGeneration/renderers/rendererText.py b/modules/services/serviceGeneration/renderers/rendererText.py new file mode 100644 index 00000000..6ca1415b --- /dev/null +++ b/modules/services/serviceGeneration/renderers/rendererText.py @@ -0,0 +1,234 @@ +""" +Text renderer for report generation. +""" + +from .rendererBaseTemplate import BaseRenderer +from typing import Dict, Any, Tuple, List + +class RendererText(BaseRenderer): + """Renders content to plain text format with format-specific extraction.""" + + @classmethod + def get_supported_formats(cls) -> List[str]: + """Return supported text formats (excluding formats with dedicated renderers).""" + return [ + 'txt', 'text', 'plain', + # Programming languages + 'js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx', + 'py', 'python', 'java', 'cpp', 'c', 'h', 'hpp', + 'cs', 'csharp', 'php', 'rb', 'ruby', 'go', 'rs', 'rust', + 'swift', 'kt', 'kotlin', 'scala', 'r', 'm', 'objc', + 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', + # Web technologies (excluding html/htm which have dedicated renderer) + 'css', 'scss', 'sass', 'less', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', + # Data formats (excluding csv, md/markdown which have dedicated renderers) + 'tsv', 'log', 'rst', 'sql', 'dockerfile', 'dockerignore', 'gitignore', + # Configuration files + 'env', 'properties', 'conf', 'config', 'rc', + 'gitattributes', 'editorconfig', 'eslintrc', + # Documentation + 'readme', 'changelog', 'license', 'authors', + 'contributing', 'todo', 'notes', 'docs' + ] + + @classmethod + def get_format_aliases(cls) -> List[str]: + """Return format aliases.""" + return [ + 'ascii', 'utf8', 'utf-8', 'code', 'source', + 'script', 'program', 'file', 'document', + 'raw', 'unformatted', 'plaintext' + ] + + @classmethod + def get_priority(cls) -> int: + """Return priority for text renderer.""" + return 90 + + async def render(self, extracted_content: Dict[str, Any], title: str, user_prompt: str = None, ai_service=None) -> Tuple[str, str]: + """Render extracted JSON content to plain text format.""" + try: + # Generate text from JSON structure + text_content = self._generate_text_from_json(extracted_content, title) + + return text_content, "text/plain" + + except Exception as e: + self.logger.error(f"Error rendering text: {str(e)}") + # Return minimal text fallback + return f"{title}\n\nError rendering report: {str(e)}", "text/plain" + + def _generate_text_from_json(self, json_content: Dict[str, Any], title: str) -> str: + """Generate text content from structured JSON document.""" + try: + # Validate JSON structure + if not isinstance(json_content, dict): + raise ValueError("JSON content must be a dictionary") + + if "sections" not in json_content: + raise ValueError("JSON content must contain 'sections' field") + + # Use title from JSON metadata if available, otherwise use provided title + document_title = json_content.get("metadata", {}).get("title", title) + + # Build text content + text_parts = [] + + # Document title + text_parts.append(document_title) + text_parts.append("=" * len(document_title)) + text_parts.append("") + + # Process each section + sections = json_content.get("sections", []) + for section in sections: + section_text = self._render_json_section(section) + if section_text: + text_parts.append(section_text) + text_parts.append("") # Add spacing between sections + + # Add generation info + text_parts.append("") + text_parts.append(f"Generated: {self._format_timestamp()}") + + return '\n'.join(text_parts) + + except Exception as e: + self.logger.error(f"Error generating text from JSON: {str(e)}") + raise Exception(f"Text generation failed: {str(e)}") + + def _render_json_section(self, section: Dict[str, Any]) -> str: + """Render a single JSON section to text.""" + try: + section_type = self._get_section_type(section) + section_data = self._get_section_data(section) + + if section_type == "table": + return self._render_json_table(section_data) + elif section_type == "bullet_list": + return self._render_json_bullet_list(section_data) + elif section_type == "heading": + return self._render_json_heading(section_data) + elif section_type == "paragraph": + return self._render_json_paragraph(section_data) + elif section_type == "code_block": + return self._render_json_code_block(section_data) + elif section_type == "image": + return self._render_json_image(section_data) + else: + # Fallback to paragraph for unknown types + return self._render_json_paragraph(section_data) + + except Exception as e: + self.logger.warning(f"Error rendering section {self._get_section_id(section)}: {str(e)}") + return f"[Error rendering section: {str(e)}]" + + def _render_json_table(self, table_data: Dict[str, Any]) -> str: + """Render a JSON table to text.""" + try: + headers = table_data.get("headers", []) + rows = table_data.get("rows", []) + + if not headers or not rows: + return "" + + text_parts = [] + + # Create table header + header_line = " | ".join(str(header) for header in headers) + text_parts.append(header_line) + + # Add separator line + separator_line = " | ".join("-" * len(str(header)) for header in headers) + text_parts.append(separator_line) + + # Add data rows + for row in rows: + row_line = " | ".join(str(cell_data) for cell_data in row) + text_parts.append(row_line) + + return '\n'.join(text_parts) + + except Exception as e: + self.logger.warning(f"Error rendering table: {str(e)}") + return "" + + def _render_json_bullet_list(self, list_data: Dict[str, Any]) -> str: + """Render a JSON bullet list to text.""" + try: + items = list_data.get("items", []) + + if not items: + return "" + + text_parts = [] + for item in items: + if isinstance(item, str): + text_parts.append(f"- {item}") + elif isinstance(item, dict) and "text" in item: + text_parts.append(f"- {item['text']}") + + return '\n'.join(text_parts) + + except Exception as e: + self.logger.warning(f"Error rendering bullet list: {str(e)}") + return "" + + def _render_json_heading(self, heading_data: Dict[str, Any]) -> str: + """Render a JSON heading to text.""" + try: + level = heading_data.get("level", 1) + text = heading_data.get("text", "") + + if text: + level = max(1, min(6, level)) + if level == 1: + return f"{text}\n{'=' * len(text)}" + elif level == 2: + return f"{text}\n{'-' * len(text)}" + else: + return f"{'#' * level} {text}" + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering heading: {str(e)}") + return "" + + def _render_json_paragraph(self, paragraph_data: Dict[str, Any]) -> str: + """Render a JSON paragraph to text.""" + try: + text = paragraph_data.get("text", "") + return text if text else "" + + except Exception as e: + self.logger.warning(f"Error rendering paragraph: {str(e)}") + return "" + + def _render_json_code_block(self, code_data: Dict[str, Any]) -> str: + """Render a JSON code block to text.""" + try: + code = code_data.get("code", "") + language = code_data.get("language", "") + + if code: + if language: + return f"Code ({language}):\n{code}" + else: + return code + + return "" + + except Exception as e: + self.logger.warning(f"Error rendering code block: {str(e)}") + return "" + + def _render_json_image(self, image_data: Dict[str, Any]) -> str: + """Render a JSON image to text.""" + try: + alt_text = image_data.get("altText", "Image") + return f"[Image: {alt_text}]" + + except Exception as e: + self.logger.warning(f"Error rendering image: {str(e)}") + return f"[Image: {image_data.get('altText', 'Image')}]" diff --git a/modules/services/serviceGeneration/renderers/text_renderer.py b/modules/services/serviceGeneration/renderers/text_renderer.py deleted file mode 100644 index 67e32069..00000000 --- a/modules/services/serviceGeneration/renderers/text_renderer.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Text renderer for report generation. -""" - -from .base_renderer import BaseRenderer -from typing import Dict, Any, Tuple, List - -class TextRenderer(BaseRenderer): - """Renders content to plain text format with format-specific extraction.""" - - @classmethod - def get_supported_formats(cls) -> List[str]: - """Return supported text formats (excluding formats with dedicated renderers).""" - return [ - 'txt', 'text', 'plain', - # Programming languages - 'js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx', - 'py', 'python', 'java', 'cpp', 'c', 'h', 'hpp', - 'cs', 'csharp', 'php', 'rb', 'ruby', 'go', 'rs', 'rust', - 'swift', 'kt', 'kotlin', 'scala', 'r', 'm', 'objc', - 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', - # Web technologies (excluding html/htm which have dedicated renderer) - 'css', 'scss', 'sass', 'less', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg', - # Data formats (excluding csv, md/markdown which have dedicated renderers) - 'tsv', 'log', 'rst', 'sql', 'dockerfile', 'dockerignore', 'gitignore', - # Configuration files - 'env', 'properties', 'conf', 'config', 'rc', - 'gitattributes', 'editorconfig', 'eslintrc', - # Documentation - 'readme', 'changelog', 'license', 'authors', - 'contributing', 'todo', 'notes', 'docs' - ] - - @classmethod - def get_format_aliases(cls) -> List[str]: - """Return format aliases.""" - return [ - 'ascii', 'utf8', 'utf-8', 'code', 'source', - 'script', 'program', 'file', 'document', - 'raw', 'unformatted', 'plaintext' - ] - - @classmethod - def get_priority(cls) -> int: - """Return priority for text renderer.""" - return 90 - - def getExtractionPrompt(self, user_prompt: str, title: str) -> str: - """Return only plain-text guidelines; global prompt is built centrally.""" - return ( - "TEXT FORMAT GUIDELINES:\n" - "- Output ONLY plain text (no markdown or HTML).\n" - "- Use clear headings (you may underline with === or --- when helpful).\n" - "- Use simple bullet lists with '-' and tables with '|' when needed.\n" - "- Preserve indentation for code-like content if present.\n" - "OUTPUT: Return ONLY the raw text content." - ) - - async def render(self, extracted_content: str, title: str) -> Tuple[str, str]: - """Render extracted content to plain text format.""" - try: - # The extracted content should already be formatted text from the AI - # Just clean it up - text_content = self._clean_text_content(extracted_content, title) - - return text_content, "text/plain" - - except Exception as e: - self.logger.error(f"Error rendering text: {str(e)}") - # Return minimal text fallback - return f"{title}\n\nError rendering report: {str(e)}", "text/plain" - - def _clean_text_content(self, content: str, title: str) -> str: - """Clean and validate text content from AI.""" - content = content.strip() - - # Remove markdown code blocks if present - if content.startswith("```") and content.endswith("```"): - lines = content.split('\n') - if len(lines) > 2: - content = '\n'.join(lines[1:-1]).strip() - - # Remove any remaining markdown formatting - content = content.replace('**', '').replace('*', '') - content = content.replace('__', '').replace('_', '') - - # Clean up any HTML-like tags that might have slipped through - import re - content = re.sub(r'<[^>]+>', '', content) - - # Ensure proper line endings - content = content.replace('\r\n', '\n').replace('\r', '\n') - - return content diff --git a/modules/services/serviceGeneration/subPromptBuilder.py b/modules/services/serviceGeneration/subPromptBuilder.py index e21b5017..f7054adb 100644 --- a/modules/services/serviceGeneration/subPromptBuilder.py +++ b/modules/services/serviceGeneration/subPromptBuilder.py @@ -103,8 +103,20 @@ Return only the JSON structure with actual data from the documents. Do not inclu finalPrompt = genericIntro # Debug output - print(f"๐Ÿ” DEBUG: Extraction Prompt: {finalPrompt}") - print(f"๐Ÿ” DEBUG: Extraction Intent: {extractionIntent}") + print(f"๐Ÿ” EXTRACTION INTENT: {extractionIntent}") + + # Save full extraction prompt to debug file + try: + import os + from datetime import datetime, UTC + ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + debug_root = "./test-chat/ai" + os.makedirs(debug_root, exist_ok=True) + with open(os.path.join(debug_root, f"{ts}_extraction_prompt.txt"), "w", encoding="utf-8") as f: + f.write(f"EXTRACTION PROMPT:\n{finalPrompt}\n\n") + f.write(f"EXTRACTION INTENT:\n{extractionIntent}\n") + except Exception: + pass return finalPrompt @@ -127,6 +139,9 @@ async def buildGenerationPrompt( # Protect userPrompt from injection safeUserPrompt = userPrompt.replace('"', '\\"').replace("'", "\\'").replace('\n', ' ').replace('\r', ' ') + # Debug output + print(f"๐Ÿ” GENERATION PROMPT REQUEST: buildGenerationPrompt called with outputFormat='{outputFormat}', title='{title}'") + # AI call to generate the appropriate generation prompt generationPromptRequest = f""" Based on this user request, create a detailed generation prompt for creating a {outputFormat} document. @@ -144,17 +159,23 @@ Create a generation prompt that: IMPORTANT: Always generate content in STANDARDIZED JSON FORMAT. In your response, include the exact text "PLACEHOLDER_FOR_FORMAT_RULES" where specific format rules will be inserted afterwards automatically. +CRITICAL: You MUST start your response with exactly "Generate a {outputFormat} document that:" - do NOT use "docx" or any other format. Use the exact format specified: {outputFormat} + Return only the generation prompt, starting with "Generate a {outputFormat} document that..." """ # Call AI service to generate the prompt - print(f"๐Ÿ” DEBUG: Calling AI for generation prompt...") - result = await aiService.callAi( - prompt=generationPromptRequest, - documents=None, - options=None - ) - print(f"๐Ÿ” DEBUG: AI generation prompt result: '{result}'") + print(f"๐Ÿ” GENERATION PROMPT REQUEST: Calling AI for generation prompt...") + + # Import and set proper options for AI call + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + request_options = AiCallOptions() + request_options.operationType = OperationType.GENERAL + + request = AiCallRequest(prompt=generationPromptRequest, context="", options=request_options) + response = await aiService.aiObjects.call(request) + result = response.content if response else "" + print(f"๐Ÿ” GENERATION PROMPT AI RESPONSE: '{result}'") # Replace the placeholder that the AI created with actual format rules if result: @@ -162,7 +183,21 @@ Return only the generation prompt, starting with "Generate a {outputFormat} docu result = result.replace("PLACEHOLDER_FOR_FORMAT_RULES", formatRules) # Debug output - print(f"๐Ÿ” DEBUG: Generation Prompt: {result if result else 'None'}") + print(f"๐Ÿ” GENERATION PROMPT FINAL: {result if result else 'None'}") + + # Save full generation prompt and AI response to debug file + try: + import os + from datetime import datetime, UTC + ts = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + debug_root = "./test-chat/ai" + os.makedirs(debug_root, exist_ok=True) + with open(os.path.join(debug_root, f"{ts}_generation_prompt.txt"), "w", encoding="utf-8") as f: + f.write(f"GENERATION PROMPT REQUEST:\n{generationPromptRequest}\n\n") + f.write(f"GENERATION PROMPT AI RESPONSE:\n{response.content if response else 'No response'}\n\n") + f.write(f"GENERATION PROMPT FINAL:\n{result if result else 'None'}\n") + except Exception: + pass return result if result else f"Generate a comprehensive {outputFormat} document titled '{title}' based on the extracted content. User requirements: {userPrompt}" @@ -216,11 +251,15 @@ Do not include formatting instructions, file types, or output methods. # Call AI service to extract intention print(f"๐Ÿ” DEBUG: Calling AI for extraction intent...") - result = await aiService.callAi( - prompt=extractionPrompt, - documents=None, - options=None - ) + + # Import and set proper options for AI call + from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType + request_options = AiCallOptions() + request_options.operationType = OperationType.GENERAL + + request = AiCallRequest(prompt=extractionPrompt, context="", options=request_options) + response = await aiService.aiObjects.call(request) + result = response.content if response else "" print(f"๐Ÿ” DEBUG: AI extraction intent result: '{result}'") return result if result else f"Extract all relevant content from the document according to the user's requirements: {userPrompt}" diff --git a/rename_renderers.py b/rename_renderers.py deleted file mode 100644 index 7c398bec..00000000 --- a/rename_renderers.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to rename renderer files from _renderer.py to renderer.py -and update all references in the codebase. -""" - -import os -import re -import shutil -from pathlib import Path -from typing import Dict, List, Tuple - -def get_renderer_files(renderers_dir: Path) -> List[Tuple[str, str]]: - """Get list of renderer files to rename.""" - renderer_files = [] - - for file_path in renderers_dir.glob("*_renderer.py"): - if file_path.name not in ['base_renderer.py', 'registry.py']: - old_name = file_path.name - # Extract the name part (e.g., "csv" from "csv_renderer.py") - name_part = old_name.replace('_renderer.py', '') - # Create new name (e.g., "rendererCsv.py") - new_name = f"renderer{name_part.capitalize()}.py" - renderer_files.append((old_name, new_name)) - - return renderer_files - -def update_file_imports(file_path: Path, old_to_new: Dict[str, str]) -> bool: - """Update import statements in a file.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - original_content = content - changes_made = False - - # Update import statements - for old_name, new_name in old_to_new.items(): - old_module = old_name.replace('.py', '') - new_module = new_name.replace('.py', '') - - # Pattern for from .old_module import - pattern1 = rf'from \.{re.escape(old_module)} import' - replacement1 = f'from .{new_module} import' - if re.search(pattern1, content): - content = re.sub(pattern1, replacement1, content) - changes_made = True - - # Pattern for from modules.services.serviceGeneration.renderers.old_module import - pattern2 = rf'from modules\.services\.serviceGeneration\.renderers\.{re.escape(old_module)} import' - replacement2 = f'from modules.services.serviceGeneration.renderers.{new_module} import' - if re.search(pattern2, content): - content = re.sub(pattern2, replacement2, content) - changes_made = True - - if changes_made: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(content) - print(f"โœ… Updated imports in: {file_path}") - return True - else: - print(f"โ„น๏ธ No imports to update in: {file_path}") - return False - - except Exception as e: - print(f"โŒ Error updating {file_path}: {str(e)}") - return False - -def update_class_names_in_file(file_path: Path, old_to_new: Dict[str, str]) -> bool: - """Update class names in renderer files.""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - original_content = content - changes_made = False - - # Update class names - for old_name, new_name in old_to_new.items(): - old_module = old_name.replace('.py', '') - new_module = new_name.replace('.py', '') - - # Extract the name part for class name - name_part = old_module.replace('_renderer', '') - old_class = f"{name_part.capitalize()}Renderer" - new_class = f"Renderer{name_part.capitalize()}" - - # Update class definition - pattern1 = rf'class {re.escape(old_class)}\(' - replacement1 = f'class {new_class}(' - if re.search(pattern1, content): - content = re.sub(pattern1, replacement1, content) - changes_made = True - - # Update class instantiation - pattern2 = rf'{re.escape(old_class)}\(' - replacement2 = f'{new_class}(' - if re.search(pattern2, content): - content = re.sub(pattern2, replacement2, content) - changes_made = True - - if changes_made: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(content) - print(f"โœ… Updated class names in: {file_path}") - return True - else: - print(f"โ„น๏ธ No class names to update in: {file_path}") - return False - - except Exception as e: - print(f"โŒ Error updating class names in {file_path}: {str(e)}") - return False - -def main(): - """Main function to rename renderer files and update references.""" - print("๐Ÿ”„ Starting renderer file renaming process...") - - # Get the gateway directory - gateway_dir = Path(__file__).parent - renderers_dir = gateway_dir / "modules" / "services" / "serviceGeneration" / "renderers" - - if not renderers_dir.exists(): - print(f"โŒ Renderers directory not found: {renderers_dir}") - return - - print(f"๐Ÿ“ Working in directory: {renderers_dir}") - - # Get list of files to rename - renderer_files = get_renderer_files(renderers_dir) - - if not renderer_files: - print("โ„น๏ธ No renderer files found to rename.") - return - - print(f"๐Ÿ“‹ Found {len(renderer_files)} renderer files to rename:") - for old_name, new_name in renderer_files: - print(f" {old_name} โ†’ {new_name}") - - # Create mapping dictionary - old_to_new = {old_name: new_name for old_name, new_name in renderer_files} - - # Step 1: Update imports in all Python files - print("\n๐Ÿ”„ Step 1: Updating import statements...") - updated_files = [] - - # Search in gateway directory - for py_file in gateway_dir.rglob("*.py"): - if py_file.name != "rename_renderers.py": # Skip this script - if update_file_imports(py_file, old_to_new): - updated_files.append(py_file) - - print(f"โœ… Updated imports in {len(updated_files)} files") - - # Step 2: Update class names in renderer files - print("\n๐Ÿ”„ Step 2: Updating class names in renderer files...") - class_updated_files = [] - - for old_name, new_name in renderer_files: - old_file_path = renderers_dir / old_name - if old_file_path.exists(): - if update_class_names_in_file(old_file_path, old_to_new): - class_updated_files.append(old_file_path) - - print(f"โœ… Updated class names in {len(class_updated_files)} files") - - # Step 3: Rename the files - print("\n๐Ÿ”„ Step 3: Renaming files...") - renamed_files = [] - - for old_name, new_name in renderer_files: - old_file_path = renderers_dir / old_name - new_file_path = renderers_dir / new_name - - if old_file_path.exists(): - try: - shutil.move(str(old_file_path), str(new_file_path)) - renamed_files.append((old_name, new_name)) - print(f"โœ… Renamed: {old_name} โ†’ {new_name}") - except Exception as e: - print(f"โŒ Error renaming {old_name}: {str(e)}") - else: - print(f"โš ๏ธ File not found: {old_name}") - - print(f"\n๐ŸŽ‰ Renaming process completed!") - print(f"๐Ÿ“Š Summary:") - print(f" - Files renamed: {len(renamed_files)}") - print(f" - Import statements updated: {len(updated_files)}") - print(f" - Class names updated: {len(class_updated_files)}") - - if renamed_files: - print(f"\n๐Ÿ“‹ Renamed files:") - for old_name, new_name in renamed_files: - print(f" โœ… {old_name} โ†’ {new_name}") - -if __name__ == "__main__": - main() diff --git a/test_document_processing.py b/test_document_processing.py index d21ae00b..6170a5c5 100644 --- a/test_document_processing.py +++ b/test_document_processing.py @@ -154,9 +154,11 @@ async def process_documents_and_generate_summary(): # userPrompt = "Analyze these documents and create a comprehensive DOCX summary document including: 1) Document types and purposes, 2) Key information and main points, 3) Important details and numbers, 4) Notable sections, 5) Overall assessment and recommendations." + userPrompt = "Extract the table from file and produce 2 lists in excel. one list with all entries, one list only with entries that are yellow highlighted." + # userPrompt = "Create a docx file containing a summary and the COMPLETE list from the pdf file, having one additional column with a 'x' marker for all items, which are yellow highlighted." - userPrompt = "Create a docx file containing the combined documents in french language." + # userPrompt = "Create a docx file containing the combined documents in french language." try: # Single AI call with DOCX generation @@ -164,7 +166,7 @@ async def process_documents_and_generate_summary(): prompt=userPrompt, documents=documents, options=ai_options, - outputFormat="docx", + outputFormat="xlsx", title="Document Analysis Summary" ) diff --git a/test_fallback_mechanism.py b/test_fallback_mechanism.py deleted file mode 100644 index 076ce22f..00000000 --- a/test_fallback_mechanism.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to verify the fallback mechanism in interfaceAiObjects.py -""" - -import asyncio -import sys -import os -import logging -from pathlib import Path - -# Add the gateway directory to the Python path -gateway_dir = Path(__file__).parent -sys.path.insert(0, str(gateway_dir)) - -# Set up logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -async def test_fallback_mechanism(): - """Test the fallback mechanism by simulating a failing primary model.""" - try: - from modules.interfaces.interfaceAiObjects import AiObjects - from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationType - - logger.info("๐Ÿงช Testing fallback mechanism...") - - # Create AiObjects instance - ai_objects = await AiObjects.create() - logger.info("โœ… AiObjects created successfully") - - # Test 1: Normal operation (should work with primary model) - logger.info("๐Ÿ“ Test 1: Normal operation") - request = AiCallRequest( - prompt="Hello, this is a test prompt. Please respond with 'Test successful'.", - context="", - options=AiCallOptions(operationType=OperationType.GENERAL) - ) - - try: - response = await ai_objects.call(request) - logger.info(f"โœ… Test 1 successful: {response.modelName} - {response.content[:50]}...") - except Exception as e: - logger.warning(f"โš ๏ธ Test 1 failed: {str(e)}") - - # Test 2: Image analysis fallback - logger.info("๐Ÿ–ผ๏ธ Test 2: Image analysis fallback") - try: - # Create a dummy image data (base64 encoded 1x1 pixel) - dummy_image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" - result = await ai_objects.callImage( - prompt="Describe this image", - imageData=dummy_image, - mimeType="image/png", - options=AiCallOptions(operationType=OperationType.IMAGE_ANALYSIS) - ) - logger.info(f"โœ… Test 2 successful: {result[:50]}...") - except Exception as e: - logger.warning(f"โš ๏ธ Test 2 failed: {str(e)}") - - # Test 3: Test fallback model selection - logger.info("๐Ÿ”„ Test 3: Fallback model selection") - fallback_models = ai_objects._getFallbackModels(OperationType.GENERAL) - logger.info(f"โœ… Fallback models for GENERAL: {fallback_models}") - - fallback_models_image = ai_objects._getFallbackModels(OperationType.IMAGE_ANALYSIS) - logger.info(f"โœ… Fallback models for IMAGE_ANALYSIS: {fallback_models_image}") - - logger.info("๐ŸŽ‰ Fallback mechanism test completed!") - - except Exception as e: - logger.error(f"โŒ Test failed: {str(e)}") - import traceback - traceback.print_exc() - -if __name__ == "__main__": - asyncio.run(test_fallback_mechanism()) diff --git a/test_json_to_docx.docx b/test_json_to_docx.docx deleted file mode 100644 index 15112f375e364d364373717859d07499ade02f53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37131 zcmagFWk6iZwl0dhdw}2)Jh;2Ny9W10g1ZIx;7)LN2~L2<3BlbV!Cf1;oh5s(b?&+E z{h2^jjZemyT~)J~ts)N%g98BpfdHY}B(GnsR-Bju2>~$*2LXW%Zq*kDIDWKn{AjG< zFgOdl|_imM;sluV%zDR6JsKwL~Yg$kM1JNi2~Ha-8f&Q z=Cn#U9CKoJ#+@I@N`qjxZwrmSxWtK}VYc=c3g}YbFKdO&rLh?XN(ngCYG-koTBpp5l34cuZ2ZA6o#NCI0_q4`(>KDpp()`L+x;w zc89@wZ&bzoc|5>_;z2x6GWU6rnMNzeE(_^v5Knrf$+@P~CzGb60oQu>35G@&D^2Hy zmA+y$&mm+0#*bZxUS^LmZT3UBhVqzeb-i^g+pygefL$m}-ONwuT57BKbyl48A9(nakT8C-e5Y~MsmZ?aG7kK@zuVeci-;r=!)a1gdP?njP7P<6dlGj z1BcN+acT#_11L`W-b6W{6G*e(qrPF^-d0T|pllbAu=MlNI@{NR0A84U;ZBKagj-v1hN6`pE zm8A|4?_&h~uvd-Ao;<5)*aTN*e z*?3c9tuNh6q<#Y)5wmo0l3cfuMy%KmkU9txFztuwx zJ(eCcQQOb9cvlCbS$u6Rrq2|%1#kLuB;_BkOG35?zSc%daj)Io@reOM{ ztV$%f0AW?%eH}1RTo0U`-CkvKr4AJSXx_$NhyC0##zUL-!9RPg01fCNXcdl_^ZD-j z!iF{Lvn)=jXf}-OSD7D--4lgB8v|aYU*0SdGu(4uzz`f-p&8N5c!X z75pP`UF=~XATa(}mk&-(f0RXI%yH#CM&}a)jN!*98+cJwf~4Si(P{BD!n&mF*4a@C zxkQ+D)@^^khX`EWPb@c){EhriO`}f>qL%PYO4gdVV-JPHD$Z+2&9za${gsIW3Rjp4 z*m7L0K}^C!7D0Ft^vmM#dT$ewTQaZ%>PG=r$J{(9$<>$8I*?J9yHEQ$b=aRpY>1b> zVp91{P76uakJVMmZ8KMq3)T$LQl-9qG9@KqFrz!wJ~$9x&@)uhkHyX7B*b*5-U{leP{k}8SbXHslc`^JpYE>qXl|7j;#L7hI`{cJZxZH#9 zF6lW;rHAAi

MQ!WW%-wgW5#3K@1ieEZ{Kh{54IRCzC@ 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 ZLv0-fc(Vd?5GYp(G9&`C$CMrr4**i`Q3L=0 diff --git a/test_json_to_docx.py b/test_json_to_docx.py deleted file mode 100644 index a03e0b28..00000000 --- a/test_json_to_docx.py +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script for JSON-to-DOCX rendering pipeline. -""" - -import asyncio -import json -import sys -import os - -from modules.services.serviceGeneration.renderers.docx_renderer import DocxRenderer - -async def test_json_to_docx(): - """Test the JSON-to-DOCX rendering pipeline.""" - - # Create test JSON document - test_json = { - "metadata": { - "title": "Test Document", - "version": "1.0" - }, - "sections": [ - { - "id": "heading1", - "type": "heading", - "data": { - "level": 1, - "text": "Document Overview" - } - }, - { - "id": "paragraph1", - "type": "paragraph", - "data": { - "text": "This is a test paragraph to verify JSON-to-DOCX rendering works correctly." - } - }, - { - "id": "table1", - "type": "table", - "data": { - "headers": ["Name", "Quantity", "Status"], - "rows": [ - ["Item 1", "5", "Active"], - ["Item 2", "3", "Inactive"], - ["Item 3", "10", "Active"] - ] - } - }, - { - "id": "list1", - "type": "bullet_list", - "data": { - "items": [ - "First bullet point", - "Second bullet point", - "Third bullet point" - ] - } - }, - { - "id": "heading2", - "type": "heading", - "data": { - "level": 2, - "text": "Summary" - } - }, - { - "id": "paragraph2", - "type": "paragraph", - "data": { - "text": "This document demonstrates the new JSON-based rendering system." - } - } - ] - } - - print("๐Ÿงช Testing JSON-to-DOCX rendering...") - print(f"๐Ÿ“„ Test document has {len(test_json['sections'])} sections") - - try: - # Create renderer - renderer = DocxRenderer() - - # Test rendering - docx_content, mime_type = await renderer.render( - extracted_content=test_json, - title="Test Document", - user_prompt="Create a test document" - ) - - print(f"โœ… Rendering successful!") - print(f"๐Ÿ“Š MIME type: {mime_type}") - print(f"๐Ÿ“ Content length: {len(docx_content)} characters") - print(f"๐Ÿ” Content preview: {docx_content[:100]}...") - - # Save test file - import base64 - docx_bytes = base64.b64decode(docx_content) - with open("test_json_to_docx.docx", "wb") as f: - f.write(docx_bytes) - - print(f"๐Ÿ’พ Test DOCX saved as: test_json_to_docx.docx") - - return True - - except Exception as e: - print(f"โŒ Rendering failed: {str(e)}") - import traceback - traceback.print_exc() - return False - -if __name__ == "__main__": - success = asyncio.run(test_json_to_docx()) - if success: - print("\n๐ŸŽ‰ JSON-to-DOCX rendering test PASSED!") - else: - print("\n๐Ÿ’ฅ JSON-to-DOCX rendering test FAILED!") - sys.exit(1)