deep-research / research_workflow.py
finpy1789's picture
Update research_workflow.py
8b06357 verified
Raw
History Blame Contribute Delete
11.6 kB
from __future__ import annotations
import asyncio
import json
import logging
import os
from functools import lru_cache
from typing import Any
from dotenv import load_dotenv
from duckduckgo_search import DDGS
from openai import AsyncOpenAI
from pydantic import BaseModel, field_validator
try:
from tavily import AsyncTavilyClient
TAVILY_AVAILABLE = True
except ImportError:
AsyncTavilyClient = None
TAVILY_AVAILABLE = False
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
DOMAIN_DEPTH_MAP = {
"finance": 5,
"law": 5,
"healthcare": 5,
"governance": 4,
"economics": 4,
"ai": 4,
"academia": 4,
"science": 4,
}
DEFAULT_MAX_SUBTOPICS = 3
DEFAULT_MAX_SEARCH_RESULTS = 4
class Subtopics(BaseModel):
subtopics: list[str]
class ResearchResults(BaseModel):
findings: str | list[Any] | dict[str, Any]
@field_validator("findings", mode="after")
@classmethod
def normalize_findings(cls, value: str | list[Any] | dict[str, Any]) -> str:
return normalize_model_text(value)
class OptimizationDecision(BaseModel):
justification: str
needs_more_research: bool
class SummaryReport(BaseModel):
report: str | list[Any] | dict[str, Any]
def as_text(self) -> str:
return normalize_report_text(self.report)
def normalize_model_text(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "\n".join(format_model_item(item, index) for index, item in enumerate(value, 1))
if isinstance(value, dict):
return format_model_item(value)
return str(value)
def normalize_report_text(value: Any) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "\n\n".join(format_model_item(item) for item in value)
if isinstance(value, dict):
sections = []
for key, section_value in value.items():
heading = str(key).replace("_", " ").title()
body = normalize_model_text(section_value)
sections.append(f"## {heading}\n{body}")
return "\n\n".join(sections)
return str(value)
def format_model_item(item: Any, index: int | None = None) -> str:
prefix = f"{index}. " if index is not None else ""
if isinstance(item, str):
return f"{prefix}{item}"
if not isinstance(item, dict):
return f"{prefix}{item}"
fact = item.get("fact") or item.get("finding") or item.get("insight") or item.get("summary")
source = item.get("source") or item.get("url") or item.get("citation")
title = item.get("title")
parts = []
if fact:
parts.append(str(fact))
else:
parts.append(json.dumps(item, ensure_ascii=False))
if title:
parts.append(f"Title: {title}")
if source:
parts.append(f"Source: {source}")
return prefix + " | ".join(parts)
@lru_cache(maxsize=1)
def get_deepseek_client() -> AsyncOpenAI:
api_key = os.getenv("DEEPSEEK_API_KEY")
if not api_key:
raise RuntimeError("DEEPSEEK_API_KEY is not configured.")
return AsyncOpenAI(api_key=api_key, base_url="https://api.deepseek.com/v1")
@lru_cache(maxsize=1)
def get_tavily_client():
api_key = os.getenv("TAVILY_API_KEY")
if not TAVILY_AVAILABLE or not api_key:
return None
return AsyncTavilyClient(api_key=api_key)
def get_research_depth(topic: str) -> tuple[int, int]:
topic_lower = topic.lower()
for domain, depth in DOMAIN_DEPTH_MAP.items():
if domain in topic_lower:
logger.info("Detected domain '%s' -> depth %s", domain, depth)
return depth, depth
return DEFAULT_MAX_SUBTOPICS, DEFAULT_MAX_SEARCH_RESULTS
async def call_deepseek_json(
system_prompt: str,
user_prompt: str,
output_model: type[BaseModel],
temperature: float = 0.3,
) -> BaseModel:
response = await get_deepseek_client().chat.completions.create(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
response_format={"type": "json_object"},
temperature=temperature,
)
content = response.choices[0].message.content or "{}"
try:
data = json.loads(content)
except json.JSONDecodeError as exc:
logger.error("Invalid JSON from DeepSeek: %s", content[:300])
raise ValueError("Model returned invalid JSON.") from exc
return output_model.model_validate(data)
async def call_deepseek_text(
messages: list[dict[str, str]],
temperature: float = 0.4,
max_tokens: int = 700,
) -> str:
response = await get_deepseek_client().chat.completions.create(
model=os.getenv("DEEPSEEK_MODEL", "deepseek-chat"),
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
return response.choices[0].message.content or ""
async def run_chat_completion(user_query: str, memory: list[dict[str, str]]) -> str:
system = (
"You are the normal chat mode of a deep research assistant. Answer directly, "
"briefly, and helpfully. Do not claim to have searched the web. If the user "
"needs current sources or a detailed report, suggest asking for deep research."
)
recent_memory = memory[-8:]
messages = [{"role": "system", "content": system}, *recent_memory, {"role": "user", "content": user_query}]
try:
return await call_deepseek_text(messages)
except Exception as exc:
logger.error("Chat completion failed: %s", exc)
return (
"I can help with quick chat or deep research. The chat model is not available "
"right now, so please check the DeepSeek API key configuration."
)
async def web_search_multi(query: str, max_results: int = 4) -> str:
tasks = [web_search_duckduckgo(query, max_results)]
if get_tavily_client():
tasks.append(web_search_tavily(query, max_results))
results = await asyncio.gather(*tasks, return_exceptions=True)
combined = []
for result in results:
if isinstance(result, str) and result.strip():
combined.append(result)
elif isinstance(result, Exception):
logger.warning("Search engine error: %s", result)
return "\n\n---\n\n".join(combined) if combined else "No web results found."
async def web_search_duckduckgo(query: str, max_results: int) -> str:
try:
return await asyncio.to_thread(_duckduckgo_search_sync, query, max_results)
except Exception as exc:
logger.error("DuckDuckGo error: %s", exc)
return f"[DuckDuckGo] Search failed: {exc}"
def _duckduckgo_search_sync(query: str, max_results: int) -> str:
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=max_results))
if not results:
return "[DuckDuckGo] No results found."
snippets = []
for index, result in enumerate(results, 1):
title = result.get("title", "No title")
body = result.get("body", "No content")
href = result.get("href", "")
snippets.append(f"{index}. {title}\n {body}\n Source: {href}")
return "[DuckDuckGo Results]\n" + "\n\n".join(snippets)
async def web_search_tavily(query: str, max_results: int) -> str:
tavily_client = get_tavily_client()
if not tavily_client:
return "[Tavily] Not configured."
try:
response = await tavily_client.search(
query=query,
max_results=max_results,
search_depth="basic",
include_answer=True,
include_raw_content=False,
)
except Exception as exc:
logger.error("Tavily error: %s", exc)
return f"[Tavily] Search failed: {exc}"
if not response.get("results"):
return "[Tavily] No results found."
snippets = []
for index, result in enumerate(response["results"][:max_results], 1):
title = result.get("title", "No title")
content = result.get("content", "No content")
url = result.get("url", "")
snippets.append(f"{index}. {title}\n {content}\n Source: {url}")
answer = response.get("answer", "")
result_text = "[Tavily Results]\n" + "\n\n".join(snippets)
return f"Tavily AI Summary: {answer}\n\n{result_text}" if answer else result_text
async def split_topic(user_query: str, num_subtopics: int) -> Subtopics:
system = (
f"Break the user's research request into exactly {num_subtopics} concrete, "
"searchable subtopics. Return JSON with a 'subtopics' list."
)
return await call_deepseek_json(system, user_query, Subtopics)
async def research_subtopic(subtopic: str, max_search_results: int) -> ResearchResults:
search_results = await web_search_multi(subtopic, max_results=max_search_results)
system = (
"Given web search results for a subtopic, extract relevant facts, data, "
"source-backed claims, and insights. Return JSON with a 'findings' field."
)
user_prompt = f"Subtopic: {subtopic}\n\nWeb search results:\n{search_results}"
return await call_deepseek_json(system, user_prompt, ResearchResults)
async def optimize_research(findings: str) -> OptimizationDecision:
system = (
"Decide whether the findings are sufficient to answer the original research "
"request. Return JSON with 'justification' and 'needs_more_research'."
)
return await call_deepseek_json(system, f"Findings so far:\n{findings}", OptimizationDecision)
async def synthesize_report(findings: str) -> SummaryReport:
system = (
"Combine the provided research findings into a structured final report. "
"Include an introduction, key findings, caveats, and conclusion. Return JSON "
"with a 'report' field."
)
return await call_deepseek_json(system, findings, SummaryReport)
async def research_workflow(user_query: str) -> str:
num_subtopics, max_search_results = get_research_depth(user_query)
logger.info("Research depth: %s subtopics, %s results each", num_subtopics, max_search_results)
subtopics_obj = await split_topic(user_query, num_subtopics)
subtopics_list = subtopics_obj.subtopics[:num_subtopics]
logger.info("Subtopics: %s", subtopics_list)
research_tasks = [
research_subtopic(subtopic, max_search_results)
for subtopic in subtopics_list
]
research_results = await asyncio.gather(*research_tasks)
return "\n\n---\n\n".join(result.findings for result in research_results)
async def run_research_pipeline(user_query: str, memory: list[dict[str, str]]) -> str:
try:
context_text = "\n".join(
f"{item['role']}: {item['content']}" for item in memory[-8:]
)
enhanced_query = f"Previous conversation:\n{context_text}\n\nCurrent query:\n{user_query}"
research = await research_workflow(enhanced_query)
optimizer_decision = await optimize_research(research)
if optimizer_decision.needs_more_research:
logger.info("Optimizer requested one additional research pass")
research = await research_workflow(enhanced_query)
final_report = await synthesize_report(research)
report_text = final_report.as_text()
memory.append({"role": "user", "content": user_query})
memory.append({"role": "assistant", "content": report_text})
return report_text
except Exception as exc:
logger.error("Pipeline error: %s", exc)
return f"Error: {exc}"