From 7eeb35ab077107b30142435f16d15f9727d89017 Mon Sep 17 00:00:00 2001 From: Abid Ali Awan Date: Tue, 12 May 2026 18:21:09 +0500 Subject: [PATCH] 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. --- .gitignore | 8 +- README.md | 37 ++- app/app.py | 532 ++++++++++++++------------------------ app/pdf_export.py | 221 ++++++++++++++++ app/report_formatting.py | 73 ++++++ app/research_assistant.py | 20 +- app/state.py | 138 ++++++++++ requirements.txt | 1 + 8 files changed, 652 insertions(+), 378 deletions(-) create mode 100644 app/pdf_export.py create mode 100644 app/report_formatting.py create mode 100644 app/state.py 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