feat: solution for 'Повторный экзамен #2: Сравнительный обзор 3 сущностей (Tavily)'
This commit is contained in:
@@ -1,64 +1,70 @@
|
|||||||
# LangGraph Comparative Review Agent
|
# Research Brief Generator
|
||||||
|
|
||||||
This project implements a LangGraph agent that, given three entities (e.g., technologies, products, or approaches), produces a comparative review. The agent:
|
This project builds a LangGraph agent that produces a cohesive research brief comparing three entities (e.g., vector databases).
|
||||||
|
The agent:
|
||||||
|
|
||||||
1. Generates 3–5 comparison criteria using an LLM.
|
1. Generates comparison criteria using an LLM.
|
||||||
2. Performs a web search for each entity‑criterion pair via Tavily and stores a short note.
|
2. Performs iterative web searches with Tavily for each entity‑criterion pair.
|
||||||
3. Builds a Markdown table with the findings.
|
3. Aggregates findings into a concise research brief.
|
||||||
4. Produces a verdict recommending which entity suits which use case.
|
4. Provides a recommendation verdict.
|
||||||
|
|
||||||
## Features
|
## Prerequisites
|
||||||
|
|
||||||
- **LLM powered**: Uses OpenAI’s GPT model to generate criteria and verdicts.
|
- Python 3.10+
|
||||||
- **Web search**: Uses Tavily to fetch up-to-date information for each pair.
|
- An OpenAI API key (set in `OPENAI_API_KEY` environment variable).
|
||||||
- **CLI**: Run from the command line with default or custom entities.
|
- A Tavily API key (set in `TAVILY_API_KEY` environment variable).
|
||||||
- **Modular**: Separate files for state, nodes, graph, and CLI.
|
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a virtual environment (optional but recommended)
|
# Clone the repository
|
||||||
|
git clone https://github.com/yourusername/research-brief.git
|
||||||
|
cd research-brief
|
||||||
|
|
||||||
|
# Create a virtual environment
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate # On Windows use `.venv\Scripts\activate`
|
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Create a .env file with your API keys
|
# Create a .env file with your API keys
|
||||||
cp .env.example .env
|
echo "OPENAI_API_KEY=your_openai_key" >> .env
|
||||||
# Edit .env and fill in your keys
|
echo "TAVILY_API_KEY=your_tavily_key" >> .env
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```bash
|
Run the CLI with default entities (Chroma, FAISS, Qdrant):
|
||||||
python src/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The script will compare the default entities: **Chroma, FAISS, Qdrant**.
|
|
||||||
You can also provide custom entities:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/main.py --entities "TensorFlow, PyTorch, JAX"
|
python -m src.main
|
||||||
```
|
```
|
||||||
|
|
||||||
The output will display:
|
Provide custom entities:
|
||||||
|
|
||||||
1. Generated comparison criteria.
|
```bash
|
||||||
2. The Markdown table of findings.
|
python -m src.main --entities "EntityA, EntityB, EntityC"
|
||||||
3. The final verdict.
|
```
|
||||||
|
|
||||||
|
The output will display the research brief followed by the verdict.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── cli.py # CLI entry point
|
├── cli.py # CLI entry point
|
||||||
├── graph.py # LangGraph definition
|
├── graph.py # LangGraph workflow
|
||||||
├── main.py # Script to run the graph
|
├── main.py # Package entry
|
||||||
├── nodes.py # Node implementations
|
├── nodes.py # Node implementations
|
||||||
└── state.py # TypedDict for state
|
└── state.py # State schema
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Extending
|
||||||
|
|
||||||
|
- Replace the LLM with a local model (e.g., Ollama) by adjusting the `llm` initialization in `nodes.py`.
|
||||||
|
- Add more sophisticated parsing or error handling as needed.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
+1
-2
@@ -2,5 +2,4 @@ langgraph
|
|||||||
langchain-openai
|
langchain-openai
|
||||||
langchain-tavily
|
langchain-tavily
|
||||||
tavily-python
|
tavily-python
|
||||||
python-dotenv
|
python-dotenv
|
||||||
openai
|
|
||||||
+21
-20
@@ -1,44 +1,45 @@
|
|||||||
import argparse
|
import argparse
|
||||||
from typing import List
|
import os
|
||||||
|
from src.graph import build_graph
|
||||||
|
from src.state import CompareState
|
||||||
|
|
||||||
from .state import CompareState
|
def parse_entities(arg: str) -> list[str]:
|
||||||
from .graph import create_graph
|
|
||||||
|
|
||||||
def parse_entities(arg: str) -> List[str]:
|
|
||||||
return [e.strip() for e in arg.split(",") if e.strip()]
|
return [e.strip() for e in arg.split(",") if e.strip()]
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="LangGraph Comparative Review Agent")
|
parser = argparse.ArgumentParser(description="Research Brief Generator")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
"-e",
|
||||||
"--entities",
|
"--entities",
|
||||||
type=str,
|
type=str,
|
||||||
help="Comma-separated list of three entities to compare. "
|
help="Comma-separated list of 3 entities to compare",
|
||||||
"If omitted, defaults to Chroma, FAISS, Qdrant.",
|
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.entities:
|
if args.entities:
|
||||||
entities = parse_entities(args.entities)
|
entities = parse_entities(args.entities)
|
||||||
if len(entities) != 3:
|
if len(entities) != 3:
|
||||||
raise ValueError("Please provide exactly three entities.")
|
print("Please provide exactly 3 entities.")
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
|
# Default entities
|
||||||
entities = ["Chroma", "FAISS", "Qdrant"]
|
entities = ["Chroma", "FAISS", "Qdrant"]
|
||||||
|
|
||||||
initial_state: CompareState = {
|
# Initial state
|
||||||
|
state: CompareState = {
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
|
"criteria": [],
|
||||||
|
"findings": {},
|
||||||
|
"final_brief": None,
|
||||||
|
"verdict": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
graph = create_graph()
|
graph = build_graph()
|
||||||
final_state = graph.invoke(initial_state)
|
final_state = graph.invoke(state)
|
||||||
|
|
||||||
print("\n=== Comparison Criteria ===")
|
print("\n=== Research Brief ===\n")
|
||||||
for idx, crit in enumerate(final_state["criteria"], 1):
|
print(final_state["final_brief"])
|
||||||
print(f"{idx}. {crit}")
|
print("\n=== Verdict ===\n")
|
||||||
|
|
||||||
print("\n=== Findings Table ===")
|
|
||||||
print(final_state["final_table"])
|
|
||||||
|
|
||||||
print("\n=== Verdict ===")
|
|
||||||
print(final_state["verdict"])
|
print(final_state["verdict"])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+26
-8
@@ -1,21 +1,39 @@
|
|||||||
from langgraph.graph import StateGraph
|
from langgraph import StateGraph
|
||||||
from .state import CompareState
|
from src.state import CompareState
|
||||||
from .nodes import plan_criteria, research_entity, build_table, verdict
|
from src.nodes import (
|
||||||
|
plan_criteria,
|
||||||
|
research_entity,
|
||||||
|
build_brief,
|
||||||
|
verdict,
|
||||||
|
)
|
||||||
|
|
||||||
def create_graph() -> StateGraph:
|
def build_graph() -> StateGraph:
|
||||||
graph = StateGraph(CompareState)
|
graph = StateGraph(CompareState)
|
||||||
|
|
||||||
# Add nodes
|
# Add nodes
|
||||||
graph.add_node("plan_criteria", plan_criteria)
|
graph.add_node("plan_criteria", plan_criteria)
|
||||||
graph.add_node("research_entity", research_entity)
|
graph.add_node("research_entity", research_entity)
|
||||||
graph.add_node("build_table", build_table)
|
graph.add_node("build_brief", build_brief)
|
||||||
graph.add_node("verdict", verdict)
|
graph.add_node("verdict", verdict)
|
||||||
|
|
||||||
# Define edges
|
# Define edges
|
||||||
graph.set_entry_point("plan_criteria")
|
graph.set_entry_point("plan_criteria")
|
||||||
graph.add_edge("plan_criteria", "research_entity")
|
graph.add_edge("plan_criteria", "research_entity")
|
||||||
graph.add_edge("research_entity", "build_table")
|
|
||||||
graph.add_edge("build_table", "verdict")
|
# Conditional loop: if research still needed, stay in research_entity
|
||||||
graph.add_edge("verdict", END)
|
def research_done(state: CompareState) -> str:
|
||||||
|
entities = state["entities"]
|
||||||
|
criteria = state["criteria"]
|
||||||
|
findings = state["findings"]
|
||||||
|
# Check if all entities have enough findings
|
||||||
|
for entity in entities:
|
||||||
|
if len(findings[entity]) < len(criteria):
|
||||||
|
return "research_entity"
|
||||||
|
return "build_brief"
|
||||||
|
|
||||||
|
graph.add_conditional_edges("research_entity", research_done)
|
||||||
|
|
||||||
|
graph.add_edge("build_brief", "verdict")
|
||||||
|
graph.add_edge("verdict", "__end__")
|
||||||
|
|
||||||
return graph
|
return graph
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
from .cli import main
|
# Entry point for the package
|
||||||
|
# This file simply calls the CLI main function
|
||||||
|
from src.cli import main
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
+65
-83
@@ -1,117 +1,99 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List
|
||||||
|
|
||||||
from langgraph.graph import StateGraph, END
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from tavily import TavilyClient
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
from .state import CompareState
|
from langchain_tavily import TavilySearchTool
|
||||||
|
from langgraph.prebuilt import create_chat_agent
|
||||||
|
from langgraph import add_messages, StateGraph
|
||||||
|
from src.state import CompareState
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Initialize LLM and Tavily client
|
# LLM and Tavily tool
|
||||||
llm = ChatOpenAI(
|
llm = ChatOpenAI(temperature=0.7)
|
||||||
temperature=0.2,
|
tavily = TavilySearchTool(api_key=os.getenv("TAVILY_API_KEY"))
|
||||||
model="gpt-4o-mini",
|
|
||||||
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
|
||||||
)
|
|
||||||
|
|
||||||
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
|
# Helper to format findings for LLM
|
||||||
|
def format_findings(findings: Dict[str, List[str]]) -> str:
|
||||||
|
parts = []
|
||||||
|
for entity, notes in findings.items():
|
||||||
|
parts.append(f"**{entity}**:")
|
||||||
|
for note in notes:
|
||||||
|
parts.append(f"- {note}")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
# Node: Generate comparison criteria
|
||||||
def plan_criteria(state: CompareState) -> CompareState:
|
def plan_criteria(state: CompareState) -> CompareState:
|
||||||
"""
|
entities = state["entities"]
|
||||||
Generate 3–5 comparison criteria for the given entities.
|
|
||||||
"""
|
|
||||||
entities = state.get("entities", [])
|
|
||||||
if not entities:
|
|
||||||
raise ValueError("No entities provided for criteria planning.")
|
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Given the following entities: {', '.join(entities)}.\n"
|
f"Generate 3-5 concise comparison criteria for the following entities: "
|
||||||
"Suggest 3 to 5 key criteria to compare them. "
|
f"{', '.join(entities)}. Return a numbered list."
|
||||||
"Return the criteria as a numbered list, one per line."
|
|
||||||
)
|
)
|
||||||
response = llm.invoke(prompt)
|
response = llm.invoke(prompt)
|
||||||
criteria_text = response.content.strip()
|
|
||||||
# Parse numbered list
|
# Parse numbered list
|
||||||
criteria = []
|
criteria = []
|
||||||
for line in criteria_text.splitlines():
|
for line in response.splitlines():
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line:
|
if line:
|
||||||
# Remove leading numbers if present
|
# Remove leading numbers
|
||||||
if line[0].isdigit() and (len(line) > 1 and line[1] in ". "):
|
if line[0].isdigit() and (len(line) > 1 and line[1] in ". "):
|
||||||
line = line[2:].strip()
|
line = line[2:].strip()
|
||||||
criteria.append(line)
|
criteria.append(line)
|
||||||
state["criteria"] = criteria
|
state["criteria"] = criteria
|
||||||
|
# Initialize findings dict
|
||||||
|
state["findings"] = {entity: [] for entity in entities}
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
# Node: Research one entity-criterion pair
|
||||||
def research_entity(state: CompareState) -> CompareState:
|
def research_entity(state: CompareState) -> CompareState:
|
||||||
"""
|
entities = state["entities"]
|
||||||
For each entity–criterion pair, perform a Tavily web search
|
criteria = state["criteria"]
|
||||||
and store a short note in findings.
|
findings = state["findings"]
|
||||||
"""
|
|
||||||
entities = state.get("entities", [])
|
|
||||||
criteria = state.get("criteria", [])
|
|
||||||
findings: Dict[str, List[str]] = {entity: [] for entity in entities}
|
|
||||||
|
|
||||||
|
# Find next entity needing research
|
||||||
for entity in entities:
|
for entity in entities:
|
||||||
for criterion in criteria:
|
if len(findings[entity]) < len(criteria):
|
||||||
|
idx = len(findings[entity])
|
||||||
|
criterion = criteria[idx]
|
||||||
query = f"{entity} {criterion}"
|
query = f"{entity} {criterion}"
|
||||||
try:
|
# Tavily search
|
||||||
result = tavily.search(query=query, max_results=1)
|
results = tavily.invoke({"query": query, "max_results": 3})
|
||||||
if result and result["results"]:
|
# Take first result snippet
|
||||||
snippet = result["results"][0]["content"][:200]
|
if results and "results" in results and len(results["results"]) > 0:
|
||||||
else:
|
snippet = results["results"][0]["snippet"]
|
||||||
snippet = "No relevant information found."
|
source = results["results"][0]["url"]
|
||||||
except Exception as e:
|
note = f"{criterion}: {snippet} (Source: {source})"
|
||||||
snippet = f"Error during search: {e}"
|
else:
|
||||||
findings[entity].append(snippet)
|
note = f"{criterion}: No recent information found."
|
||||||
|
findings[entity].append(note)
|
||||||
|
break
|
||||||
state["findings"] = findings
|
state["findings"] = findings
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def build_table(state: CompareState) -> CompareState:
|
# Node: Build cohesive research brief
|
||||||
"""
|
def build_brief(state: CompareState) -> CompareState:
|
||||||
Build a Markdown table from findings.
|
findings_text = format_findings(state["findings"])
|
||||||
Rows: criteria, Columns: entities.
|
prompt = (
|
||||||
"""
|
f"Using the following findings, write a cohesive research brief that summarizes "
|
||||||
entities = state.get("entities", [])
|
f"the strengths and weaknesses of each entity. The brief should be clear, "
|
||||||
criteria = state.get("criteria", [])
|
f"structured, and suitable for a technical audience.\n\n"
|
||||||
findings = state.get("findings", {})
|
f"Findings:\n{findings_text}"
|
||||||
|
)
|
||||||
header = "| Criterion | " + " | ".join(entities) + " |\n"
|
brief = llm.invoke(prompt)
|
||||||
separator = "|---" * (len(entities) + 1) + "|\n"
|
state["final_brief"] = brief
|
||||||
|
|
||||||
rows = ""
|
|
||||||
for idx, criterion in enumerate(criteria):
|
|
||||||
row = f"| {criterion} | "
|
|
||||||
for entity in entities:
|
|
||||||
notes = findings.get(entity, [])
|
|
||||||
note = notes[idx] if idx < len(notes) else ""
|
|
||||||
# Escape pipe characters
|
|
||||||
note = note.replace("|", "\\|")
|
|
||||||
row += f"{note} | "
|
|
||||||
rows += row + "\n"
|
|
||||||
|
|
||||||
table = header + separator + rows
|
|
||||||
state["final_table"] = table
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
# Node: Generate verdict/recommendation
|
||||||
def verdict(state: CompareState) -> CompareState:
|
def verdict(state: CompareState) -> CompareState:
|
||||||
"""
|
brief = state["final_brief"]
|
||||||
Generate a verdict recommendation based on the table and criteria.
|
criteria = state["criteria"]
|
||||||
"""
|
|
||||||
table = state.get("final_table", "")
|
|
||||||
criteria = state.get("criteria", [])
|
|
||||||
entities = state.get("entities", [])
|
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Here is a comparative table of the following entities: {', '.join(entities)}.\n\n"
|
f"Based on the research brief below and the comparison criteria, provide a "
|
||||||
f"{table}\n\n"
|
f"clear recommendation on which entity is best suited for a typical use case. "
|
||||||
f"Based on the criteria: {', '.join(criteria)}.\n"
|
f"Explain your reasoning in 2-4 sentences.\n\n"
|
||||||
"Provide a concise recommendation (2–4 sentences) indicating which entity is best suited for which use case."
|
f"Research Brief:\n{brief}\n\n"
|
||||||
|
f"Criteria:\n- " + "\n- ".join(criteria)
|
||||||
)
|
)
|
||||||
response = llm.invoke(prompt)
|
recommendation = llm.invoke(prompt)
|
||||||
state["verdict"] = response.content.strip()
|
state["verdict"] = recommendation
|
||||||
return state
|
return state
|
||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
from typing import TypedDict, List, Dict, Optional
|
from typing import TypedDict, List, Dict, Optional
|
||||||
|
|
||||||
class CompareState(TypedDict, total=False):
|
class CompareState(TypedDict):
|
||||||
entities: List[str] # 3 names to compare
|
entities: List[str] # 3 names to compare
|
||||||
criteria: List[str] # 3–5 comparison criteria
|
criteria: List[str] # 3–5 comparison criteria
|
||||||
findings: Dict[str, List[str]] # entity -> list of notes per criterion
|
findings: Dict[str, List[str]] # entity -> list of notes per criterion
|
||||||
final_table: Optional[str]
|
final_brief: Optional[str] # cohesive research brief
|
||||||
verdict: Optional[str]
|
verdict: Optional[str] # recommendation
|
||||||
Reference in New Issue
Block a user