diff --git a/.gitignore b/.gitignore
index fa87e4a..dce1a5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/README.md b/README.md
index ae90bea..23a0282 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/app/app.py b/app/app.py
index a51cc61..9577f81 100644
--- a/app/app.py
+++ b/app/app.py
@@ -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 = """
-
-"""
-
-
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 + '
' + State.report_html + "
",
+ MARKDOWN_CSS
+ + ''
+ + State.report_html
+ + "
",
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,
diff --git a/app/pdf_export.py b/app/pdf_export.py
new file mode 100644
index 0000000..875fde7
--- /dev/null
+++ b/app/pdf_export.py
@@ -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. ", value)
+ value = re.sub(r"`([^`]+)`", r"\1", value)
+ value = re.sub(r"\*\*([^*]+)\*\*", r"\1", value)
+ value = re.sub(r"\*([^*]+)\*", r"\1", value)
+ return re.sub(
+ r"\[([^\]]+)\]\(([^)]+)\)",
+ r'\1',
+ value,
+ )
diff --git a/app/report_formatting.py b/app/report_formatting.py
new file mode 100644
index 0000000..edb40be
--- /dev/null
+++ b/app/report_formatting.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import re
+
+import markdown
+from markdown.extensions import Extension
+from markdown.treeprocessors import Treeprocessor
+
+
+MARKDOWN_CSS = """
+
+"""
+
+_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)
diff --git a/app/research_assistant.py b/app/research_assistant.py
index e6b7aa5..efbf7aa 100644
--- a/app/research_assistant.py
+++ b/app/research_assistant.py
@@ -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}
diff --git a/app/state.py b/app/state.py
new file mode 100644
index 0000000..5511bf7
--- /dev/null
+++ b/app/state.py
@@ -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")
diff --git a/requirements.txt b/requirements.txt
index 479d86a..8dd3029 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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