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"]