File size: 10,895 Bytes
5e5edc7 2fb3630 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 | import os
import time
import re
from re import T
import json
import operator
from typing import Annotated, Dict, Any, List, Union, Optional
from typing_extensions import TypedDict, Annotated, Literal
from dataclasses import dataclass, field
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.runnables import RunnableConfig
from langgraph.graph import START, END, StateGraph
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from tavily import TavilyClient
from model import get_gemma
gemma_model = get_gemma()
TAVILY_API_KEY = os.environ.get("TAVILY_API_KEY")
tavily_client = TavilyClient(api_key=TAVILY_API_KEY)
max_web_research_loops: int = 4
@dataclass(kw_only=True)
class SummaryState:
research_topic: str = field(default=None)
search_query: str = field(default=None)
web_search_results: Annotated[list, operator.add] = field(default_factory=list)
sources_gathered: Annotated[list, operator.add] = field(default=list)
research_loop_count: int = 0
running_summary: str = None
@dataclass(kw_only=True)
class SummaryStateInput:
research_topic: str = None
@dataclass(kw_only=True)
class SummaryStateOutput:
running_summary: str = None
# Query Writer
query_writer_instructions = """Your gola is to generate web search query.
The query will gather information about specific topic.
Topic: {research_topic}
Return your query as JSON object:
{{
"query": "string",
"aspect" : "string",
"rationale" : "string"
}}
"""
# Summerizer Instructions
summerizer_instructions = """Your goal is to generate high-quality summary of the web search results.
when EXTENDING an existing summary:
1. Seamlessly integrate new information without repeating what's already covered.
2. Maintain consistancy with existing content's style.
3. Only add new and non-redudant information.
4. Ensure smooth transition between existing and new content.
when creating a NEW summary:
1. Highlight the most relevant information from each source.
2. Provide concise overview of the key points related to each report topic.
3. Emphasize on significant findings or insights.
4. Ensure coherent flow of information.
In both cases:
1. Focus on factual & objective information
2. Maintain consistat technical depth
3. Avoid repetition & redundancy
4. DON'T use phrases like "based on new results"
5. DON'T add preamble like "Here is an extended summary ...", instead just provide summary directly
6. DON'T add references or works cited section.
7. You will generate tables using markdown when user asks you to do.
"""
# Reflection Instructions
reflection_summary = """You are an expert research assistant analyzing summary about {research_topic}.
Your Tasks :
1. Identify knowledge gaps or areas the need further exploration.
2. Generate a follow-up question that would help in expanding the understanding.
3. Focus on technical details, implementation specifics.
Ensure follow-up question is self-contained and includes necessary context for web search.
Return response as JSON object:
{{
"knowledge_gap" : "string",
"follow_up_query" : "string"
}}
"""
def generate_query(state: SummaryState):
# To generate query for web search
system_message_for_query_writer = query_writer_instructions.format(research_topic=state.research_topic)
result = gemma_model.invoke(
[
HumanMessage(content=f"IMPORTANT INSTRUCTIONS:\n{system_message_for_query_writer}\n\nGenerate a query for web search")
]
)
# print(f"[FUN] GENERATE_QUERY:\nType: {type(result)}\nContent: {result}")
raw_content = result.content.strip()
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw_content, re.DOTALL)
if match:
json_str = match.group(1)
else:
raise ValueError(f"Failed to extract JSON from model response: {raw_content}")
query = json.loads(json_str)
return {"search_query" : query["query"]}
def deduplicate_and_format_sources(
search_response: Union[Dict[str, Any], List[Dict[str, Any]]],
max_tokens_per_source: int,
fetch_full_page: bool = False
) -> str:
"""
Format and deduplicate search responses from various search APIs.
Takes either a single search response or list of responses from search APIs,
deduplicates them by URL, and formats them into a structured string.
Args:
search_response (Union[Dict[str, Any], List[Dict[str, Any]]]): Either:
- A dict with a 'results' key containing a list of search results
- A list of dicts, each containing search results
max_tokens_per_source (int): Maximum number of tokens to include for each source's content
fetch_full_page (bool, optional): Whether to include the full page content. Defaults to False.
Returns:
str: Formatted string with deduplicated sources
Raises:
ValueError: If input is neither a dict with 'results' key nor a list of search results
"""
# Convert input to list of results
if isinstance(search_response, dict):
sources_list = search_response['results']
elif isinstance(search_response, list):
sources_list = []
for response in search_response:
if isinstance(response, dict) and 'results' in response:
sources_list.extend(response['results'])
else:
sources_list.extend(response)
else:
raise ValueError("Input must be either a dict with 'results' or a list of search results")
# Deduplicate by URL
unique_sources = {}
for source in sources_list:
if source['url'] not in unique_sources:
unique_sources[source['url']] = source
# Format output
formatted_text = "Sources:\n\n"
for i, source in enumerate(unique_sources.values(), 1):
formatted_text += f"Source: {source['title']}\n===\n"
formatted_text += f"URL: {source['url']}\n===\n"
formatted_text += f"Most relevant content from source: {source['content']}\n===\n"
if fetch_full_page:
# Using rough estimate of 4 characters per token
char_limit = max_tokens_per_source * 4
# Handle None raw_content
raw_content = source.get('raw_content', '')
if raw_content is None:
raw_content = ''
print(f"Warning: No raw_content found for source {source['url']}")
if len(raw_content) > char_limit:
raw_content = raw_content[:char_limit] + "... [truncated]"
formatted_text += f"Full source content limited to {max_tokens_per_source} tokens: {raw_content}\n\n"
return formatted_text.strip()
def format_sources(search_results: Dict[str, Any]) -> str:
"""
Format search results into a bullet-point list of sources with URLs.
Creates a simple bulleted list of search results with title and URL for each source.
Args:
search_results (Dict[str, Any]): Search response containing a 'results' key with
a list of search result objects
Returns:
str: Formatted string with sources as bullet points in the format "* title : url"
"""
return '\n'.join(
f"* {source['title']} : {source['url']}"
for source in search_results['results']
)
def web_research(state: SummaryState):
search_results = tavily_client.search(state.search_query, include_raw_content=True, max_results=1)
search_str = deduplicate_and_format_sources(search_results, max_tokens_per_source=1000)
return {
"sources_gathered" : [format_sources(search_results)],
"research_loop_count" : state.research_loop_count + 1,
"web_search_results" : [search_str]
}
def summarize_sources(state: SummaryState):
existing_summary = state.running_summary
print(state.web_search_results)
most_recent_web_search = state.web_search_results[-1]
if existing_summary:
human_message = (
f"IMPORTANT INSTRUCTIONS:\n{summerizer_instructions}\n\n"
f"Extend the existing summary: {existing_summary}\n\n"
f"Include new search results: {most_recent_web_search}"
f"That addresses the following topic: {state.research_topic}"
)
else:
human_message = (
f"IMPORTANT INSTRUCTIONS:\n{summerizer_instructions}\n\n"
f"Generate summary of these search results: {most_recent_web_search}"
f"That addresses the following topic: {state.research_topic}"
)
result = gemma_model.invoke([HumanMessage(content=human_message)])
return {"running_summary" : result.content}
def reflect_on_summary(state: SummaryState):
result = gemma_model([
HumanMessage(content=f"IMPORTANT INSTRUCTIONS:\n{reflection_summary.format(research_topic=state.research_topic)}\n\nIdentify a knowledge gap and generate a follow-up web search query based on existing knowledge: {state.running_summary}")
])
# print(f">> [FUN] REFLECT ON SUMMARY:\n{result.content}")
raw_content = result.content.strip()
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw_content, re.DOTALL)
if match:
json_str = match.group(1)
else:
raise ValueError(f"Failed to extract JSON from model response: {raw_content}")
query = json.loads(json_str)
print(query)
return {"search_query" : query["follow_up_query"]}
def finalize_summary(state: SummaryState):
all_sources = "\n".join(source for source in state.sources_gathered)
print(f"All Sources: {all_sources}")
running_summary = f"## Summary\n\n{state.running_summary}\n\nSources:\n{all_sources}"
return {"running_summary" : running_summary}
def route_research(state: SummaryState):
if state.research_loop_count <= max_web_research_loops:
return "web_research"
else:
return "finalize_summary"
def perform_deep_research(query):
builder = StateGraph(SummaryState, input_schema=SummaryStateInput, output_schema=SummaryStateOutput)
builder.add_node("generate_query", generate_query)
builder.add_node("web_research", web_research)
builder.add_node("summarize_sources", summarize_sources)
builder.add_node("reflect_on_summary", reflect_on_summary)
builder.add_node("finalize_summary", finalize_summary)
# Add edges
builder.add_edge(START, "generate_query")
builder.add_edge("generate_query", "web_research")
builder.add_edge("web_research", "summarize_sources")
builder.add_edge("summarize_sources", "reflect_on_summary")
builder.add_conditional_edges("reflect_on_summary", route_research)
builder.add_edge("finalize_summary", END)
graph = builder.compile()
research_input = SummaryStateInput(research_topic=query)
research_output = graph.invoke(research_input)
return research_output["running_summary"] |