refactor(app): modularize UI and add PDF export support

Split app.py into separate modules: state.py for Reflex state management, report_formatting.py for Markdown processing and CSS, and pdf_export.py for ReportLab-based PDF generation. Update README with app structure details and PDF download feature. Refine research_assistant.py to remove redundant date context and enhance time-sensitive query handling. Adjust .gitignore for Python bytecode and temporary files. Add reportlab dependency to requirements.txt.
This commit is contained in:
Abid Ali Awan
2026-05-12 18:21:09 +05:00
parent 4a941b4fb8
commit 7eeb35ab07
8 changed files with 652 additions and 378 deletions
+6 -2
View File
@@ -1,3 +1,7 @@
tmp
.states
.web
*.py[cod]
# Reflex generated files
.web/
.states/
@@ -206,9 +210,9 @@ cython_debug/
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Temporary file for partial code execution
+15 -22
View File
@@ -1,24 +1,10 @@
---
title: Multi-Agent Research Assistant
emoji: 🌍
colorFrom: blue
colorTo: green
sdk: docker
app_port: 7860
pinned: true
license: apache-2.0
thumbnail: >-
https://cdn-uploads.huggingface.co/production/uploads/603945d6db430f160dced222/_vUmbjxaAA8DYH5iNL0QZ.png
short_description: Manager → Answer API → Judge → Search/Scrape → Analyst.
---
# Multi-Agent Research Assistant
A multi-agent research assistant built with the OpenAI Agents SDK, Olostep, and Reflex.
https://github.com/user-attachments/assets/9aee7d1e-7d3d-4c11-b286-a6b11fef2d8d
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.
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.
## Flow
@@ -111,15 +97,22 @@ Then open the local URL printed by Reflex, usually:
http://localhost:3000
```
## App Structure
The app files live in `app/`:
- `app/app.py` — Reflex UI with styled Markdown report rendering and download.
- `app/research_assistant.py` — OpenAI Agents SDK multi-agent workflow with Olostep tools.
| File | Purpose |
|---|---|
| `app/app.py` | Reflex UI components and page registration only. |
| `app/state.py` | Reflex state, event handlers, progress logging, stop/reset behavior, and downloads. |
| `app/research_assistant.py` | OpenAI Agents SDK workflow with Manager, Judge, Analyst, and Olostep tools. |
| `app/report_formatting.py` | Markdown cleanup, browser HTML rendering, link behavior, and report CSS. |
| `app/pdf_export.py` | ReportLab-based PDF generation with headings, bullets, links, and Markdown table support. |
## Features
- **Multi-agent workflow** Manager, Judge, and Analyst agents collaborate while the manager directly controls Olostep retrieval tools.
- **Live progress logs** Watch each agent step in real time.
- **Styled Markdown report** Headings, bullets, tables, code blocks, and more render properly in the browser.
- **Download report** Export the full Markdown report with one click.
- **Deep retrieval path** If early evidence is weak, the manager runs targeted searches and scrapes at least the top 3 relevant pages.
- **Multi-agent workflow**: Manager, Judge, and Analyst agents collaborate while the manager directly controls Olostep retrieval tools.
- **Live progress logs**: Watch each agent step in real time.
- **Styled Markdown report**: Headings, bullets, tables, code blocks, and more render properly in the browser.
- **Download report**: Export the full report as a formatted PDF using ReportLab.
- **Deep retrieval path**: If early evidence is weak, the manager runs targeted searches and scrapes at least the top 3 relevant pages.
+193 -339
View File
@@ -1,173 +1,24 @@
from __future__ import annotations
import asyncio
import re
import time
import markdown
from markdown.extensions import Extension
from markdown.treeprocessors import Treeprocessor
import reflex as rx
from .research_assistant import environment_status, run_research_assistant
from .report_formatting import MARKDOWN_CSS
from .state import State
PAGE_BG = "linear-gradient(135deg, #fbfdff 0%, #fffdf7 48%, #f8fff9 100%)"
PAGE_PADDING = {"initial": "1rem", "sm": "1.25rem", "lg": "2rem"}
PANEL_PADDING = {"initial": "1rem", "sm": "1.25rem"}
_RUNNING_TASKS: dict[str, asyncio.Task] = {}
_REFERENCE_RETURN_MARKERS = re.compile(r"(?:\s*(?:↩️|↩|🔙|↪️|↪)){1,}")
def _plain_markdown(value) -> str:
if isinstance(value, str):
return _clean_markdown(value)
if isinstance(value, dict):
nested = value.get("markdown_report")
text = nested if isinstance(nested, str) else str(nested or value)
return _clean_markdown(text)
nested = getattr(value, "markdown_report", None)
text = nested if isinstance(nested, str) else str(value)
return _clean_markdown(text)
def _clean_markdown(value: str) -> str:
return _REFERENCE_RETURN_MARKERS.sub("", value)
class NewTabLinksTreeprocessor(Treeprocessor):
def run(self, root):
for element in root.iter("a"):
element.set("target", "_blank")
element.set("rel", "noopener noreferrer")
return root
class NewTabLinksExtension(Extension):
def extendMarkdown(self, md):
md.treeprocessors.register(NewTabLinksTreeprocessor(md), "new_tab_links", 15)
class State(rx.State):
query: str = ""
logs: list[str] = []
report_markdown: str = ""
report_html: str = ""
trace_url: str = ""
error: str = ""
is_running: bool = False
status: str = "Ready"
active_run_id: int = 0
step_started_at: float = 0.0
def set_query(self, value: str) -> None:
self.query = value
def _client_token(self) -> str:
return self.router.session.client_token or "default"
def _cancel_active_task(self) -> bool:
task = _RUNNING_TASKS.pop(self._client_token(), None)
if task is not None and not task.done():
task.cancel()
return True
return False
def clear_all(self) -> None:
self._cancel_active_task()
self.active_run_id += 1
self.query = ""
self.logs = []
self.report_markdown = ""
self.report_html = ""
self.trace_url = ""
self.error = ""
self.is_running = False
self.status = "Ready"
self.step_started_at = 0.0
def stop_report(self) -> None:
if not self.is_running:
return
now = time.monotonic()
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
self._cancel_active_task()
self.is_running = False
self.error = ""
self.status = "Stopped"
self.step_started_at = now
self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
async def _log(self, message: str) -> None:
async with self:
now = time.monotonic()
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
self.step_started_at = now
self.logs.append(f"{elapsed:.1f}s {message}")
def handle_key_down(self, key: str):
if key == "Enter":
return State.run_report
@rx.event(background=True)
async def run_report(self):
task = asyncio.current_task()
run_id = 0
async with self:
query = self.query.strip()
if not query:
self.error = ""
return
self.active_run_id += 1
run_id = self.active_run_id
if task is not None:
_RUNNING_TASKS[self._client_token()] = task
self.logs = []
self.report_markdown = ""
self.report_html = ""
self.trace_url = ""
self.error = ""
self.is_running = True
self.status = "Researching"
self.step_started_at = time.monotonic()
try:
report, trace_url = await run_research_assistant(query, progress=self._log)
async with self:
self.report_markdown = _plain_markdown(report.markdown_report)
self.report_html = markdown.markdown(
self.report_markdown,
extensions=["extra", "sane_lists", "tables", NewTabLinksExtension()],
output_format="html5",
)
self.trace_url = trace_url
self.status = "Complete"
except asyncio.CancelledError:
async with self:
if self.active_run_id == run_id:
self.error = ""
self.status = "Stopped"
except Exception as exc:
async with self:
if self.active_run_id == run_id:
self.error = str(exc)
self.status = "Failed"
finally:
async with self:
if self.active_run_id == run_id:
self.is_running = False
_RUNNING_TASKS.pop(self._client_token(), None)
def download_markdown(self):
if not self.report_markdown:
return rx.window_alert("Generate a report before downloading.")
return rx.download(data=self.report_markdown, filename="research-report.md")
def status_badge() -> rx.Component:
return rx.badge(
State.status,
color_scheme=rx.cond(State.status == "Complete", "green", rx.cond(State.status == "Failed", "red", "blue")),
color_scheme=rx.cond(
State.status == "Complete",
"green",
rx.cond(State.status == "Failed", "red", "blue"),
),
variant="soft",
size="2",
)
@@ -190,7 +41,12 @@ def log_panel() -> rx.Component:
rx.vstack(
rx.foreach(
State.logs,
lambda item: rx.text(item, font_family="monospace", font_size="0.8rem", color="#263238"),
lambda item: rx.text(
item,
font_family="monospace",
font_size="0.8rem",
color="#263238",
),
),
align="stretch",
spacing="2",
@@ -207,41 +63,14 @@ def log_panel() -> rx.Component:
)
_MARKDOWN_CSS = """
<style>
.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; }
.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; }
.md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
.md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
.md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
.md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
.md-report ul { list-style-type: disc; }
.md-report ol { list-style-type: decimal; }
.md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
.md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
.md-report strong { font-weight: 700; color: #111827; }
.md-report em { font-style: italic; }
.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; }
.md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
.md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
.md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
.md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
.md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
.md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
.md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
.md-report a { color: #2563eb; text-decoration: underline; }
</style>
"""
def report_panel() -> rx.Component:
return rx.box(
rx.hstack(
rx.spacer(),
rx.button(
rx.icon("download", size=17),
"Download",
on_click=State.download_markdown,
"Download PDF",
on_click=State.download_pdf,
background="#2f9e44",
color="white",
border_radius="8px",
@@ -250,7 +79,11 @@ def report_panel() -> rx.Component:
cursor="pointer",
margin_top="1rem",
margin_right="1rem",
_hover={"background": "#238b36", "transform": "translateY(-1px)", "box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)"},
_hover={
"background": "#238b36",
"transform": "translateY(-1px)",
"box_shadow": "0 4px 12px rgba(47, 158, 68, 0.35)",
},
transition="all 0.2s ease",
),
justify="end",
@@ -258,7 +91,10 @@ def report_panel() -> rx.Component:
width="100%",
),
rx.html(
_MARKDOWN_CSS + '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">' + State.report_html + "</div>",
MARKDOWN_CSS
+ '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">'
+ State.report_html
+ "</div>",
width="100%",
overflow_x="auto",
),
@@ -272,162 +108,180 @@ def report_panel() -> rx.Component:
)
def search_bar() -> rx.Component:
return rx.hstack(
rx.input(
value=State.query,
on_change=State.set_query,
on_key_down=State.handle_key_down,
placeholder="Ask anything",
height="2.8rem",
width="100%",
flex="1 1 auto",
min_width="0",
background="transparent",
border="0",
box_shadow="none",
font_size="1.05rem",
margin_left="1.5rem",
padding_left="0",
text_indent="0.85rem",
padding_right={"initial": "0.75rem", "sm": "1rem"},
),
rx.button(
rx.icon("rotate_ccw", size=19),
on_click=State.clear_all,
aria_label="Reset",
height="3.15rem",
width="3.15rem",
min_width="3.15rem",
padding="0",
flex="0 0 auto",
border_radius="8px",
background="transparent",
color="#111111",
box_shadow="none",
cursor="pointer",
_hover={"background": "#f3f4f6"},
),
rx.cond(
State.is_running,
rx.button(
rx.icon("square", size=16),
on_click=State.stop_report,
aria_label="Stop",
height="3.15rem",
width="3.15rem",
min_width="3.15rem",
padding="0",
flex="0 0 auto",
border_radius="999px",
background="#111111",
color="white",
cursor="pointer",
position="relative",
z_index="1",
margin_right="1rem",
),
rx.button(
rx.icon("search", size=17),
on_click=State.run_report,
aria_label="Search",
height="3.15rem",
width="3.15rem",
min_width="3.15rem",
padding="0",
flex="0 0 auto",
border_radius="999px",
background="#111111",
color="white",
cursor="pointer",
position="relative",
z_index="1",
margin_right="1rem",
),
),
width="100%",
min_height="5rem",
align="center",
justify="between",
spacing={"initial": "3", "sm": "4"},
align_self="center",
padding={
"initial": "0.75rem 1.85rem 0.75rem 1.2rem",
"sm": "0.8rem 2.35rem 0.8rem 1.6rem",
},
border="1px solid #d8d8d8",
border_radius="999px",
background="rgba(255, 255, 255, 0.96)",
box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
)
def prompt_suggestions() -> rx.Component:
return rx.hstack(
rx.button(
"Is remote work dying in 2026?",
on_click=State.set_query("Is remote work dying in 2026?"),
variant="soft",
color_scheme="gray",
border_radius="999px",
),
rx.button(
"What's behind the global coffee shortage?",
on_click=State.set_query("What's behind the global coffee shortage?"),
variant="soft",
color_scheme="gray",
border_radius="999px",
),
rx.button(
"Are electric cars actually cheaper to own?",
on_click=State.set_query("Are electric cars actually cheaper to own?"),
variant="soft",
color_scheme="gray",
border_radius="999px",
),
justify="center",
align="center",
wrap="wrap",
spacing="3",
width="100%",
)
def page_header() -> rx.Component:
return rx.vstack(
rx.heading(
"What do you want to research today?",
size={"initial": "6", "md": "7"},
weight="regular",
color="#101828",
text_align="center",
line_height="1.15",
),
rx.hstack(
rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
justify="center",
align="center",
wrap="wrap",
spacing="2",
),
width="100%",
align="center",
spacing="3",
)
def result_area() -> rx.Component:
return rx.cond(
State.is_running,
log_panel(),
rx.cond(
State.report_markdown != "",
report_panel(),
rx.box(
rx.text("Enter a question and press Search.", color="#52616b", align="center"),
padding="0.25rem",
width="100%",
),
),
)
def index() -> rx.Component:
_ok, _missing, _olostep_ver, _openai_ver = environment_status()
return rx.box(
rx.vstack(
rx.vstack(
rx.heading(
"What do you want to research today?",
size={"initial": "6", "md": "7"},
weight="regular",
color="#101828",
text_align="center",
line_height="1.15",
),
rx.hstack(
rx.badge("Manager", color_scheme="blue", variant="soft", size="2", border_radius="999px"),
rx.badge("Judge", color_scheme="orange", variant="soft", size="2", border_radius="999px"),
rx.badge("Researcher", color_scheme="purple", variant="soft", size="2", border_radius="999px"),
rx.badge("Analyst", color_scheme="green", variant="soft", size="2", border_radius="999px"),
justify="center",
align="center",
wrap="wrap",
spacing="2",
),
width="100%",
align="center",
spacing="3",
),
rx.hstack(
rx.input(
value=State.query,
on_change=State.set_query,
on_key_down=State.handle_key_down,
placeholder="Ask anything",
height="2.8rem",
width="100%",
flex="1 1 auto",
min_width="0",
background="transparent",
border="0",
box_shadow="none",
font_size="1.05rem",
margin_left="1.5rem",
padding_left="0",
text_indent="0.85rem",
padding_right={"initial": "0.75rem", "sm": "1rem"},
),
rx.button(
rx.icon("rotate_ccw", size=19),
on_click=State.clear_all,
aria_label="Reset",
height="3.15rem",
width="3.15rem",
min_width="3.15rem",
padding="0",
flex="0 0 auto",
border_radius="8px",
background="transparent",
color="#111111",
box_shadow="none",
cursor="pointer",
_hover={"background": "#f3f4f6"},
),
rx.cond(
State.is_running,
rx.button(
rx.icon("square", size=16),
on_click=State.stop_report,
aria_label="Stop",
height="3.15rem",
width="3.15rem",
min_width="3.15rem",
padding="0",
flex="0 0 auto",
border_radius="999px",
background="#111111",
color="white",
cursor="pointer",
position="relative",
z_index="1",
margin_right="1rem",
),
rx.button(
rx.icon("search", size=17),
on_click=State.run_report,
aria_label="Search",
height="3.15rem",
width="3.15rem",
min_width="3.15rem",
padding="0",
flex="0 0 auto",
border_radius="999px",
background="#111111",
color="white",
cursor="pointer",
position="relative",
z_index="1",
margin_right="1rem",
),
),
width="100%",
min_height="5rem",
align="center",
justify="between",
spacing={"initial": "3", "sm": "4"},
align_self="center",
padding={
"initial": "0.75rem 1.85rem 0.75rem 1.2rem",
"sm": "0.8rem 2.35rem 0.8rem 1.6rem",
},
border="1px solid #d8d8d8",
border_radius="999px",
background="rgba(255, 255, 255, 0.96)",
box_shadow="0 18px 55px rgba(16, 24, 40, 0.12)",
),
rx.hstack(
rx.button(
"Is remote work dying in 2026?",
on_click=State.set_query("Is remote work dying in 2026?"),
variant="soft",
color_scheme="gray",
border_radius="999px",
),
rx.button(
"What's behind the global coffee shortage?",
on_click=State.set_query("What's behind the global coffee shortage?"),
variant="soft",
color_scheme="gray",
border_radius="999px",
),
rx.button(
"Are electric cars actually cheaper to own?",
on_click=State.set_query("Are electric cars actually cheaper to own?"),
variant="soft",
color_scheme="gray",
border_radius="999px",
),
justify="center",
align="center",
wrap="wrap",
spacing="3",
width="100%",
),
rx.cond(State.error != "", rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%")),
page_header(),
search_bar(),
prompt_suggestions(),
rx.cond(
State.is_running,
log_panel(),
rx.cond(
State.report_markdown != "",
report_panel(),
rx.box(
rx.text("Enter a question and press Search.", color="#52616b", align="center"),
padding="0.25rem",
width="100%",
),
),
State.error != "",
rx.callout(State.error, icon="triangle_alert", color_scheme="red", width="100%"),
),
result_area(),
spacing="5",
align="stretch",
padding=PANEL_PADDING,
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import re
from html import escape
from io import BytesIO
def markdown_to_pdf_bytes(report_markdown: str) -> bytes:
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
ListFlowable,
ListItem,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
buffer = BytesIO()
styles = getSampleStyleSheet()
body_style = ParagraphStyle(
"ReportBody",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=10,
leading=14,
alignment=TA_LEFT,
spaceAfter=5,
)
title_style = ParagraphStyle(
"ReportTitle",
parent=styles["Title"],
fontName="Helvetica-Bold",
fontSize=18,
leading=22,
alignment=TA_LEFT,
spaceAfter=10,
)
heading1_style = ParagraphStyle(
"ReportHeading1",
parent=styles["Heading1"],
fontName="Helvetica-Bold",
fontSize=15,
leading=18,
alignment=TA_LEFT,
spaceBefore=10,
spaceAfter=6,
)
heading2_style = ParagraphStyle(
"ReportHeading2",
parent=styles["Heading2"],
fontName="Helvetica-Bold",
fontSize=12,
leading=15,
alignment=TA_LEFT,
spaceBefore=7,
spaceAfter=4,
)
table_header_style = ParagraphStyle(
"ReportTableHeader",
parent=body_style,
fontName="Helvetica-Bold",
fontSize=8.5,
leading=11,
)
table_cell_style = ParagraphStyle(
"ReportTableCell",
parent=body_style,
fontSize=8.5,
leading=11,
)
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=0.65 * inch,
leftMargin=0.65 * inch,
topMargin=0.65 * inch,
bottomMargin=0.65 * inch,
)
story = []
list_items = []
list_type = "bullet"
table_rows = []
def flush_list() -> None:
nonlocal list_items, list_type
if list_items:
story.append(
ListFlowable(
[ListItem(Paragraph(item, body_style)) for item in list_items],
bulletType=list_type,
bulletFontName="Helvetica",
bulletFontSize=7 if list_type == "bullet" else 9,
leftIndent=16,
bulletIndent=4,
itemSpace=3,
)
)
story.append(Spacer(1, 6))
list_items = []
list_type = "bullet"
def flush_table() -> None:
nonlocal table_rows
if not table_rows:
return
if len(table_rows) >= 2 and _is_markdown_table_separator(table_rows[1]):
rows = [table_rows[0], *table_rows[2:]]
data = []
for row_index, row in enumerate(rows):
style = table_header_style if row_index == 0 else table_cell_style
data.append(
[
Paragraph(_markdown_to_reportlab_text(cell), style)
for cell in _split_table_row(row)
]
)
if data:
col_count = max(len(row) for row in data)
for row in data:
row.extend(
Paragraph("", table_cell_style)
for _ in range(col_count - len(row))
)
table = Table(data, repeatRows=1)
table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f1f5f9")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#cbd5e1")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]
)
)
story.append(table)
story.append(Spacer(1, 8))
else:
for row in table_rows:
story.append(Paragraph(_markdown_to_reportlab_text(row), body_style))
table_rows = []
def append_list_item(item: str, item_type: str) -> None:
nonlocal list_items, list_type
if list_items and list_type != item_type:
flush_list()
list_type = item_type
list_items.append(item)
for raw_line in report_markdown.splitlines():
stripped_line = raw_line.strip()
if _is_markdown_table_line(stripped_line):
flush_list()
table_rows.append(stripped_line)
continue
flush_table()
line = _markdown_to_reportlab_text(stripped_line)
if not line:
flush_list()
story.append(Spacer(1, 5))
continue
if line.startswith("- ") or line.startswith("* "):
append_list_item(line[2:].strip(), "bullet")
continue
flush_list()
if line.startswith("# "):
story.append(Paragraph(line[2:].strip(), title_style))
elif line.startswith("## "):
story.append(Paragraph(line[3:].strip(), heading1_style))
elif line.startswith("### "):
story.append(Paragraph(line[4:].strip(), heading2_style))
elif set(line) <= {"-", "_", "*"}:
story.append(Spacer(1, 8))
else:
story.append(Paragraph(line, body_style))
flush_table()
flush_list()
if not story:
raise RuntimeError("No report content available for PDF generation.")
doc.build(story)
return buffer.getvalue()
def _is_markdown_table_line(value: str) -> bool:
return value.startswith("|") and value.endswith("|") and value.count("|") >= 2
def _is_markdown_table_separator(value: str) -> bool:
cells = _split_table_row(value)
return bool(cells) and all(
re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in cells
)
def _split_table_row(value: str) -> list[str]:
return [cell.strip() for cell in value.strip().strip("|").split("|")]
def _markdown_to_reportlab_text(value: str) -> str:
value = escape(value)
value = re.sub(r"^(\d+)\.\s+", r"\1.&nbsp;", value)
value = re.sub(r"`([^`]+)`", r"\1", value)
value = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", value)
value = re.sub(r"\*([^*]+)\*", r"<i>\1</i>", value)
return re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
r'<link href="\2" color="blue">\1</link>',
value,
)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import re
import markdown
from markdown.extensions import Extension
from markdown.treeprocessors import Treeprocessor
MARKDOWN_CSS = """
<style>
.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; }
.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; }
.md-report h3 { font-size: 1.12rem; font-weight: 600; margin: 1rem 0 0.4rem; color: #334155; }
.md-report h4 { font-size: 1rem; font-weight: 600; margin: 0.8rem 0 0.3rem; color: #475569; }
.md-report p { margin: 0.5rem 0; line-height: 1.7; color: #374151; }
.md-report ul, .md-report ol { margin: 0.5rem 0 0.5rem 1.5rem; padding: 0; }
.md-report ul { list-style-type: disc; }
.md-report ol { list-style-type: decimal; }
.md-report li { margin: 0.25rem 0; line-height: 1.65; color: #374151; }
.md-report li > ul, .md-report li > ol { margin: 0.2rem 0 0.2rem 1.2rem; }
.md-report strong { font-weight: 700; color: #111827; }
.md-report em { font-style: italic; }
.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; }
.md-report code { background: #f1f5f9; padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.88em; color: #7c3aed; }
.md-report pre { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 8px; overflow-x: auto; margin: 0.75rem 0; }
.md-report pre code { background: none; color: inherit; padding: 0; font-size: 0.88em; }
.md-report table { border-collapse: collapse; width: 100%; margin: 0.75rem 0; }
.md-report th, .md-report td { border: 1px solid #e5e7eb; padding: 0.5rem 0.75rem; text-align: left; }
.md-report th { background: #f8fafc; font-weight: 600; color: #1e293b; }
.md-report hr { border: none; border-top: 1px solid #e5e7eb; margin: 1.2rem 0; }
.md-report a { color: #2563eb; text-decoration: underline; }
</style>
"""
_REFERENCE_RETURN_MARKERS = re.compile(r"(?:\s*(?:↩️|↩|🔙|↪️|↪)){1,}")
def plain_markdown(value) -> str:
if isinstance(value, str):
return clean_markdown(value)
if isinstance(value, dict):
nested = value.get("markdown_report")
text = nested if isinstance(nested, str) else str(nested or value)
return clean_markdown(text)
nested = getattr(value, "markdown_report", None)
text = nested if isinstance(nested, str) else str(value)
return clean_markdown(text)
def clean_markdown(value: str) -> str:
return _REFERENCE_RETURN_MARKERS.sub("", value)
def markdown_to_html(value: str) -> str:
return markdown.markdown(
value,
extensions=["extra", "sane_lists", "tables", NewTabLinksExtension()],
output_format="html5",
)
class NewTabLinksTreeprocessor(Treeprocessor):
def run(self, root):
for element in root.iter("a"):
element.set("target", "_blank")
element.set("rel", "noopener noreferrer")
return root
class NewTabLinksExtension(Extension):
def extendMarkdown(self, md):
md.treeprocessors.register(NewTabLinksTreeprocessor(md), "new_tab_links", 15)
+5 -15
View File
@@ -128,10 +128,6 @@ def compact_json(data: Any, max_chars: int = 8000) -> str:
return text[:max_chars] + "\n... [truncated]"
def current_date_context() -> str:
return datetime.now().strftime("%B %d, %Y")
def current_year_context() -> str:
return str(datetime.now().year)
@@ -341,10 +337,12 @@ manager_agent = Agent(
name="Manager research agent",
model=MODEL,
instructions=(
f"Current date: {current_date_context()}\n"
f"Current year: {current_year_context()}\n\n"
"You are the orchestrator for a multi-agent research assistant. You must manage the workflow, "
"not answer from your own memory. Follow this policy exactly:\n"
"not answer from your own memory. For current, recent, latest, ongoing, or time-sensitive "
"topics, if the user query does not mention a year, add the current year "
f"({current_year_context()}) to the answer_query, search_with_scrape, and search_web query text "
"so the tools look for recent results. If the user already mentions a year, preserve that year. "
"Do not treat older sources as sufficient when newer coverage is needed. Follow this policy exactly:\n"
"1. Always call answer_query first to get a simple initial answer for the user's question.\n"
"2. Immediately call judge_answer_quality on the original question plus the answer_query result. "
"If the judge returns is_good_enough=true and score >= 0.85, stop researching and call "
@@ -388,15 +386,7 @@ async def run_research_assistant(
try:
await emit_progress("Starting manager research agent.")
current_date = current_date_context()
current_year = current_year_context()
prompt = f"""
Current date:
{current_date}
Current year:
{current_year}
Research question:
{query}
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
import asyncio
import time
import reflex as rx
from .pdf_export import markdown_to_pdf_bytes
from .report_formatting import markdown_to_html, plain_markdown
from .research_assistant import run_research_assistant
_RUNNING_TASKS: dict[str, asyncio.Task] = {}
class State(rx.State):
query: str = ""
logs: list[str] = []
report_markdown: str = ""
report_html: str = ""
trace_url: str = ""
error: str = ""
is_running: bool = False
status: str = "Ready"
active_run_id: int = 0
step_started_at: float = 0.0
def set_query(self, value: str) -> None:
self.query = value
def _client_token(self) -> str:
return self.router.session.client_token or "default"
def _cancel_active_task(self) -> bool:
task = _RUNNING_TASKS.pop(self._client_token(), None)
if task is not None and not task.done():
task.cancel()
return True
return False
def clear_all(self) -> None:
self._cancel_active_task()
self.active_run_id += 1
self.query = ""
self.logs = []
self.report_markdown = ""
self.report_html = ""
self.trace_url = ""
self.error = ""
self.is_running = False
self.status = "Ready"
self.step_started_at = 0.0
def stop_report(self) -> None:
if not self.is_running:
return
now = time.monotonic()
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
self._cancel_active_task()
self.is_running = False
self.error = ""
self.status = "Stopped"
self.step_started_at = now
self.logs.append(f"{elapsed:.1f}s Research stopped by user.")
async def _log(self, message: str) -> None:
async with self:
now = time.monotonic()
elapsed = max(0.0, now - self.step_started_at) if self.step_started_at else 0.0
self.step_started_at = now
self.logs.append(f"{elapsed:.1f}s {message}")
def handle_key_down(self, key: str):
if key == "Enter":
return State.run_report
@rx.event(background=True)
async def run_report(self):
task = asyncio.current_task()
run_id = 0
async with self:
query = self.query.strip()
if not query:
self.error = ""
return
self.active_run_id += 1
run_id = self.active_run_id
if task is not None:
_RUNNING_TASKS[self._client_token()] = task
self.logs = []
self.report_markdown = ""
self.report_html = ""
self.trace_url = ""
self.error = ""
self.is_running = True
self.status = "Researching"
self.step_started_at = time.monotonic()
try:
report, trace_url = await run_research_assistant(query, progress=self._log)
async with self:
self.report_markdown = plain_markdown(report.markdown_report)
self.report_html = markdown_to_html(self.report_markdown)
self.trace_url = trace_url
self.status = "Complete"
except asyncio.CancelledError:
async with self:
if self.active_run_id == run_id:
self.error = ""
self.status = "Stopped"
except Exception as exc:
async with self:
if self.active_run_id == run_id:
self.error = str(exc)
self.status = "Failed"
finally:
async with self:
if self.active_run_id == run_id:
self.is_running = False
_RUNNING_TASKS.pop(self._client_token(), None)
def download_markdown(self):
if not self.report_markdown:
return rx.window_alert("Generate a report before downloading.")
return rx.download(data=self.report_markdown, filename="research-report.md")
def download_pdf(self):
if not self.report_markdown:
return rx.window_alert("Generate a report before downloading.")
try:
pdf_bytes = markdown_to_pdf_bytes(self.report_markdown)
except ImportError:
return rx.window_alert(
"PDF support is not installed. Run: pip install -r requirements.txt"
)
except Exception as exc:
return rx.window_alert(f"Could not generate PDF: {exc}")
return rx.download(data=pdf_bytes, filename="research-report.pdf")
+1
View File
@@ -4,3 +4,4 @@ python-dotenv>=1.2.2
pydantic>=2.13.4
reflex>=0.9.2
markdown>=3.10.2
reportlab>=4.0.4