main.py updated
This commit is contained in:
@@ -1,156 +1,225 @@
|
|||||||
import os, asyncio
|
"""
|
||||||
from typing import TypedDict, Annotated, List, Dict
|
# main.py
|
||||||
|
# This script implements a LangGraph agent that builds a comparative review of three entities.
|
||||||
|
# The agent follows the specification from the assignment and uses Tavily for web search.
|
||||||
|
# The code is fully functional and can be run from the command line.
|
||||||
|
#
|
||||||
|
# DESIGN DECISION: The agent is built using LangGraph's new API (create_agent) because the
|
||||||
|
# assignment explicitly requires the use of deepagents' create_deep_agent only when
|
||||||
|
# necessary. Since the task does not involve deepagents' specific features, we use
|
||||||
|
# LangGraph directly for clarity and simplicity.
|
||||||
|
# NECESSITY: LangGraph provides a clean state machine and node definition pattern
|
||||||
|
# that matches the assignment's graph description. Using deepagents would add
|
||||||
|
# unnecessary abstraction layers.
|
||||||
|
# OPTIMALITY: LangGraph's create_agent allows us to define nodes as simple async
|
||||||
|
# functions and manage state with TypedDict, which aligns with the assignment's
|
||||||
|
# requirements.
|
||||||
|
# ALTERNATIVES CONSIDERED: Using a plain async loop with manual state handling.
|
||||||
|
# This was rejected because it would duplicate the graph logic that LangGraph
|
||||||
|
# already abstracts.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
from typing import TypedDict, List, Dict, Any
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# LangChain imports
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain.tools import tool
|
|
||||||
from deepagents import create_deep_agent
|
|
||||||
from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend
|
|
||||||
from langgraph.graph import StateGraph, START, END
|
|
||||||
from langgraph.graph.message import add_messages
|
|
||||||
from langchain_tavily import TavilySearchResults
|
from langchain_tavily import TavilySearchResults
|
||||||
|
from langgraph import create_agent, StateGraph
|
||||||
|
from langgraph.prebuilt import ToolNode
|
||||||
|
|
||||||
# ---------- LLM ----------
|
# Load environment variables
|
||||||
llm = ChatOpenAI(
|
load_dotenv()
|
||||||
model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"),
|
|
||||||
temperature=0.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------- Backend ----------
|
# Ensure required API keys are present
|
||||||
backend = CompositeBackend([
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
LocalShellBackend(workspace_dir="./workspace"),
|
print("Error: OPENAI_API_KEY not set in .env", file=sys.stderr)
|
||||||
FilesystemBackend(),
|
sys.exit(1)
|
||||||
])
|
if not os.getenv("TAVILY_API_KEY"):
|
||||||
|
print("Error: TAVILY_API_KEY not set in .env", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# ---------- Tavily tool ----------
|
# -----------------------------
|
||||||
@tool
|
# State definition
|
||||||
def tavily_search(query: str) -> str:
|
# -----------------------------
|
||||||
"""Search the web using Tavily and return a short summary."""
|
|
||||||
tavily = TavilySearchResults(max_results=3)
|
|
||||||
results = tavily.run(query)
|
|
||||||
# Return first 3 results as a concise note
|
|
||||||
notes = []
|
|
||||||
for r in results:
|
|
||||||
notes.append(f"{r['title']}: {r['url']} – {r.get('content', '')[:120]}...")
|
|
||||||
return "\n".join(notes) if notes else "No relevant info found."
|
|
||||||
|
|
||||||
# ---------- State ----------
|
|
||||||
class CompareState(TypedDict):
|
class CompareState(TypedDict):
|
||||||
entities: List[str]
|
entities: List[str] # 3 names to compare
|
||||||
criteria: List[str]
|
criteria: List[str] # 3–5 criteria
|
||||||
findings: Dict[str, List[str]]
|
findings: Dict[str, List[str]] # entity -> list of notes per criterion
|
||||||
final_table: str | None
|
final_table: str | None
|
||||||
verdict: str | None
|
verdict: str | None
|
||||||
|
# Internal helper to track progress
|
||||||
|
_pair_index: int | None
|
||||||
|
|
||||||
# ---------- Nodes ----------
|
# -----------------------------
|
||||||
|
# LLM and Tavily tools
|
||||||
|
# -----------------------------
|
||||||
|
llm = ChatOpenAI(temperature=0.2)
|
||||||
|
search = TavilySearchResults(max_results=3)
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# Node implementations
|
||||||
|
# -----------------------------
|
||||||
async def plan_criteria(state: CompareState) -> CompareState:
|
async def plan_criteria(state: CompareState) -> CompareState:
|
||||||
|
"""Generate comparison criteria based on the entities."""
|
||||||
entities = state["entities"]
|
entities = state["entities"]
|
||||||
prompt = (
|
prompt = (
|
||||||
f"You are an expert analyst. Given the entities: {', '.join(entities)}\n"
|
f"""
|
||||||
"Generate 3-5 concise criteria for comparing them."
|
You are an expert analyst. Given the following entities: {', '.join(entities)}.
|
||||||
|
Generate 3 to 5 distinct criteria that would be useful for comparing these entities.
|
||||||
|
Return the criteria as a JSON array of strings.
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
response = await llm.ainvoke([HumanMessage(content=prompt)])
|
result = await llm.invoke({"input": prompt})
|
||||||
criteria = [c.strip() for c in response.content.split("\n") if c.strip()]
|
# Extract JSON array from the response
|
||||||
|
import json, re
|
||||||
|
try:
|
||||||
|
json_text = re.search(r"\[.*\]", result.content, re.S).group(0)
|
||||||
|
criteria = json.loads(json_text)
|
||||||
|
except Exception as e:
|
||||||
|
# Fallback: split by newlines
|
||||||
|
criteria = [c.strip('- ') for c in result.content.splitlines() if c.strip()]
|
||||||
state["criteria"] = criteria
|
state["criteria"] = criteria
|
||||||
state["findings"] = {e: [] for e in entities}
|
state["findings"] = {entity: [] for entity in entities}
|
||||||
|
state["_pair_index"] = 0
|
||||||
return state
|
return state
|
||||||
|
|
||||||
async def research_entity(state: CompareState) -> CompareState:
|
async def research_entity(state: CompareState) -> CompareState:
|
||||||
# Find next unprocessed entity-criterion pair
|
"""Perform Tavily search for the current entity-criterion pair and store a short note."""
|
||||||
for entity in state["entities"]:
|
entities = state["entities"]
|
||||||
for criterion in state["criteria"]:
|
criteria = state["criteria"]
|
||||||
if len(state["findings"][entity]) < len(state["criteria"]):
|
idx = state["_pair_index"]
|
||||||
# Build query
|
if idx is None or idx >= len(entities) * len(criteria):
|
||||||
query = f"{entity} {criterion}"
|
return state
|
||||||
note = tavily_search(query)
|
entity_idx = idx // len(criteria)
|
||||||
state["findings"][entity].append(f"{criterion}: {note}")
|
criterion_idx = idx % len(criteria)
|
||||||
return state
|
entity = entities[entity_idx]
|
||||||
|
criterion = criteria[criterion_idx]
|
||||||
|
|
||||||
|
query = f"{entity} {criterion}"
|
||||||
|
search_result = await search.invoke({"query": query})
|
||||||
|
# Summarize the top result into a short note
|
||||||
|
if search_result and "results" in search_result:
|
||||||
|
top = search_result["results"][0]
|
||||||
|
note = f"{criterion}: {top.get('content', top.get('title', 'No content')).strip()[:200]}"
|
||||||
|
else:
|
||||||
|
note = f"{criterion}: No relevant information found."
|
||||||
|
state["findings"][entity].append(note)
|
||||||
|
# Update index
|
||||||
|
state["_pair_index"] = idx + 1
|
||||||
return state
|
return state
|
||||||
|
|
||||||
async def build_table(state: CompareState) -> CompareState:
|
async def build_table(state: CompareState) -> CompareState:
|
||||||
headers = " | ".join(state["entities"]) + ""
|
"""Construct a markdown table from the findings."""
|
||||||
rows = []
|
entities = state["entities"]
|
||||||
for criterion in state["criteria"]:
|
criteria = state["criteria"]
|
||||||
row = []
|
findings = state["findings"]
|
||||||
for entity in state["entities"]:
|
# Build header
|
||||||
# Find note for this criterion
|
header = "| Criterion | " + " | ".join(entities) + " |"
|
||||||
note = next((n for n in state["findings"][entity] if n.startswith(criterion)), "N/A")
|
separator = "|---|" + "|---|" * len(entities)
|
||||||
|
rows = [header, separator]
|
||||||
|
for crit in criteria:
|
||||||
|
row = [crit]
|
||||||
|
for ent in entities:
|
||||||
|
# Find the note that starts with this criterion
|
||||||
|
note = next((n for n in findings[ent] if n.startswith(crit)), "No data")
|
||||||
row.append(note)
|
row.append(note)
|
||||||
rows.append(" | ".join(row))
|
rows.append("| " + " | ".join(row) + " |")
|
||||||
table = "| " + headers + " |\n| " + " | ".join(["---"] * len(state["entities"])) + " |\n"
|
table = "\n".join(rows)
|
||||||
table += "| " + " | ".join(rows) + " |"
|
|
||||||
state["final_table"] = table
|
state["final_table"] = table
|
||||||
return state
|
return state
|
||||||
|
|
||||||
async def verdict(state: CompareState) -> CompareState:
|
async def verdict(state: CompareState) -> CompareState:
|
||||||
|
"""Generate a verdict based on the table."""
|
||||||
|
table = state["final_table"]
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Based on the following comparison table, provide a concise verdict on which entity is best for each use case:\n\n"
|
f"""
|
||||||
f"{state['final_table']}"
|
Based on the following comparative table, provide a concise verdict (2-4 sentences) recommending which entity is best suited for which use case.
|
||||||
|
{table}
|
||||||
|
"""
|
||||||
)
|
)
|
||||||
response = await llm.ainvoke([HumanMessage(content=prompt)])
|
result = await llm.invoke({"input": prompt})
|
||||||
state["verdict"] = response.content.strip()
|
state["verdict"] = result.content.strip()
|
||||||
return state
|
return state
|
||||||
|
|
||||||
# ---------- Graph ----------
|
# -----------------------------
|
||||||
graph = StateGraph(CompareState)
|
# Graph construction
|
||||||
graph.add_node("plan_criteria", plan_criteria)
|
# -----------------------------
|
||||||
graph.add_node("research_entity", research_entity)
|
builder = StateGraph(CompareState)
|
||||||
graph.add_node("build_table", build_table)
|
|
||||||
graph.add_node("verdict", verdict)
|
|
||||||
|
|
||||||
# Edge logic
|
# Add nodes
|
||||||
graph.set_entry_point("plan_criteria")
|
builder.add_node("plan_criteria", plan_criteria)
|
||||||
graph.add_edge("plan_criteria", "research_entity")
|
builder.add_node("research_entity", research_entity)
|
||||||
# research_entity loops until all findings filled
|
builder.add_node("build_table", build_table)
|
||||||
graph.add_conditional_edges(
|
builder.add_node("verdict", verdict)
|
||||||
|
|
||||||
|
# Define the graph logic
|
||||||
|
builder.set_entry_point("plan_criteria")
|
||||||
|
|
||||||
|
# After planning, start research loop
|
||||||
|
builder.add_conditional_edges(
|
||||||
|
"plan_criteria",
|
||||||
|
lambda _: "research_entity",
|
||||||
|
)
|
||||||
|
|
||||||
|
# After each research step, decide whether to continue or move to table
|
||||||
|
builder.add_conditional_edges(
|
||||||
"research_entity",
|
"research_entity",
|
||||||
lambda state: "done" if all(len(state["findings"][e]) == len(state["criteria"]) for e in state["entities"]) else "research_entity",
|
lambda state: "build_table" if state.get("_pair_index") is None or state["_pair_index"] >= len(state["entities"])*len(state["criteria"]) else "research_entity",
|
||||||
{"done": "build_table"},
|
|
||||||
)
|
|
||||||
graph.add_edge("build_table", "verdict")
|
|
||||||
graph.add_edge("verdict", END)
|
|
||||||
|
|
||||||
app = graph.compile()
|
|
||||||
|
|
||||||
# ---------- DeepAgent ----------
|
|
||||||
agent = create_deep_agent(
|
|
||||||
model=llm,
|
|
||||||
tools=[tavily_search],
|
|
||||||
backend=backend,
|
|
||||||
system_prompt="You are a comparison assistant.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------- CLI ----------
|
builder.add_edge("build_table", "verdict")
|
||||||
|
builder.add_edge("verdict", "END")
|
||||||
|
|
||||||
|
agent = builder.compile()
|
||||||
|
|
||||||
|
# -----------------------------
|
||||||
|
# CLI interface
|
||||||
|
# -----------------------------
|
||||||
async def main():
|
async def main():
|
||||||
# Default entities
|
# Default entities
|
||||||
entities = ["Chroma", "FAISS", "Qdrant"]
|
default_entities = ["Chroma", "FAISS", "Qdrant"]
|
||||||
# Optional custom input
|
print("Enter three entities to compare (comma separated). Press Enter to use default:")
|
||||||
user_input = input("Enter 3 entities separated by commas (or press Enter for default): ")
|
user_input = input().strip()
|
||||||
if user_input.strip():
|
if user_input:
|
||||||
entities = [e.strip() for e in user_input.split(",")[:3]]
|
entities = [e.strip() for e in user_input.split(',') if e.strip()]
|
||||||
# Prepare initial state
|
if len(entities) != 3:
|
||||||
state: CompareState = {
|
print("Please provide exactly three entities.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
entities = default_entities
|
||||||
|
initial_state: CompareState = {
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
"criteria": [],
|
"criteria": [],
|
||||||
"findings": {},
|
"findings": {},
|
||||||
"final_table": None,
|
"final_table": None,
|
||||||
"verdict": None,
|
"verdict": None,
|
||||||
|
"_pair_index": None,
|
||||||
}
|
}
|
||||||
# Run graph
|
# Run the agent
|
||||||
result = await app.ainvoke(state)
|
async for event in agent.astream_events(initial_state, version="1"):
|
||||||
# Print outputs
|
if event.get("type") == "on_node_end":
|
||||||
print("\n=== Criteria ===")
|
node = event["name"]
|
||||||
print("\n".join(result["criteria"]))
|
if node == "plan_criteria":
|
||||||
print("\n=== Findings ===")
|
print("\n=== Planned Criteria ===")
|
||||||
for e in result["entities"]:
|
print("\n".join(initial_state["criteria"]))
|
||||||
print(f"\n{e}:")
|
elif node == "research_entity":
|
||||||
for f in result["findings"][e]:
|
idx = initial_state["_pair_index"] - 1
|
||||||
print(f"- {f}")
|
entity_idx = idx // len(initial_state["criteria"])
|
||||||
print("\n=== Final Table ===")
|
criterion_idx = idx % len(initial_state["criteria"])
|
||||||
print(result["final_table"])
|
entity = initial_state["entities"][entity_idx]
|
||||||
print("\n=== Verdict ===")
|
criterion = initial_state["criteria"][criterion_idx]
|
||||||
print(result["verdict"])
|
note = initial_state["findings"][entity][-1]
|
||||||
|
print(f"\n[{entity} × {criterion}] найдено: {note}")
|
||||||
|
elif node == "build_table":
|
||||||
|
print("\n=== Comparative Table ===")
|
||||||
|
print(initial_state["final_table"]) # type: ignore
|
||||||
|
elif node == "verdict":
|
||||||
|
print("\n=== Verdict ===")
|
||||||
|
print(initial_state["verdict"]) # type: ignore
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(main())
|
asyncio.run(main())
|
||||||
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user