diff --git a/lumen/ai/report.py b/lumen/ai/report.py index 19df049ed..b5c355754 100644 --- a/lumen/ai/report.py +++ b/lumen/ai/report.py @@ -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 @@ -49,6 +49,38 @@ ) +def _export_checkbox(task, status_source, align="center"): + """ + Checkbox controlling whether a task's outputs are kept when the report is + exported. ``status_source`` supplies the status it tracks, so it only + appears once that has something to export. + """ + return Checkbox.from_param( + task.param.include_in_export, + label="", + align=align, + margin=(0, 4, 0, 0), + # No description: it would render an info icon; the report explains the + # checkboxes at the top instead. + description="", + visible=param.bind( + lambda status, views: ( + status in ("success", "error", "cancelled") or bool(views) + ), + status_source.param.status, status_source.param.views, + ), + ) + + +def _export_header(section, status_source, variant="h3"): + """Heading for a section, with the checkbox that keeps or discards it.""" + title = Typography(section.param.title, variant=variant, margin=0) + return Row( + _export_checkbox(section, status_source), title, + align="center", sizing_mode="stretch_width", + ) + + class Task(Viewer): """ A `Task` defines a single unit of work that can be executed and rendered. @@ -63,6 +95,9 @@ class Task(Viewer): history = param.List(doc=""" Conversation history to include as context for the task.""") + include_in_export = param.Boolean(default=True, doc=""" + Whether this task's outputs are included when the report is exported.""") + instruction = param.String(default="", doc=""" The instruction to give to the task.""") @@ -625,61 +660,6 @@ def reset(self, start: int = 0): task.reset() self._populate_view() - def to_notebook(self): - """ - Returns the notebook representation of the tasks. - """ - if len(self) and not self.status == "success": - raise RuntimeError( - "Report has not been executed, run report before exporting to_notebook." - ) - cells, extensions = [], ['tabulator'] - pending_headers = [] - for out in self.views: - cell = ext = None - if isinstance(out, Typography): - # Buffer headers; only emit them if followed by exportable content - level = int(out.variant[1:]) if out.variant and out.variant.startswith('h') else 0 - prefix = f"{'#'*level} " if level else '' - pending_headers.append(make_md_cell(f"{prefix}{out.object}")) - continue - elif isinstance(out, Markdown): - cell = make_md_cell(out.object) - elif isinstance(out, LumenEditor): - cell, ext = format_output(out) - elif isinstance(out, str): - cell = make_md_cell(out) - elif isinstance(out, ChatMessage): - obj = out.object - if isinstance(obj, str): - cell = make_md_cell(obj) - elif isinstance(obj, Markdown): - cell = make_md_cell(obj.object) - # Skip non-LumenEditor Viewables (not meaningfully exportable) - - if cell is None: - continue - # Flush buffered headers before the content cell - cells.extend(pending_headers) - pending_headers.clear() - cells.append(cell) - if ext and ext not in extensions: - extensions.append(ext) - cells = make_preamble("", extensions=extensions) + cells - return write_notebook(cells) - - def to_html(self): - """ - Returns the HTML representation of the report. - """ - if len(self) and self.status != "success": - raise RuntimeError( - "Report has not been executed, run report before exporting to html." - ) - buf = io.StringIO() - self._view.save(buf, title=self.title or "Report") - return buf.getvalue() - def validate( self, context: TContext | None = None, @@ -823,7 +803,36 @@ def _populate_view(self): placeholders = [self._placeholder] if self._task_previews is not None: placeholders.append(self._task_previews) - self._view[:] = self._header + placeholders + list(self._tasks) + self._view[:] = self._header + placeholders + [self._task_view(task) for task in self._tasks] + + def _task_view(self, task): + """ + Give every task its own export checkbox, so a single chart can be + dropped without discarding the whole section it came from. The report + only draws headers for its top level sections, so without this nothing + inside one could be selected. + + A nested section gets a heading too, unless it takes its parent's title + (a section wrapping a single plan does), which would repeat the heading + the report already shows. + """ + if isinstance(task, Section): + if task.title == self.title: + return task + return Column( + _export_header(task, task, variant="h4"), + task, + sizing_mode="stretch_width", + styles={'min-height': 'unset'}, + height_policy='fit', + ) + return Row( + _export_checkbox(task, task, align="start"), + task, + sizing_mode="stretch_width", + styles={'min-height': 'unset'}, + height_policy='fit', + ) async def _run_task(self, i: int, task: Task | Actor, context: TContext | None, **kwargs) -> list[Any]: if context is not None: @@ -914,6 +923,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) ) @@ -1021,7 +1031,14 @@ def _init_view(self): large=self._menu, sizing_mode="stretch_width" ) + self._export_hint = Typography( + "Untick a chart, table or note to leave it out of the exported report.", + variant="body2", margin=(0, 10, 10, 10), + sx={"color": "text.secondary"}, + visible=self.param.status.rx().rx.in_(("success", "error", "cancelled")), + ) self._container = Column( + self._export_hint, self._view, self._dialog, margin=(0, 0, 0, 5), @@ -1094,6 +1111,104 @@ 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 + return self._selected_views(self) + + def _selected_views(self, group, include_header=True): + """ + The views of a task group, skipping sections discarded from the export. + Walks the tasks rather than reading ``group.views`` so that a nested + section's selection is honoured, not just a top level one's. + """ + views = list(group._header) if include_header else [] + for task in group: + if not task.include_in_export: + continue + views += self._selected_views(task) if isinstance(task, TaskGroup) else list(task.views) + return views + + def _export_view(self): + # Task-less reports (e.g. Report.from_views) populate ``_view`` directly. + if not len(self): + return self._view + # Build each card from the section's selected views rather than the + # section itself, so a discarded nested section is left out of the HTML + # too. The card header already shows the title, hence no heading. + cards = [ + ( + section.title, + Column(*self._selected_views(section, include_header=False), sizing_mode="stretch_width"), + ) + 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, + ) + + def to_notebook(self): + """ + Returns the notebook representation of the report. + """ + if len(self) and not self.status == "success": + raise RuntimeError( + "Report has not been executed, run report before exporting to_notebook." + ) + cells, extensions = [], ['tabulator'] + pending_headers = [] + for out in self._export_views: + cell = ext = None + if isinstance(out, Typography): + # Buffer headers; only emit them if followed by exportable content + level = int(out.variant[1:]) if out.variant and out.variant.startswith('h') else 0 + prefix = f"{'#'*level} " if level else '' + pending_headers.append(make_md_cell(f"{prefix}{out.object}")) + continue + elif isinstance(out, Markdown): + cell = make_md_cell(out.object) + elif isinstance(out, LumenEditor): + cell, ext = format_output(out) + elif isinstance(out, str): + cell = make_md_cell(out) + elif isinstance(out, ChatMessage): + obj = out.object + if isinstance(obj, str): + cell = make_md_cell(obj) + elif isinstance(obj, Markdown): + cell = make_md_cell(obj.object) + # Skip non-LumenEditor Viewables (not meaningfully exportable) + + if cell is None: + continue + # Flush buffered headers before the content cell + cells.extend(pending_headers) + pending_headers.clear() + cells.append(cell) + if ext and ext not in extensions: + extensions.append(ext) + cells = make_preamble("", extensions=extensions) + cells + return write_notebook(cells) + + def to_html(self): + """ + Returns the HTML representation of the report. + """ + if len(self) and self.status != "success": + raise RuntimeError( + "Report has not been executed, run report before exporting to html." + ) + buf = io.StringIO() + self._export_view().save(buf, title=self.title or "Report") + return buf.getvalue() + async def _export_report(self, item=None): if len(self) and self.status not in ("success", "cancelled", "error"): await self.execute() @@ -1116,8 +1231,19 @@ 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.""" + return _export_header(section, self) + 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))) diff --git a/lumen/tests/ai/test_report.py b/lumen/tests/ai/test_report.py index eaaf9241b..e62a3e758 100644 --- a/lumen/tests/ai/test_report.py +++ b/lumen/tests/ai/test_report.py @@ -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 @@ -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): @@ -399,6 +400,181 @@ 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_export_excludes_a_single_deselected_chart(): + # Two charts made in one session: each is selectable on its own. + bar, pie = A(order=[], title='Bar'), B(order=[], title='Pie') + report = Report(Section(bar, pie, title='Genre Streams'), title='Multi Report') + await report.execute() + + sources = "".join( + "".join(cell["source"]) for cell in json.loads(report.to_notebook())["cells"] + ) + assert "A done" in sources and "B done" in sources + + # Dropping one chart keeps the other, and keeps the section it came from. + pie.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 "Genre Streams" in sources + + +async def test_report_export_excludes_deselected_nested_section(): + nested = Section(B(order=[]), title='Nested Section') + report = Report( + Section(A(order=[]), nested, title='Outer Section'), + title='Multi Report', + ) + await report.execute() + + # A nested section is included by default, like a top level one. + sources = "".join( + "".join(cell["source"]) for cell in json.loads(report.to_notebook())["cells"] + ) + assert "A done" in sources + assert "B done" in sources + + # Deselecting the nested section drops it (and its heading) but keeps the + # rest of the section it lives in. + nested.include_in_export = False + sources = "".join( + "".join(cell["source"]) for cell in json.loads(report.to_notebook())["cells"] + ) + assert "A done" in sources + assert "Outer Section" in sources + assert "B done" not in sources + assert "Nested Section" not in sources + + # The HTML export honours the nested selection too. + html = report.to_html() + assert "A done" in html + assert "B done" not in html + + +def _find_checkboxes(obj, found=None): + """Every Checkbox rendered anywhere in a component tree.""" + found = [] if found is None else found + if isinstance(obj, Checkbox): + found.append(obj) + return found + for child in (getattr(obj, 'objects', None) or []): + _find_checkboxes(child[1] if isinstance(child, tuple) else child, found) + return found + + +async def test_section_renders_a_checkbox_per_task(): + nested = Section(B(order=[]), title='Nested Section') + outer = Section(A(order=[]), nested, title='Outer Section') + report = Report(outer, title='Multi Report') + await report.execute() + + # Everything inside a section is selectable, so a single chart can be + # dropped on its own: the action, the nested section, and the action within + # it each get a checkbox. + assert len(_find_checkboxes(outer._view)) == 3 + + # Each checkbox is bound to the include_in_export of its own task. + nested.include_in_export = False + assert False in [checkbox.value for checkbox in _find_checkboxes(outer._view)] + + +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")]