Skip to content

Commit 77536c1

Browse files
authored
Allow selecting which report sections to keep on export (#1922)
1 parent fd0b208 commit 77536c1

2 files changed

Lines changed: 363 additions & 61 deletions

File tree

lumen/ai/report.py

Lines changed: 185 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
from panel.viewable import Viewable, Viewer
2424
from panel_material_ui import (
2525
Accordion, BreakpointSwitcher, Button, Card, ChatFeed, ChatMessage,
26-
Container, Dialog, Divider, FileDownload, IconButton, MenuButton, Progress,
27-
Select, SpeedDial, TextAreaInput, TextInput, Typography,
26+
Checkbox, Container, Dialog, Divider, FileDownload, IconButton, MenuButton,
27+
Progress, Select, SpeedDial, TextAreaInput, TextInput, Typography,
2828
)
2929

3030
from ..views.base import Panel, View
@@ -49,6 +49,38 @@
4949
)
5050

5151

52+
def _export_checkbox(task, status_source, align="center"):
53+
"""
54+
Checkbox controlling whether a task's outputs are kept when the report is
55+
exported. ``status_source`` supplies the status it tracks, so it only
56+
appears once that has something to export.
57+
"""
58+
return Checkbox.from_param(
59+
task.param.include_in_export,
60+
label="",
61+
align=align,
62+
margin=(0, 4, 0, 0),
63+
# No description: it would render an info icon; the report explains the
64+
# checkboxes at the top instead.
65+
description="",
66+
visible=param.bind(
67+
lambda status, views: (
68+
status in ("success", "error", "cancelled") or bool(views)
69+
),
70+
status_source.param.status, status_source.param.views,
71+
),
72+
)
73+
74+
75+
def _export_header(section, status_source, variant="h3"):
76+
"""Heading for a section, with the checkbox that keeps or discards it."""
77+
title = Typography(section.param.title, variant=variant, margin=0)
78+
return Row(
79+
_export_checkbox(section, status_source), title,
80+
align="center", sizing_mode="stretch_width",
81+
)
82+
83+
5284
class Task(Viewer):
5385
"""
5486
A `Task` defines a single unit of work that can be executed and rendered.
@@ -63,6 +95,9 @@ class Task(Viewer):
6395
history = param.List(doc="""
6496
Conversation history to include as context for the task.""")
6597

98+
include_in_export = param.Boolean(default=True, doc="""
99+
Whether this task's outputs are included when the report is exported.""")
100+
66101
instruction = param.String(default="", doc="""
67102
The instruction to give to the task.""")
68103

@@ -625,61 +660,6 @@ def reset(self, start: int = 0):
625660
task.reset()
626661
self._populate_view()
627662

