Spaces:
Running
Running
| # Export Engine - Delivery Intelligence | |
| """ | |
| Generates executive-ready exports from chat responses. | |
| Supports PDF, PPTX, and Email HTML formats. | |
| No user rework required - one-click export with company branding. | |
| """ | |
| import io | |
| import base64 | |
| from datetime import datetime | |
| from typing import Dict, List, Optional, Any | |
| from dataclasses import dataclass | |
| import json | |
| # PDF generation | |
| try: | |
| from reportlab.lib import colors | |
| from reportlab.lib.pagesizes import letter, A4 | |
| from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle | |
| from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image | |
| from reportlab.lib.units import inch | |
| PDF_AVAILABLE = True | |
| except ImportError: | |
| PDF_AVAILABLE = False | |
| print("⚠️ ReportLab not installed - PDF export disabled") | |
| # PPTX generation | |
| try: | |
| from pptx import Presentation | |
| from pptx.util import Inches, Pt | |
| from pptx.dml.color import RGBColor | |
| from pptx.enum.text import PP_ALIGN | |
| PPTX_AVAILABLE = True | |
| except ImportError as e: | |
| PPTX_AVAILABLE = False | |
| print(f"⚠️ python-pptx not installed - PPTX export disabled: {e}") | |
| class ExportContent: | |
| """Content to export""" | |
| title: str | |
| answer: str | |
| charts: List[Dict] = None # List of chart data/images | |
| tables: List[Dict] = None # List of table data | |
| metadata: Dict = None # Additional metadata | |
| def __post_init__(self): | |
| self.charts = self.charts or [] | |
| self.tables = self.tables or [] | |
| self.metadata = self.metadata or {} | |
| class CompanyBranding: | |
| """Company branding for exports""" | |
| company_name: str = "Business Report" | |
| primary_color: str = "#3B82F6" | |
| secondary_color: str = "#1E40AF" | |
| logo_base64: Optional[str] = None | |
| font_family: str = "Helvetica" | |
| class PDFExporter: | |
| """Generates executive PDF reports""" | |
| def __init__(self, branding: CompanyBranding = None): | |
| self.branding = branding or CompanyBranding() | |
| def export(self, content: ExportContent) -> bytes: | |
| """Generate PDF from content""" | |
| if not PDF_AVAILABLE: | |
| raise RuntimeError("ReportLab not installed. Run: pip install reportlab") | |
| buffer = io.BytesIO() | |
| doc = SimpleDocTemplate(buffer, pagesize=letter, topMargin=0.5*inch, bottomMargin=0.5*inch) | |
| styles = getSampleStyleSheet() | |
| # Custom styles | |
| title_style = ParagraphStyle( | |
| 'CustomTitle', | |
| parent=styles['Heading1'], | |
| fontSize=18, | |
| textColor=colors.HexColor(self.branding.primary_color), | |
| spaceAfter=12, | |
| ) | |
| body_style = ParagraphStyle( | |
| 'CustomBody', | |
| parent=styles['Normal'], | |
| fontSize=10, | |
| spaceAfter=8, | |
| leading=14, | |
| ) | |
| # Build story | |
| story = [] | |
| # Header with branding | |
| story.append(Paragraph(self.branding.company_name, title_style)) | |
| story.append(Paragraph(content.title, styles['Heading2'])) | |
| story.append(Paragraph(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles['Normal'])) | |
| story.append(Spacer(1, 0.25*inch)) | |
| # Main content | |
| # Split answer into paragraphs and format | |
| for paragraph in content.answer.split('\n\n'): | |
| if paragraph.strip(): | |
| # Handle markdown bold | |
| paragraph = paragraph.replace('**', '<b>').replace('**', '</b>') | |
| story.append(Paragraph(paragraph.strip(), body_style)) | |
| story.append(Spacer(1, 0.5*inch)) | |
| # Tables | |
| for table_data in content.tables: | |
| if table_data.get('rows'): | |
| headers = table_data.get('headers', []) | |
| rows = table_data.get('rows', []) | |
| table_rows = [headers] if headers else [] | |
| table_rows.extend(rows) | |
| if table_rows: | |
| t = Table(table_rows) | |
| t.setStyle(TableStyle([ | |
| ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor(self.branding.primary_color)), | |
| ('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), 10), | |
| ('BOTTOMPADDING', (0, 0), (-1, 0), 12), | |
| ('BACKGROUND', (0, 1), (-1, -1), colors.beige), | |
| ('GRID', (0, 0), (-1, -1), 1, colors.black), | |
| ])) | |
| story.append(t) | |
| story.append(Spacer(1, 0.25*inch)) | |
| # Footer | |
| story.append(Spacer(1, 0.5*inch)) | |
| story.append(Paragraph( | |
| f"© {datetime.now().year} {self.branding.company_name} | DataVision", | |
| styles['Normal'] | |
| )) | |
| doc.build(story) | |
| buffer.seek(0) | |
| return buffer.getvalue() | |
| class PPTXExporter: | |
| """Generates executive PowerPoint presentations""" | |
| def __init__(self, branding: CompanyBranding = None): | |
| self.branding = branding or CompanyBranding() | |
| def export(self, content: ExportContent) -> bytes: | |
| """Generate PPTX from content""" | |
| if not PPTX_AVAILABLE: | |
| raise RuntimeError("python-pptx not installed. Run: pip install python-pptx") | |
| prs = Presentation() | |
| prs.slide_width = Inches(13.333) | |
| prs.slide_height = Inches(7.5) | |
| # Title slide | |
| title_slide_layout = prs.slide_layouts[6] # Blank | |
| slide = prs.slides.add_slide(title_slide_layout) | |
| # Add title | |
| txBox = slide.shapes.add_textbox(Inches(0.5), Inches(2), Inches(12), Inches(1.5)) | |
| tf = txBox.text_frame | |
| p = tf.paragraphs[0] | |
| p.text = content.title | |
| p.font.size = Pt(44) | |
| p.font.bold = True | |
| p.alignment = PP_ALIGN.CENTER | |
| # Add subtitle with company name | |
| txBox2 = slide.shapes.add_textbox(Inches(0.5), Inches(3.5), Inches(12), Inches(1)) | |
| tf2 = txBox2.text_frame | |
| p2 = tf2.paragraphs[0] | |
| p2.text = f"{self.branding.company_name} | {datetime.now().strftime('%B %d, %Y')}" | |
| p2.font.size = Pt(24) | |
| p2.alignment = PP_ALIGN.CENTER | |
| # Content slide with KPIs and insights | |
| content_layout = prs.slide_layouts[6] # Blank | |
| slide2 = prs.slides.add_slide(content_layout) | |
| # Add content | |
| txBox3 = slide2.shapes.add_textbox(Inches(0.5), Inches(0.5), Inches(12), Inches(6.5)) | |
| tf3 = txBox3.text_frame | |
| tf3.word_wrap = True | |
| # Parse content (simplified) | |
| paragraphs = content.answer.split('\n\n')[:5] # Limit for slide | |
| for i, para in enumerate(paragraphs): | |
| if i == 0: | |
| p = tf3.paragraphs[0] | |
| else: | |
| p = tf3.add_paragraph() | |
| # Clean markdown | |
| para = para.replace('**', '').replace('*', '') | |
| p.text = para[:300] # Limit length | |
| p.font.size = Pt(18 if i == 0 else 14) | |
| p.font.bold = (i == 0) | |
| # Add chart slides if charts are provided | |
| if content.charts: | |
| for i, chart_data in enumerate(content.charts): | |
| chart_slide = prs.slides.add_slide(prs.slide_layouts[6]) | |
| # Chart title | |
| chart_title = chart_data.get('title', f'Chart {i+1}') | |
| title_box = chart_slide.shapes.add_textbox(Inches(0.5), Inches(0.3), Inches(12), Inches(0.8)) | |
| title_tf = title_box.text_frame | |
| title_p = title_tf.paragraphs[0] | |
| title_p.text = chart_title | |
| title_p.font.size = Pt(28) | |
| title_p.font.bold = True | |
| title_p.alignment = PP_ALIGN.CENTER | |
| # If chart has base64 image data, add it | |
| if chart_data.get('image_base64'): | |
| try: | |
| # Decode base64 image | |
| img_data = chart_data['image_base64'] | |
| if ',' in img_data: | |
| img_data = img_data.split(',')[1] # Remove data:image/png;base64, prefix | |
| img_bytes = base64.b64decode(img_data) | |
| img_stream = io.BytesIO(img_bytes) | |
| # Add image centered on slide | |
| chart_slide.shapes.add_picture( | |
| img_stream, | |
| Inches(1.5), # left | |
| Inches(1.2), # top | |
| Inches(10), # width | |
| Inches(5.5) # height | |
| ) | |
| except Exception as e: | |
| # If image fails, add placeholder text | |
| placeholder = chart_slide.shapes.add_textbox(Inches(2), Inches(3), Inches(9), Inches(1)) | |
| placeholder.text_frame.paragraphs[0].text = f"Chart: {chart_title}\n(Image not available)" | |
| placeholder.text_frame.paragraphs[0].font.size = Pt(18) | |
| else: | |
| # No image - add chart description as text | |
| desc_box = chart_slide.shapes.add_textbox(Inches(1), Inches(1.5), Inches(11), Inches(5)) | |
| desc_tf = desc_box.text_frame | |
| desc_tf.word_wrap = True | |
| # Add chart type info | |
| chart_type = chart_data.get('type', 'chart') | |
| desc_p = desc_tf.paragraphs[0] | |
| desc_p.text = f"Chart Type: {chart_type.replace('_', ' ').title()}" | |
| desc_p.font.size = Pt(16) | |
| # Add data summary if available | |
| if chart_data.get('data'): | |
| data_info = desc_tf.add_paragraph() | |
| data_p = chart_data.get('data', {}) | |
| if isinstance(data_p, list) and len(data_p) > 0: | |
| first_trace = data_p[0] | |
| x_vals = first_trace.get('x', [])[:5] | |
| y_vals = first_trace.get('y', [])[:5] | |
| data_info.text = f"\nSample Data:\nX: {x_vals}\nY: {y_vals}" | |
| data_info.font.size = Pt(12) | |
| # Save to bytes | |
| buffer = io.BytesIO() | |
| prs.save(buffer) | |
| buffer.seek(0) | |
| return buffer.getvalue() | |
| class EmailExporter: | |
| """Generates executive email HTML""" | |
| def __init__(self, branding: CompanyBranding = None): | |
| self.branding = branding or CompanyBranding() | |
| def export(self, content: ExportContent) -> str: | |
| """Generate email HTML from content""" | |
| # Convert markdown-ish to HTML | |
| body_html = self._markdown_to_html(content.answer) | |
| html = f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <style> | |
| body {{ | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| line-height: 1.6; | |
| color: #333; | |
| max-width: 600px; | |
| margin: 0 auto; | |
| padding: 20px; | |
| }} | |
| .header {{ | |
| background: linear-gradient(135deg, {self.branding.primary_color}, {self.branding.secondary_color}); | |
| color: white; | |
| padding: 30px 20px; | |
| border-radius: 8px 8px 0 0; | |
| text-align: center; | |
| }} | |
| .header h1 {{ | |
| margin: 0; | |
| font-size: 24px; | |
| }} | |
| .header p {{ | |
| margin: 10px 0 0 0; | |
| opacity: 0.9; | |
| font-size: 14px; | |
| }} | |
| .content {{ | |
| background: #ffffff; | |
| border: 1px solid #e0e0e0; | |
| border-top: none; | |
| padding: 30px 20px; | |
| border-radius: 0 0 8px 8px; | |
| }} | |
| .content h2 {{ | |
| color: {self.branding.primary_color}; | |
| font-size: 18px; | |
| margin-top: 0; | |
| }} | |
| .content p {{ | |
| margin: 15px 0; | |
| }} | |
| .key-insight {{ | |
| background: #f8f9fa; | |
| border-left: 4px solid {self.branding.primary_color}; | |
| padding: 15px 20px; | |
| margin: 20px 0; | |
| }} | |
| .footer {{ | |
| text-align: center; | |
| margin-top: 30px; | |
| padding: 20px; | |
| color: #666; | |
| font-size: 12px; | |
| }} | |
| table {{ | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 20px 0; | |
| }} | |
| th, td {{ | |
| border: 1px solid #ddd; | |
| padding: 10px; | |
| text-align: left; | |
| }} | |
| th {{ | |
| background: {self.branding.primary_color}; | |
| color: white; | |
| }} | |
| tr:nth-child(even) {{ | |
| background: #f9f9f9; | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>{self.branding.company_name}</h1> | |
| <p>Business Intelligence Report | {datetime.now().strftime('%B %d, %Y')}</p> | |
| </div> | |
| <div class="content"> | |
| <h2>{content.title}</h2> | |
| {body_html} | |
| </div> | |
| <div class="footer"> | |
| Generated by DataVision<br> | |
| © {datetime.now().year} {self.branding.company_name} | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| return html | |
| def _markdown_to_html(self, text: str) -> str: | |
| """Convert markdown-like text to HTML""" | |
| import re | |
| # Bold | |
| text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text) | |
| text = re.sub(r'__(.+?)__', r'<strong>\1</strong>', text) | |
| # Italic | |
| text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text) | |
| # Convert paragraphs | |
| paragraphs = text.split('\n\n') | |
| html_parts = [] | |
| for para in paragraphs: | |
| para = para.strip() | |
| if not para: | |
| continue | |
| # Check if it's a key insight (starts with bold) | |
| if para.startswith('<strong>'): | |
| html_parts.append(f'<div class="key-insight">{para}</div>') | |
| else: | |
| html_parts.append(f'<p>{para}</p>') | |
| return '\n'.join(html_parts) | |
| class ExportEngine: | |
| """ | |
| Unified export engine for all formats. | |
| """ | |
| def __init__(self, branding: CompanyBranding = None): | |
| self.branding = branding or CompanyBranding() | |
| self.pdf = PDFExporter(self.branding) | |
| self.pptx = PPTXExporter(self.branding) | |
| self.email = EmailExporter(self.branding) | |
| def export_pdf(self, content: ExportContent) -> bytes: | |
| """Export to PDF""" | |
| return self.pdf.export(content) | |
| def export_pptx(self, content: ExportContent) -> bytes: | |
| """Export to PowerPoint""" | |
| return self.pptx.export(content) | |
| def export_email(self, content: ExportContent) -> str: | |
| """Export to HTML email""" | |
| return self.email.export(content) | |
| def get_available_formats(self) -> List[str]: | |
| """Get list of available export formats""" | |
| formats = ['email'] # Email always available (pure HTML) | |
| if PDF_AVAILABLE: | |
| formats.append('pdf') | |
| if PPTX_AVAILABLE: | |
| formats.append('pptx') | |
| return formats | |
| def get_export_engine(workspace_id: str = None) -> ExportEngine: | |
| """ | |
| Get export engine with company branding if available. | |
| """ | |
| branding = CompanyBranding() | |
| if workspace_id: | |
| try: | |
| from core.company_profile import get_company_profile, get_company_branding | |
| profile = get_company_profile(workspace_id) | |
| if profile: | |
| company_branding = get_company_branding(workspace_id) | |
| branding = CompanyBranding( | |
| company_name=profile.company_name, | |
| primary_color=company_branding.primary_color, | |
| secondary_color=company_branding.secondary_color, | |
| logo_base64=company_branding.logo_base64, | |
| font_family=company_branding.font_family, | |
| ) | |
| except Exception as e: | |
| print(f"⚠️ Could not load company branding: {e}") | |
| return ExportEngine(branding) | |