Abid Ali Awan commited on
Commit
4569df2
·
1 Parent(s): c14da16

feat(pdf): enable PDF export for research reports

Browse files

Refactor app structure by splitting UI logic into separate modules for better maintainability. Introduce PDF generation using ReportLab, update dependencies, and enhance README with app structure and feature details. Add custom styling for professional report output.

Files changed (7) hide show
  1. .gitignore +6 -2
  2. README.md +16 -8
  3. app/app.py +292 -438
  4. app/pdf_export.py +221 -0
  5. app/report_formatting.py +73 -0
  6. app/state.py +138 -0
  7. requirements.txt +2 -1
.gitignore CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  # Reflex generated files
2
  .web/
3
  .states/
@@ -206,9 +210,9 @@ cython_debug/
206
  .abstra/
207
 
208
  # Visual Studio Code
209
- # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
210
  # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
211
- # and can be added to the global gitignore or merged into this file. However, if you prefer,
212
  # you could uncomment the following to ignore the entire vscode folder
213
  # .vscode/
214
  # Temporary file for partial code execution
 
1
+ tmp
2
+ .states
3
+ .web
4
+ *.py[cod]
5
  # Reflex generated files
6
  .web/
7
  .states/
 
210
  .abstra/
211
 
212
  # Visual Studio Code
213
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
214
  # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
215
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
216
  # you could uncomment the following to ignore the entire vscode folder
217
  # .vscode/
218
  # Temporary file for partial code execution
README.md CHANGED
@@ -18,7 +18,7 @@ A multi-agent research assistant built with the OpenAI Agents SDK, Olostep, and
18
 
19
  https://github.com/user-attachments/assets/9aee7d1e-7d3d-4c11-b286-a6b11fef2d8d
20
 
21
- Enter a research question and the manager agent coordinates judges, retrieval tools, and an analyst agent to produce a polished, source-backed Markdown research report. The original notebook is included, and the same logic is also available as a Reflex web app.
22
 
23
  ## Flow
24
 
@@ -111,15 +111,23 @@ Then open the local URL printed by Reflex, usually:
111
  http://localhost:3000
112
  ```
113
 
 
 
 
114
  The app files live in `app/`:
115
 
116
- - `app/app.py` Reflex UI with styled Markdown report rendering and download.
117
- - `app/research_assistant.py` — OpenAI Agents SDK multi-agent workflow with Olostep tools.
 
 
 
 
 
118
 
119
  ## Features
120
 
121
- - **Multi-agent workflow** Manager, Judge, and Analyst agents collaborate while the manager directly controls Olostep retrieval tools.
122
- - **Live progress logs** Watch each agent step in real time.
123
- - **Styled Markdown report** Headings, bullets, tables, code blocks, and more render properly in the browser.
124
- - **Download report** Export the full Markdown report with one click.
125
- - **Deep retrieval path** If early evidence is weak, the manager runs targeted searches and scrapes at least the top 3 relevant pages.
 
18
 
19
  https://github.com/user-attachments/assets/9aee7d1e-7d3d-4c11-b286-a6b11fef2d8d
20
 
21
+ Enter a research question and the manager agent coordinates judges, retrieval tools, and an analyst agent to produce a polished, source-backed Markdown research report. The Reflex app renders the report in the browser and exports a formatted PDF.
22
 
23
  ## Flow
24
 
 
111
  http://localhost:3000
112
  ```
113
 
114
+
115
+ ## App Structure
116
+
117
  The app files live in `app/`:
118
 
119
+ | File | Purpose |
120
+ |---|---|
121
+ | `app/app.py` | Reflex UI components and page registration only. |
122
+ | `app/state.py` | Reflex state, event handlers, progress logging, stop/reset behavior, and downloads. |
123
+ | `app/research_assistant.py` | OpenAI Agents SDK workflow with Manager, Judge, Analyst, and Olostep tools. |
124
+ | `app/report_formatting.py` | Markdown cleanup, browser HTML rendering, link behavior, and report CSS. |
125
+ | `app/pdf_export.py` | ReportLab-based PDF generation with headings, bullets, links, and Markdown table support. |
126
 
127
  ## Features
128
 
129
+ - **Multi-agent workflow**: Manager, Judge, and Analyst agents collaborate while the manager directly controls Olostep retrieval tools.
130
+ - **Live progress logs**: Watch each agent step in real time.
131
+ - **Styled Markdown report**: Headings, bullets, tables, code blocks, and more render properly in the browser.
132
+ - **Download report**: Export the full report as a formatted PDF using ReportLab.
133
+ - **Deep retrieval path**: If early evidence is weak, the manager runs targeted searches and scrapes at least the top 3 relevant pages.
app/app.py CHANGED
@@ -1,451 +1,305 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import re
5
- import time
6
 
7
- import markdown
8
- from markdown.extensions import Extension
9
- from markdown.treeprocessors import Treeprocessor
10
  import reflex as rx
11
-
12
- from .research_assistant import environment_status, run_research_assistant
13
-
14
-
15
- PAGE_BG = "linear-gradient(135deg, #fbfdff 0%, #fffdf7 48%, #f8fff9 100%)"
16
- PAGE_PADDING = {"initial": "1rem", "sm": "1.25rem", "lg": "2rem"}
 
17
  PANEL_PADDING = {"initial": "1rem", "sm": "1.25rem"}
18
- _RUNNING_TASKS: dict[str, asyncio.Task] = {}
19
- _REFERENCE_RETURN_MARKERS = re.compile(r"(?:\s*(?:↩️|↩|🔙|↪️|↪)){1,}")
20
 
21
 
