commit 08e2e01b18ae47c81193aa384221daf7c7fdbf89 Author: kuzakhmetovartur Date: Mon Jun 29 12:03:54 2026 +0300 feat: solution for 'Повторный экзамен #2: Сравнительный обзор 3 сущностей (Tavily)' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b16538b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ +build/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..91e402f --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# LangGraph Comparative Review Agent + +This project implements a LangGraph agent that, given three entities (e.g., technologies, products, or approaches), produces a comparative review. 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. + +## Features + +- **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. + +## Setup + +```bash +# Create a virtual environment (optional but recommended) +python -m venv .venv +source .venv/bin/activate # On Windows use `.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 +``` + +## Usage + +```bash +python src/main.py +``` + +The script will compare the default entities: **Chroma, FAISS, Qdrant**. +You can also provide custom entities: + +```bash +python src/main.py --entities "TensorFlow, PyTorch, JAX" +``` + +The output will display: + +1. Generated comparison criteria. +2. The Markdown table of findings. +3. The final verdict. + +## Project Structure + +``` +src/ +├── cli.py # CLI entry point +├── graph.py # LangGraph definition +├── main.py # Script to run the graph +├── nodes.py # Node implementations +└── state.py # TypedDict for state +``` + +## License + +MIT License \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ca5ca1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +langgraph +langchain-openai +langchain-tavily +tavily-python +python-dotenv +openai \ No newline at end of file diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..4a839ee --- /dev/null +++ b/src/cli.py @@ -0,0 +1,45 @@ +import argparse +from typing import List + +from .state import CompareState +from .graph import create_graph + +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.add_argument( + "--entities", + type=str, + help="Comma-separated list of three entities to compare. " + "If omitted, defaults to Chroma, FAISS, Qdrant.", + ) + args = parser.parse_args() + + if args.entities: + entities = parse_entities(args.entities) + if len(entities) != 3: + raise ValueError("Please provide exactly three entities.") + else: + entities = ["Chroma", "FAISS", "Qdrant"] + + initial_state: CompareState = { + "entities": entities, + } + + graph = create_graph() + final_state = graph.invoke(initial_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(final_state["verdict"]) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/graph.py b/src/graph.py new file mode 100644 index 0000000..6f70e8b --- /dev/null +++ b/src/graph.py @@ -0,0 +1,21 @@ +from langgraph.graph import StateGraph +from .state import CompareState +from .nodes import plan_criteria, research_entity, build_table, verdict + +def create_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("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) + + return graph \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..27d14a7 --- /dev/null +++ b/src/main.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/nodes.py b/src/nodes.py new file mode 100644 index 0000000..d6aade4 --- /dev/null +++ b/src/nodes.py @@ -0,0 +1,117 @@ +import os +from typing import Dict, List, Tuple + +from langgraph.graph import StateGraph, END +from langchain_openai import ChatOpenAI +from tavily import TavilyClient +from dotenv import load_dotenv + +from .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"), +) + +tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY")) + +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.") + + 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." + ) + response = llm.invoke(prompt) + criteria_text = response.content.strip() + # Parse numbered list + criteria = [] + for line in criteria_text.splitlines(): + line = line.strip() + if line: + # Remove leading numbers if present + if line[0].isdigit() and (len(line) > 1 and line[1] in ". "): + line = line[2:].strip() + criteria.append(line) + state["criteria"] = criteria + return state + +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} + + for entity in entities: + for criterion in criteria: + 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) + + 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 + 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 = ( + 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." + ) + response = llm.invoke(prompt) + state["verdict"] = response.content.strip() + return state \ No newline at end of file diff --git a/src/state.py b/src/state.py new file mode 100644 index 0000000..98a297c --- /dev/null +++ b/src/state.py @@ -0,0 +1,8 @@ +from typing import TypedDict, List, Dict, Optional + +class CompareState(TypedDict, total=False): + 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