Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions lumen/ai/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
from panel.viewable import Viewable, Viewer
from panel_material_ui import (
Accordion, BreakpointSwitcher, Button, Card, ChatFeed, ChatMessage,
Container, Dialog, Divider, FileDownload, IconButton, MenuButton, Progress,
Select, SpeedDial, TextAreaInput, TextInput, Typography,
Checkbox, Container, Dialog, Divider, FileDownload, IconButton, MenuButton,
Progress, Select, SpeedDial, TextAreaInput, TextInput, Typography,
)

from ..views.base import Panel, View
Expand Down Expand Up @@ -625,6 +625,21 @@ def reset(self, start: int = 0):
task.reset()
self._populate_view()

@property
def _export_views(self):
"""
The flat list of views to include when exporting. Overridden by
subclasses that support selecting which parts to export.
"""
return self.views

def _export_view(self):
"""
The renderable view to save when exporting to HTML. Overridden by
subclasses that support selecting which parts to export.
"""
return self._view

@ahuang11 ahuang11 Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are these necessary? Seems like unnecessary wrappers?

def to_notebook(self):
"""
Returns the notebook representation of the tasks.
Expand All @@ -635,7 +650,7 @@ def to_notebook(self):
)
cells, extensions = [], ['tabulator']
pending_headers = []
for out in self.views:
for out in self._export_views:
cell = ext = None
if isinstance(out, Typography):
# Buffer headers; only emit them if followed by exportable content
Expand Down Expand Up @@ -677,7 +692,7 @@ def to_html(self):
"Report has not been executed, run report before exporting to html."
)
buf = io.StringIO()
self._view.save(buf, title=self.title or "Report")
self._export_view().save(buf, title=self.title or "Report")
return buf.getvalue()

def validate(
Expand Down Expand Up @@ -731,6 +746,9 @@ class Section(TaskGroup):
A `Section` is a `TaskGroup` representing a sequence of related tasks.
"""

include_in_export = param.Boolean(default=True, doc="""
Whether this section is included when the report is exported.""")

level = 2

def __init__(self, *tasks, **params):
Expand Down Expand Up @@ -914,6 +932,7 @@ def from_views(cls, views: list, title: str | None = None) -> Report:
return report

def _init_view(self):
self._section_headers = {}
self._header_title = Typography(
self.param.title, variant="h1", margin=(0, 0, 0, 10)
)
Expand Down Expand Up @@ -1094,6 +1113,35 @@ def _handle_cancel(self, event=None):
if self._active_task is not None and not self._active_task.done():
self._active_task.cancel()

@property
def _export_views(self):
# Task-less reports (e.g. Report.from_views) populate ``views`` directly.
if not len(self):
return self.views
selected = [
view
for section in self
if section.include_in_export
for view in section.views
]
return list(self._header) + selected

def _export_view(self):
# Task-less reports (e.g. Report.from_views) populate ``_view`` directly.
if not len(self):
return self._view
cards = [
(section.title, section) for section in self if section.include_in_export
]
return Accordion(
*cards,
active=list(range(len(cards))),
sizing_mode="stretch_width",
min_height=0,
margin=(0, 5, 15, 5),
sx=self._view.sx,
)

async def _export_report(self, item=None):
if len(self) and self.status not in ("success", "cancelled", "error"):
await self.execute()
Expand All @@ -1116,8 +1164,33 @@ def _expand_all(self, event=None):
def _open_settings(self, event=None):
self._dialog.open = True

def _section_header(self, section):
"""Card header with a checkbox to keep/discard the section on export."""
checkbox = Checkbox.from_param(
section.param.include_in_export,
label="",
align="center",
margin=(0, 4, 0, 0),
description="Include this section when exporting the report",
visible=param.bind(
lambda status, views: (
status in ("success", "error", "cancelled") or bool(views)
),
self.param.status, self.param.views,
),
)
title = Typography(section.param.title, variant="h3", margin=0)
return Row(checkbox, title, align="center", sizing_mode="stretch_width")