22
- def _plain_markdown(value) -> str:
23
- if isinstance(value, str):
24
- return _clean_markdown(value)
25
- if isinstance(value, dict):
26
- nested = value.get("markdown_report")
27
- text = nested if isinstance(nested, str) else str(nested or value)
28
- return _clean_markdown(text)
29
- nested = getattr(value, "markdown_report", None)
30
- text = nested if isinstance(nested, str) else str(value)
31
- return _clean_markdown(text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
 
34
- def _clean_markdown(value: str) -> str:
35
- return _REFERENCE_RETURN_MARKERS.sub("", value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
- class NewTabLinksTreeprocessor(Treeprocessor):
39
- def run(self, root):
40
- for element in root.iter("a"):
41
- element.set("target", "_blank")
42
- element.set("rel", "noopener noreferrer")
43
- return root
 
 
 
 
 
 
 
 
44
 
45
 
46
- class NewTabLinksExtension(Extension):
47
- def extendMarkdown(self, md):
48
- md.treeprocessors.register(NewTabLinksTreeprocessor(md), "new_tab_links", 15)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
 
51
- class State(rx.State):
52
- query: str = ""
53
- logs: list[str] = []
54
- report_markdown: str = ""
55
- report_html: str = ""
56
- trace_url: str = ""
57
- error: str = ""
58
- is_running: bool = False
59
- status: str = "Ready"
60
- active_run_id: int = 0
61
- step_started_at: float = 0.0
62
-
63
- def set_query(self, value: str) -> None:
64
- self.query = value
65
-
66
- def _client_token(self) -> str:
67
- return self.router.session.client_token or "default"
68
-
69
- def _cancel_active_task(self) -> bool:
70
- task = _RUNNING_TASKS.pop(self._client_token(), None)
71
- if task is not None and not task.done():
72
- task.cancel()
73
- return True
74
- return False
75
-
76
- def clear_all(self) -> None:
77
- self._cancel_active_task()
78
- self.active_run_id += 1
79
- self.query = ""
80
- self.logs = []
81
- self.report_markdown = ""
82
- self.report_html = ""
83
- self.trace_url = ""
84
- self.error = ""
85
- self.is_running = False
86
- self.status = "Ready"
87
- self.step_started_at = 0.0
88
-
89
- def stop_report(self) -> None:
90
- if not self.is_running:
91
- return
92
- now = time.monotonic()
93
- elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
94
- self._cancel_active_task()
95
- self.is_running = False
96
- self.error = ""
97
- self.status = "Stopped"
98
- self.step_started_at = now
99
- self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
100
-
101
- async def _log(self, message: str) -> None:
102
- async with self:
103
- now = time.monotonic()
104
- elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
105
- self.step_started_at = now
106
- self.logs.append(f"{elapsed:.1f}s {message}")
107
-
108
- def handle_key_down(self, key: str):
109
- if key == "Enter":
110
- return State.run_report
111
-
112
- @rx.event(background=True)
113
- async def run_report(self):
114
- task = asyncio.current_task()
115
- run_id = 0
116
- async with self:
117
- query = self.query.strip()
118
- if not query:
119
- self.error = ""
120
- return
121
- self.active_run_id += 1
122
- run_id = self.active_run_id
123
- if task is not None:
124
- _RUNNING_TASKS[self._client_token()] = task
125
- self.logs = []
126
- self.report_markdown = ""
127
- self.report_html = ""
128
- self.trace_url = ""
129
- self.error = ""
130
- self.is_running = True
131
- self.status = "Researching"
132
- self.step_started_at = time.monotonic()
133
-
134
- try:
135
- report, trace_url = await run_research_assistant(query, progress=self._log)
136
- async with self:
137
- self.report_markdown = _plain_markdown(report.markdown_report)
138
- self.report_html = markdown.markdown(
139
- self.report_markdown,
140
- extensions=["extra", "sane_lists", "tables", NewTabLinksExtension()],
141
- output_format="html5",
142
- )
143
- self.trace_url = trace_url
144
- self.status = "Complete"
145
- except asyncio.CancelledError:
146
- async with self:
147
- if self.active_run_id == run_id:
148
- self.error = ""
149
- self.status = "Stopped"
150
- except Exception as exc:
151
- async with self:
152
- if self.active_run_id == run_id:
153
- self.error = str(exc)
154
- self.status = "Failed"
155
- finally:
156
- async with self:
157
- if self.active_run_id == run_id:
158
- self.is_running = False
159
- _RUNNING_TASKS.pop(self._client_token(), None)
160
-
161
- def download_markdown(self):
162
- if not self.report_markdown:
163
- return rx.window_alert("Generate a report before downloading.")
164
- return rx.download(data=self.report_markdown, filename="research-report.md")
165
-
166
-
167
- def status_badge() -> rx.Component:
168
- return rx.badge(
169
- State.status,
170
- color_scheme=rx.cond(State.status == "Complete", "green", rx.cond(State.status == "Failed", "red", "blue")),
171
- variant="soft",
172
- size="2",
173
- )
174
-
175
-
176
- def log_panel() -> rx.Component:
177
- return rx.box(
178
- rx.hstack(
179
- rx.hstack(
180
- rx.icon("activity", size=18, color="#0b7285"),
181
- rx.heading("Working", size="3", color="#14323b"),
182
- align="center",
183
- spacing="2",
184
- ),
185
- status_badge(),
186
- justify="between",
187
- align="center",
188
- width="100%",
189
- ),
190
- rx.vstack(
191
- rx.foreach(
192
- State.logs,
193
- lambda item: rx.text(item, font_family="monospace", font_size="0.8rem", color="#263238"),
194
- ),
195
- align="stretch",
196
- spacing="2",
197
- margin_top="0.6rem",
198
- ),
199
- max_height="10rem",
200
- overflow_y="auto",
201
- padding="0.85rem",
202
- border="1px solid #b6e3ea",
203
- border_radius="8px",
204
- background="rgba(235, 251, 255, 0.9)",
205
- box_shadow="0 8px 24px rgba(8, 92, 115, 0.10)",
206
- width="100%",
207
- )
208
-
209
-
210
- _MARKDOWN_CSS = """
211
- <style>
212
- .md-report h1 { font-size: 1.65rem; font-weight: 700; margin: 1.4rem 0 0.6rem; color: #101828; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.35rem; }
213
- .md-report h2 { font-size: 1.35rem; font-weight: 600; margin: 1.2rem 0 0.5rem; color: #1e293b; border-bottom: 1px solid #f1f5f9; padding-bottom: 0.25rem; }
214
- .md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
215
- .md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
216
- .md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
217
- .md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
218
- .md-report ul { list-style-type: disc; }
219
- .md-report ol { list-style-type: decimal; }
220
- .md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
221
- .md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
222
- .md-report strong { font-weight: 700; color: #111827; }
223
- .md-report em { font-style: italic; }
224
- .md-report blockquote { border-left: 4px solid #94a3b8; margin: 0.75rem 0; padding: 0.5rem 1rem; background: #f8fafc; color: #475569; border-radius: 0 4px 4px 0; }
225
- .md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
226
- .md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
227
- .md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
228
- .md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
229
- .md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
230
- .md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
231
- .md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
232
- .md-report a { color: #2563eb; text-decoration: underline; }
233
- </style>
234
- """
235
-
236
-
237
- def report_panel() -> rx.Component:
238
- return rx.box(
239
- rx.hstack(
240
- rx.spacer(),
241
- rx.button(
242
- rx.icon("download", size=17),
243
- "Download",
244
- on_click=State.download_markdown,
245
- background="#2f9e44",
246
- color="white",
247
- border_radius="8px",
248
- padding_x="1rem",
249
- padding_y="0.45rem",
250
- cursor="pointer",
251
- margin_top="1rem",
252
- margin_right="1rem",
253
- _hover={"background": "#238b36", "transform": "translateY(-1px)", "box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)"},
254
- transition="all 0.2s ease",
255
- ),
256
- justify="end",
257
- align="start",
258
- width="100%",
259
- ),
260
- rx.html(
261
- _MARKDOWN_CSS + '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">' + State.report_html + "</div>",
262
- width="100%",
263
- overflow_x="auto",
264
- ),
265
- padding=PANEL_PADDING,
266
- border="1px solid #dde6ec",
267
- border_radius="8px",
268
- background="rgba(255, 255, 255, 0.96)",
269
- box_shadow="0 16px 40px rgba(36, 48, 58, 0.10)",
270
- width="100%",
271
- overflow_x="auto",
272
- )
273
-
274
-
275
- def index() -> rx.Component:
276
- _ok, _missing, _olostep_ver, _openai_ver = environment_status()
277
- return rx.box(
278
- rx.vstack(
279
- rx.vstack(
280
- rx.heading(
281
- "What do you want to research today?",
282
- size={"initial": "6", "md": "7"},
283
- weight="regular",
284
- color="#101828",
285
- text_align="center",
286
- line_height="1.15",
287
- ),
288
- rx.hstack(
289
- rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
290
- rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
291
- rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
292
- rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
293
- justify="center",
294
- align="center",
295
- wrap="wrap",
296
- spacing="2",
297
- ),
298
- width="100%",
299
- align="center",
300
- spacing="3",
301
- ),
302
- rx.hstack(
303
- rx.input(
304
- value=State.query,
305
- on_change=State.set_query,
306
- on_key_down=State.handle_key_down,
307
- placeholder="Ask anything",
308
- height="2.8rem",
309
- width="100%",
310
- flex="1 1 auto",
311
- min_width="0",
312
- background="transparent",
313
- border="0",
314
- box_shadow="none",
315
- font_size="1.05rem",
316
- margin_left="1.5rem",
317
- padding_left="0",
318
- text_indent="0.85rem",
319
- padding_right={"initial": "0.75rem", "sm": "1rem"},
320
- ),
321
- rx.button(
322
- rx.icon("rotate_ccw", size=19),
323
- on_click=State.clear_all,
324
- aria_label="Reset",
325
- height="3.15rem",
326
- width="3.15rem",
327
- min_width="3.15rem",
328
- padding="0",
329
- flex="0 0 auto",
330
- border_radius="8px",
331
- background="transparent",
332
- color="#111111",
333
- box_shadow="none",
334
- cursor="pointer",
335
- _hover={"background": "#f3f4f6"},
336
- ),
337
- rx.cond(
338
- State.is_running,
339
- rx.button(
340
- rx.icon("square", size=16),
341
- on_click=State.stop_report,
342
- aria_label="Stop",
343
- height="3.15rem",
344
- width="3.15rem",
345
- min_width="3.15rem",
346
- padding="0",
347
- flex="0 0 auto",
348
- border_radius="999px",
349
- background="#111111",
350
- color="white",
351
- cursor="pointer",
352
- position="relative",
353
- z_index="1",
354
- margin_right="1rem",
355
- ),
356
- rx.button(
357
- rx.icon("search", size=17),
358
- on_click=State.run_report,
359
- aria_label="Search",
360
- height="3.15rem",
361
- width="3.15rem",
362
- min_width="3.15rem",
363
- padding="0",
364
- flex="0 0 auto",
365
- border_radius="999px",
366
- background="#111111",
367
- color="white",
368
- cursor="pointer",
369
- position="relative",
370
- z_index="1",
371
- margin_right="1rem",
372
- ),
373
- ),
374
- width="100%",
375
- min_height="5rem",
376
- align="center",
377
- justify="between",
378
- spacing={"initial": "3", "sm": "4"},
379
- align_self="center",
380
- padding={
381
- "initial": "0.75rem 1.85rem 0.75rem 1.2rem",
382
- "sm": "0.8rem 2.35rem 0.8rem 1.6rem",
383
- },
384
- border="1px solid #d8d8d8",
385
- border_radius="999px",
386
- background="rgba(255, 255, 255, 0.96)",
387
- box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
388
- ),
389
- rx.hstack(
390
- rx.button(
391
- "Is remote work dying in 2026?",
392
- on_click=State.set_query("Is remote work dying in 2026?"),
393
- variant="soft",
394
- color_scheme="gray",
395
- border_radius="999px",
396
- ),
397
- rx.button(
398
- "What's behind the global coffee shortage?",
399
- on_click=State.set_query("What's behind the global coffee shortage?"),
400
- variant="soft",
401
- color_scheme="gray",
402
- border_radius="999px",
403
- ),
404
- rx.button(
405
- "Are electric cars actually cheaper to own?",
406
- on_click=State.set_query("Are electric cars actually cheaper to own?"),
407
- variant="soft",
408
- color_scheme="gray",
409
- border_radius="999px",
410
- ),
411
- justify="center",
412
- align="center",
413
- wrap="wrap",
414
- spacing="3",
415
- width="100%",
416
- ),
417
- rx.cond(State.error != "", rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%")),
418
- rx.cond(
419
- State.is_running,
420
- log_panel(),
421
- rx.cond(
422
- State.report_markdown != "",
423
- report_panel(),
424
- rx.box(
425
- rx.text("Enter a question and press Search.", color="#52616b", align="center"),
426
- padding="0.25rem",
427
- width="100%",
428
- ),
429
- ),
430
- ),
431
- spacing="5",
432
- align="stretch",
433
- padding=PANEL_PADDING,
434
- width="100%",
435
- max_width="780px",
436
- margin_x="auto",
437
- ),
438
- width="100%",
439
- max_width="100vw",
440
- padding=PAGE_PADDING,
441
- min_height="100vh",
442
- background=PAGE_BG,
443
- overflow_x="hidden",
444
- display="flex",
445
- align_items="center",
446
- justify_content="center",
447
- )
448
-
449
-
450
- app = rx.App()
451
- app.add_page(index, route="/", title="Multi-Agent Research Assistant")
 
1
+ from __future__ import annotations
 
 
 
 
2
 
 
 
 
3
  import reflex as rx
4
+
5
+ from .report_formatting import MARKDOWN_CSS
6
+ from .state import State
7
+
8
+
9
+ PAGE_BG = "linear-gradient(135deg, #fbfdff 0%, #fffdf7 48%, #f8fff9 100%)"
10
+ PAGE_PADDING = {"initial": "1rem", "sm": "1.25rem", "lg": "2rem"}
11
  PANEL_PADDING = {"initial": "1rem", "sm": "1.25rem"}
 
 
12
 
13
 
14
+ def status_badge() -> rx.Component:
15
+ return rx.badge(
16
+ State.status,
17
+ color_scheme=rx.cond(
18
+ State.status == "Complete",
19
+ "green",
20
+ rx.cond(State.status == "Failed", "red", "blue"),
21
+ ),
22
+ variant="soft",
23
+ size="2",
24
+ )
25
+
26
+
27
+ def log_panel() -> rx.Component:
28
+ return rx.box(
29
+ rx.hstack(
30
+ rx.hstack(
31
+ rx.icon("activity", size=18, color="#0b7285"),
32
+ rx.heading("Working", size="3", color="#14323b"),
33
+ align="center",
34
+ spacing="2",
35
+ ),
36
+ status_badge(),
37
+ justify="between",
38
+ align="center",
39
+ width="100%",
40
+ ),
41
+ rx.vstack(
42
+ rx.foreach(
43
+ State.logs,
44
+ lambda item: rx.text(
45
+ item,
46
+ font_family="monospace",
47
+ font_size="0.8rem",
48
+ color="#263238",
49
+ ),
50
+ ),
51
+ align="stretch",
52
+ spacing="2",
53
+ margin_top="0.6rem",
54
+ ),
55
+ max_height="10rem",
56
+ overflow_y="auto",
57
+ padding="0.85rem",
58
+ border="1px solid #b6e3ea",
59
+ border_radius="8px",
60
+ background="rgba(235, 251, 255, 0.9)",
61
+ box_shadow="0 8px 24px rgba(8, 92, 115, 0.10)",
62
+ width="100%",
63
+ )
64
+
65
+
66
+ def report_panel() -> rx.Component:
67
+ return rx.box(
68
+ rx.hstack(
69
+ rx.spacer(),
70
+ rx.button(
71
+ rx.icon("download", size=17),
72
+ "Download PDF",
73
+ on_click=State.download_pdf,
74
+ background="#2f9e44",
75
+ color="white",
76
+ border_radius="8px",
77
+ padding_x="1rem",
78
+ padding_y="0.45rem",
79
+ cursor="pointer",
80
+ margin_top="1rem",
81
+ margin_right="1rem",
82
+ _hover={
83
+ "background": "#238b36",
84
+ "transform": "translateY(-1px)",
85
+ "box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)",
86
+ },
87
+ transition="all 0.2s ease",
88
+ ),
89
+ justify="end",
90
+ align="start",
91
+ width="100%",
92
+ ),
93
+ rx.html(
94
+ MARKDOWN_CSS
95
+ + '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">'
96
+ + State.report_html
97
+ + "</div>",
98
+ width="100%",
99
+ overflow_x="auto",
100
+ ),
101
+ padding=PANEL_PADDING,
102
+ border="1px solid #dde6ec",
103
+ border_radius="8px",
104
+ background="rgba(255, 255, 255, 0.96)",
105
+ box_shadow="0 16px 40px rgba(36, 48, 58, 0.10)",
106
+ width="100%",
107
+ overflow_x="auto",
108
+ )
109
+
110
+
111
+ def search_bar() -> rx.Component:
112
+ return rx.hstack(
113
+ rx.input(
114
+ value=State.query,
115
+ on_change=State.set_query,
116
+ on_key_down=State.handle_key_down,
117
+ placeholder="Ask anything",
118
+ height="2.8rem",
119
+ width="100%",
120
+ flex="1 1 auto",
121
+ min_width="0",
122
+ background="transparent",
123
+ border="0",
124
+ box_shadow="none",
125
+ font_size="1.05rem",
126
+ margin_left="1.5rem",
127
+ padding_left="0",
128
+ text_indent="0.85rem",
129
+ padding_right={"initial": "0.75rem", "sm": "1rem"},
130
+ ),
131
+ rx.button(
132
+ rx.icon("rotate_ccw", size=19),
133
+ on_click=State.clear_all,
134
+ aria_label="Reset",
135
+ height="3.15rem",
136
+ width="3.15rem",
137
+ min_width="3.15rem",
138
+ padding="0",
139
+ flex="0 0 auto",
140
+ border_radius="8px",
141
+ background="transparent",
142
+ color="#111111",
143
+ box_shadow="none",
144
+ cursor="pointer",
145
+ _hover={"background": "#f3f4f6"},
146
+ ),
147
+ rx.cond(
148
+ State.is_running,
149
+ rx.button(
150
+ rx.icon("square", size=16),
151
+ on_click=State.stop_report,
152
+ aria_label="Stop",
153
+ height="3.15rem",
154
+ width="3.15rem",
155
+ min_width="3.15rem",
156
+ padding="0",
157
+ flex="0 0 auto",
158
+ border_radius="999px",
159
+ background="#111111",
160
+ color="white",
161
+ cursor="pointer",
162
+ position="relative",
163
+ z_index="1",
164
+ margin_right="1rem",
165
+ ),
166
+ rx.button(
167
+ rx.icon("search", size=17),
168
+ on_click=State.run_report,
169
+ aria_label="Search",
170
+ height="3.15rem",
171
+ width="3.15rem",
172
+ min_width="3.15rem",
173
+ padding="0",
174
+ flex="0 0 auto",
175
+ border_radius="999px",
176
+ background="#111111",
177
+ color="white",
178
+ cursor="pointer",
179
+ position="relative",
180
+ z_index="1",
181
+ margin_right="1rem",
182
+ ),
183
+ ),
184
+ width="100%",
185
+ min_height="5rem",
186
+ align="center",
187
+ justify="between",
188
+ spacing={"initial": "3", "sm": "4"},
189
+ align_self="center",
190
+ padding={
191
+ "initial": "0.75rem 1.85rem 0.75rem 1.2rem",
192
+ "sm": "0.8rem 2.35rem 0.8rem 1.6rem",
193
+ },
194
+ border="1px solid #d8d8d8",
195
+ border_radius="999px",
196
+ background="rgba(255, 255, 255, 0.96)",
197
+ box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
198
+ )
199
+
200
+
201
+ def prompt_suggestions() -> rx.Component:
202
+ return rx.hstack(
203
+ rx.button(
204
+ "Is remote work dying in 2026?",
205
+ on_click=State.set_query("Is remote work dying in 2026?"),
206
+ variant="soft",
207
+ color_scheme="gray",
208
+ border_radius="999px",
209
+ ),
210
+ rx.button(
211
+ "What's behind the global coffee shortage?",
212
+ on_click=State.set_query("What's behind the global coffee shortage?"),
213
+ variant="soft",
214
+ color_scheme="gray",
215
+ border_radius="999px",
216
+ ),
217
+ rx.button(
218
+ "Are electric cars actually cheaper to own?",
219
+ on_click=State.set_query("Are electric cars actually cheaper to own?"),
220
+ variant="soft",
221
+ color_scheme="gray",
222
+ border_radius="999px",
223
+ ),
224
+ justify="center",
225
+ align="center",
226
+ wrap="wrap",
227
+ spacing="3",
228
+ width="100%",
229
+ )
230
 
231
 
232
+ def page_header() -> rx.Component:
233
+ return rx.vstack(
234
+ rx.heading(
235
+ "What do you want to research today?",
236
+ size={"initial": "6", "md": "7"},
237
+ weight="regular",
238
+ color="#101828",
239
+ text_align="center",
240
+ line_height="1.15",
241
+ ),
242
+ rx.hstack(
243
+ rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
244
+ rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
245
+ rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
246
+ rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
247
+ justify="center",
248
+ align="center",
249
+ wrap="wrap",
250
+ spacing="2",
251
+ ),
252
+ width="100%",
253
+ align="center",
254
+ spacing="3",
255
+ )
256
 
257
 
258
+ def result_area() -> rx.Component:
259
+ return rx.cond(
260
+ State.is_running,
261
+ log_panel(),
262
+ rx.cond(
263
+ State.report_markdown != "",
264
+ report_panel(),
265
+ rx.box(
266
+ rx.text("Enter a question and press Search.", color="#52616b", align="center"),
267
+ padding="0.25rem",
268
+ width="100%",
269
+ ),
270
+ ),
271
+ )
272
 
273
 
274
+ def index() -> rx.Component:
275
+ return rx.box(
276
+ rx.vstack(
277
+ page_header(),
278
+ search_bar(),
279
+ prompt_suggestions(),
280
+ rx.cond(
281
+ State.error != "",
282
+ rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%"),
283
+ ),
284
+ result_area(),
285
+ spacing="5",
286
+ align="stretch",
287
+ padding=PANEL_PADDING,
288
+ width="100%",
289
+ max_width="780px",
290
+ margin_x="auto",
291
+ ),
292
+ width="100%",
293
+ max_width="100vw",
294
+ padding=PAGE_PADDING,
295
+ min_height="100vh",
296
+ background=PAGE_BG,
297
+ overflow_x="hidden",
298
+ display="flex",
299
+ align_items="center",
300
+ justify_content="center",
301
+ )
302
 
303
 
304
+ app = rx.App()
305
+ app.add_page(index, route="/", title="Multi-Agent Research Assistant")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/pdf_export.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from html import escape
5
+ from io import BytesIO
6
+
7
+
8
+ def markdown_to_pdf_bytes(report_markdown: str) -> bytes:
9
+ from reportlab.lib import colors
10
+ from reportlab.lib.enums import TA_LEFT
11
+ from reportlab.lib.pagesizes import A4
12
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
13
+ from reportlab.lib.units import inch
14
+ from reportlab.platypus import (
15
+ ListFlowable,
16
+ ListItem,
17
+ Paragraph,
18
+ SimpleDocTemplate,
19
+ Spacer,
20
+ Table,
21
+ TableStyle,
22
+ )
23
+
24
+ buffer = BytesIO()
25
+ styles = getSampleStyleSheet()
26
+ body_style = ParagraphStyle(
27
+ "ReportBody",
28
+ parent=styles["BodyText"],
29
+ fontName="Helvetica",
30
+ fontSize=10,
31
+ leading=14,
32
+ alignment=TA_LEFT,
33
+ spaceAfter=5,
34
+ )
35
+ title_style = ParagraphStyle(
36
+ "ReportTitle",
37
+ parent=styles["Title"],
38
+ fontName="Helvetica-Bold",
39
+ fontSize=18,
40
+ leading=22,
41
+ alignment=TA_LEFT,
42
+ spaceAfter=10,
43
+ )
44
+ heading1_style = ParagraphStyle(
45
+ "ReportHeading1",
46
+ parent=styles["Heading1"],
47
+ fontName="Helvetica-Bold",
48
+ fontSize=15,
49
+ leading=18,
50
+ alignment=TA_LEFT,
51
+ spaceBefore=10,
52
+ spaceAfter=6,
53
+ )
54
+ heading2_style = ParagraphStyle(
55
+ "ReportHeading2",
56
+ parent=styles["Heading2"],
57
+ fontName="Helvetica-Bold",
58
+ fontSize=12,
59
+ leading=15,
60
+ alignment=TA_LEFT,
61
+ spaceBefore=7,
62
+ spaceAfter=4,
63
+ )
64
+ table_header_style = ParagraphStyle(
65
+ "ReportTableHeader",
66
+ parent=body_style,
67
+ fontName="Helvetica-Bold",
68
+ fontSize=8.5,
69
+ leading=11,
70
+ )
71
+ table_cell_style = ParagraphStyle(
72
+ "ReportTableCell",
73
+ parent=body_style,
74
+ fontSize=8.5,
75
+ leading=11,
76
+ )
77
+
78
+ doc = SimpleDocTemplate(
79
+ buffer,
80
+ pagesize=A4,
81
+ rightMargin=0.65 * inch,
82
+ leftMargin=0.65 * inch,
83
+ topMargin=0.65 * inch,
84
+ bottomMargin=0.65 * inch,
85
+ )
86
+ story = []
87
+ list_items = []
88
+ list_type = "bullet"
89
+ table_rows = []
90
+
91
+ def flush_list() -> None:
92
+ nonlocal list_items, list_type
93
+ if list_items:
94
+ story.append(
95
+ ListFlowable(
96
+ [ListItem(Paragraph(item, body_style)) for item in list_items],
97
+ bulletType=list_type,
98
+ bulletFontName="Helvetica",
99
+ bulletFontSize=7 if list_type == "bullet" else 9,
100
+ leftIndent=16,
101
+ bulletIndent=4,
102
+ itemSpace=3,
103
+ )
104
+ )
105
+ story.append(Spacer(1, 6))
106
+ list_items = []
107
+ list_type = "bullet"
108
+
109
+ def flush_table() -> None:
110
+ nonlocal table_rows
111
+ if not table_rows:
112
+ return
113
+ if len(table_rows) >= 2 and _is_markdown_table_separator(table_rows[1]):
114
+ rows = [table_rows[0], *table_rows[2:]]
115
+ data = []
116
+ for row_index, row in enumerate(rows):
117
+ style = table_header_style if row_index == 0 else table_cell_style
118
+ data.append(
119
+ [
120
+ Paragraph(_markdown_to_reportlab_text(cell), style)
121
+ for cell in _split_table_row(row)
122
+ ]
123
+ )
124
+ if data:
125
+ col_count = max(len(row) for row in data)
126
+ for row in data:
127
+ row.extend(
128
+ Paragraph("", table_cell_style)
129
+ for _ in range(col_count - len(row))
130
+ )
131
+ table = Table(data, repeatRows=1)
132
+ table.setStyle(
133
+ TableStyle(
134
+ [
135
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")),
136
+ ("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
137
+ ("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#cbd5e1")),
138
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
139
+ ("LEFTPADDING", (0, 0), (-1, -1), 5),
140
+ ("RIGHTPADDING", (0, 0), (-1, -1), 5),
141
+ ("TOPPADDING", (0, 0), (-1, -1), 4),
142
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
143
+ ]
144
+ )
145
+ )
146
+ story.append(table)
147
+ story.append(Spacer(1, 8))
148
+ else:
149
+ for row in table_rows:
150
+ story.append(Paragraph(_markdown_to_reportlab_text(row), body_style))
151
+ table_rows = []
152
+
153
+ def append_list_item(item: str, item_type: str) -> None:
154
+ nonlocal list_items, list_type
155
+ if list_items and list_type != item_type:
156
+ flush_list()
157
+ list_type = item_type
158
+ list_items.append(item)
159
+
160
+ for raw_line in report_markdown.splitlines():
161
+ stripped_line = raw_line.strip()
162
+ if _is_markdown_table_line(stripped_line):
163
+ flush_list()
164
+ table_rows.append(stripped_line)
165
+ continue
166
+
167
+ flush_table()
168
+ line = _markdown_to_reportlab_text(stripped_line)
169
+ if not line:
170
+ flush_list()
171
+ story.append(Spacer(1, 5))
172
+ continue
173
+ if line.startswith("- ") or line.startswith("* "):
174
+ append_list_item(line[2:].strip(), "bullet")
175
+ continue
176
+ flush_list()
177
+ if line.startswith("# "):
178
+ story.append(Paragraph(line[2:].strip(), title_style))
179
+ elif line.startswith("## "):
180
+ story.append(Paragraph(line[3:].strip(), heading1_style))
181
+ elif line.startswith("### "):
182
+ story.append(Paragraph(line[4:].strip(), heading2_style))
183
+ elif set(line) <= {"-", "_", "*"}:
184
+ story.append(Spacer(1, 8))
185
+ else:
186
+ story.append(Paragraph(line, body_style))
187
+
188
+ flush_table()
189
+ flush_list()
190
+ if not story:
191
+ raise RuntimeError("No report content available for PDF generation.")
192
+ doc.build(story)
193
+ return buffer.getvalue()
194
+
195
+
196
+ def _is_markdown_table_line(value: str) -> bool:
197
+ return value.startswith("|") and value.endswith("|") and value.count("|") >= 2
198
+
199
+
200
+ def _is_markdown_table_separator(value: str) -> bool:
201
+ cells = _split_table_row(value)
202
+ return bool(cells) and all(
203
+ re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells
204
+ )
205
+
206
+
207
+ def _split_table_row(value: str) -> list[str]:
208
+ return [cell.strip() for cell in value.strip().strip("|").split("|")]
209
+
210
+
211
+ def _markdown_to_reportlab_text(value: str) -> str:
212
+ value = escape(value)
213
+ value = re.sub(r"^(\d+)\.\s+", r"\1.&nbsp;", value)
214
+ value = re.sub(r"`([^`]+)`", r"\1", value)
215
+ value = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", value)
216
+ value = re.sub(r"\*([^*]+)\*", r"<i>\1</i>", value)
217
+ return re.sub(
218
+ r"\[([^\]]+)\]\(([^)]+)\)",
219
+ r'<link href="\2" color="blue">\1</link>',
220
+ value,
221
+ )
app/report_formatting.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ import markdown
6
+ from markdown.extensions import Extension
7
+ from markdown.treeprocessors import Treeprocessor
8
+
9
+
10
+ MARKDOWN_CSS = """
11
+ <style>
12
+ .md-report h1 { font-size: 1.65rem; font-weight: 700; margin: 1.4rem 0 0.6rem; color: #101828; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.35rem; }
13
+ .md-report h2 { font-size: 1.35rem; font-weight: 600; margin: 1.2rem 0 0.5rem; color: #1e293b; border-bottom: 1px solid #f1f5f9; padding-bottom: 0.25rem; }
14
+ .md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
15
+ .md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
16
+ .md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
17
+ .md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
18
+ .md-report ul { list-style-type: disc; }
19
+ .md-report ol { list-style-type: decimal; }
20
+ .md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
21
+ .md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
22
+ .md-report strong { font-weight: 700; color: #111827; }
23
+ .md-report em { font-style: italic; }
24
+ .md-report blockquote { border-left: 4px solid #94a3b8; margin: 0.75rem 0; padding: 0.5rem 1rem; background: #f8fafc; color: #475569; border-radius: 0 4px 4px 0; }
25
+ .md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
26
+ .md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
27
+ .md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
28
+ .md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
29
+ .md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
30
+ .md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
31
+ .md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
32
+ .md-report a { color: #2563eb; text-decoration: underline; }
33
+ </style>
34
+ """
35
+
36
+ _REFERENCE_RETURN_MARKERS = re.compile(r"(?:\s*(?:↩️|↩|🔙|↪️|↪)){1,}")
37
+
38
+
39
+ def plain_markdown(value) -> str:
40
+ if isinstance(value, str):
41
+ return clean_markdown(value)
42
+ if isinstance(value, dict):
43
+ nested = value.get("markdown_report")
44
+ text = nested if isinstance(nested, str) else str(nested or value)
45
+ return clean_markdown(text)
46
+ nested = getattr(value, "markdown_report", None)
47
+ text = nested if isinstance(nested, str) else str(value)
48
+ return clean_markdown(text)
49
+
50
+
51
+ def clean_markdown(value: str) -> str:
52
+ return _REFERENCE_RETURN_MARKERS.sub("", value)
53
+
54
+
55
+ def markdown_to_html(value: str) -> str:
56
+ return markdown.markdown(
57
+ value,
58
+ extensions=["extra", "sane_lists", "tables", NewTabLinksExtension()],
59
+ output_format="html5",
60
+ )
61
+
62
+
63
+ class NewTabLinksTreeprocessor(Treeprocessor):
64
+ def run(self, root):
65
+ for element in root.iter("a"):
66
+ element.set("target", "_blank")
67
+ element.set("rel", "noopener noreferrer")
68
+ return root
69
+
70
+
71
+ class NewTabLinksExtension(Extension):
72
+ def extendMarkdown(self, md):
73
+ md.treeprocessors.register(NewTabLinksTreeprocessor(md), "new_tab_links", 15)
app/state.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+
6
+ import reflex as rx
7
+
8
+ from .pdf_export import markdown_to_pdf_bytes
9
+ from .report_formatting import markdown_to_html, plain_markdown
10
+ from .research_assistant import run_research_assistant
11
+
12
+
13
+ _RUNNING_TASKS: dict[str, asyncio.Task] = {}
14
+
15
+
16
+ class State(rx.State):
17
+ query: str = ""
18
+ logs: list[str] = []
19
+ report_markdown: str = ""
20
+ report_html: str = ""
21
+ trace_url: str = ""
22
+ error: str = ""
23
+ is_running: bool = False
24
+ status: str = "Ready"
25
+ active_run_id: int = 0
26
+ step_started_at: float = 0.0
27
+
28
+ def set_query(self, value: str) -> None:
29
+ self.query = value
30
+
31
+ def _client_token(self) -> str:
32
+ return self.router.session.client_token or "default"
33
+
34
+ def _cancel_active_task(self) -> bool:
35
+ task = _RUNNING_TASKS.pop(self._client_token(), None)
36
+ if task is not None and not task.done():
37
+ task.cancel()
38
+ return True
39
+ return False
40
+
41
+ def clear_all(self) -> None:
42
+ self._cancel_active_task()
43
+ self.active_run_id += 1
44
+ self.query = ""
45
+ self.logs = []
46
+ self.report_markdown = ""
47
+ self.report_html = ""
48
+ self.trace_url = ""
49
+ self.error = ""
50
+ self.is_running = False
51
+ self.status = "Ready"
52
+ self.step_started_at = 0.0
53
+
54
+ def stop_report(self) -> None:
55
+ if not self.is_running:
56
+ return
57
+ now = time.monotonic()
58
+ elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
59
+ self._cancel_active_task()
60
+ self.is_running = False
61
+ self.error = ""
62
+ self.status = "Stopped"
63
+ self.step_started_at = now
64
+ self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
65
+
66
+ async def _log(self, message: str) -> None:
67
+ async with self:
68
+ now = time.monotonic()
69
+ elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
70
+ self.step_started_at = now
71
+ self.logs.append(f"{elapsed:.1f}s {message}")
72
+
73
+ def handle_key_down(self, key: str):
74
+ if key == "Enter":
75
+ return State.run_report
76
+
77
+ @rx.event(background=True)
78
+ async def run_report(self):
79
+ task = asyncio.current_task()
80
+ run_id = 0
81
+ async with self:
82
+ query = self.query.strip()
83
+ if not query:
84
+ self.error = ""
85
+ return
86
+ self.active_run_id += 1
87
+ run_id = self.active_run_id
88
+ if task is not None:
89
+ _RUNNING_TASKS[self._client_token()] = task
90
+ self.logs = []
91
+ self.report_markdown = ""
92
+ self.report_html = ""
93
+ self.trace_url = ""
94
+ self.error = ""
95
+ self.is_running = True
96
+ self.status = "Researching"
97
+ self.step_started_at = time.monotonic()
98
+
99
+ try:
100
+ report, trace_url = await run_research_assistant(query, progress=self._log)
101
+ async with self:
102
+ self.report_markdown = plain_markdown(report.markdown_report)
103
+ self.report_html = markdown_to_html(self.report_markdown)
104
+ self.trace_url = trace_url
105
+ self.status = "Complete"
106
+ except asyncio.CancelledError:
107
+ async with self:
108
+ if self.active_run_id == run_id:
109
+ self.error = ""
110
+ self.status = "Stopped"
111
+ except Exception as exc:
112
+ async with self:
113
+ if self.active_run_id == run_id:
114
+ self.error = str(exc)
115
+ self.status = "Failed"
116
+ finally:
117
+ async with self:
118
+ if self.active_run_id == run_id:
119
+ self.is_running = False
120
+ _RUNNING_TASKS.pop(self._client_token(), None)
121
+
122
+ def download_markdown(self):
123
+ if not self.report_markdown:
124
+ return rx.window_alert("Generate a report before downloading.")
125
+ return rx.download(data=self.report_markdown, filename="research-report.md")
126
+
127
+ def download_pdf(self):
128
+ if not self.report_markdown:
129
+ return rx.window_alert("Generate a report before downloading.")
130
+ try:
131
+ pdf_bytes = markdown_to_pdf_bytes(self.report_markdown)
132
+ except ImportError:
133
+ return rx.window_alert(
134
+ "PDF support is not installed. Run: pip install -r requirements.txt"
135
+ )
136
+ except Exception as exc:
137
+ return rx.window_alert(f"Could not generate PDF: {exc}")
138
+ return rx.download(data=pdf_bytes, filename="research-report.pdf")
requirements.txt CHANGED
@@ -3,4 +3,5 @@ olostep>=1.1.0
3
  python-dotenv>=1.2.2
4
  pydantic>=2.13.4
5
  reflex>=0.9.2
6
- markdown>=3.10.2
 
 
3
  python-dotenv>=1.2.2
4
  pydantic>=2.13.4
5
  reflex>=0.9.2
6
+ markdown>=3.10.2
7
+ reportlab>=4.0.4