From 56c15b71cc958fcef06821538e770fe8b8b99b93 Mon Sep 17 00:00:00 2001 From: kuzakhmetovartur Date: Mon, 29 Jun 2026 16:24:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20solution=20for=20'=D0=9F=D0=BE=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=BD=D1=8B=D0=B9=20=D1=8D=D0=BA=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=20#2:=20=D0=A1=D1=80=D0=B0=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BE=D0=B1=D0=B7?= =?UTF-8?q?=D0=BE=D1=80=203=20=D1=81=D1=83=D1=89=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B5=D0=B9=20(Tavily)'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 64 ++++++++++---------- requirements.txt | 3 +- src/cli.py | 41 ++++++------- src/graph.py | 34 ++++++++--- src/main.py | 4 +- src/nodes.py | 148 +++++++++++++++++++++-------------------------- src/state.py | 6 +- 7 files changed, 154 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index 91e402f..9d06ea8 100644 --- a/README.md +++ b/README.md @@ -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. -2. Performs a web search for each entity‑criterion pair via Tavily and stores a short note. -3. Builds a Markdown table with the findings. -4. Produces a verdict recommending which entity suits which use case. +1. Generates comparison criteria using an LLM. +2. Performs iterative web searches with Tavily for each entity‑criterion pair. +3. Aggregates findings into a concise research brief. +4. Provides a recommendation verdict. -## Features +## Prerequisites -- **LLM powered**: Uses OpenAI’s GPT model to generate criteria and verdicts. -- **Web search**: Uses Tavily to fetch up-to-date information for each pair. -- **CLI**: Run from the command line with default or custom entities. -- **Modular**: Separate files for state, nodes, graph, and CLI. +- Python 3.10+ +- An OpenAI API key (set in `OPENAI_API_KEY` environment variable). +- A Tavily API key (set in `TAVILY_API_KEY` environment variable). ## Setup ```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 -source .venv/bin/activate # On Windows use `.venv\Scripts\activate` +source .venv/bin/activate # On Windows: .venv\Scripts\activate # Install dependencies pip install -r requirements.txt # Create a .env file with your API keys -cp .env.example .env -# Edit .env and fill in your keys +echo "OPENAI_API_KEY=your_openai_key" >> .env +echo "TAVILY_API_KEY=your_tavily_key" >> .env ``` ## Usage -```bash -python src/main.py -``` - -The script will compare the default entities: **Chroma, FAISS, Qdrant**. -You can also provide custom entities: +Run the CLI with default entities (Chroma, FAISS, Qdrant): ```bash -python src/main.py --entities "TensorFlow, PyTorch, JAX" +python -m src.main ``` -The output will display: +Provide custom entities: -1. Generated comparison criteria. -2. The Markdown table of findings. -3. The final verdict. +```bash +python -m src.main --entities "EntityA, EntityB, EntityC" +``` + +The output will display the research brief followed by the verdict. ## Project Structure ``` src/ ├── cli.py # CLI entry point -├── graph.py # LangGraph definition -├── main.py # Script to run the graph +├── graph.py # LangGraph workflow +├── main.py # Package entry ├── 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 MIT License \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2ca5ca1..2d94960 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,5 +2,4 @@ langgraph langchain-openai langchain-tavily tavily-python -python-dotenv -openai \ No newline at end of file +python-dotenv \ No newline at end of file diff --git a/src/cli.py b/src/cli.py index 4a839ee..9fa4d2e 100644 --- a/src/cli.py +++ b/src/cli.py @@ -1,44 +1,45 @@ import argparse -from typing import List +import os +from src.graph import build_graph +from src.state import CompareState -from .state import CompareState -from .graph import create_graph - -def parse_entities(arg: str) -> List[str]: +def parse_entities(arg: str) -> list[str]: return [e.strip() for e in arg.split(",") if e.strip()] def main(): - parser = argparse.ArgumentParser(description="LangGraph Comparative Review Agent") + parser = argparse.ArgumentParser(description="Research Brief Generator") parser.add_argument( + "-e", "--entities", type=str, - help="Comma-separated list of three entities to compare. " - "If omitted, defaults to Chroma, FAISS, Qdrant.", + help="Comma-separated list of 3 entities to compare", ) args = parser.parse_args() if args.entities: entities = parse_entities(args.entities) if len(entities) != 3: - raise ValueError("Please provide exactly three entities.") + print("Please provide exactly 3 entities.") + return else: + # Default entities entities = ["Chroma", "FAISS", "Qdrant"] - initial_state: CompareState = { + # Initial state + state: CompareState = { "entities": entities, + "criteria": [], + "findings": {}, + "final_brief": None, + "verdict": None, } - graph = create_graph() - final_state = graph.invoke(initial_state) + graph = build_graph() + final_state = graph.invoke(state) - print("\n=== Comparison Criteria ===") - for idx, crit in enumerate(final_state["criteria"], 1): - print(f"{idx}. {crit}") - - print("\n=== Findings Table ===") - print(final_state["final_table"]) - - print("\n=== Verdict ===") + print("\n=== Research Brief ===\n") + print(final_state["final_brief"]) + print("\n=== Verdict ===\n") print(final_state["verdict"]) if __name__ == "__main__": diff --git a/src/graph.py b/src/graph.py index 6f70e8b..5998de3 100644 --- a/src/graph.py +++ b/src/graph.py @@ -1,21 +1,39 @@ -from langgraph.graph import StateGraph -from .state import CompareState -from .nodes import plan_criteria, research_entity, build_table, verdict +from langgraph import StateGraph +from src.state import CompareState +from src.nodes import ( + plan_criteria, + research_entity, + build_brief, + verdict, +) -def create_graph() -> StateGraph: +def build_graph() -> StateGraph: graph = StateGraph(CompareState) # Add nodes graph.add_node("plan_criteria", plan_criteria) 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) # Define edges graph.set_entry_point("plan_criteria") graph.add_edge("plan_criteria", "research_entity") - graph.add_edge("research_entity", "build_table") - graph.add_edge("build_table", "verdict") - graph.add_edge("verdict", END) + + # Conditional loop: if research still needed, stay in research_entity + 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 \ No newline at end of file diff --git a/src/main.py b/src/main.py index 27d14a7..ace6d6d 100644 --- a/src/main.py +++ b/src/main.py @@ -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__": main() \ No newline at end of file diff --git a/src/nodes.py b/src/nodes.py index d6aade4..4679723 100644 --- a/src/nodes.py +++ b/src/nodes.py @@ -1,117 +1,99 @@ import os -from typing import Dict, List, Tuple - -from langgraph.graph import StateGraph, END -from langchain_openai import ChatOpenAI -from tavily import TavilyClient +from typing import Dict, List from dotenv import load_dotenv - -from .state import CompareState +from langchain_openai import ChatOpenAI +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() -# Initialize LLM and Tavily client -llm = ChatOpenAI( - temperature=0.2, - model="gpt-4o-mini", - openai_api_key=os.getenv("OPENAI_API_KEY"), -) +# LLM and Tavily tool +llm = ChatOpenAI(temperature=0.7) +tavily = TavilySearchTool(api_key=os.getenv("TAVILY_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: - """ - Generate 3–5 comparison criteria for the given entities. - """ - entities = state.get("entities", []) - if not entities: - raise ValueError("No entities provided for criteria planning.") - + entities = state["entities"] prompt = ( - f"Given the following entities: {', '.join(entities)}.\n" - "Suggest 3 to 5 key criteria to compare them. " - "Return the criteria as a numbered list, one per line." + f"Generate 3-5 concise comparison criteria for the following entities: " + f"{', '.join(entities)}. Return a numbered list." ) response = llm.invoke(prompt) - criteria_text = response.content.strip() # Parse numbered list criteria = [] - for line in criteria_text.splitlines(): + for line in response.splitlines(): line = line.strip() if line: - # Remove leading numbers if present + # Remove leading numbers if line[0].isdigit() and (len(line) > 1 and line[1] in ". "): line = line[2:].strip() criteria.append(line) state["criteria"] = criteria + # Initialize findings dict + state["findings"] = {entity: [] for entity in entities} return state +# Node: Research one entity-criterion pair def research_entity(state: CompareState) -> CompareState: - """ - For each entity–criterion pair, perform a Tavily web search - and store a short note in findings. - """ - entities = state.get("entities", []) - criteria = state.get("criteria", []) - findings: Dict[str, List[str]] = {entity: [] for entity in entities} + entities = state["entities"] + criteria = state["criteria"] + findings = state["findings"] + # Find next entity needing research 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}" - try: - result = tavily.search(query=query, max_results=1) - if result and result["results"]: - snippet = result["results"][0]["content"][:200] - else: - snippet = "No relevant information found." - except Exception as e: - snippet = f"Error during search: {e}" - findings[entity].append(snippet) - + # Tavily search + results = tavily.invoke({"query": query, "max_results": 3}) + # Take first result snippet + 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: + note = f"{criterion}: No recent information found." + findings[entity].append(note) + break state["findings"] = findings return state -def build_table(state: CompareState) -> CompareState: - """ - Build a Markdown table from 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 +# Node: Build cohesive research brief +def build_brief(state: CompareState) -> CompareState: + findings_text = format_findings(state["findings"]) + prompt = ( + f"Using the following findings, write a cohesive research brief that summarizes " + f"the strengths and weaknesses of each entity. The brief should be clear, " + f"structured, and suitable for a technical audience.\n\n" + f"Findings:\n{findings_text}" + ) + brief = llm.invoke(prompt) + state["final_brief"] = brief return state +# Node: Generate verdict/recommendation 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", []) - + brief = state["final_brief"] + criteria = state["criteria"] prompt = ( - f"Here is a comparative table of the following entities: {', '.join(entities)}.\n\n" - f"{table}\n\n" - f"Based on the criteria: {', '.join(criteria)}.\n" - "Provide a concise recommendation (2–4 sentences) indicating which entity is best suited for which use case." + 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) ) - response = llm.invoke(prompt) - state["verdict"] = response.content.strip() + recommendation = llm.invoke(prompt) + state["verdict"] = recommendation return state \ No newline at end of file diff --git a/src/state.py b/src/state.py index 98a297c..bddcc10 100644 --- a/src/state.py +++ b/src/state.py @@ -1,8 +1,8 @@ from typing import TypedDict, List, Dict, Optional -class CompareState(TypedDict, total=False): +class CompareState(TypedDict): entities: List[str] # 3 names to compare criteria: List[str] # 3–5 comparison criteria findings: Dict[str, List[str]] # entity -> list of notes per criterion - final_table: Optional[str] - verdict: Optional[str] \ No newline at end of file + final_brief: Optional[str] # cohesive research brief + verdict: Optional[str] # recommendation \ No newline at end of file