# space/tools/report_tool.py import os import logging from typing import Optional, Dict, Any from datetime import datetime import pandas as pd from jinja2 import Environment, FileSystemLoader, TemplateNotFound from utils.tracing import Tracer from utils.config import AppConfig logger = logging.getLogger(__name__) # Constants MAX_PREVIEW_ROWS = 100 MAX_REPORT_SIZE_MB = 50 class ReportToolError(Exception): """Custom exception for report tool errors.""" pass class ReportTool: """ Generates HTML reports from analysis results. Includes error handling, size limits, and proper template management. """ def __init__(self, cfg: AppConfig, tracer: Tracer): self.cfg = cfg self.tracer = tracer # Setup Jinja2 environment try: templates_dir = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "templates") ) if not os.path.exists(templates_dir): logger.warning(f"Templates directory not found: {templates_dir}. Creating it.") os.makedirs(templates_dir, exist_ok=True) self.env = Environment( loader=FileSystemLoader(templates_dir), autoescape=False, # We control the content trim_blocks=True, lstrip_blocks=True ) logger.info(f"Report tool initialized with templates from: {templates_dir}") except Exception as e: raise ReportToolError(f"Failed to initialize report tool: {e}") from e def _validate_inputs( self, user_query: str, sql_preview: Optional[pd.DataFrame], predict_preview: Optional[pd.DataFrame], explain_images: Dict[str, str], plan: Dict[str, Any] ) -> tuple[bool, str]: """ Validate report generation inputs. Returns (is_valid, error_message). """ if not user_query or not user_query.strip(): return False, "User query is empty" if not plan or not isinstance(plan, dict): return False, "Plan is invalid" # Check explain_images size if explain_images: total_size = sum(len(img) for img in explain_images.values()) size_mb = total_size / (1024 * 1024) if size_mb > MAX_REPORT_SIZE_MB: return False, f"Embedded images too large: {size_mb:.2f} MB (max {MAX_REPORT_SIZE_MB} MB)" return True, "" def _prepare_dataframe_preview(self, df: Optional[pd.DataFrame], max_rows: int = MAX_PREVIEW_ROWS) -> str: """ Convert dataframe to markdown table with row limit. Returns empty string if no data. """ if df is None or df.empty: return "" try: # Limit rows if len(df) > max_rows: preview_df = df.head(max_rows) suffix = f"\n\n*... and {len(df) - max_rows} more rows*" else: preview_df = df suffix = "" # Convert to markdown markdown = preview_df.to_markdown(index=False, tablefmt="github") return markdown + suffix except Exception as e: logger.warning(f"Failed to convert dataframe to markdown: {e}") return f"*Error displaying data: {str(e)}*" def _get_template_name(self) -> str: """ Determine which template to use. Falls back to creating a default if none exists. """ template_name = "report_template.md" try: # Check if template exists self.env.get_template(template_name) return template_name except TemplateNotFound: logger.warning(f"Template '{template_name}' not found. Creating default template.") self._create_default_template() return template_name def _create_default_template(self): """Create a default report template if none exists.""" default_template = """# Analysis Report **Generated:** {{ timestamp }} ## User Query {{ user_query }} ## Execution Plan **Steps:** {{ plan.steps | join(', ') }} **Rationale:** {{ plan.rationale }} {% if sql_preview %} ## Data Query Results {{ sql_preview }} {% endif %} {% if predict_preview %} ## Predictions {{ predict_preview }} {% endif %} {% if explain_images %} ## Model Explanations {% if explain_images.global_bar %} ### Feature Importance  {% endif %} {% if explain_images.beeswarm %} ### Feature Effects  {% endif %} {% endif %} --- *Report generated by Tabular Agentic XAI* """ templates_dir = self.env.loader.searchpath[0] template_path = os.path.join(templates_dir, "report_template.md") try: with open(template_path, 'w', encoding='utf-8') as f: f.write(default_template) logger.info(f"Created default template at: {template_path}") except Exception as e: logger.error(f"Failed to create default template: {e}") def _render_template( self, user_query: str, sql_preview_md: str, predict_preview_md: str, explain_images: Dict[str, str], plan: Dict[str, Any] ) -> str: """ Render the report template with provided data. """ try: template_name = self._get_template_name() template = self.env.get_template(template_name) context = { "timestamp": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"), "user_query": user_query, "plan": plan, "sql_preview": sql_preview_md, "predict_preview": predict_preview_md, "explain_images": explain_images or {} } html_body = template.render(**context) logger.info(f"Template rendered successfully: {len(html_body)} characters") return html_body except Exception as e: raise ReportToolError(f"Template rendering failed: {e}") from e def _save_report(self, html_content: str) -> str: """ Save HTML report to file. Returns the filename. """ try: # Generate unique filename timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') filename = f"report_{timestamp}.html" # Determine output path output_dir = os.getenv("REPORT_OUTPUT_DIR", os.getcwd()) os.makedirs(output_dir, exist_ok=True) filepath = os.path.abspath(os.path.join(output_dir, filename)) # Add CSS styling css_path = os.path.join( os.path.dirname(__file__), "..", "templates", "report_styles.css" ) if os.path.exists(css_path): css_link = f'' else: # Inline basic CSS if external file not found css_link = """ """ # Construct full HTML full_html = f"""