Abid Ali Awan commited on
Commit ·
4569df2
1
Parent(s): c14da16
feat(pdf): enable PDF export for research reports
Browse filesRefactor 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.
- .gitignore +6 -2
- README.md +16 -8
- app/app.py +292 -438
- app/pdf_export.py +221 -0
- app/report_formatting.py +73 -0
- app/state.py +138 -0
- 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
|
| 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 |
-
|
| 117 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
## Features
|
| 120 |
|
| 121 |
-
- **Multi-agent workflow**
|
| 122 |
-
- **Live progress logs**
|
| 123 |
-
- **Styled Markdown report**
|
| 124 |
-
- **Download report**
|
| 125 |
-
- **Deep retrieval path**
|
|
|
|
| 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 .
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 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
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
-
def
|
| 35 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 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. ", 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
|