Files
patchy631--ai-engineering-hub/multiplatform_deep_researcher/flow.py
T
2026-07-13 12:37:47 +08:00

295 lines
12 KiB
Python

import json
import logging
import os
from typing import Any, Dict, List, Literal, Optional
from crewai import Agent, Crew, LLM, Task
from crewai.flow.flow import Flow, listen, start
from crewai_tools import MCPServerAdapter
from dotenv import load_dotenv
from mcp import StdioServerParameters
from pydantic import BaseModel, Field, HttpUrl
load_dotenv()
# ---------- Pydantic Schemas ----------
Platform = Literal["instagram", "linkedin", "youtube", "x", "web"]
class URLBuckets(BaseModel):
instagram: List[str] = Field(default_factory=list)
linkedin: List[str] = Field(default_factory=list)
youtube: List[str] = Field(default_factory=list)
x: List[str] = Field(default_factory=list)
web: List[str] = Field(default_factory=list)
class SpecialistOutput(BaseModel):
platform: Platform
url: str
summary: str
metadata: Dict[str, Any] = Field(default_factory=dict)
# ---------- Flow State ----------
class DeepResearchFlowState(BaseModel):
query: str = ""
final_response: Optional[str] = None
# ---------- MCP Server Configurations ----------
def server_params() -> StdioServerParameters:
token = os.getenv("BRIGHT_DATA_API_TOKEN")
if not token:
raise RuntimeError("BRIGHT_DATA_API_TOKEN is not set")
return StdioServerParameters(
command="npx",
args=["@brightdata/mcp"],
env={"API_TOKEN": token, "PRO_MODE": "true"},
)
# ---------- Flow Definition ----------
class DeepResearchFlow(Flow[DeepResearchFlowState]):
search_llm: Any = LLM(model="openai/gpt-4o", temperature=0.0)
specialist_llm: Any = LLM(model="openai/o3-mini", temperature=0.1)
response_llm: Any = LLM(model="ollama/gpt-oss", temperature=0.3)
@start()
def start_flow(self) -> Dict[str, Any]:
"""Start the flow by setting the query in the state."""
# Entry: state.query already populated by caller
return {"query": self.state.query}
@listen(start_flow)
def collect_urls(self) -> Dict[str, Any]:
"""Search web for user query and return URLBuckets object."""
try:
with MCPServerAdapter(server_params()) as mcp_tools:
search_agent = Agent(
role="Multiplatform Web Discovery Specialist",
goal=(
"Your objective is to identify and return a well-organized JSON object containing only public, directly relevant links for a given user query. "
"The links should be grouped by platform: Instagram, LinkedIn, YouTube, X (formerly Twitter), and the open web."
),
backstory=(
"You are an expert web researcher skilled in using advanced search operators and platform-specific techniques. "
"You rigorously verify that every link is public, accessible, and highly relevant to the query. "
"You never include duplicates or irrelevant results, and you never fabricate information. "
"If no suitable links are found for a platform, you return an empty list for that platform. "
"Your output is always precise, clean, and strictly follows the required schema."
),
tools=[mcp_tools["search_engine"]],
llm=self.search_llm,
)
search_task = Task(
description=f"""
You are collecting public URLs for this query: "{self.state.query}".
Return ONLY a JSON object matching the URLBuckets schema with EXACT keys:
["instagram","linkedin","youtube","x","web"], each a list of HTTPS URLs.
Classification rules (strict):
- instagram: instagram.com/*
- linkedin: linkedin.com/*
- youtube: youtube.com/*
- x: x.com/* or twitter.com/*
- web: only web pages that opens to an article or blog post (exclude the above domains and landing pages)
Quality + validity:
- No duplicates within or across lists.
- Cap each list at 3 URLs, ordered by likely usefulness.
If a platform yields nothing, return an empty list [] for that key.
Output must be pure JSON, no code fences, no commentary.
Example shape (not a template):
{{"instagram":[], "linkedin":[], "youtube":[], "x":[], "web":[]}}
""",
agent=search_agent,
output_pydantic=URLBuckets, # Enforces the schema
expected_output="Strict JSON for URLBuckets. No extra text or formatting.",
)
crew = Crew(agents=[search_agent], tasks=[search_task], verbose=True)
out: URLBuckets = crew.kickoff()
return {"urls_buckets": out.model_dump(mode="raw")}
except Exception as e:
logging.exception("collect_urls failed")
empty = URLBuckets().model_dump(mode="raw")
return {"urls_buckets": empty, "error": f"{e}"}
@listen(collect_urls)
def dispatch_to_specialists(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""Fan-out to platform specialists. Each platform is processed independently."""
results: List[SpecialistOutput] = []
# Helper to process a single platform bucket
def _process_platform(
platform: Platform, urls: List[HttpUrl]
) -> List[SpecialistOutput]:
# Check if no URLs are provided
if not urls:
# Skip the function if no URLs are provided
return []
with MCPServerAdapter(server_params()) as mcp_tools:
tools_map: Dict[str, List[Any]] = {
"instagram": [mcp_tools["web_data_instagram_posts"]],
"linkedin": [mcp_tools["web_data_linkedin_posts"]],
"youtube": [mcp_tools["web_data_youtube_videos"]],
"x": [mcp_tools["web_data_x_posts"]],
"web": [mcp_tools["scrape_as_markdown"]],
}
specialist_research_agent = Agent(
role=f"{platform.capitalize()} Specialist Research Agent",
goal=(
f"You are a {platform.capitalize()} deep content analysis/research specialist. "
f"Given one or more public {platform} URLs, your task is to extract high-signal facts, "
"insights, and key information from the content. "
"For each URL, return a strictly valid object (no extra attributes, no commentary) matching the output schema."
),
backstory=(
"You operate with deep-research rigor and platform expertise. "
"Never speculate or infer beyond what is directly available. "
"Prioritize accuracy, clarity, and completeness in your extraction, and always adhere to the output schema."
),
tools=tools_map[platform],
llm=self.specialist_llm,
)
# One task per URL to keep outputs atomic and typed
specialist_research_task = Task(
description=f"""
Process this {platform} URL: {urls}
Requirements:
- Use the provided tools to fetch content only.
- Summarize in bullet points ~500-750 words total (avoid fluff).
- Do not fabricate fields; leave unknowns out.
Output:
- Return ONLY valid JSON matching SpecialistOutput schema:
{{ "platform": "{platform}", "url": "<canonical_url>", "summary": "<summary>", "metadata": {{...}} }}
""",
agent=specialist_research_agent,
output_pydantic=SpecialistOutput,
expected_output="Strict JSON for SpecialistOutput; no prose, no code fences.",
)
crew = Crew(
agents=[specialist_research_agent],
tasks=[specialist_research_task],
verbose=True,
)
platform_output: SpecialistOutput = crew.kickoff()
return [platform_output]
# Process each platform bucket with clear failure isolation
url_buckets_dict = (
json.loads(inputs["urls_buckets"]["raw"])
if isinstance(inputs["urls_buckets"]["raw"], str)
else inputs["urls_buckets"]["raw"]
)
for platform, bucket in url_buckets_dict.items():
try:
platform_output = _process_platform(platform, bucket)
results.append(platform_output)
except Exception as e:
logging.exception(f"{platform} specialist failed")
results.append(
SpecialistOutput(
platform=platform,
url="https://invalid.local",
summary=f"Error: {type(e).__name__}",
metadata={"detail": str(e)},
)
)
# Flatten the results list and filter out empty results
flattened_results = []
for result in results:
if isinstance(result, list):
flattened_results.extend(result)
elif result: # Non-empty result
flattened_results.append(result)
return {"specialist_results": flattened_results}
@listen(dispatch_to_specialists)
def synthesize_response(self, inputs: Dict[str, Any]) -> Dict[str, Any]:
"""Final deep research response synthesis."""
response_agent = Agent(
role="Deep Research Synthesis Specialist",
goal=(
"Synthesize comprehensive research findings into a clear, engaging, and informative response "
"that answers the user's query with depth and accuracy. Present findings in a structured, "
"easy-to-read format similar to ChatGPT's deep research mode."
),
backstory=(
"You are an expert research analyst with deep expertise in synthesizing complex information "
"from multiple sources. You excel at creating comprehensive, well-structured responses that "
"provide users with actionable insights while maintaining clarity and engagement."
),
llm=self.response_llm,
)
response_task = Task(
description=f"""
Original Query: "{self.state.query}"
Research Context:
{inputs["specialist_results"]}
Your Task:
Create a comprehensive, well-structured markdown response that:
1. **Directly answers the user's query** with clear, actionable insights
2. **Synthesizes findings** from all available sources into coherent themes
3. **Provides specific details** with supporting evidence from sources
4. **Uses clear headings** and bullet points for easy scanning
5. **Includes source links** where applicable for credibility
6. **Highlights key takeaways** and important implications
7. **Maintains an engaging tone** while being informative
Structure your response with:
- Executive Summary (2-3 key points)
- Detailed Findings (organized by topic/theme)
- Key Insights & Implications
- Sources & References
Make it comprehensive yet readable, similar to high-quality research reports or ChatGPT's or Gemini's deep research mode.
""",
expected_output="Comprehensive markdown response with clear structure, detailed findings, and source references.",
agent=response_agent,
markdown=True,
)
crew = Crew(agents=[response_agent], tasks=[response_task], verbose=True)
final_md: str = crew.kickoff()
self.state.final_response = str(final_md)
return {"result": self.state.final_response}
# Usage example
async def main():
flow = DeepResearchFlow()
flow.state.query = "What is the latest update on iphone 17 launch?"
result = await flow.kickoff_async()
print(f"\n{'='*50}")
print(f"FINAL RESULT")
print(f"{'='*50}")
print(result["result"])
if __name__ == "__main__":
import asyncio
asyncio.run(main())