@@ -29,154 +29,249 @@ async def _execute(self, context, **kwargs):
2929 return [Markdown ("B done" )], {'text' : 'B' }
3030
3131
32- async def test_story_agent_write_story ( llm ):
33- from lumen . ai . agents . story import Story , StoryAgent
32+ class ChartAction ( Action ):
33+ """Emits a pipeline-backed LumenEditor so the story catalog has real views."""
3434
35- llm .set_responses ([Story (chain_of_thought = "arc" , title = "Olympics" , markdown = "# Olympics\n Body" )])
36- agent = StoryAgent (llm = llm )
35+ source = param .Parameter ()
3736
38- result = await agent . write_story ( "Section: Medals \n <data summary>" , title = "Olympic Report " )
37+ label = param . String ( default = "Chart " )
3938
40- assert isinstance ( result , Story )
41- assert result . title == "Olympics"
42- assert result . markdown == "# Olympics \n Body"
39+ async def _execute ( self , context , ** kwargs ):
40+ from lumen . ai . editors import LumenEditor
41+ from lumen . pipeline import Pipeline
4342
43+ pipeline = Pipeline (source = self .source , table = 'tiny' )
44+ return [LumenEditor (component = pipeline , title = self .label )], {'text' : self .label }
4445
45- async def test_story_agent_write_note (llm ):
46- from lumen .ai .agents .story import SectionNote , StoryAgent
4746
48- llm .set_responses ([SectionNote (chain_of_thought = "key" , markdown = "Men-only sports dominate." )])
49- agent = StoryAgent (llm = llm )
47+ def _nb_text (report ):
48+ return "" .join (
49+ "" .join (cell ["source" ]) for cell in json .loads (report .to_notebook ())["cells" ]
50+ )
5051
51- result = await agent .write_note ("Section: Gender\n <data summary>" , title = "Gender Exclusive" )
5252
53- assert isinstance ( result , SectionNote )
54- assert "Men-only" in result . markdown
53+ def _kinds ( report ):
54+ return [ kind for kind , _ in report . _story_blocks ]
5555
5656
57- async def test_build_report_context_includes_titles_and_text ():
58- from lumen .ai .agents .story import (
59- build_report_context , build_section_context ,
60- )
57+ async def test_story_agent_write_story (llm ):
58+ from lumen .ai .agents .story import Story , StoryAgent , StoryBlock
6159
62- report = Report (
63- Section (A (order = []), title = 'Section A' ),
64- Section (B (order = []), title = 'Section B' ),
65- title = 'My Report' ,
66- )
67- await report .execute ()
60+ llm .set_responses ([Story (
61+ chain_of_thought = "arc" , title = "Olympics" ,
62+ blocks = [StoryBlock (prose = "Intro." ), StoryBlock (view = 1 ), StoryBlock (prose = "Wrap up." )],
63+ )])
64+ agent = StoryAgent (llm = llm )
6865
69- ctx = await build_report_context ([s for s in report if s .include_in_export ])
70- assert "Section A" in ctx
71- assert "A done" in ctx
72- assert "Section B" in ctx
73- assert "B done" in ctx
66+ result = await agent .write_story (
67+ "### View 1: Medals\n <summary>" , guidance = "focus on trends" , title = "Olympic Report"
68+ )
7469
75- section_ctx = await build_section_context ( report [ 0 ] )
76- assert "A done" in section_ctx
77- assert "B done" not in section_ctx
70+ assert isinstance ( result , Story )
71+ assert result . title == "Olympics"
72+ assert [ block . view for block in result . blocks ] == [ None , 1 , None ]
7873
7974
80- async def test_build_section_context_summarizes_table_pipeline (tiny_source ):
81- from lumen .ai .agents .story import build_view_context
75+ async def test_build_catalog_numbers_and_summarizes (tiny_source ):
76+ from lumen .ai .agents .story import build_catalog
8277 from lumen .ai .editors import LumenEditor
8378 from lumen .pipeline import Pipeline
8479
85- pipeline = Pipeline (source = tiny_source , table = 'tiny' )
86- editor = LumenEditor (component = pipeline , title = 'Tiny table' )
80+ editor = LumenEditor (component = Pipeline (source = tiny_source , table = 'tiny' ), title = 'Tiny table' )
8781
88- ctx = await build_view_context ( editor )
82+ ctx = await build_catalog ([ editor ] )
8983
90- # The table's columns should surface in the summary sent to the LLM.
84+ # Views are numbered so the story can reference them, and the data surfaces.
85+ assert "View 1" in ctx
9186 assert "category" in ctx
9287 assert "value" in ctx
9388
9489
95- def _nb_text (report ):
96- return "" .join (
97- "" .join (cell ["source" ]) for cell in json .loads (report .to_notebook ())["cells" ]
98- )
99-
100-
101- async def test_report_annotate_inserts_story_and_notes (llm ):
102- from lumen .ai .agents .story import SectionNote , Story
90+ async def test_report_annotate_interleaves_prose_and_view (llm , tiny_source ):
91+ from lumen .ai .agents .story import Story , StoryBlock
10392
10493 report = Report (
105- Section (A (order = []), title = 'Section A' ),
106- Section (B (order = []), title = 'Section B' ),
94+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
10795 title = 'My Report' ,
10896 llm = llm ,
10997 )
11098 await report .execute ()
111- llm .set_responses ([
112- Story ( chain_of_thought = "c" , title = "The Big Picture" , markdown = "Overall narrative ." ),
113- SectionNote ( chain_of_thought = "c" , markdown = "Note for A." ),
114- SectionNote ( chain_of_thought = "c" , markdown = "Note for B ." ),
115- ])
99+ llm .set_responses ([Story ( chain_of_thought = "c" , title = "The Big Picture" , blocks = [
100+ StoryBlock ( prose = "Lead-in prose ." ),
101+ StoryBlock ( view = 1 ),
102+ StoryBlock ( prose = "Closing prose ." ),
103+ ])])
116104
117105 await report ._annotate_report ()
118106
119107 nb = _nb_text (report )
120108 assert "The Big Picture" in nb
121- assert "Overall narrative." in nb
122- assert "Note for A." in nb
123- assert "Note for B." in nb
109+ # Prose is interleaved around the chart's reconstructed spec, blog-post style.
110+ assert nb .index ("Lead-in prose." ) < nb .index ("lm.Pipeline.from_spec" ) < nb .index ("Closing prose." )
124111
125- # The overall story and the notes must both survive HTML export too.
112+ # The flow must survive HTML export too.
126113 html = report .to_html ()
127114 assert "The Big Picture" in html
128- assert "Overall narrative." in html
129- assert "Note for A." in html
130- assert "Note for B." in html
115+ assert "Lead-in prose." in html
116+ assert "Closing prose." in html
131117
132118
133- async def test_report_annotate_only_selected_sections (llm ):
134- from lumen .ai .agents .story import SectionNote , Story
119+ async def test_report_annotate_appends_unreferenced_views (llm , tiny_source ):
120+ from lumen .ai .agents .story import Story , StoryBlock
135121
136122 report = Report (
137- Section (A (order = []), title = 'Section A' ),
138- Section (B (order = []), title = 'Section B' ),
139- title = 'My Report' ,
123+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
124+ Section (ChartAction (source = tiny_source , label = 'Chart B' ), title = 'Section B' ),
125+ title = 'R' ,
126+ llm = llm ,
127+ )
128+ await report .execute ()
129+ # The story only references the second chart; the first must not be dropped.
130+ llm .set_responses ([Story (chain_of_thought = "c" , title = "S" , blocks = [
131+ StoryBlock (prose = "Only about the second." ),
132+ StoryBlock (view = 2 ),
133+ ])])
134+
135+ await report ._annotate_report ()
136+
137+ assert _kinds (report ).count ("view" ) == 2
138+
139+
140+ async def test_report_annotate_only_selected_sections (llm , tiny_source ):
141+ from lumen .ai .agents .story import Story , StoryBlock
142+
143+ report = Report (
144+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
145+ Section (ChartAction (source = tiny_source , label = 'Chart B' ), title = 'Section B' ),
146+ title = 'R' ,
140147 llm = llm ,
141148 )
142149 await report .execute ()
143150 report [1 ].include_in_export = False
144- llm .set_responses ([
145- Story (chain_of_thought = "c" , title = "S" , markdown = "overall" ),
146- SectionNote (chain_of_thought = "c" , markdown = "Note for A." ),
147- ])
151+ llm .set_responses ([Story (chain_of_thought = "c" , title = "S" , blocks = [
152+ StoryBlock (prose = "p" ), StoryBlock (view = 1 ),
153+ ])])
148154
149155 await report ._annotate_report ()
150156
151- nb = _nb_text (report )
152- assert "Note for A." in nb
153- assert "Note for B." not in nb
157+ # Only the selected section's chart is catalogued.
158+ assert _kinds (report ).count ("view" ) == 1
154159
155160
156- async def test_report_annotate_is_idempotent (llm ):
157- from lumen .ai .agents .story import SectionNote , Story
161+ async def test_report_annotate_is_idempotent (llm , tiny_source ):
162+ from lumen .ai .agents .story import Story , StoryBlock
158163
159- report = Report (Section (A (order = []), title = 'Section A' ), title = 'My Report' , llm = llm )
164+ report = Report (
165+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
166+ title = 'R' , llm = llm ,
167+ )
160168 await report .execute ()
161169
162- llm .set_responses ([Story (chain_of_thought = "c" , title = "S1" , markdown = "v1" ), SectionNote (chain_of_thought = "c" , markdown = "n1" )])
170+ llm .set_responses ([Story (chain_of_thought = "c" , title = "S1" , blocks = [
171+ StoryBlock (prose = "v1" ), StoryBlock (view = 1 ),
172+ ])])
163173 await report ._annotate_report ()
164- llm .set_responses ([Story (chain_of_thought = "c" , title = "S2" , markdown = "v2" ), SectionNote (chain_of_thought = "c" , markdown = "n2" )])
174+ llm .set_responses ([Story (chain_of_thought = "c" , title = "S2" , blocks = [
175+ StoryBlock (prose = "v2" ), StoryBlock (view = 1 ),
176+ ])])
165177 await report ._annotate_report ()
166178
167179 nb = _nb_text (report )
168- assert "v2" in nb and "n2" in nb
169- assert "v1" not in nb and "n1 " not in nb
180+ assert "v2" in nb and "v1" not in nb
181+ assert "S2" in nb and "S1 " not in nb
170182
171183
172- async def test_report_annotate_no_llm_is_noop ():
173- report = Report (Section (A (order = []), title = 'Section A' ), title = 'My Report' )
184+ async def test_report_annotate_no_llm_is_noop (tiny_source ):
185+ report = Report (
186+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
187+ title = 'R' ,
188+ )
174189 await report .execute ()
175190
176191 await report ._annotate_report () # must not raise
177192
178- nb = _nb_text (report )
179- assert "A done" in nb
193+ # Without a story the chart still exports on its own.
194+ assert "lm.Pipeline.from_spec" in _nb_text (report )
195+
196+
197+ async def test_report_story_dialog_presets_and_generate (llm , tiny_source ):
198+ from lumen .ai .agents .story import Story , StoryBlock
199+
200+ report = Report (
201+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
202+ title = 'R' , llm = llm ,
203+ )
204+ await report .execute ()
205+
206+ # Clicking Annotate opens the guidance dialog instead of generating immediately.
207+ report ._open_story_dialog ()
208+ assert report ._story_dialog .open is True
209+
210+ # A tone preset fills the guidance box.
211+ report ._apply_story_preset (text = "Executive summary tone." )
212+ assert report ._story_guidance .value == "Executive summary tone."
213+
214+ # Generate runs the annotation with that guidance and closes the dialog.
215+ llm .set_responses ([Story (chain_of_thought = "c" , title = "S" , blocks = [
216+ StoryBlock (prose = "p" ), StoryBlock (view = 1 ),
217+ ])])
218+ await report ._generate_story ()
219+
220+ assert report ._story_dialog .open is False
221+ assert "prose" in _kinds (report )
222+
223+
224+ async def test_report_view_toggle_switches_story_and_sections (llm , tiny_source ):
225+ from lumen .ai .agents .story import Story , StoryBlock
226+
227+ report = Report (
228+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
229+ title = 'R' , llm = llm ,
230+ )
231+ await report .execute ()
232+ llm .set_responses ([Story (chain_of_thought = "c" , title = "Headline" , blocks = [
233+ StoryBlock (prose = "Story prose." ), StoryBlock (view = 1 ),
234+ ])])
235+ await report ._annotate_report ()
236+
237+ # After annotate the story is shown and exported, and the toggle is visible.
238+ assert report ._show_story is True
239+ assert report ._view_toggle .visible is True
240+ assert "Story prose." in _nb_text (report )
241+
242+ # Toggle to the section view: the story is kept, export follows the sections.
243+ report ._toggle_view ()
244+ assert report ._show_story is False
245+ assert report ._story_blocks # still remembered
246+ nb_sections = _nb_text (report )
247+ assert "Story prose." not in nb_sections
248+ assert "lm.Pipeline.from_spec" in nb_sections
249+
250+ # Toggle again: the story returns without regenerating.
251+ report ._toggle_view ()
252+ assert report ._show_story is True
253+ assert "Story prose." in _nb_text (report )
254+
255+
256+ async def test_report_reset_discards_story (llm , tiny_source ):
257+ from lumen .ai .agents .story import Story , StoryBlock
258+
259+ report = Report (
260+ Section (ChartAction (source = tiny_source , label = 'Chart A' ), title = 'Section A' ),
261+ title = 'R' , llm = llm ,
262+ )
263+ await report .execute ()
264+ llm .set_responses ([Story (chain_of_thought = "c" , title = "H" , blocks = [
265+ StoryBlock (prose = "p" ), StoryBlock (view = 1 ),
266+ ])])
267+ await report ._annotate_report ()
268+ assert report ._story_blocks
269+
270+ report .reset ()
271+
272+ assert report ._story_blocks == []
273+ assert report ._show_story is False
274+ assert report ._view_toggle .visible is False
180275
181276
182277async def test_report_outline_defaults_empty ():
0 commit comments