feat: solution for 'Повторный экзамен #2: Сравнительный обзор 3 сущностей (Tavily)'

This commit is contained in:
2026-06-29 16:24:53 +03:00
parent 08e2e01b18
commit 56c15b71cc
7 changed files with 154 additions and 146 deletions
+35 -29
View File
@@ -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 35 comparison criteria using an LLM. 1. Generates comparison criteria using an LLM.
2. Performs a web search for each entitycriterion pair via Tavily and stores a short note. 2. Performs iterative web searches with Tavily for each entitycriterion 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 OpenAIs 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
View File
@@ -3,4 +3,3 @@ langchain-openai
langchain-tavily langchain-tavily
tavily-python tavily-python
python-dotenv python-dotenv
openai
+21 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+67 -85
View File
@@ -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 35 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 entitycriterion 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:
snippet = results["results"][0]["snippet"]
source = results["results"][0]["url"]
note = f"{criterion}: {snippet} (Source: {source})"
else: else:
snippet = "No relevant information found." note = f"{criterion}: No recent information found."
except Exception as e: findings[entity].append(note)
snippet = f"Error during search: {e}" break
findings[entity].append(snippet)
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.
"""
entities = state.get("entities", [])
criteria = state.get("criteria", [])
findings = state.get("findings", {})
header = "| Criterion | " + " | ".join(entities) + " |\n"
separator = "|---" * (len(entities) + 1) + "|\n"
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
def verdict(state: CompareState) -> CompareState:
"""
Generate a verdict recommendation based on the table and 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"Using the following findings, write a cohesive research brief that summarizes "
f"{table}\n\n" f"the strengths and weaknesses of each entity. The brief should be clear, "
f"Based on the criteria: {', '.join(criteria)}.\n" f"structured, and suitable for a technical audience.\n\n"
"Provide a concise recommendation (24 sentences) indicating which entity is best suited for which use case." f"Findings:\n{findings_text}"
) )
response = llm.invoke(prompt) brief = llm.invoke(prompt)
state["verdict"] = response.content.strip() state["final_brief"] = brief
return state
# Node: Generate verdict/recommendation
def verdict(state: CompareState) -> CompareState:
brief = state["final_brief"]
criteria = state["criteria"]
prompt = (
f"Based on the research brief below and the comparison criteria, provide a "
f"clear recommendation on which entity is best suited for a typical use case. "
f"Explain your reasoning in 2-4 sentences.\n\n"
f"Research Brief:\n{brief}\n\n"
f"Criteria:\n- " + "\n- ".join(criteria)
)
recommendation = llm.invoke(prompt)
state["verdict"] = recommendation
return state return state
+3 -3
View File
@@ -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] # 35 comparison criteria criteria: List[str] # 35 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