diff --git a/lumen/ai/assets/lumen_template.docx b/lumen/ai/assets/lumen_template.docx new file mode 100644 index 000000000..756a927c7 Binary files /dev/null and b/lumen/ai/assets/lumen_template.docx differ diff --git a/lumen/ai/export.py b/lumen/ai/export.py index 63daf15c2..443903ab7 100644 --- a/lumen/ai/export.py +++ b/lumen/ai/export.py @@ -1,22 +1,32 @@ import base64 +import io import os import re +import tempfile +import warnings +from datetime import datetime from io import BytesIO +from pathlib import Path from textwrap import dedent from typing import Any import nbformat import yaml +from docx.shared import Mm +from docxtpl import ( + DocxTemplate, InlineImage, R, RichText, +) from panel import Column from panel.chat import ChatMessage, ChatStep from panel.pane.image import ImageBase +from panel_material_ui import Typography from ..config import config from ..pipeline import Pipeline from ..views import View -from .views import LumenOutput +from .views import LumenOutput, VegaLiteOutput def make_md_cell(text: str): @@ -135,3 +145,182 @@ def export_notebook(messages: list[ChatMessage], preamble: str = ""): cells, extensions = render_cells(messages) cells = make_preamble(preamble, extensions=extensions) + cells return write_notebook(cells) + + +def render_docx_template( + sections: list, + docx_template_path: str | Path, + **docx_context: dict, +) -> io.BytesIO: + """ + Export outputs to a Word document (.docx) format. + + Arguments + --------- + sections: list + List of sections to process + docx_context : dict | None + Context dictionary for docx template rendering. If keys are not provided, + the following defaults will be used: + + - 'title': title parameter or 'Lumen Report' + - 'subtitle': 'Generated on {date}' (e.g., 'Generated on October 27, 2025') + - 'cover_page_header': '' (empty string) + - 'cover_page_footer': '' (empty string) + - 'content_page_header': '' (empty string) + + The following keys are always auto-generated and cannot be overridden: + - 'sections': List of section dicts with 'title', 'image', and 'caption' + - 'page_break': R('\f') for page breaks + docx_template_path : str | None + Path to the docx template file. If None, uses the default Lumen template. + + Returns + ------- + BytesIO + A BytesIO buffer containing the rendered docx document. + + Raises + ------ + RuntimeError + If the outputs list is empty. + FileNotFoundError + If the template file is not found. + + Example + ------- + buffer = to_docx( + outputs=report.outputs, + **{ + 'subtitle': 'Quarterly Analysis', + 'cover_page_header': 'ACME Corporation' + } + ) + """ + # Set default template path + if docx_template_path is None: + docx_template_path = str(Path(__file__).parent / "assets" / "lumen_template.docx") + + # Load template + template_path = Path(docx_template_path) + if not template_path.exists(): + raise FileNotFoundError(f"Template file not found: {template_path}") + + doc = DocxTemplate(str(template_path)) + + # Start with copy of docx_context or empty dict + context = dict(docx_context) if docx_context else {} + + # Set defaults for missing keys + if 'title' not in context: + context['title'] = "Lumen Report" + + if 'subtitle' not in context: + date_string = datetime.now().strftime("%B %d, %Y") + context['subtitle'] = f"Generated on {date_string}" + + if 'cover_page_header' not in context: + context['cover_page_header'] = "" + + if 'cover_page_footer' not in context: + context['cover_page_footer'] = "" + + if 'content_page_header' not in context: + context['content_page_header'] = "" + + # Always generate sections + context['sections'] = generate_docx_sections(doc, sections) + + # Always set page_break + context['page_break'] = R("\f") + + # Render template + doc.render(context) + + # Return as BytesIO + buffer = io.BytesIO() + doc.save(buffer) + buffer.seek(0) + return buffer + + +def generate_docx_sections(doc: DocxTemplate, sections: list) -> list[dict]: + """ + Generate sections list from report tasks for docx template. + + Arguments + --------- + doc : DocxTemplate + The document template instance (needed for InlineImage creation) + sections : list + List of sections to process + + Returns + ------- + list[dict] + List of section dictionaries with title, image, and caption + """ + output_sections = [] + for section in sections: + section_dict = { + "title": section.title or "Untitled Section", + "image": None, + "caption": RichText("") + } + + # Process section outputs to find visualizations and captions + image_found = False + for i, out in enumerate(section.outputs): + if isinstance(out, VegaLiteOutput) and not image_found: + # Convert LumenOutput to image + image_path = output_to_image(out) + if image_path: + section_dict["image"] = InlineImage(doc, image_path, width=Mm(160)) + image_found = True + + # Check if next output is a Typography for caption + if i + 1 < len(section.outputs): + next_out = section.outputs[i + 1] + if isinstance(next_out, Typography): + section_dict["caption"] = RichText(next_out.object) + break + + if section_dict["image"]: # Only add section if it has an image + output_sections.append(section_dict) + return output_sections + + +def output_to_image(output: VegaLiteOutput) -> str | None: + """ + Convert a VegaLiteOutput to an image file path. + + Arguments + --------- + output : VegaLiteOutput + The output to convert + + Returns + ------- + str | None + Path to temporary image file, or None if conversion failed + """ + # Create a temporary file for the image + tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False) + tmp_path = tmp.name + tmp.close() + try: + # Render the component and save as image + component = output.component + with open(tmp_path, 'wb') as f: + vega_pane = component.__panel__()._pane + vega_pane.param.update( + width=650, + height=400, + ) + image_bytes = vega_pane.export("png", scale=2, ppi=144) + f.write(image_bytes) + return tmp_path + except Exception as e: + warnings.warn(f"Failed to convert output to image: {e}", stacklevel=2) + os.unlink(tmp_path) + return None diff --git a/lumen/ai/report.py b/lumen/ai/report.py index 5527d4781..fa619a04b 100644 --- a/lumen/ai/report.py +++ b/lumen/ai/report.py @@ -7,6 +7,7 @@ from abc import abstractmethod from collections.abc import Iterator from functools import partial +from pathlib import Path from types import FunctionType from typing import Any, final @@ -32,11 +33,11 @@ from .agents import AnalystAgent from .config import MissingContextError from .export import ( - format_output, make_md_cell, make_preamble, write_notebook, + format_output, make_md_cell, make_preamble, render_docx_template, + write_notebook, ) from .llm import Llm from .memory import _Memory -from .schemas import get_metaset from .tools import FunctionTool, Tool from .utils import ( describe_data, extract_block_source, get_block_names, @@ -607,6 +608,32 @@ class Report(TaskGroup): _tasks = param.List(item_type=Section) + docx_context = param.Dict(default={}, doc=""" + Context dictionary for docx template rendering. If keys are not provided, + the following defaults will be used: + + - 'title': self.title or 'Lumen Report' + - 'subtitle': 'Generated on {date}' (e.g., 'Generated on October 27, 2025') + - 'cover_page_header': '' (empty string) + - 'cover_page_footer': '' (empty string) + - 'content_page_header': '' (empty string) + + The following keys are always auto-generated and cannot be overridden: + - 'sections': List of section dicts with 'title', 'image', and 'caption' + - 'page_break': R('\f') for page breaks + + Example: + report.docx_context = { + 'title': 'Q4 Sales Report', + 'subtitle': 'Quarterly Analysis', + 'cover_page_header': 'ACME Corporation', + 'cover_page_footer': 'Confidential' + }""") + + docx_template_path = param.String( + default=str(Path(__file__).parent / "assets" / "lumen_template.docx"), + doc="""Path to the docx template file.""") + level = 1 def _init_view(self): @@ -633,12 +660,18 @@ def _init_view(self): icon="settings", on_click=self._open_settings, size="large", color="default", margin=0, description="Configure Report" ) - self._export = FileDownload( + self._notebook_export_btn = FileDownload( callback=self._notebook_export, label="\u200b", variant='text', icon='get_app', icon_size="2.4em", color="default", margin=(8, 0, 10, 0), sx={".MuiButton-startIcon": {"mr": 0, "color": "var(--mui-palette-default-dark)"}}, description="Export Report to .ipynb", filename=f"{self.title or 'Report'}.ipynb" ) + self._docx_export_btn = FileDownload( + callback=self._docx_export, label="\u200b", variant='text', icon='description', + icon_size="2.4em", color="default", margin=(8, 0, 10, 0), + sx={".MuiButton-startIcon": {"mr": 0, "color": "var(--mui-palette-default-dark)"}}, + description="Export Report to .docx", filename=f"{self.title or 'Report'}.docx" + ) self._dialog = Dialog( TextInput.from_param(self.param.title, margin=(10, 0, 0, 0), sizing_mode="stretch_width"), show_close_button=True, @@ -650,7 +683,8 @@ def _init_view(self): self._run, self._clear, self._collapse, - self._export, + self._notebook_export_btn, + self._docx_export_btn, self._settings, sizing_mode="stretch_width" ) @@ -663,7 +697,8 @@ def _init_view(self): @param.depends('title', watch=True) def _update_filename(self): - self._export.filename = f"{self.title or 'Report'}.ipynb" + self._notebook_export_btn.filename = f"{self.title or 'Report'}.ipynb" + self._docx_export_btn.filename = f"{self.title or 'Report'}.docx" def _add_outputs(self, i: int, task: Task | Actor, outputs: list, **kwargs): self.outputs += outputs @@ -671,6 +706,10 @@ def _add_outputs(self, i: int, task: Task | Actor, outputs: list, **kwargs): def _notebook_export(self): return io.StringIO(self.to_notebook()) + def _docx_export(self): + """Callback for FileDownload to export report as docx.""" + return self.to_docx() + async def _execute(self, *args): with self._run.param.update(loading=True): return await super()._execute() @@ -699,6 +738,33 @@ async def _run_task(self, i: int, task: Section, **kwargs): task.param.unwatch(watcher) return outputs + def to_docx(self) -> io.BytesIO: + """ + Export the report to a Word document (.docx) format. + + Returns + ------- + BytesIO + A BytesIO buffer containing the rendered docx document. + + Raises + ------ + RuntimeError + If the report has not been executed yet. + FileNotFoundError + If the template file is not found. + """ + if not len(self.outputs): + raise RuntimeError( + "Report has not been executed, run report before exporting to_docx." + ) + + return render_docx_template( + [task for task in self._tasks if isinstance(task, Section)], + self.docx_template_path, + **self.docx_context, + ) + def __panel__(self): return Column( self._menu, @@ -733,8 +799,8 @@ class SQLQuery(Action): and generates an LumenOutput to be rendered. """ - generate_caption = param.Boolean(default=True, doc=""" - Whether to generate a caption for the data.""") + schema = param.Dict(default=None, doc=""" + Optional schema to use to not infer schema from data.""") source = param.ClassSelector(class_=BaseSQLSource, doc=""" The Source to execute the SQL expression on.""") @@ -749,8 +815,12 @@ class SQLQuery(Action): table = param.String(doc=""" The name of the table generated from the SQL expression.""") - user_content = param.String(default="Generate a short caption for the data", doc=""" - Additional instructions to provide to the analyst agent, i.e. what to focus on.""") + template_overrides = param.Dict(default={}, doc=""" + Template overrides to provide to the AnalystAgent.""") + + analyst_instructions = param.String(default=None, doc=""" + Instructions to provide to the analyst agent, i.e. what to focus on; + if unset no additional instructions are provided.""") def _render_controls(self): return [ @@ -800,7 +870,7 @@ async def _execute(self, **kwargs): # Pass table_params if provided params = {self.table: self.table_params} if self.table_params else None source = source.create_sql_expr_source({self.table: self.sql_expr}, params=params) - pipeline = Pipeline(source=source, table=self.table) + pipeline = Pipeline(source=source, table=self.table, schema=self.schema) if self.memory is not None: self.memory["source"] = source if "sources" not in self.memory: @@ -808,13 +878,12 @@ async def _execute(self, **kwargs): self.memory["sources"].append(source) self.memory["pipeline"] = pipeline self.memory["data"] = await describe_data(pipeline.data) - self.memory["sql_metaset"] = await get_metaset([source], [self.table]) self.memory["table"] = self.table out = LumenOutput(component=pipeline) outputs = [Typography(f"### {self.title}", variant='h4', margin=(10, 10, 0, 10)), out] if self.title else [out] - if self.generate_caption: - caption = await AnalystAgent(llm=self.llm).respond( - [{"role": "user", "content": self.user_content}] + if self.analyst_instructions: + caption = await AnalystAgent(llm=self.llm, template_overrides=self.template_overrides).respond( + [{"role": "user", "content": self.analyst_instructions}] ) outputs.append(Typography(caption.object)) return outputs diff --git a/lumen/ai/schemas.py b/lumen/ai/schemas.py index 181638abb..30455b660 100644 --- a/lumen/ai/schemas.py +++ b/lumen/ai/schemas.py @@ -222,7 +222,7 @@ def __str__(self) -> str: return self.table_context -async def get_metaset(sources: list[Source], tables: list[str]) -> SQLMetaset: +async def get_metaset(sources: list[Source], tables: list[str], schema: dict | None = None) -> SQLMetaset: """ Get the metaset for the given sources and tables. @@ -232,6 +232,8 @@ async def get_metaset(sources: list[Source], tables: list[str]) -> SQLMetaset: The sources to get the metaset for. tables: list[str] The tables to get the metaset for. + schema: dict | None + Optional schema to use instead of fetching from sources. Returns ------- @@ -253,7 +255,8 @@ async def get_metaset(sources: list[Source], tables: list[str]) -> SQLMetaset: source_name = next(iter(sources)).name table_name = table_slug source = next((s for s in sources if s.name == source_name), None) - schema = await get_schema(source, table_name, include_count=True) + if schema is None: + schema = await get_schema(source, table_name, include_count=True) tables_info[table_slug] = SQLMetadata( table_slug=table_slug, schema=schema, diff --git a/pyproject.toml b/pyproject.toml index ce9621e8c..19094ab68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ tests = ['pytest', 'pytest-rerunfailures', 'pytest-asyncio'] sql = ['duckdb', 'intake-sql', 'sqlalchemy'] ai = [ 'griffe', 'nbformat', 'duckdb >= 1.2.0', 'pyarrow', 'instructor >=1.6.4', 'pydantic >=2.8.0', 'pydantic-extra-types', 'panel-graphic-walker[kernel] >=0.6.4', - 'markitdown', 'semchunk', 'tiktoken', 'chardet', "panel-material-ui >=0.4.0", "tabulate" + 'markitdown', 'semchunk', 'tiktoken', 'chardet', "panel-material-ui >=0.4.0", "tabulate", "docxtpl" ] ai-local = ['lumen[ai]', 'huggingface_hub', 'hf_xet'] ai-openai = ['lumen[ai]', 'openai']