mirror of
https://github.com/furyhawk/Multi-Agent-Research-Assistant.git
synced 2026-07-21 00:25:35 +00:00
feat: add Reflex web app with styled markdown report and download
- Reflex UI with styled markdown rendering (headings, bullets, tables, code) - Download button for markdown report export - Source research agent now includes current date for recent results - Reduced report width with proper padding to prevent text cutoff - Updated README with app structure and features
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OLOSTEP_API_KEY=your_olostep_api_key
|
||||
OPENAI_MODEL=gpt-5.4-mini
|
||||
RUN_LIVE_EXAMPLE=true
|
||||
+11
@@ -1,3 +1,14 @@
|
||||
# Reflex generated files
|
||||
.web/
|
||||
.states/
|
||||
assets/external/
|
||||
frontend.zip
|
||||
backend.zip
|
||||
reflex.out.log
|
||||
reflex.err.log
|
||||
.playwright-cli/
|
||||
*.db
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Multi-Agent Research Assistant
|
||||
|
||||
A Jupyter notebook that builds a multi-agent research assistant with the OpenAI Agents SDK and Olostep.
|
||||
A multi-agent research assistant built with the OpenAI Agents SDK, Olostep, and Reflex.
|
||||
|
||||
The workflow uses a manager agent to orchestrate a judge agent, source research agent, and analyst agent. It tries Olostep Answer API first, escalates to search-with-scrape when needed, and returns a polished Markdown research report with sources.
|
||||
Enter a research question and a team of AI agents collaborates 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.
|
||||
|
||||
## Flow
|
||||
|
||||
@@ -34,18 +34,61 @@ Manager agent
|
||||
| v
|
||||
+----------> Markdown research report + sources
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Role |
|
||||
|---|---|
|
||||
| **Manager** | Orchestrates the workflow: answer, judge, research if needed, then write. |
|
||||
| **Judge** | Evaluates whether the initial answer is good enough or needs deeper research. |
|
||||
| **Source Researcher** | Gathers evidence using Olostep search, scrape, and targeted web queries. Prioritizes the most recent sources. |
|
||||
| **Analyst** | Writes the final Markdown research report from the gathered evidence. |
|
||||
|
||||
## Setup
|
||||
|
||||
Create a `.env` file:
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Create a `.env` file from `.env.template`:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OLOSTEP_API_KEY=your_olostep_api_key
|
||||
RUN_LIVE_EXAMPLE=false
|
||||
OPENAI_MODEL=gpt-5.4-mini
|
||||
```
|
||||
|
||||
## Run the Reflex app
|
||||
|
||||
```bash
|
||||
reflex run
|
||||
```
|
||||
|
||||
Then open the local URL printed by Reflex, usually:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-agent workflow** — Manager, Judge, Source Researcher, and Analyst agents collaborate automatically.
|
||||
- **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.
|
||||
- **Recent results** — The source research agent is aware of the current date and prioritizes up-to-date sources.
|
||||
|
||||
## Run the notebook
|
||||
|
||||
Open and run:
|
||||
|
||||
```text
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import markdown
|
||||
import reflex as rx
|
||||
|
||||
from .research_assistant import environment_status, run_research_assistant
|
||||
|
||||
|
||||
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] = {}
|
||||
|
||||
|
||||
def _plain_markdown(value) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
nested = value.get("markdown_report")
|
||||
return nested if isinstance(nested, str) else str(nested or value)
|
||||
nested = getattr(value, "markdown_report", None)
|
||||
return nested if isinstance(nested, str) else str(value)
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
@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"],
|
||||
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")),
|
||||
variant="soft",
|
||||
size="2",
|
||||
)
|
||||
|
||||
|
||||
def log_panel() -> rx.Component:
|
||||
return rx.box(
|
||||
rx.hstack(
|
||||
rx.hstack(
|
||||
rx.icon("activity", size=18, color="#0b7285"),
|
||||
rx.heading("Working", size="3", color="#14323b"),
|
||||
align="center",
|
||||
spacing="2",
|
||||
),
|
||||
status_badge(),
|
||||
justify="between",
|
||||
align="center",
|
||||
width="100%",
|
||||
),
|
||||
rx.vstack(
|
||||
rx.foreach(
|
||||
State.logs,
|
||||
lambda item: rx.text(item, font_family="monospace", font_size="0.8rem", color="#263238"),
|
||||
),
|
||||
align="stretch",
|
||||
spacing="2",
|
||||
margin_top="0.6rem",
|
||||
),
|
||||
max_height="10rem",
|
||||
overflow_y="auto",
|
||||
padding="0.85rem",
|
||||
border="1px solid #b6e3ea",
|
||||
border_radius="8px",
|
||||
background="rgba(235, 251, 255, 0.9)",
|
||||
box_shadow="0 8px 24px rgba(8, 92, 115, 0.10)",
|
||||
width="100%",
|
||||
)
|
||||
|
||||
|
||||
_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,
|
||||
background="#2f9e44",
|
||||
color="white",
|
||||
border_radius="8px",
|
||||
padding_x="1rem",
|
||||
padding_y="0.45rem",
|
||||
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)"},
|
||||
transition="all 0.2s ease",
|
||||
),
|
||||
justify="end",
|
||||
align="start",
|
||||
width="100%",
|
||||
),
|
||||
rx.html(
|
||||
_MARKDOWN_CSS + '<div class="md-report" style="padding: 0.5rem 1.25rem 1rem;">' + State.report_html + "</div>",
|
||||
width="100%",
|
||||
overflow_x="auto",
|
||||
),
|
||||
padding=PANEL_PADDING,
|
||||
border="1px solid #dde6ec",
|
||||
border_radius="8px",
|
||||
background="rgba(255, 255, 255, 0.96)",
|
||||
box_shadow="0 16px 40px rgba(36, 48, 58, 0.10)",
|
||||
width="100%",
|
||||
overflow_x="auto",
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
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(
|
||||
"AI agent trends?",
|
||||
on_click=State.set_query("AI agent trends?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
rx.button(
|
||||
"OpenAI model updates?",
|
||||
on_click=State.set_query("OpenAI model updates?"),
|
||||
variant="soft",
|
||||
color_scheme="gray",
|
||||
border_radius="999px",
|
||||
),
|
||||
rx.button(
|
||||
"Best research tools?",
|
||||
on_click=State.set_query("Best research tools?"),
|
||||
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%")),
|
||||
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%",
|
||||
),
|
||||
),
|
||||
),
|
||||
spacing="5",
|
||||
align="stretch",
|
||||
padding=PANEL_PADDING,
|
||||
width="100%",
|
||||
max_width="780px",
|
||||
margin_x="auto",
|
||||
),
|
||||
width="100%",
|
||||
max_width="100vw",
|
||||
padding=PAGE_PADDING,
|
||||
min_height="100vh",
|
||||
background=PAGE_BG,
|
||||
overflow_x="hidden",
|
||||
display="flex",
|
||||
align_items="center",
|
||||
justify_content="center",
|
||||
)
|
||||
|
||||
|
||||
app = rx.App()
|
||||
app.add_page(index, route="/", title="Multi-Agent Research Assistant")
|
||||
@@ -0,0 +1,341 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import importlib.metadata
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from agents import Agent, Runner, custom_span, flush_traces, function_tool, gen_trace_id, trace
|
||||
from dotenv import load_dotenv
|
||||
from olostep import Olostep
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
load_dotenv()
|
||||
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
OLOSTEP_API_KEY = os.getenv("OLOSTEP_API_KEY")
|
||||
MODEL = os.getenv("OPENAI_MODEL", "gpt-5.4-mini")
|
||||
|
||||
warnings.filterwarnings("ignore", message=".*extra field.*SDK model.*")
|
||||
|
||||
ProgressCallback = Callable[[str], Awaitable[None]]
|
||||
_progress_callback: contextvars.ContextVar[ProgressCallback | None] = contextvars.ContextVar(
|
||||
"progress_callback",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class OlostepError(RuntimeError):
|
||||
"""Raised when an Olostep SDK request fails."""
|
||||
|
||||
|
||||
class Judgment(BaseModel):
|
||||
is_good_enough: bool = Field(description="Whether the answer is sufficient for the user query.")
|
||||
score: float = Field(ge=0, le=1, description="Quality score from 0 to 1.")
|
||||
reason: str = Field(description="Short explanation of the decision.")
|
||||
missing_information: list[str] = Field(default_factory=list, description="Important gaps to fix.")
|
||||
|
||||
|
||||
class SourceResearchReport(BaseModel):
|
||||
key_findings: list[str] = Field(description="Concise findings from gathered sources.")
|
||||
important_urls: list[str] = Field(description="Only the most important URLs used for synthesis.")
|
||||
source_notes: list[str] = Field(description="Brief notes connecting sources to findings.")
|
||||
remaining_gaps: list[str] = Field(default_factory=list, description="Gaps that could not be resolved.")
|
||||
|
||||
|
||||
class MarkdownResearchReport(BaseModel):
|
||||
title: str = Field(description="Research report title.")
|
||||
executive_summary: str = Field(description="Short answer-first summary.")
|
||||
key_findings: list[str] = Field(description="Most important findings.")
|
||||
markdown_report: str = Field(
|
||||
description="Complete Markdown report with polished headings, clear analysis, reader-friendly structure, and citations."
|
||||
)
|
||||
citations: list[str] = Field(default_factory=list, description="Source URLs used in the report.")
|
||||
confidence: str = Field(description="Low, medium, or high confidence.")
|
||||
method_used: str = Field(description="Retrieval path used by the manager agent.")
|
||||
|
||||
|
||||
async def emit_progress(message: str) -> None:
|
||||
callback = _progress_callback.get()
|
||||
if callback is not None:
|
||||
await callback(message)
|
||||
|
||||
|
||||
def openai_trace_url(trace_id: str) -> str:
|
||||
return f"https://platform.openai.com/logs/trace?trace_id={trace_id}"
|
||||
|
||||
|
||||
def environment_status() -> tuple[bool, list[str], str, str]:
|
||||
missing = [
|
||||
name
|
||||
for name, value in {
|
||||
"OPENAI_API_KEY": OPENAI_API_KEY,
|
||||
"OLOSTEP_API_KEY": OLOSTEP_API_KEY,
|
||||
}.items()
|
||||
if not value
|
||||
]
|
||||
try:
|
||||
olostep_version = importlib.metadata.version("olostep")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
olostep_version = "not installed"
|
||||
try:
|
||||
openai_version = importlib.metadata.version("openai-agents")
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
openai_version = "not installed"
|
||||
return not missing, missing, olostep_version, openai_version
|
||||
|
||||
|
||||
def require_olostep_key() -> str:
|
||||
if not OLOSTEP_API_KEY:
|
||||
raise OlostepError("OLOSTEP_API_KEY is not set. Add it to .env and restart the app.")
|
||||
return OLOSTEP_API_KEY
|
||||
|
||||
|
||||
def get_olostep_client() -> Olostep:
|
||||
return Olostep(api_key=require_olostep_key())
|
||||
|
||||
|
||||
def sdk_result_to_dict(result: Any) -> dict[str, Any]:
|
||||
if hasattr(result, "model_dump"):
|
||||
return result.model_dump()
|
||||
if hasattr(result, "__dict__"):
|
||||
return {key: value for key, value in vars(result).items() if not key.startswith("_")}
|
||||
return {"value": str(result)}
|
||||
|
||||
|
||||
def compact_json(data: Any, max_chars: int = 8000) -> str:
|
||||
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
return text[:max_chars] + "\n... [truncated]"
|
||||
|
||||
|
||||
def normalize_search_links(links: list[dict[str, Any]], limit: int = 8) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for link in links[:limit]:
|
||||
markdown = link.get("markdown_content") or ""
|
||||
rows.append(
|
||||
{
|
||||
"title": link.get("title") or "Untitled",
|
||||
"url": link.get("url") or "",
|
||||
"description": link.get("description") or "",
|
||||
"markdown_chars": len(markdown),
|
||||
"markdown_preview": markdown[:1500] if markdown else "",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _answer_query_impl(query: str) -> str:
|
||||
with custom_span("olostep.answer_query", {"query": query}):
|
||||
result = get_olostep_client().answers.create(task=query)
|
||||
return compact_json(sdk_result_to_dict(result))
|
||||
|
||||
|
||||
def _search_web_impl(query: str, limit: int = 8) -> str:
|
||||
with custom_span("olostep.search_web", {"query": query, "limit": limit}):
|
||||
search = get_olostep_client().searches.create(query=query, limit=limit)
|
||||
data = sdk_result_to_dict(search)
|
||||
return compact_json(
|
||||
{
|
||||
"query": query,
|
||||
"results": normalize_search_links(data.get("links", []), limit=limit),
|
||||
"raw": data,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _search_with_scrape_impl(query: str, limit: int = 5) -> str:
|
||||
scrape_options = {"formats": ["markdown"], "timeout": 25}
|
||||
with custom_span("olostep.search_with_scrape", {"query": query, "limit": limit, "scrape_options": scrape_options}):
|
||||
search = get_olostep_client().searches.create(
|
||||
query=query,
|
||||
limit=limit,
|
||||
scrape_options=scrape_options,
|
||||
)
|
||||
data = sdk_result_to_dict(search)
|
||||
return compact_json(
|
||||
{
|
||||
"query": query,
|
||||
"results": normalize_search_links(data.get("links", []), limit=limit),
|
||||
"raw": data,
|
||||
},
|
||||
max_chars=12000,
|
||||
)
|
||||
|
||||
|
||||
def _scrape_url_impl(url: str) -> str:
|
||||
with custom_span("olostep.scrape_url", {"url": url, "formats": ["markdown"]}):
|
||||
scrape = get_olostep_client().scrapes.create(url=url, formats=["markdown"])
|
||||
return compact_json({"url": url, "scrape": sdk_result_to_dict(scrape)}, max_chars=10000)
|
||||
|
||||
|
||||
@function_tool
|
||||
async def answer_query(query: str) -> str:
|
||||
"""Answer a natural-language research query using Olostep Answer API."""
|
||||
await emit_progress("Calling Olostep Answer API.")
|
||||
try:
|
||||
result = await asyncio.to_thread(_answer_query_impl, query)
|
||||
except Exception as exc:
|
||||
raise OlostepError(f"Olostep Answer API failed: {exc}") from exc
|
||||
await emit_progress("Olostep Answer API returned evidence.")
|
||||
return result
|
||||
|
||||
|
||||
@function_tool
|
||||
async def search_web(query: str, limit: int = 8) -> str:
|
||||
"""Search the web using Olostep Search and return normalized results."""
|
||||
await emit_progress(f"Searching the web with Olostep: {query}")
|
||||
try:
|
||||
result = await asyncio.to_thread(_search_web_impl, query, limit)
|
||||
except Exception as exc:
|
||||
raise OlostepError(f"Olostep Search API failed: {exc}") from exc
|
||||
await emit_progress("Olostep Search returned results.")
|
||||
return result
|
||||
|
||||
|
||||
@function_tool
|
||||
async def search_with_scrape(query: str, limit: int = 5) -> str:
|
||||
"""Search the web and scrape each returned link using Olostep Search with Scrape."""
|
||||
await emit_progress(f"Running Olostep search with scrape: {query}")
|
||||
try:
|
||||
result = await asyncio.to_thread(_search_with_scrape_impl, query, limit)
|
||||
except Exception as exc:
|
||||
raise OlostepError(f"Olostep Search with Scrape failed: {exc}") from exc
|
||||
await emit_progress("Search with scrape returned source content.")
|
||||
return result
|
||||
|
||||
|
||||
@function_tool
|
||||
async def scrape_url(url: str) -> str:
|
||||
"""Scrape one URL with Olostep and return compact page content."""
|
||||
await emit_progress(f"Scraping selected source: {url}")
|
||||
try:
|
||||
result = await asyncio.to_thread(_scrape_url_impl, url)
|
||||
except Exception as exc:
|
||||
raise OlostepError(f"Olostep Scrape API failed: {exc}") from exc
|
||||
await emit_progress("Selected source scrape completed.")
|
||||
return result
|
||||
|
||||
|
||||
judge_agent = Agent(
|
||||
name="Judge agent",
|
||||
model=MODEL,
|
||||
instructions=(
|
||||
"You judge whether the provided answer is good enough for the original research question. "
|
||||
"Reward direct, specific, source-backed answers. Reject vague, stale, or unsupported answers. "
|
||||
"Return only the structured judgment."
|
||||
),
|
||||
output_type=Judgment,
|
||||
)
|
||||
|
||||
def _build_source_research_agent():
|
||||
from datetime import datetime
|
||||
now = datetime.now().strftime("%B %d, %Y %I:%M %p")
|
||||
return Agent(
|
||||
name="Source research agent",
|
||||
model=MODEL,
|
||||
instructions=(
|
||||
f"Current date and time: {now}. "
|
||||
"You gather evidence for a research report using only the provided Olostep tools. "
|
||||
"Always include the current year in your search queries to get the most recent results. "
|
||||
"Prefer recent, official, primary, and reputable sources. "
|
||||
"First try search_with_scrape for the original query. If the scraped search result is weak, "
|
||||
"run two or three targeted search_web calls, select only the most important URLs, scrape those URLs, "
|
||||
"and summarize the evidence. "
|
||||
"Return only the structured source research report."
|
||||
),
|
||||
tools=[search_web, search_with_scrape, scrape_url],
|
||||
output_type=SourceResearchReport,
|
||||
)
|
||||
|
||||
analyst_agent = Agent(
|
||||
name="Analyst agent",
|
||||
model=MODEL,
|
||||
instructions=(
|
||||
"You write a proper Markdown research report from the evidence. "
|
||||
"Write for a professional reader who wants a clear, polished research brief on any topic. "
|
||||
"Adapt the report to the user's question. The markdown_report must be substantial, easy to scan, and use these general sections only: "
|
||||
"Executive Summary, Key Findings, Context, Evidence Review, Detailed Analysis, Implications, Source Notes, and References. "
|
||||
"If the topic is event-driven, include timeline details inside Context or Detailed Analysis instead of adding a separate Timeline section. "
|
||||
"If the topic is comparative, include a compact comparison table inside Detailed Analysis. "
|
||||
"Do not include sections titled Limitations, Next Steps, Recommendations, or Action Items. "
|
||||
"Avoid bare caveats like 'I relied on...'. Instead, integrate source quality naturally in Source Notes. "
|
||||
"Use short paragraphs, bullets where helpful, and citations as Markdown links or URL bullets. "
|
||||
"Add enough context that a non-expert reader understands the issue, why it matters, and what evidence supports it. "
|
||||
"Return only the structured report."
|
||||
),
|
||||
output_type=MarkdownResearchReport,
|
||||
)
|
||||
|
||||
judge_tool = judge_agent.as_tool(
|
||||
tool_name="judge_answer_quality",
|
||||
tool_description="Judge whether an answer or evidence is good enough for the original research question.",
|
||||
)
|
||||
|
||||
source_research_tool = _build_source_research_agent().as_tool(
|
||||
tool_name="run_source_research",
|
||||
tool_description="Run Olostep search-with-scrape, targeted searches, URL selection, and URL scraping to gather source evidence.",
|
||||
)
|
||||
|
||||
analyst_tool = analyst_agent.as_tool(
|
||||
tool_name="write_markdown_research_report",
|
||||
tool_description="Write the final structured Markdown research report from the gathered evidence.",
|
||||
)
|
||||
|
||||
manager_agent = Agent(
|
||||
name="Manager research agent",
|
||||
model=MODEL,
|
||||
instructions=(
|
||||
"You are the orchestrator for a multi-agent research assistant. Follow this exact policy:\n"
|
||||
"1. Always call answer_query first for the user's question.\n"
|
||||
"2. Call judge_answer_quality on that Answer API result.\n"
|
||||
"3. If the judge says the answer is good enough, call write_markdown_research_report using the answer result.\n"
|
||||
"4. If the judge says the answer is not good enough, call run_source_research. The source researcher must use search_with_scrape first, then targeted searches and scrape_url if needed.\n"
|
||||
"5. Call write_markdown_research_report using all evidence.\n"
|
||||
"6. Return a complete MarkdownResearchReport. Do not return a casual chat answer."
|
||||
),
|
||||
tools=[answer_query, judge_tool, source_research_tool, analyst_tool],
|
||||
output_type=MarkdownResearchReport,
|
||||
)
|
||||
|
||||
|
||||
async def run_research_assistant(query: str, progress: ProgressCallback | None = None) -> tuple[MarkdownResearchReport, str]:
|
||||
if not OPENAI_API_KEY:
|
||||
raise RuntimeError("OPENAI_API_KEY is not set. Add it to .env and restart the app.")
|
||||
require_olostep_key()
|
||||
|
||||
token = _progress_callback.set(progress)
|
||||
trace_id = gen_trace_id()
|
||||
trace_url = openai_trace_url(trace_id)
|
||||
|
||||
try:
|
||||
await emit_progress("Starting manager research agent.")
|
||||
prompt = f"""
|
||||
Research question:
|
||||
{query}
|
||||
|
||||
Return a polished, reader-friendly Markdown research report with substantial detail for the user's specific question. Follow the required workflow exactly:
|
||||
- Answer API first.
|
||||
- Judge agent second.
|
||||
- If weak, source research agent with search_with_scrape, targeted search_web calls, URL selection, and scrape_url.
|
||||
- Analyst agent writes the final Markdown report. Do not include Limitations or Next Steps sections.
|
||||
"""
|
||||
with trace(
|
||||
workflow_name="multi_agent_research_assistant_olostep",
|
||||
trace_id=trace_id,
|
||||
metadata={"query": query, "app": "reflex_research_assistant"},
|
||||
):
|
||||
with custom_span("manager.run", {"query": query}):
|
||||
result = await Runner.run(manager_agent, prompt, max_turns=20)
|
||||
|
||||
await emit_progress("Manager run completed. Flushing OpenAI traces.")
|
||||
flush_traces()
|
||||
await emit_progress("Trace flushed. Rendering Markdown report.")
|
||||
return result.final_output, trace_url
|
||||
finally:
|
||||
_progress_callback.reset(token)
|
||||
@@ -743,7 +743,8 @@
|
||||
},
|
||||
"language_info": {
|
||||
"name": "python",
|
||||
"pygments_lexer": "ipython3"
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
openai-agents>=0.17.1
|
||||
olostep>=1.1.0
|
||||
python-dotenv>=1.2.2
|
||||
pydantic>=2.13.4
|
||||
reflex>=0.9.2
|
||||
markdown>=3.10.2
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import reflex as rx
|
||||
|
||||
|
||||
config = rx.Config(
|
||||
app_name="app",
|
||||
env_file=".env",
|
||||
show_built_with_reflex=False,
|
||||
plugins=[
|
||||
rx.plugins.SitemapPlugin(),
|
||||
rx.plugins.RadixThemesPlugin(
|
||||
theme=rx.theme(
|
||||
appearance="light",
|
||||
accent_color="blue",
|
||||
gray_color="slate",
|
||||
radius="medium",
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user