628-
def to_notebook(self):
629-
"""
630-
Returns the notebook representation of the tasks.
631-
"""
632-
if len(self) and not self.status == "success":
633-
raise RuntimeError(
634-
"Report has not been executed, run report before exporting to_notebook."
635-
)
636-
cells, extensions = [], ['tabulator']
637-
pending_headers = []
638-
for out in self.views:
639-
cell = ext = None
640-
if isinstance(out, Typography):
641-
# Buffer headers; only emit them if followed by exportable content
642-
level = int(out.variant[1:]) if out.variant and out.variant.startswith('h') else 0
643-
prefix = f"{'#'*level} " if level else ''
644-
pending_headers.append(make_md_cell(f"{prefix}{out.object}"))
645-
continue
646-
elif isinstance(out, Markdown):
647-
cell = make_md_cell(out.object)
648-
elif isinstance(out, LumenEditor):
649-
cell, ext = format_output(out)
650-
elif isinstance(out, str):
651-
cell = make_md_cell(out)
652-
elif isinstance(out, ChatMessage):
653-
obj = out.object
654-
if isinstance(obj, str):
655-
cell = make_md_cell(obj)
656-
elif isinstance(obj, Markdown):
657-
cell = make_md_cell(obj.object)
658-
# Skip non-LumenEditor Viewables (not meaningfully exportable)
659-
660-
if cell is None:
661-
continue
662-
# Flush buffered headers before the content cell
663-
cells.extend(pending_headers)
664-
pending_headers.clear()
665-
cells.append(cell)
666-
if ext and ext not in extensions:
667-
extensions.append(ext)
668-
cells = make_preamble("", extensions=extensions) + cells
669-
return write_notebook(cells)
670-
671-
def to_html(self):
672-
"""
673-
Returns the HTML representation of the report.
674-
"""
675-
if len(self) and self.status != "success":
676-
raise RuntimeError(
677-
"Report has not been executed, run report before exporting to html."
678-
)
679-
buf = io.StringIO()
680-
self._view.save(buf, title=self.title or "Report")
681-
return buf.getvalue()
682-
683663
def validate(
684664
self,
685665
context: TContext | None = None,
@@ -823,7 +803,36 @@ def _populate_view(self):
823803
placeholders = [self._placeholder]
824804
if self._task_previews is not None:
825805
placeholders.append(self._task_previews)
826-
self._view[:] = self._header + placeholders + list(self._tasks)
806+
self._view[:] = self._header + placeholders + [self._task_view(task) for task in self._tasks]
807+
808+
def _task_view(self, task):
809+
"""
810+
Give every task its own export checkbox, so a single chart can be
811+
dropped without discarding the whole section it came from. The report
812+
only draws headers for its top level sections, so without this nothing
813+
inside one could be selected.
814+
815+
A nested section gets a heading too, unless it takes its parent's title
816+
(a section wrapping a single plan does), which would repeat the heading
817+
the report already shows.
818+
"""
819+
if isinstance(task, Section):
820+
if task.title == self.title:
821+
return task
822+
return Column(
823+
_export_header(task, task, variant="h4"),
824+
task,
825+
sizing_mode="stretch_width",
826+
styles={'min-height': 'unset'},
827+
height_policy='fit',
828+
)
829+
return Row(
830+
_export_checkbox(task, task, align="start"),
831+
task,
832+
sizing_mode="stretch_width",
833+
styles={'min-height': 'unset'},
834+
height_policy='fit',
835+
)
827836

828837
async def _run_task(self, i: int, task: Task | Actor, context: TContext | None, **kwargs) -> list[Any]:
829838
if context is not None:
@@ -914,6 +923,7 @@ def from_views(cls, views: list, title: str | None = None) -> Report:
914923
return report
915924

916925
def _init_view(self):
926+
self._section_headers = {}
917927
self._header_title = Typography(
918928
self.param.title, variant="h1", margin=(0, 0, 0, 10)
919929
)
@@ -1021,7 +1031,14 @@ def _init_view(self):
10211031
large=self._menu,
10221032
sizing_mode="stretch_width"
10231033
)
1034+
self._export_hint = Typography(
1035+
"Untick a chart, table or note to leave it out of the exported report.",
1036+
variant="body2", margin=(0, 10, 10, 10),
1037+
sx={"color": "text.secondary"},
1038+
visible=self.param.status.rx().rx.in_(("success", "error", "cancelled")),
1039+
)
10241040
self._container = Column(
1041+
self._export_hint,
10251042
self._view,
10261043
self._dialog,
10271044
margin=(0, 0, 0, 5),
@@ -1094,6 +1111,104 @@ def _handle_cancel(self, event=None):
10941111
if self._active_task is not None and not self._active_task.done():
10951112
self._active_task.cancel()
10961113

1114+
@property
1115+
def _export_views(self):
1116+
# Task-less reports (e.g. Report.from_views) populate ``views`` directly.
1117+
if not len(self):
1118+
return self.views
1119+
return self._selected_views(self)
1120+
1121+
def _selected_views(self, group, include_header=True):
1122+
"""
1123+
The views of a task group, skipping sections discarded from the export.
1124+
Walks the tasks rather than reading ``group.views`` so that a nested
1125+
section's selection is honoured, not just a top level one's.
1126+
"""
1127+
views = list(group._header) if include_header else []
1128+
for task in group:
1129+
if not task.include_in_export:
1130+
continue
1131+
views += self._selected_views(task) if isinstance(task, TaskGroup) else list(task.views)
1132+
return views
1133+
1134+
def _export_view(self):
1135+
# Task-less reports (e.g. Report.from_views) populate ``_view`` directly.
1136+
if not len(self):
1137+
return self._view
1138+
# Build each card from the section's selected views rather than the
1139+
# section itself, so a discarded nested section is left out of the HTML
1140+
# too. The card header already shows the title, hence no heading.
1141+
cards = [
1142+
(
1143+
section.title,
1144+
Column(*self._selected_views(section, include_header=False), sizing_mode="stretch_width"),
1145+
)
1146+
for section in self if section.include_in_export
1147+
]
1148+
return Accordion(
1149+
*cards,
1150+
active=list(range(len(cards))),
1151+
sizing_mode="stretch_width",
1152+
min_height=0,
1153+
margin=(0, 5, 15, 5),
1154+
sx=self._view.sx,
1155+
)
1156+
1157+
def to_notebook(self):
1158+
"""
1159+
Returns the notebook representation of the report.
1160+
"""
1161+
if len(self) and not self.status == "success":
1162+
raise RuntimeError(
1163+
"Report has not been executed, run report before exporting to_notebook."
1164+
)
1165+
cells, extensions = [], ['tabulator']
1166+
pending_headers = []
1167+
for out in self._export_views:
1168+
cell = ext = None
1169+
if isinstance(out, Typography):
1170+
# Buffer headers; only emit them if followed by exportable content
1171+
level = int(out.variant[1:]) if out.variant and out.variant.startswith('h') else 0
1172+
prefix = f"{'#'*level} " if level else ''
1173+
pending_headers.append(make_md_cell(f"{prefix}{out.object}"))
1174+
continue
1175+
elif isinstance(out, Markdown):
1176+
cell = make_md_cell(out.object)
1177+
elif isinstance(out, LumenEditor):
1178+
cell, ext = format_output(out)
1179+
elif isinstance(out, str):
1180+
cell = make_md_cell(out)
1181+
elif isinstance(out, ChatMessage):
1182+
obj = out.object
1183+
if isinstance(obj, str):
1184+
cell = make_md_cell(obj)
1185+
elif isinstance(obj, Markdown):
1186+
cell = make_md_cell(obj.object)
1187+
# Skip non-LumenEditor Viewables (not meaningfully exportable)
1188+
1189+
if cell is None:
1190+
continue
1191+
# Flush buffered headers before the content cell
1192+
cells.extend(pending_headers)
1193+
pending_headers.clear()
1194+
cells.append(cell)
1195+
if ext and ext not in extensions:
1196+
extensions.append(ext)
1197+
cells = make_preamble("", extensions=extensions) + cells
1198+
return write_notebook(cells)
1199+
1200+
def to_html(self):
1201+
"""
1202+
Returns the HTML representation of the report.
1203+
"""
1204+
if len(self) and self.status != "success":
1205+
raise RuntimeError(
1206+
"Report has not been executed, run report before exporting to html."
1207+
)
1208+
buf = io.StringIO()
1209+
self._export_view().save(buf, title=self.title or "Report")
1210+
return buf.getvalue()
1211+
10971212
async def _export_report(self, item=None):
10981213
if len(self) and self.status not in ("success", "cancelled", "error"):
10991214
await self.execute()
@@ -1116,8 +1231,19 @@ def _expand_all(self, event=None):
11161231
def _open_settings(self, event=None):
11171232
self._dialog.open = True
11181233

1234+
def _section_header(self, section):
1235+
"""Card header with a checkbox to keep/discard the section on export."""
1236+
return _export_header(section, self)
1237+
11191238
def _populate_view(self):
1120-
self._view[:] = objects = [(task.title, task) for task in self]
1239+
headers = {}
1240+
objects = []
1241+
for section in self:
1242+
header = self._section_headers.get(section) or self._section_header(section)
1243+
headers[section] = header
1244+
objects.append((header, section))
1245+
self._section_headers = headers
1246+
self._view[:] = objects
11211247
has_outputs = self.status in ("success", "error", "cancelled") or bool(self.views)
11221248
if has_outputs:
11231249
self._view.active = list(range(len(objects)))

0 commit comments

Comments
 (0)