def _populate_view(self):
self._view[:] = objects = [(task.title, task) for task in self]
headers = {}
objects = []
for section in self:
header = self._section_headers.get(section) or self._section_header(section)
headers[section] = header
objects.append((header, section))
self._section_headers = headers
self._view[:] = objects
has_outputs = self.status in ("success", "error", "cancelled") or bool(self.views)
if has_outputs:
self._view.active = list(range(len(objects)))
Expand Down
100 changes: 98 additions & 2 deletions lumen/tests/ai/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
except ModuleNotFoundError:
pytest.skip("lumen.ai could not be imported, skipping tests.", allow_module_level=True)

from panel.layout import Column
from panel.layout import Column, Row
from panel.pane import Markdown
from typing_extensions import NotRequired

Expand All @@ -20,9 +20,10 @@
)

try:
from panel_material_ui import ChatMessage
from panel_material_ui import ChatMessage, Checkbox
except ImportError:
ChatMessage = None
Checkbox = None


class HelloAction(Action):
Expand Down Expand Up @@ -399,6 +400,101 @@ async def test_report_to_html():
assert "Hello" in html_string


async def test_section_include_in_export_default():
section = Section(HelloAction())
assert section.include_in_export is True


async def test_report_to_notebook_excludes_deselected_section():
report = Report(
Section(A(order=[]), title='Section A'),
Section(B(order=[]), title='Section B'),
title='Multi Report',
)
await report.execute()

# By default every section is included in the export.
all_sources = "".join(
"".join(cell["source"]) for cell in json.loads(report.to_notebook())["cells"]
)
assert "A done" in all_sources
assert "B done" in all_sources

# Deselecting a section drops it (and its header) from the notebook.
report[1].include_in_export = False
sources = "".join(
"".join(cell["source"]) for cell in json.loads(report.to_notebook())["cells"]
)
assert "A done" in sources
assert "B done" not in sources
assert "Section B" not in sources
assert "Multi Report" in sources


async def test_report_to_html_excludes_deselected_section():
report = Report(
Section(A(order=[]), title='Section A'),
Section(B(order=[]), title='Section B'),
title='Multi Report',
)
await report.execute()

report[1].include_in_export = False
html = report.to_html()

assert "A done" in html
assert "B done" not in html
# Exporting a subset must not disturb the live report view.
assert len(report._view) == 2


async def test_report_section_header_has_bound_export_checkbox():
report = Report(
Section(A(order=[]), title='Section A'),
Section(B(order=[]), title='Section B'),
title='Multi Report',
)
await report.execute()

headers = report._view._headers
assert len(headers) == len(report) == 2
for section, header in zip(report, headers):
assert isinstance(header, Row)
checkboxes = [obj for obj in header.objects if isinstance(obj, Checkbox)]
assert len(checkboxes) == 1
checkbox = checkboxes[0]
# Checked by default and bound to include_in_export in both directions.
assert checkbox.value is True
section.include_in_export = False
assert checkbox.value is False
checkbox.value = True
assert section.include_in_export is True


async def test_report_export_empty_selection():
report = Report(
Section(A(order=[]), title='Section A'),
Section(B(order=[]), title='Section B'),
title='Multi Report',
)
await report.execute()

report[0].include_in_export = False
report[1].include_in_export = False

cells = json.loads(report.to_notebook())["cells"]
sources = "".join("".join(cell["source"]) for cell in cells)
assert "A done" not in sources
assert "B done" not in sources
# Only the import preamble survives when nothing is selected.
assert len(cells) == 1

# HTML export of an empty selection should not raise.
html = report.to_html()
assert "A done" not in html
assert "B done" not in html


def test_report_from_views_assembles_exportable_report():
views = [Markdown("# My Title"), Markdown("Some body text")]

Expand Down
Loading