Compare commits
21 Commits
d0abbad7a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2dafe440 | ||
|
|
e34e569de4 | ||
|
|
f586b75b46 | ||
|
|
35d7f027df | ||
|
|
d9c44fe764 | ||
|
|
23f6aa51a9 | ||
|
|
3214a7e1d7 | ||
|
|
d3fd999cb6 | ||
|
|
972392d3f1 | ||
|
|
7f9597656d | ||
|
|
1c4d5a7c93 | ||
|
|
26c0b47b6c | ||
|
|
211c23f826 | ||
|
|
3bf5d97bc7 | ||
|
|
3da4c4e412 | ||
|
|
b0dd02b92e | ||
|
|
70c348c241 | ||
|
|
c429cb2e0c | ||
|
|
8dde8c6cc2 | ||
|
|
e5386031c5 | ||
|
|
aa4fb9dd51 |
16
README.md
16
README.md
@@ -12,7 +12,7 @@ This project demonstrates a fullstack application using a React frontend and a L
|
|||||||
- 🌐 Integrated web research via Google Search API.
|
- 🌐 Integrated web research via Google Search API.
|
||||||
- 🤔 Reflective reasoning to identify knowledge gaps and refine searches.
|
- 🤔 Reflective reasoning to identify knowledge gaps and refine searches.
|
||||||
- 📄 Generates answers with citations from gathered sources.
|
- 📄 Generates answers with citations from gathered sources.
|
||||||
- 🔄 Hot-reloading for both frontend and backend development during development.
|
- 🔄 Hot-reloading for both frontend and backend during development.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -73,13 +73,25 @@ The core of the backend is a LangGraph agent defined in `backend/src/agent/graph
|
|||||||
4. **Iterative Refinement:** If gaps are found or the information is insufficient, it generates follow-up queries and repeats the web research and reflection steps (up to a configured maximum number of loops).
|
4. **Iterative Refinement:** If gaps are found or the information is insufficient, it generates follow-up queries and repeats the web research and reflection steps (up to a configured maximum number of loops).
|
||||||
5. **Finalize Answer:** Once the research is deemed sufficient, the agent synthesizes the gathered information into a coherent answer, including citations from the web sources, using a Gemini model.
|
5. **Finalize Answer:** Once the research is deemed sufficient, the agent synthesizes the gathered information into a coherent answer, including citations from the web sources, using a Gemini model.
|
||||||
|
|
||||||
|
## CLI Example
|
||||||
|
|
||||||
|
For quick one-off questions you can execute the agent from the command line. The
|
||||||
|
script `backend/examples/cli_research.py` runs the LangGraph agent and prints the
|
||||||
|
final answer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python examples/cli_research.py "What are the latest trends in renewable energy?"
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
In production, the backend server serves the optimized static frontend build. LangGraph requires a Redis instance and a Postgres database. Redis is used as a pub-sub broker to enable streaming real time output from background runs. Postgres is used to store assistants, threads, runs, persist thread state and long term memory, and to manage the state of the background task queue with 'exactly once' semantics. For more details on how to deploy the backend server, take a look at the [LangGraph Documentation](https://langchain-ai.github.io/langgraph/concepts/deployment_options/). Below is an example of how to build a Docker image that includes the optimized frontend build and the backend server and run it via `docker-compose`.
|
In production, the backend server serves the optimized static frontend build. LangGraph requires a Redis instance and a Postgres database. Redis is used as a pub-sub broker to enable streaming real time output from background runs. Postgres is used to store assistants, threads, runs, persist thread state and long term memory, and to manage the state of the background task queue with 'exactly once' semantics. For more details on how to deploy the backend server, take a look at the [LangGraph Documentation](https://langchain-ai.github.io/langgraph/concepts/deployment_options/). Below is an example of how to build a Docker image that includes the optimized frontend build and the backend server and run it via `docker-compose`.
|
||||||
|
|
||||||
_Note: For the docker-compose.yml example you need a LangSmith API key, you can get one from [LangSmith](https://smith.langchain.com/settings)._
|
_Note: For the docker-compose.yml example you need a LangSmith API key, you can get one from [LangSmith](https://smith.langchain.com/settings)._
|
||||||
|
|
||||||
_Note: If you are not running the docker-compose.yml example or exposing the backend server to the public internet, you update the `apiUrl` in the `frontend/src/App.tsx` file your host. Currently the `apiUrl` is set to `http://localhost:8123` for docker-compose or `http://localhost:2024` for development._
|
_Note: If you are not running the docker-compose.yml example or exposing the backend server to the public internet, you should update the `apiUrl` in the `frontend/src/App.tsx` file to your host. Currently the `apiUrl` is set to `http://localhost:8123` for docker-compose or `http://localhost:2024` for development._
|
||||||
|
|
||||||
**1. Build the Docker Image:**
|
**1. Build the Docker Image:**
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
# GEMINI_API_KEY=
|
# OPENAI_API_KEY=
|
||||||
|
# OPENAI_BASE_URL=
|
||||||
|
# TAVILY_API_KEY=
|
||||||
43
backend/examples/cli_research.py
Normal file
43
backend/examples/cli_research.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import argparse
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from agent.graph import graph
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Run the research agent from the command line."""
|
||||||
|
parser = argparse.ArgumentParser(description="Run the LangGraph research agent")
|
||||||
|
parser.add_argument("question", help="Research question")
|
||||||
|
parser.add_argument(
|
||||||
|
"--initial-queries",
|
||||||
|
type=int,
|
||||||
|
default=3,
|
||||||
|
help="Number of initial search queries",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max-loops",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Maximum number of research loops",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reasoning-model",
|
||||||
|
default="openai/gpt-5.4",
|
||||||
|
help="Model for the final answer",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
state = {
|
||||||
|
"messages": [HumanMessage(content=args.question)],
|
||||||
|
"initial_search_query_count": args.initial_queries,
|
||||||
|
"max_research_loops": args.max_loops,
|
||||||
|
"reasoning_model": args.reasoning_model,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = graph.invoke(state)
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
if messages:
|
||||||
|
print(messages[-1].content)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -11,13 +11,13 @@ requires-python = ">=3.11,<4.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"langgraph>=0.2.6",
|
"langgraph>=0.2.6",
|
||||||
"langchain>=0.3.19",
|
"langchain>=0.3.19",
|
||||||
"langchain-google-genai",
|
"langchain-openai",
|
||||||
"python-dotenv>=1.0.1",
|
"python-dotenv>=1.0.1",
|
||||||
"langgraph-sdk>=0.1.57",
|
"langgraph-sdk>=0.1.57",
|
||||||
"langgraph-cli",
|
"langgraph-cli",
|
||||||
"langgraph-api",
|
"langgraph-api",
|
||||||
"fastapi",
|
"fastapi",
|
||||||
"google-genai",
|
"tavily-python",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,26 +9,33 @@ class Configuration(BaseModel):
|
|||||||
"""The configuration for the agent."""
|
"""The configuration for the agent."""
|
||||||
|
|
||||||
query_generator_model: str = Field(
|
query_generator_model: str = Field(
|
||||||
default="gemini-2.0-flash",
|
default="openai/gpt-5.4",
|
||||||
metadata={
|
metadata={
|
||||||
"description": "The name of the language model to use for the agent's query generation."
|
"description": "The name of the language model to use for the agent's query generation."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
reflection_model: str = Field(
|
reflection_model: str = Field(
|
||||||
default="gemini-2.5-flash",
|
default="openai/gpt-5.4",
|
||||||
metadata={
|
metadata={
|
||||||
"description": "The name of the language model to use for the agent's reflection."
|
"description": "The name of the language model to use for the agent's reflection."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
answer_model: str = Field(
|
answer_model: str = Field(
|
||||||
default="gemini-2.5-pro",
|
default="openai/gpt-5.4",
|
||||||
metadata={
|
metadata={
|
||||||
"description": "The name of the language model to use for the agent's answer."
|
"description": "The name of the language model to use for the agent's answer."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
reasoning_model: str = Field(
|
||||||
|
default="openai/gpt-5.4",
|
||||||
|
metadata={
|
||||||
|
"description": "Fallback model used when the client does not provide a supported reasoning model."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
number_of_initial_queries: int = Field(
|
number_of_initial_queries: int = Field(
|
||||||
default=3,
|
default=3,
|
||||||
metadata={"description": "The number of initial search queries to generate."},
|
metadata={"description": "The number of initial search queries to generate."},
|
||||||
|
|||||||
@@ -1,91 +1,134 @@
|
|||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from agent.tools_and_schemas import SearchQueryList, Reflection
|
from agent.configuration import Configuration
|
||||||
from dotenv import load_dotenv
|
from agent.prompts import (
|
||||||
from langchain_core.messages import AIMessage
|
answer_instructions,
|
||||||
from langgraph.types import Send
|
get_current_date,
|
||||||
from langgraph.graph import StateGraph
|
query_writer_instructions,
|
||||||
from langgraph.graph import START, END
|
reflection_instructions,
|
||||||
from langchain_core.runnables import RunnableConfig
|
)
|
||||||
from google.genai import Client
|
|
||||||
|
|
||||||
from agent.state import (
|
from agent.state import (
|
||||||
OverallState,
|
OverallState,
|
||||||
QueryGenerationState,
|
QueryGenerationState,
|
||||||
ReflectionState,
|
ReflectionState,
|
||||||
WebSearchState,
|
WebSearchState,
|
||||||
)
|
)
|
||||||
from agent.configuration import Configuration
|
from agent.tools_and_schemas import Reflection, SearchQueryList
|
||||||
from agent.prompts import (
|
|
||||||
get_current_date,
|
|
||||||
query_writer_instructions,
|
|
||||||
web_searcher_instructions,
|
|
||||||
reflection_instructions,
|
|
||||||
answer_instructions,
|
|
||||||
)
|
|
||||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
||||||
from agent.utils import (
|
from agent.utils import (
|
||||||
get_citations,
|
format_sources_for_prompt,
|
||||||
get_research_topic,
|
get_research_topic,
|
||||||
insert_citation_markers,
|
normalize_model_name,
|
||||||
resolve_urls,
|
normalize_tavily_sources,
|
||||||
|
shorten_search_query,
|
||||||
)
|
)
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain_core.messages import AIMessage
|
||||||
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langgraph.graph import END, START, StateGraph
|
||||||
|
from langgraph.types import Send
|
||||||
|
from tavily import TavilyClient
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
if os.getenv("GEMINI_API_KEY") is None:
|
TAVILY_TOPIC = "general"
|
||||||
raise ValueError("GEMINI_API_KEY is not set")
|
TAVILY_SEARCH_DEPTH = "advanced"
|
||||||
|
TAVILY_MAX_RESULTS = 5
|
||||||
# Used for Google Search API
|
TAVILY_CHUNKS_PER_SOURCE = 3
|
||||||
genai_client = Client(api_key=os.getenv("GEMINI_API_KEY"))
|
|
||||||
|
|
||||||
|
def require_env(name: str) -> str:
|
||||||
|
"""Return an environment variable or raise a clear error."""
|
||||||
|
value = os.getenv(name)
|
||||||
|
if not value:
|
||||||
|
raise ValueError(f"{name} is not set")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_model(model_name: str, temperature: float = 0) -> ChatOpenAI:
|
||||||
|
"""Create the OpenAI chat model lazily for easier testing."""
|
||||||
|
base_url = os.getenv("OPENAI_BASE_URL")
|
||||||
|
return ChatOpenAI(
|
||||||
|
model=normalize_model_name(model_name),
|
||||||
|
api_key=require_env("OPENAI_API_KEY"),
|
||||||
|
base_url=base_url or None,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tavily_client() -> TavilyClient:
|
||||||
|
"""Create the Tavily client lazily for easier testing."""
|
||||||
|
return TavilyClient(api_key=require_env("TAVILY_API_KEY"))
|
||||||
|
|
||||||
|
|
||||||
|
def create_structured_response(
|
||||||
|
prompt: str,
|
||||||
|
model_name: str,
|
||||||
|
schema_model: type[Any],
|
||||||
|
temperature: float = 1,
|
||||||
|
) -> Any:
|
||||||
|
"""Call the LangChain OpenAI chat model and parse structured output."""
|
||||||
|
llm = get_chat_model(model_name, temperature=temperature)
|
||||||
|
return llm.with_structured_output(
|
||||||
|
schema_model,
|
||||||
|
method="function_calling",
|
||||||
|
).invoke(prompt)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_content(content: Any) -> str:
|
||||||
|
"""Normalize LangChain message content into a string."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
text_chunks = []
|
||||||
|
for chunk in content:
|
||||||
|
if isinstance(chunk, dict) and chunk.get("type") == "text":
|
||||||
|
text_chunks.append(str(chunk.get("text", "")))
|
||||||
|
return "\n".join(text_chunks).strip()
|
||||||
|
|
||||||
|
return str(content).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def create_text_response(prompt: str, model_name: str, temperature: float = 0) -> str:
|
||||||
|
"""Call the LangChain OpenAI chat model and return markdown output."""
|
||||||
|
response = get_chat_model(model_name, temperature=temperature).invoke(prompt)
|
||||||
|
text_output = extract_text_content(response.content)
|
||||||
|
|
||||||
|
if not text_output:
|
||||||
|
raise ValueError("OpenAI response did not include text output")
|
||||||
|
|
||||||
|
return text_output
|
||||||
|
|
||||||
|
|
||||||
# Nodes
|
|
||||||
def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState:
|
def generate_query(state: OverallState, config: RunnableConfig) -> QueryGenerationState:
|
||||||
"""LangGraph node that generates a search queries based on the User's question.
|
"""Generate initial search queries from the user's request."""
|
||||||
|
|
||||||
Uses Gemini 2.0 Flash to create an optimized search query for web research based on
|
|
||||||
the User's question.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Current graph state containing the User's question
|
|
||||||
config: Configuration for the runnable, including LLM provider settings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with state update, including search_query key containing the generated query
|
|
||||||
"""
|
|
||||||
configurable = Configuration.from_runnable_config(config)
|
configurable = Configuration.from_runnable_config(config)
|
||||||
|
reasoning_model = normalize_model_name(
|
||||||
|
state.get("reasoning_model"),
|
||||||
|
configurable.reasoning_model or configurable.query_generator_model,
|
||||||
|
)
|
||||||
|
|
||||||
# check for custom initial search query count
|
|
||||||
if state.get("initial_search_query_count") is None:
|
if state.get("initial_search_query_count") is None:
|
||||||
state["initial_search_query_count"] = configurable.number_of_initial_queries
|
state["initial_search_query_count"] = configurable.number_of_initial_queries
|
||||||
|
|
||||||
# init Gemini 2.0 Flash
|
|
||||||
llm = ChatGoogleGenerativeAI(
|
|
||||||
model=configurable.query_generator_model,
|
|
||||||
temperature=1.0,
|
|
||||||
max_retries=2,
|
|
||||||
api_key=os.getenv("GEMINI_API_KEY"),
|
|
||||||
)
|
|
||||||
structured_llm = llm.with_structured_output(SearchQueryList)
|
|
||||||
|
|
||||||
# Format the prompt
|
|
||||||
current_date = get_current_date()
|
|
||||||
formatted_prompt = query_writer_instructions.format(
|
formatted_prompt = query_writer_instructions.format(
|
||||||
current_date=current_date,
|
current_date=get_current_date(),
|
||||||
research_topic=get_research_topic(state["messages"]),
|
research_topic=get_research_topic(state["messages"]),
|
||||||
number_queries=state["initial_search_query_count"],
|
number_queries=state["initial_search_query_count"],
|
||||||
)
|
)
|
||||||
# Generate the search queries
|
result = create_structured_response(
|
||||||
result = structured_llm.invoke(formatted_prompt)
|
formatted_prompt,
|
||||||
|
reasoning_model,
|
||||||
|
SearchQueryList,
|
||||||
|
temperature=1,
|
||||||
|
)
|
||||||
return {"search_query": result.query}
|
return {"search_query": result.query}
|
||||||
|
|
||||||
|
|
||||||
def continue_to_web_research(state: QueryGenerationState):
|
def continue_to_web_research(state: QueryGenerationState) -> list[Send]:
|
||||||
"""LangGraph node that sends the search queries to the web research node.
|
"""Fan out search queries into parallel web research nodes."""
|
||||||
|
|
||||||
This is used to spawn n number of web research nodes, one for each search query.
|
|
||||||
"""
|
|
||||||
return [
|
return [
|
||||||
Send("web_research", {"search_query": search_query, "id": int(idx)})
|
Send("web_research", {"search_query": search_query, "id": int(idx)})
|
||||||
for idx, search_query in enumerate(state["search_query"])
|
for idx, search_query in enumerate(state["search_query"])
|
||||||
@@ -93,83 +136,63 @@ def continue_to_web_research(state: QueryGenerationState):
|
|||||||
|
|
||||||
|
|
||||||
def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
|
def web_research(state: WebSearchState, config: RunnableConfig) -> OverallState:
|
||||||
"""LangGraph node that performs web research using the native Google Search API tool.
|
"""Execute Tavily search and return raw evidence for one query."""
|
||||||
|
search_query = shorten_search_query(state["search_query"])
|
||||||
Executes a web search using the native Google Search API tool in combination with Gemini 2.0 Flash.
|
tavily_response = get_tavily_client().search(
|
||||||
|
query=search_query,
|
||||||
Args:
|
topic=TAVILY_TOPIC,
|
||||||
state: Current graph state containing the search query and research loop count
|
search_depth=TAVILY_SEARCH_DEPTH,
|
||||||
config: Configuration for the runnable, including search API settings
|
chunks_per_source=TAVILY_CHUNKS_PER_SOURCE,
|
||||||
|
max_results=TAVILY_MAX_RESULTS,
|
||||||
Returns:
|
include_answer=False,
|
||||||
Dictionary with state update, including sources_gathered, research_loop_count, and web_research_results
|
include_raw_content=False,
|
||||||
"""
|
|
||||||
# Configure
|
|
||||||
configurable = Configuration.from_runnable_config(config)
|
|
||||||
formatted_prompt = web_searcher_instructions.format(
|
|
||||||
current_date=get_current_date(),
|
|
||||||
research_topic=state["search_query"],
|
|
||||||
)
|
)
|
||||||
|
sources = normalize_tavily_sources(tavily_response.get("results", []))
|
||||||
|
|
||||||
# Uses the google genai client as the langchain client doesn't return grounding metadata
|
if not sources:
|
||||||
response = genai_client.models.generate_content(
|
return {
|
||||||
model=configurable.query_generator_model,
|
"sources_gathered": [],
|
||||||
contents=formatted_prompt,
|
"search_query": [search_query],
|
||||||
config={
|
"web_research_result": [
|
||||||
"tools": [{"google_search": {}}],
|
f'No Tavily results were returned for "{search_query}".'
|
||||||
"temperature": 0,
|
],
|
||||||
},
|
}
|
||||||
|
|
||||||
|
evidence = "\n\n".join(
|
||||||
|
[
|
||||||
|
f"Search Query: {search_query}",
|
||||||
|
"Source Evidence:",
|
||||||
|
format_sources_for_prompt(sources),
|
||||||
|
]
|
||||||
)
|
)
|
||||||
# resolve the urls to short urls for saving tokens and time
|
|
||||||
resolved_urls = resolve_urls(
|
|
||||||
response.candidates[0].grounding_metadata.grounding_chunks, state["id"]
|
|
||||||
)
|
|
||||||
# Gets the citations and adds them to the generated text
|
|
||||||
citations = get_citations(response, resolved_urls)
|
|
||||||
modified_text = insert_citation_markers(response.text, citations)
|
|
||||||
sources_gathered = [item for citation in citations for item in citation["segments"]]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"sources_gathered": sources_gathered,
|
"sources_gathered": sources,
|
||||||
"search_query": [state["search_query"]],
|
"search_query": [search_query],
|
||||||
"web_research_result": [modified_text],
|
"web_research_result": [evidence],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
|
def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
|
||||||
"""LangGraph node that identifies knowledge gaps and generates potential follow-up queries.
|
"""Decide whether additional research is needed."""
|
||||||
|
|
||||||
Analyzes the current summary to identify areas for further research and generates
|
|
||||||
potential follow-up queries. Uses structured output to extract
|
|
||||||
the follow-up query in JSON format.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Current graph state containing the running summary and research topic
|
|
||||||
config: Configuration for the runnable, including LLM provider settings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with state update, including search_query key containing the generated follow-up query
|
|
||||||
"""
|
|
||||||
configurable = Configuration.from_runnable_config(config)
|
configurable = Configuration.from_runnable_config(config)
|
||||||
# Increment the research loop count and get the reasoning model
|
|
||||||
state["research_loop_count"] = state.get("research_loop_count", 0) + 1
|
state["research_loop_count"] = state.get("research_loop_count", 0) + 1
|
||||||
reasoning_model = state.get("reasoning_model", configurable.reflection_model)
|
reasoning_model = normalize_model_name(
|
||||||
|
state.get("reasoning_model"),
|
||||||
|
configurable.reasoning_model,
|
||||||
|
)
|
||||||
|
|
||||||
# Format the prompt
|
|
||||||
current_date = get_current_date()
|
|
||||||
formatted_prompt = reflection_instructions.format(
|
formatted_prompt = reflection_instructions.format(
|
||||||
current_date=current_date,
|
current_date=get_current_date(),
|
||||||
research_topic=get_research_topic(state["messages"]),
|
research_topic=get_research_topic(state["messages"]),
|
||||||
summaries="\n\n---\n\n".join(state["web_research_result"]),
|
summaries="\n\n---\n\n".join(state["web_research_result"]),
|
||||||
)
|
)
|
||||||
# init Reasoning Model
|
result = create_structured_response(
|
||||||
llm = ChatGoogleGenerativeAI(
|
formatted_prompt,
|
||||||
model=reasoning_model,
|
reasoning_model,
|
||||||
temperature=1.0,
|
Reflection,
|
||||||
max_retries=2,
|
temperature=1,
|
||||||
api_key=os.getenv("GEMINI_API_KEY"),
|
|
||||||
)
|
)
|
||||||
result = llm.with_structured_output(Reflection).invoke(formatted_prompt)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"is_sufficient": result.is_sufficient,
|
"is_sufficient": result.is_sufficient,
|
||||||
@@ -183,19 +206,8 @@ def reflection(state: OverallState, config: RunnableConfig) -> ReflectionState:
|
|||||||
def evaluate_research(
|
def evaluate_research(
|
||||||
state: ReflectionState,
|
state: ReflectionState,
|
||||||
config: RunnableConfig,
|
config: RunnableConfig,
|
||||||
) -> OverallState:
|
) -> OverallState | list[Send]:
|
||||||
"""LangGraph routing function that determines the next step in the research flow.
|
"""Route either back into research or into final answer synthesis."""
|
||||||
|
|
||||||
Controls the research loop by deciding whether to continue gathering information
|
|
||||||
or to finalize the summary based on the configured maximum number of research loops.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Current graph state containing the research loop count
|
|
||||||
config: Configuration for the runnable, including max_research_loops setting
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
String literal indicating the next node to visit ("web_research" or "finalize_summary")
|
|
||||||
"""
|
|
||||||
configurable = Configuration.from_runnable_config(config)
|
configurable = Configuration.from_runnable_config(config)
|
||||||
max_research_loops = (
|
max_research_loops = (
|
||||||
state.get("max_research_loops")
|
state.get("max_research_loops")
|
||||||
@@ -204,90 +216,53 @@ def evaluate_research(
|
|||||||
)
|
)
|
||||||
if state["is_sufficient"] or state["research_loop_count"] >= max_research_loops:
|
if state["is_sufficient"] or state["research_loop_count"] >= max_research_loops:
|
||||||
return "finalize_answer"
|
return "finalize_answer"
|
||||||
else:
|
|
||||||
return [
|
return [
|
||||||
Send(
|
Send(
|
||||||
"web_research",
|
"web_research",
|
||||||
{
|
{
|
||||||
"search_query": follow_up_query,
|
"search_query": follow_up_query,
|
||||||
"id": state["number_of_ran_queries"] + int(idx),
|
"id": state["number_of_ran_queries"] + int(idx),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
for idx, follow_up_query in enumerate(state["follow_up_queries"])
|
for idx, follow_up_query in enumerate(state["follow_up_queries"])
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def finalize_answer(state: OverallState, config: RunnableConfig):
|
def finalize_answer(state: OverallState, config: RunnableConfig) -> OverallState:
|
||||||
"""LangGraph node that finalizes the research summary.
|
"""Generate the final cited answer from the accumulated research summaries."""
|
||||||
|
|
||||||
Prepares the final output by deduplicating and formatting sources, then
|
|
||||||
combining them with the running summary to create a well-structured
|
|
||||||
research report with proper citations.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
state: Current graph state containing the running summary and sources gathered
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dictionary with state update, including running_summary key containing the formatted final summary with sources
|
|
||||||
"""
|
|
||||||
configurable = Configuration.from_runnable_config(config)
|
configurable = Configuration.from_runnable_config(config)
|
||||||
reasoning_model = state.get("reasoning_model") or configurable.answer_model
|
reasoning_model = normalize_model_name(
|
||||||
|
state.get("reasoning_model"),
|
||||||
|
configurable.reasoning_model or configurable.answer_model,
|
||||||
|
)
|
||||||
|
|
||||||
# Format the prompt
|
|
||||||
current_date = get_current_date()
|
|
||||||
formatted_prompt = answer_instructions.format(
|
formatted_prompt = answer_instructions.format(
|
||||||
current_date=current_date,
|
current_date=get_current_date(),
|
||||||
research_topic=get_research_topic(state["messages"]),
|
research_topic=get_research_topic(state["messages"]),
|
||||||
summaries="\n---\n\n".join(state["web_research_result"]),
|
summaries="\n---\n\n".join(state["web_research_result"]),
|
||||||
)
|
)
|
||||||
|
answer = create_text_response(formatted_prompt, reasoning_model, temperature=0)
|
||||||
# init Reasoning Model, default to Gemini 2.5 Flash
|
|
||||||
llm = ChatGoogleGenerativeAI(
|
|
||||||
model=reasoning_model,
|
|
||||||
temperature=0,
|
|
||||||
max_retries=2,
|
|
||||||
api_key=os.getenv("GEMINI_API_KEY"),
|
|
||||||
)
|
|
||||||
result = llm.invoke(formatted_prompt)
|
|
||||||
|
|
||||||
# Replace the short urls with the original urls and add all used urls to the sources_gathered
|
|
||||||
unique_sources = []
|
|
||||||
for source in state["sources_gathered"]:
|
|
||||||
if source["short_url"] in result.content:
|
|
||||||
result.content = result.content.replace(
|
|
||||||
source["short_url"], source["value"]
|
|
||||||
)
|
|
||||||
unique_sources.append(source)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"messages": [AIMessage(content=result.content)],
|
"messages": [AIMessage(content=answer)],
|
||||||
"sources_gathered": unique_sources,
|
"sources_gathered": state.get("sources_gathered", []),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Create our Agent Graph
|
|
||||||
builder = StateGraph(OverallState, config_schema=Configuration)
|
builder = StateGraph(OverallState, config_schema=Configuration)
|
||||||
|
|
||||||
# Define the nodes we will cycle between
|
|
||||||
builder.add_node("generate_query", generate_query)
|
builder.add_node("generate_query", generate_query)
|
||||||
builder.add_node("web_research", web_research)
|
builder.add_node("web_research", web_research)
|
||||||
builder.add_node("reflection", reflection)
|
builder.add_node("reflection", reflection)
|
||||||
builder.add_node("finalize_answer", finalize_answer)
|
builder.add_node("finalize_answer", finalize_answer)
|
||||||
|
|
||||||
# Set the entrypoint as `generate_query`
|
|
||||||
# This means that this node is the first one called
|
|
||||||
builder.add_edge(START, "generate_query")
|
builder.add_edge(START, "generate_query")
|
||||||
# Add conditional edge to continue with search queries in a parallel branch
|
|
||||||
builder.add_conditional_edges(
|
builder.add_conditional_edges(
|
||||||
"generate_query", continue_to_web_research, ["web_research"]
|
"generate_query", continue_to_web_research, ["web_research"]
|
||||||
)
|
)
|
||||||
# Reflect on the web research
|
|
||||||
builder.add_edge("web_research", "reflection")
|
builder.add_edge("web_research", "reflection")
|
||||||
# Evaluate the research
|
|
||||||
builder.add_conditional_edges(
|
builder.add_conditional_edges(
|
||||||
"reflection", evaluate_research, ["web_research", "finalize_answer"]
|
"reflection", evaluate_research, ["web_research", "finalize_answer"]
|
||||||
)
|
)
|
||||||
# Finalize the answer
|
|
||||||
builder.add_edge("finalize_answer", END)
|
builder.add_edge("finalize_answer", END)
|
||||||
|
|
||||||
graph = builder.compile(name="pro-search-agent")
|
graph = builder.compile(name="pro-search-agent")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Instructions:
|
|||||||
- Queries should be diverse, if the topic is broad, generate more than 1 query.
|
- Queries should be diverse, if the topic is broad, generate more than 1 query.
|
||||||
- Don't generate multiple similar queries, 1 is enough.
|
- Don't generate multiple similar queries, 1 is enough.
|
||||||
- Query should ensure that the most current information is gathered. The current date is {current_date}.
|
- Query should ensure that the most current information is gathered. The current date is {current_date}.
|
||||||
|
- Keep every individual query under 400 characters. Prefer concise search terms over full-sentence restatements.
|
||||||
|
|
||||||
Format:
|
Format:
|
||||||
- Format your response as a JSON object with ALL two of these exact keys:
|
- Format your response as a JSON object with ALL two of these exact keys:
|
||||||
@@ -34,26 +35,31 @@ Topic: What revenue grew more last year apple stock or the number of people buyi
|
|||||||
Context: {research_topic}"""
|
Context: {research_topic}"""
|
||||||
|
|
||||||
|
|
||||||
web_searcher_instructions = """Conduct targeted Google Searches to gather the most recent, credible information on "{research_topic}" and synthesize it into a verifiable text artifact.
|
web_searcher_instructions = """Review the provided Tavily search results for "{research_topic}" and synthesize them into a verifiable research note.
|
||||||
|
|
||||||
Instructions:
|
Instructions:
|
||||||
- Query should ensure that the most current information is gathered. The current date is {current_date}.
|
- Query should ensure that the most current information is gathered. The current date is {current_date}.
|
||||||
- Conduct multiple, diverse searches to gather comprehensive information.
|
- Use only the provided Tavily search results. Do not invent or infer facts that are not supported by those results.
|
||||||
- Consolidate key findings while meticulously tracking the source(s) for each specific piece of information.
|
- Consolidate the key findings into a concise research note for this query.
|
||||||
- The output should be a well-written summary or report based on your search findings.
|
- Every factual paragraph or bullet must include at least one markdown citation using the exact title and URL from the provided sources, for example: [Reuters](https://www.reuters.com/example).
|
||||||
- Only include the information found in the search results, don't make up any information.
|
- Preserve full URLs in citations. Do not use placeholders, IDs, or shortened URLs.
|
||||||
|
- If the results are insufficient, explicitly say what is missing and cite the closest available sources.
|
||||||
|
|
||||||
Research Topic:
|
Research Topic:
|
||||||
{research_topic}
|
{research_topic}
|
||||||
|
|
||||||
|
Search Results:
|
||||||
|
{search_results}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
reflection_instructions = """You are an expert research assistant analyzing summaries about "{research_topic}".
|
reflection_instructions = """You are an expert research assistant analyzing collected research evidence about "{research_topic}".
|
||||||
|
|
||||||
Instructions:
|
Instructions:
|
||||||
- Identify knowledge gaps or areas that need deeper exploration and generate a follow-up query. (1 or multiple).
|
- Identify knowledge gaps or areas that need deeper exploration and generate a follow-up query. (1 or multiple).
|
||||||
- If provided summaries are sufficient to answer the user's question, don't generate a follow-up query.
|
- If the provided evidence is sufficient to answer the user's question, don't generate a follow-up query.
|
||||||
- If there is a knowledge gap, generate a follow-up query that would help expand your understanding.
|
- If there is a knowledge gap, generate a follow-up query that would help expand your understanding.
|
||||||
- Focus on technical details, implementation specifics, or emerging trends that weren't fully covered.
|
- Focus on technical details, implementation specifics, or emerging trends that weren't fully covered.
|
||||||
|
- Keep every follow-up query under 400 characters. Prefer compact search phrases over long natural-language questions.
|
||||||
|
|
||||||
Requirements:
|
Requirements:
|
||||||
- Ensure the follow-up query is self-contained and includes necessary context for web search.
|
- Ensure the follow-up query is self-contained and includes necessary context for web search.
|
||||||
@@ -73,24 +79,26 @@ Example:
|
|||||||
}}
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
Reflect carefully on the Summaries to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format:
|
Reflect carefully on the Research Evidence to identify knowledge gaps and produce a follow-up query. Then, produce your output following this JSON format:
|
||||||
|
|
||||||
Summaries:
|
Research Evidence:
|
||||||
{summaries}
|
{summaries}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
answer_instructions = """Generate a high-quality answer to the user's question based on the provided summaries.
|
answer_instructions = """Generate a high-quality answer to the user's question based on the provided research evidence.
|
||||||
|
|
||||||
Instructions:
|
Instructions:
|
||||||
- The current date is {current_date}.
|
- The current date is {current_date}.
|
||||||
- You are the final step of a multi-step research process, don't mention that you are the final step.
|
- You are the final step of a multi-step research process, don't mention that you are the final step.
|
||||||
- You have access to all the information gathered from the previous steps.
|
- You have access to all the information gathered from the previous steps.
|
||||||
- You have access to the user's question.
|
- You have access to the user's question.
|
||||||
- Generate a high-quality answer to the user's question based on the provided summaries and the user's question.
|
- Generate a high-quality answer to the user's question based on the provided research evidence and the user's question.
|
||||||
- you MUST include all the citations from the summaries in the answer correctly.
|
- Use only the evidence present in the Research Evidence.
|
||||||
|
- Preserve or reuse source citations from the Research Evidence as full markdown links. Every factual paragraph should include at least one citation.
|
||||||
|
- Do not invent sources, placeholders, or shortened URLs.
|
||||||
|
|
||||||
User Context:
|
User Context:
|
||||||
- {research_topic}
|
- {research_topic}
|
||||||
|
|
||||||
Summaries:
|
Research Evidence:
|
||||||
{summaries}"""
|
{summaries}"""
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ from typing_extensions import Annotated
|
|||||||
|
|
||||||
|
|
||||||
import operator
|
import operator
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing_extensions import Annotated
|
|
||||||
|
|
||||||
|
|
||||||
class OverallState(TypedDict):
|
class OverallState(TypedDict):
|
||||||
@@ -31,13 +29,8 @@ class ReflectionState(TypedDict):
|
|||||||
number_of_ran_queries: int
|
number_of_ran_queries: int
|
||||||
|
|
||||||
|
|
||||||
class Query(TypedDict):
|
|
||||||
query: str
|
|
||||||
rationale: str
|
|
||||||
|
|
||||||
|
|
||||||
class QueryGenerationState(TypedDict):
|
class QueryGenerationState(TypedDict):
|
||||||
search_query: list[Query]
|
search_query: list[str]
|
||||||
|
|
||||||
|
|
||||||
class WebSearchState(TypedDict):
|
class WebSearchState(TypedDict):
|
||||||
|
|||||||
@@ -1,166 +1,106 @@
|
|||||||
from typing import Any, Dict, List
|
from __future__ import annotations
|
||||||
from langchain_core.messages import AnyMessage, AIMessage, HumanMessage
|
|
||||||
|
import re
|
||||||
|
from typing import Any, List
|
||||||
|
|
||||||
|
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
|
||||||
|
|
||||||
|
DEFAULT_REASONING_MODEL = "openai/gpt-5.4"
|
||||||
|
SUPPORTED_MODEL_PREFIXES = ("gpt-", "deepseek-", "glm-")
|
||||||
|
MAX_TAVILY_QUERY_LENGTH = 380
|
||||||
|
|
||||||
|
|
||||||
def get_research_topic(messages: List[AnyMessage]) -> str:
|
def get_research_topic(messages: List[AnyMessage]) -> str:
|
||||||
"""
|
"""Get the research topic from the messages."""
|
||||||
Get the research topic from the messages.
|
|
||||||
"""
|
|
||||||
# check if request has a history and combine the messages into a single string
|
|
||||||
if len(messages) == 1:
|
if len(messages) == 1:
|
||||||
research_topic = messages[-1].content
|
return str(messages[-1].content)
|
||||||
else:
|
|
||||||
research_topic = ""
|
research_topic = ""
|
||||||
for message in messages:
|
for message in messages:
|
||||||
if isinstance(message, HumanMessage):
|
if isinstance(message, HumanMessage):
|
||||||
research_topic += f"User: {message.content}\n"
|
research_topic += f"User: {message.content}\n"
|
||||||
elif isinstance(message, AIMessage):
|
elif isinstance(message, AIMessage):
|
||||||
research_topic += f"Assistant: {message.content}\n"
|
research_topic += f"Assistant: {message.content}\n"
|
||||||
return research_topic
|
return research_topic
|
||||||
|
|
||||||
|
|
||||||
def resolve_urls(urls_to_resolve: List[Any], id: int) -> Dict[str, str]:
|
def normalize_model_name(
|
||||||
"""
|
model_name: str | None,
|
||||||
Create a map of the vertex ai search urls (very long) to a short url with a unique id for each url.
|
fallback: str = DEFAULT_REASONING_MODEL,
|
||||||
Ensures each original URL gets a consistent shortened form while maintaining uniqueness.
|
) -> str:
|
||||||
"""
|
"""Normalize stale or unsupported model names to a supported default."""
|
||||||
prefix = f"https://vertexaisearch.cloud.google.com/id/"
|
candidate = (model_name or "").strip()
|
||||||
urls = [site.web.uri for site in urls_to_resolve]
|
if candidate.startswith(SUPPORTED_MODEL_PREFIXES):
|
||||||
|
return candidate
|
||||||
|
|
||||||
# Create a dictionary that maps each unique URL to its first occurrence index
|
provider, _, model = candidate.partition("/")
|
||||||
resolved_map = {}
|
if provider and model.startswith(SUPPORTED_MODEL_PREFIXES):
|
||||||
for idx, url in enumerate(urls):
|
return candidate
|
||||||
if url not in resolved_map:
|
|
||||||
resolved_map[url] = f"{prefix}{id}-{idx}"
|
|
||||||
|
|
||||||
return resolved_map
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def insert_citation_markers(text, citations_list):
|
def _normalize_source_title(source: dict[str, Any], index: int) -> str:
|
||||||
"""
|
title = (source.get("title") or "").strip()
|
||||||
Inserts citation markers into a text string based on start and end indices.
|
return title or f"Source {index}"
|
||||||
|
|
||||||
Args:
|
|
||||||
text (str): The original text string.
|
|
||||||
citations_list (list): A list of dictionaries, where each dictionary
|
|
||||||
contains 'start_index', 'end_index', and
|
|
||||||
'segment_string' (the marker to insert).
|
|
||||||
Indices are assumed to be for the original text.
|
|
||||||
|
|
||||||
Returns:
|
def normalize_tavily_sources(results: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||||
str: The text with citation markers inserted.
|
"""Normalize Tavily results into a stable source shape."""
|
||||||
"""
|
normalized_sources: list[dict[str, str]] = []
|
||||||
# Sort citations by end_index in descending order.
|
seen_urls: set[str] = set()
|
||||||
# If end_index is the same, secondary sort by start_index descending.
|
|
||||||
# This ensures that insertions at the end of the string don't affect
|
|
||||||
# the indices of earlier parts of the string that still need to be processed.
|
|
||||||
sorted_citations = sorted(
|
|
||||||
citations_list, key=lambda c: (c["end_index"], c["start_index"]), reverse=True
|
|
||||||
)
|
|
||||||
|
|
||||||
modified_text = text
|
for index, result in enumerate(results, start=1):
|
||||||
for citation_info in sorted_citations:
|
url = (result.get("url") or "").strip()
|
||||||
# These indices refer to positions in the *original* text,
|
if not url or url in seen_urls:
|
||||||
# but since we iterate from the end, they remain valid for insertion
|
continue
|
||||||
# relative to the parts of the string already processed.
|
|
||||||
end_idx = citation_info["end_index"]
|
normalized_sources.append(
|
||||||
marker_to_insert = ""
|
{
|
||||||
for segment in citation_info["segments"]:
|
"label": f"S{len(normalized_sources) + 1}",
|
||||||
marker_to_insert += f" [{segment['label']}]({segment['short_url']})"
|
"title": _normalize_source_title(result, index),
|
||||||
# Insert the citation marker at the original end_idx position
|
"value": url,
|
||||||
modified_text = (
|
"content": (result.get("content") or "").strip(),
|
||||||
modified_text[:end_idx] + marker_to_insert + modified_text[end_idx:]
|
}
|
||||||
|
)
|
||||||
|
seen_urls.add(url)
|
||||||
|
|
||||||
|
return normalized_sources
|
||||||
|
|
||||||
|
|
||||||
|
def format_sources_for_prompt(sources: list[dict[str, str]]) -> str:
|
||||||
|
"""Format normalized search results for prompt injection."""
|
||||||
|
if not sources:
|
||||||
|
return "No search results were returned."
|
||||||
|
|
||||||
|
formatted_sources = []
|
||||||
|
for source in sources:
|
||||||
|
snippet = source.get("content") or "No snippet available."
|
||||||
|
formatted_sources.append(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
f"{source['label']}: {source['title']}",
|
||||||
|
f"URL: {source['value']}",
|
||||||
|
f"Snippet: {snippet}",
|
||||||
|
]
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return modified_text
|
return "\n\n".join(formatted_sources)
|
||||||
|
|
||||||
|
|
||||||
def get_citations(response, resolved_urls_map):
|
def shorten_search_query(
|
||||||
"""
|
query: str,
|
||||||
Extracts and formats citation information from a Gemini model's response.
|
max_length: int = MAX_TAVILY_QUERY_LENGTH,
|
||||||
|
) -> str:
|
||||||
|
"""Normalize and trim search queries to fit Tavily's length limit."""
|
||||||
|
normalized_query = re.sub(r"\s+", " ", query).strip()
|
||||||
|
if len(normalized_query) <= max_length:
|
||||||
|
return normalized_query
|
||||||
|
|
||||||
This function processes the grounding metadata provided in the response to
|
truncated_query = normalized_query[:max_length]
|
||||||
construct a list of citation objects. Each citation object includes the
|
last_space = truncated_query.rfind(" ")
|
||||||
start and end indices of the text segment it refers to, and a string
|
if last_space > max_length // 2:
|
||||||
containing formatted markdown links to the supporting web chunks.
|
truncated_query = truncated_query[:last_space]
|
||||||
|
|
||||||
Args:
|
return truncated_query.rstrip(" ,;:-")
|
||||||
response: The response object from the Gemini model, expected to have
|
|
||||||
a structure including `candidates[0].grounding_metadata`.
|
|
||||||
It also relies on a `resolved_map` being available in its
|
|
||||||
scope to map chunk URIs to resolved URLs.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list: A list of dictionaries, where each dictionary represents a citation
|
|
||||||
and has the following keys:
|
|
||||||
- "start_index" (int): The starting character index of the cited
|
|
||||||
segment in the original text. Defaults to 0
|
|
||||||
if not specified.
|
|
||||||
- "end_index" (int): The character index immediately after the
|
|
||||||
end of the cited segment (exclusive).
|
|
||||||
- "segments" (list[str]): A list of individual markdown-formatted
|
|
||||||
links for each grounding chunk.
|
|
||||||
- "segment_string" (str): A concatenated string of all markdown-
|
|
||||||
formatted links for the citation.
|
|
||||||
Returns an empty list if no valid candidates or grounding supports
|
|
||||||
are found, or if essential data is missing.
|
|
||||||
"""
|
|
||||||
citations = []
|
|
||||||
|
|
||||||
# Ensure response and necessary nested structures are present
|
|
||||||
if not response or not response.candidates:
|
|
||||||
return citations
|
|
||||||
|
|
||||||
candidate = response.candidates[0]
|
|
||||||
if (
|
|
||||||
not hasattr(candidate, "grounding_metadata")
|
|
||||||
or not candidate.grounding_metadata
|
|
||||||
or not hasattr(candidate.grounding_metadata, "grounding_supports")
|
|
||||||
):
|
|
||||||
return citations
|
|
||||||
|
|
||||||
for support in candidate.grounding_metadata.grounding_supports:
|
|
||||||
citation = {}
|
|
||||||
|
|
||||||
# Ensure segment information is present
|
|
||||||
if not hasattr(support, "segment") or support.segment is None:
|
|
||||||
continue # Skip this support if segment info is missing
|
|
||||||
|
|
||||||
start_index = (
|
|
||||||
support.segment.start_index
|
|
||||||
if support.segment.start_index is not None
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure end_index is present to form a valid segment
|
|
||||||
if support.segment.end_index is None:
|
|
||||||
continue # Skip if end_index is missing, as it's crucial
|
|
||||||
|
|
||||||
# Add 1 to end_index to make it an exclusive end for slicing/range purposes
|
|
||||||
# (assuming the API provides an inclusive end_index)
|
|
||||||
citation["start_index"] = start_index
|
|
||||||
citation["end_index"] = support.segment.end_index
|
|
||||||
|
|
||||||
citation["segments"] = []
|
|
||||||
if (
|
|
||||||
hasattr(support, "grounding_chunk_indices")
|
|
||||||
and support.grounding_chunk_indices
|
|
||||||
):
|
|
||||||
for ind in support.grounding_chunk_indices:
|
|
||||||
try:
|
|
||||||
chunk = candidate.grounding_metadata.grounding_chunks[ind]
|
|
||||||
resolved_url = resolved_urls_map.get(chunk.web.uri, None)
|
|
||||||
citation["segments"].append(
|
|
||||||
{
|
|
||||||
"label": chunk.web.title.split(".")[:-1][0],
|
|
||||||
"short_url": resolved_url,
|
|
||||||
"value": chunk.web.uri,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except (IndexError, AttributeError, NameError):
|
|
||||||
# Handle cases where chunk, web, uri, or resolved_map might be problematic
|
|
||||||
# For simplicity, we'll just skip adding this particular segment link
|
|
||||||
# In a production system, you might want to log this.
|
|
||||||
pass
|
|
||||||
citations.append(citation)
|
|
||||||
return citations
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ services:
|
|||||||
langgraph-postgres:
|
langgraph-postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
GEMINI_API_KEY: ${GEMINI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
OPENAI_BASE_URL: ${OPENAI_BASE_URL}
|
||||||
|
TAVILY_API_KEY: ${TAVILY_API_KEY}
|
||||||
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY}
|
LANGSMITH_API_KEY: ${LANGSMITH_API_KEY}
|
||||||
REDIS_URI: redis://langgraph-redis:6379
|
REDIS_URI: redis://langgraph-redis:6379
|
||||||
POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable
|
POSTGRES_URI: postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable
|
||||||
|
|||||||
@@ -6,10 +6,27 @@ import { WelcomeScreen } from "@/components/WelcomeScreen";
|
|||||||
import { ChatMessagesView } from "@/components/ChatMessagesView";
|
import { ChatMessagesView } from "@/components/ChatMessagesView";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const getSourceDisplayName = (source: any): string | null => {
|
||||||
|
if (source?.title) {
|
||||||
|
return source.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source?.value) {
|
||||||
|
try {
|
||||||
|
return new URL(source.value).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source?.label || null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [processedEventsTimeline, setProcessedEventsTimeline] = useState<
|
const [processedEventsTimeline, setProcessedEventsTimeline] = useState<
|
||||||
ProcessedEvent[]
|
ProcessedEvent[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [selectedModel, setSelectedModel] = useState("openai/gpt-5.4");
|
||||||
const [historicalActivities, setHistoricalActivities] = useState<
|
const [historicalActivities, setHistoricalActivities] = useState<
|
||||||
Record<string, ProcessedEvent[]>
|
Record<string, ProcessedEvent[]>
|
||||||
>({});
|
>({});
|
||||||
@@ -38,7 +55,7 @@ export default function App() {
|
|||||||
const sources = event.web_research.sources_gathered || [];
|
const sources = event.web_research.sources_gathered || [];
|
||||||
const numSources = sources.length;
|
const numSources = sources.length;
|
||||||
const uniqueLabels = [
|
const uniqueLabels = [
|
||||||
...new Set(sources.map((s: any) => s.label).filter(Boolean)),
|
...new Set(sources.map(getSourceDisplayName).filter(Boolean)),
|
||||||
];
|
];
|
||||||
const exampleLabels = uniqueLabels.slice(0, 3).join(", ");
|
const exampleLabels = uniqueLabels.slice(0, 3).join(", ");
|
||||||
processedEvent = {
|
processedEvent = {
|
||||||
@@ -157,6 +174,8 @@ export default function App() {
|
|||||||
handleSubmit={handleSubmit}
|
handleSubmit={handleSubmit}
|
||||||
isLoading={thread.isLoading}
|
isLoading={thread.isLoading}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
|
model={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
/>
|
/>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full">
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
@@ -181,6 +200,8 @@ export default function App() {
|
|||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
liveActivityEvents={processedEventsTimeline}
|
liveActivityEvents={processedEventsTimeline}
|
||||||
historicalActivities={historicalActivities}
|
historicalActivities={historicalActivities}
|
||||||
|
model={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ interface ChatMessagesViewProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
liveActivityEvents: ProcessedEvent[];
|
liveActivityEvents: ProcessedEvent[];
|
||||||
historicalActivities: Record<string, ProcessedEvent[]>;
|
historicalActivities: Record<string, ProcessedEvent[]>;
|
||||||
|
model: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatMessagesView({
|
export function ChatMessagesView({
|
||||||
@@ -240,6 +242,8 @@ export function ChatMessagesView({
|
|||||||
onCancel,
|
onCancel,
|
||||||
liveActivityEvents,
|
liveActivityEvents,
|
||||||
historicalActivities,
|
historicalActivities,
|
||||||
|
model,
|
||||||
|
onModelChange,
|
||||||
}: ChatMessagesViewProps) {
|
}: ChatMessagesViewProps) {
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -316,6 +320,8 @@ export function ChatMessagesView({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
hasHistory={messages.length > 0}
|
hasHistory={messages.length > 0}
|
||||||
|
model={model}
|
||||||
|
onModelChange={onModelChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface InputFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hasHistory: boolean;
|
hasHistory: boolean;
|
||||||
|
model: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputForm: React.FC<InputFormProps> = ({
|
export const InputForm: React.FC<InputFormProps> = ({
|
||||||
@@ -23,10 +25,11 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
onCancel,
|
onCancel,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasHistory,
|
hasHistory,
|
||||||
|
model,
|
||||||
|
onModelChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [internalInputValue, setInternalInputValue] = useState("");
|
const [internalInputValue, setInternalInputValue] = useState("");
|
||||||
const [effort, setEffort] = useState("medium");
|
const [effort, setEffort] = useState("medium");
|
||||||
const [model, setModel] = useState("gemini-2.5-flash-preview-04-17");
|
|
||||||
|
|
||||||
const handleInternalSubmit = (e?: React.FormEvent) => {
|
const handleInternalSubmit = (e?: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
@@ -35,10 +38,9 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
setInternalInputValue("");
|
setInternalInputValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInternalKeyDown = (
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
e: React.KeyboardEvent<HTMLTextAreaElement>
|
// Submit with Ctrl+Enter (Windows/Linux) or Cmd+Enter (Mac)
|
||||||
) => {
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleInternalSubmit();
|
handleInternalSubmit();
|
||||||
}
|
}
|
||||||
@@ -59,7 +61,7 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={internalInputValue}
|
value={internalInputValue}
|
||||||
onChange={(e) => setInternalInputValue(e.target.value)}
|
onChange={(e) => setInternalInputValue(e.target.value)}
|
||||||
onKeyDown={handleInternalKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Who won the Euro 2024 and scored the most goals?"
|
placeholder="Who won the Euro 2024 and scored the most goals?"
|
||||||
className={`w-full text-neutral-100 placeholder-neutral-500 resize-none border-0 focus:outline-none focus:ring-0 outline-none focus-visible:ring-0 shadow-none
|
className={`w-full text-neutral-100 placeholder-neutral-500 resize-none border-0 focus:outline-none focus:ring-0 outline-none focus-visible:ring-0 shadow-none
|
||||||
md:text-base min-h-[56px] max-h-[200px]`}
|
md:text-base min-h-[56px] max-h-[200px]`}
|
||||||
@@ -131,33 +133,41 @@ export const InputForm: React.FC<InputFormProps> = ({
|
|||||||
<Cpu className="h-4 w-4 mr-2" />
|
<Cpu className="h-4 w-4 mr-2" />
|
||||||
Model
|
Model
|
||||||
</div>
|
</div>
|
||||||
<Select value={model} onValueChange={setModel}>
|
<Select value={model} onValueChange={onModelChange}>
|
||||||
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
|
<SelectTrigger className="w-[150px] bg-transparent border-none cursor-pointer">
|
||||||
<SelectValue placeholder="Model" />
|
<SelectValue placeholder="Model" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
|
<SelectContent className="bg-neutral-700 border-neutral-600 text-neutral-300 cursor-pointer">
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="gemini-2.0-flash"
|
value="openai/gpt-5.4"
|
||||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Zap className="h-4 w-4 mr-2 text-yellow-400" /> 2.0 Flash
|
<Zap className="h-4 w-4 mr-2 text-emerald-400" /> openai/gpt-5.4
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="gemini-2.5-flash-preview-04-17"
|
value="deepseek-v3"
|
||||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Zap className="h-4 w-4 mr-2 text-orange-400" /> 2.5 Flash
|
<Cpu className="h-4 w-4 mr-2 text-cyan-400" /> deepseek-v3
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
value="gemini-2.5-pro-preview-05-06"
|
value="glm-4.5-air"
|
||||||
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Cpu className="h-4 w-4 mr-2 text-purple-400" /> 2.5 Pro
|
<Cpu className="h-4 w-4 mr-2 text-sky-400" /> glm-4.5-air
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem
|
||||||
|
value="glm-4.7"
|
||||||
|
className="hover:bg-neutral-600 focus:bg-neutral-600 cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Cpu className="h-4 w-4 mr-2 text-violet-400" /> glm-4.7
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -8,12 +8,16 @@ interface WelcomeScreenProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
model: string;
|
||||||
|
onModelChange: (model: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
|
export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
model,
|
||||||
|
onModelChange,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-center px-4 flex-1 w-full max-w-3xl mx-auto gap-4">
|
<div className="h-full flex flex-col items-center justify-center text-center px-4 flex-1 w-full max-w-3xl mx-auto gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -30,6 +34,8 @@ export const WelcomeScreen: React.FC<WelcomeScreenProps> = ({
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
hasHistory={false}
|
hasHistory={false}
|
||||||
|
model={model}
|
||||||
|
onModelChange={onModelChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-500">
|
<p className="text-xs text-neutral-500">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default defineConfig({
|
|||||||
base: "/app/",
|
base: "/app/",
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(new URL(".", import.meta.url).pathname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user