Files
openai--openai-agents-python/examples/financial_research_agent/manager.py
T
2026-07-13 12:39:17 +08:00

266 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import Sequence
from datetime import datetime, timezone
from pydantic import BaseModel
from rich.console import Console
from agents import Runner, RunResult, RunResultStreaming, custom_span, gen_trace_id, trace
from examples.web_search_utils import extract_url_citations, extract_web_search_source_urls
from .agents.financials_agent import financials_agent
from .agents.planner_agent import FinancialSearchItem, FinancialSearchPlan, planner_agent
from .agents.risk_agent import risk_agent
from .agents.search_agent import FinancialSearchSummary, search_agent
from .agents.verifier_agent import VerificationResult, verifier_agent
from .agents.writer_agent import REVISION_PROMPT, FinancialReportData, writer_agent
from .printer import Printer
class FinancialSource(BaseModel):
title: str
url: str
class FinancialSearchEvidence(BaseModel):
query: str
reason: str
summary: str
sources: list[FinancialSource]
retrieved_at: str
def _extract_financial_sources(items: Sequence[object]) -> list[FinancialSource]:
sources: list[FinancialSource] = []
seen: set[str] = set()
for citation in extract_url_citations(items):
if citation.url in seen:
continue
seen.add(citation.url)
sources.append(FinancialSource(title=citation.title, url=citation.url))
for url in extract_web_search_source_urls(items):
if url in seen:
continue
seen.add(url)
sources.append(FinancialSource(title=url, url=url))
return sources
async def _summary_extractor(run_result: RunResult | RunResultStreaming) -> str:
"""Custom output extractor for subagents that return an AnalysisSummary."""
# The financial/risk analyst agents emit an AnalysisSummary with a `summary` field.
# We want the tool call to return just that summary text so the writer can drop it inline.
return str(run_result.final_output.summary)
class FinancialResearchManager:
"""
Orchestrates the full flow: planning, searching, subanalysis, writing, and verification.
"""
def __init__(self) -> None:
self.console = Console()
self.printer = Printer(self.console)
self.research_cutoff = datetime.now(timezone.utc).date().isoformat()
async def run(self, query: str) -> None:
trace_id = gen_trace_id()
try:
with trace("Financial research trace", trace_id=trace_id):
self.printer.update_item(
"trace_id",
f"View trace: https://platform.openai.com/traces/trace?trace_id={trace_id}",
is_done=True,
hide_checkmark=True,
)
self.printer.update_item("start", "Starting financial research...", is_done=True)
search_plan = await self._plan_searches(query)
search_results = await self._perform_searches(search_plan)
report, verification = await self._produce_verified_report(query, search_results)
final_report = f"Report summary\n\n{report.short_summary}"
self.printer.update_item("final_report", final_report, is_done=True)
finally:
self.printer.end()
# Print to stdout
print("\n\n=====REPORT=====\n\n")
print(f"Report:\n{report.markdown_report}")
print("\n\n=====FOLLOW UP QUESTIONS=====\n\n")
print("\n".join(report.follow_up_questions))
print("\n\n=====VERIFICATION=====\n\n")
print(verification)
async def _produce_verified_report(
self,
query: str,
search_results: Sequence[FinancialSearchEvidence],
) -> tuple[FinancialReportData, VerificationResult]:
report = await self._write_report(query, search_results)
verification = await self._verify_report(query, report, search_results)
if verification.verified:
return report, verification
report = await self._revise_report(query, report, search_results, verification)
verification = await self._verify_report(query, report, search_results)
if not verification.verified:
raise RuntimeError(
"Financial report failed evidence verification after one revision: "
f"{verification.model_dump_json()}"
)
return report, verification
async def _plan_searches(self, query: str) -> FinancialSearchPlan:
self.printer.update_item("planning", "Planning searches...")
result = await Runner.run(planner_agent, f"Query: {query}")
self.printer.update_item(
"planning",
f"Will perform {len(result.final_output.searches)} searches",
is_done=True,
)
return result.final_output_as(FinancialSearchPlan)
async def _perform_searches(
self, search_plan: FinancialSearchPlan
) -> Sequence[FinancialSearchEvidence]:
with custom_span("Search the web"):
self.printer.update_item("searching", "Searching...")
tasks = [asyncio.create_task(self._search(item)) for item in search_plan.searches]
results: list[FinancialSearchEvidence] = []
num_completed = 0
num_succeeded = 0
num_failed = 0
for task in asyncio.as_completed(tasks):
result = await task
if result is not None:
results.append(result)
num_succeeded += 1
else:
num_failed += 1
num_completed += 1
status = f"Searching... {num_completed}/{len(tasks)} finished"
if num_failed:
status += f" ({num_succeeded} succeeded, {num_failed} failed)"
self.printer.update_item(
"searching",
status,
)
summary = f"Searches finished: {num_succeeded}/{len(tasks)} succeeded"
if num_failed:
summary += f", {num_failed} failed"
self.printer.update_item("searching", summary, is_done=True)
return results
async def _search(self, item: FinancialSearchItem) -> FinancialSearchEvidence | None:
input_data = f"Search term: {item.query}\nReason: {item.reason}"
try:
result = await Runner.run(search_agent, input_data)
search_summary = result.final_output_as(FinancialSearchSummary)
sources = _extract_financial_sources(result.new_items)
if not sources:
return None
return FinancialSearchEvidence(
query=item.query,
reason=item.reason,
summary=search_summary.summary,
sources=sources,
retrieved_at=self.research_cutoff,
)
except Exception:
return None
async def _write_report(
self,
query: str,
search_results: Sequence[FinancialSearchEvidence],
) -> FinancialReportData:
# Expose the specialist analysts as tools so the writer can invoke them inline
# and still produce the final FinancialReportData output.
fundamentals_tool = financials_agent.as_tool(
tool_name="fundamentals_analysis",
tool_description="Use to get a short writeup of key financial metrics",
custom_output_extractor=_summary_extractor,
)
risk_tool = risk_agent.as_tool(
tool_name="risk_analysis",
tool_description="Use to get a short writeup of potential red flags",
custom_output_extractor=_summary_extractor,
)
writer_with_tools = writer_agent.clone(tools=[fundamentals_tool, risk_tool])
self.printer.update_item("writing", "Thinking about report...")
input_data = self._report_input(query, search_results)
result = Runner.run_streamed(writer_with_tools, input_data)
update_messages = [
"Planning report structure...",
"Writing sections...",
"Finalizing report...",
]
last_update = time.time()
next_message = 0
async for _ in result.stream_events():
if time.time() - last_update > 5 and next_message < len(update_messages):
self.printer.update_item("writing", update_messages[next_message])
next_message += 1
last_update = time.time()
self.printer.mark_item_done("writing")
return result.final_output_as(FinancialReportData)
async def _revise_report(
self,
query: str,
report: FinancialReportData,
search_results: Sequence[FinancialSearchEvidence],
verification: VerificationResult,
) -> FinancialReportData:
self.printer.update_item("revising", "Revising report from verification feedback...")
revision_agent = writer_agent.clone(instructions=REVISION_PROMPT)
input_data = (
f"{self._report_input(query, search_results)}\n"
f"Existing report:\n{report.model_dump_json()}\n"
f"Verification feedback:\n{verification.model_dump_json()}"
)
result = await Runner.run(revision_agent, input_data)
self.printer.mark_item_done("revising")
return result.final_output_as(FinancialReportData)
async def _verify_report(
self,
query: str,
report: FinancialReportData,
search_results: Sequence[FinancialSearchEvidence],
) -> VerificationResult:
self.printer.update_item("verifying", "Verifying report...")
input_data = json.dumps(
{
"original_query": query,
"research_cutoff": self.research_cutoff,
"report": report.model_dump(mode="json"),
"evidence": [item.model_dump(mode="json") for item in search_results],
},
ensure_ascii=False,
)
result = await Runner.run(verifier_agent, input_data)
self.printer.mark_item_done("verifying")
return result.final_output_as(VerificationResult)
def _report_input(
self,
query: str,
search_results: Sequence[FinancialSearchEvidence],
) -> str:
return json.dumps(
{
"original_query": query,
"research_cutoff": self.research_cutoff,
"evidence": [item.model_dump(mode="json") for item in search_results],
},
ensure_ascii=False,
)