feat: solution for 'Повторный экзамен: Граф с рефлексией и доработкой'
This commit is contained in:
@@ -1,90 +1,82 @@
|
|||||||
# LangGraph Research Brief Agent
|
# LangGraph Reflection Agent
|
||||||
|
|
||||||
This project demonstrates how to build a LangGraph agent that generates a short research brief for a given topic.
|
This project demonstrates a simple LangGraph agent that:
|
||||||
The agent:
|
|
||||||
|
|
||||||
1. Creates an outline of 4‑5 research steps.
|
1. Generates a concise answer to a user‑supplied question.
|
||||||
2. For each step, performs a web search (via Tavily) and writes a concise note.
|
2. Critiques the answer using an LLM.
|
||||||
3. Synthesizes all notes into a coherent brief.
|
3. Rewrites the answer if the critic says *needs_revision*, up to a maximum number of rounds.
|
||||||
|
|
||||||
## Prerequisites
|
The agent is implemented in Python 3.10+ and uses the `langgraph` framework together with `langchain-openai`.
|
||||||
|
|
||||||
- Python 3.10+
|
## Features
|
||||||
- A **Tavily** API key (free tier available).
|
|
||||||
- An **OpenAI** API key (or any compatible LLM provider).
|
|
||||||
|
|
||||||
## Setup
|
- **Draft generation** – 5–10 sentence answer.
|
||||||
|
- **LLM critic** – returns a verdict (`ok` or `needs_revision`) and 2–3 critique points.
|
||||||
|
- **Rewrite loop** – rewrites the draft until the verdict is `ok` or the maximum number of rounds is reached.
|
||||||
|
- **CLI** – run the agent from the command line.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
|
||||||
git clone https://github.com/your-username/langgraph-research-brief.git
|
|
||||||
cd langgraph-research-brief
|
|
||||||
|
|
||||||
# Create a virtual environment (optional but recommended)
|
# Create a virtual environment (optional but recommended)
|
||||||
python -m venv venv
|
python -m venv .venv
|
||||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
source .venv/bin/activate # On Windows use `.venv\Scripts\activate`
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a `.env` file in the project root based on the example:
|
### OpenAI API Key
|
||||||
|
|
||||||
|
The agent uses OpenAI’s GPT‑3.5‑Turbo by default.
|
||||||
|
Set your API key in the environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
export OPENAI_API_KEY="sk-..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env` and replace the placeholders with your actual keys:
|
If you prefer to use a local LLM via Ollama, replace the `langchain-openai` dependency with `langchain-ollama` and adjust the LLM initialization in `src/graph.py`.
|
||||||
|
|
||||||
```
|
## Usage
|
||||||
OPENAI_API_KEY=sk-...
|
|
||||||
TAVILY_API_KEY=your_tavily_key
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running the Agent
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python src/main.py
|
python -m src.main "Explain the difference between a tool and a resource in MCP to a student."
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see output similar to:
|
Optional arguments:
|
||||||
|
|
||||||
|
- `--max_rounds N` – maximum number of rewrite rounds (default: 2).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m src.main "Explain the difference between a tool and a resource in MCP." --max_rounds 3
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will print:
|
||||||
|
|
||||||
```
|
```
|
||||||
=== Outline ===
|
=== Final Answer ===
|
||||||
1. Identify the security requirements for MCP integration
|
<rewritten answer>
|
||||||
2. Review LangChain's authentication mechanisms
|
|
||||||
3. Evaluate secure communication protocols
|
|
||||||
4. Test the integration in a sandbox environment
|
|
||||||
5. Document best practices and compliance checks
|
|
||||||
|
|
||||||
=== Notes ===
|
=== Verdict ===
|
||||||
[Step 1] ... (5‑8 sentence note)
|
ok
|
||||||
[Step 2] ... (5‑8 sentence note)
|
|
||||||
...
|
|
||||||
|
|
||||||
=== Final Brief ===
|
|
||||||
...
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If the final verdict is `needs_revision`, the critique points will also be shown.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main.py # Entry point
|
├── main.py # CLI entry point
|
||||||
├── graph.py # LangGraph definition
|
├── graph.py # LangGraph graph definition
|
||||||
├── nodes.py # Node implementations
|
└── state.py # TypedDict for the agent state
|
||||||
├── state.py # TypedDict for state
|
|
||||||
├── .env.example # Environment variable template
|
|
||||||
requirements.txt
|
requirements.txt
|
||||||
README.md
|
README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
- **Topic**: Change the `default_topic` variable in `src/main.py` to generate a brief on a different subject.
|
|
||||||
- **LLM**: Swap `ChatOpenAI` for another provider (e.g., Ollama) by adjusting the imports and initialization in `src/nodes.py`.
|
|
||||||
- **Search**: Replace `TavilySearchResults` with another search tool if desired.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
+1
-3
@@ -1,5 +1,3 @@
|
|||||||
langgraph
|
langgraph
|
||||||
langchain-openai
|
langchain-openai
|
||||||
langchain-tavily
|
openai
|
||||||
tavily-python
|
|
||||||
python-dotenv
|
|
||||||
+100
-23
@@ -1,28 +1,105 @@
|
|||||||
from langgraph import StateGraph
|
import json
|
||||||
from src.state import BriefState
|
from typing import Dict, Any
|
||||||
from src.nodes import outline_node, research_step_node, synthesize_node
|
|
||||||
|
from langgraph.graph import StateGraph, END
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
from langchain.prompts import PromptTemplate
|
||||||
|
|
||||||
|
from .state import ReflectState
|
||||||
|
|
||||||
|
# LLM configuration
|
||||||
|
llm = ChatOpenAI(temperature=0, model_name="gpt-3.5-turbo")
|
||||||
|
|
||||||
|
# Prompt templates
|
||||||
|
DRAFT_PROMPT = PromptTemplate(
|
||||||
|
input_variables=["question"],
|
||||||
|
template=(
|
||||||
|
"You are an assistant that writes a concise answer (5–10 sentences) to the following question:\n\n"
|
||||||
|
"Question: {question}\n\n"
|
||||||
|
"Answer:"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
REFLECT_PROMPT = PromptTemplate(
|
||||||
|
input_variables=["question", "draft"],
|
||||||
|
template=(
|
||||||
|
"You are a critical reviewer. Evaluate the following draft answer to the question.\n\n"
|
||||||
|
"Question: {question}\n\n"
|
||||||
|
"Draft answer:\n{draft}\n\n"
|
||||||
|
"Provide a JSON object with the following keys:\n"
|
||||||
|
" verdict: \"ok\" if the answer is complete, concrete, and free of fluff; otherwise \"needs_revision\"\n"
|
||||||
|
" critique: a list of 2–3 specific points for improvement.\n"
|
||||||
|
"Example output:\n"
|
||||||
|
"{{\"verdict\": \"ok\", \"critique\": []}}\n"
|
||||||
|
"Your output should be valid JSON."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
REWRITE_PROMPT = PromptTemplate(
|
||||||
|
input_variables=["draft", "critique"],
|
||||||
|
template=(
|
||||||
|
"Rewrite the following draft answer to address the critique points below. "
|
||||||
|
"Make the answer clearer, more concrete, and remove any unnecessary fluff.\n\n"
|
||||||
|
"Critique points:\n{critique}\n\n"
|
||||||
|
"Original draft:\n{draft}\n\n"
|
||||||
|
"Rewritten answer:"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def draft_answer(state: ReflectState) -> ReflectState:
|
||||||
|
"""Generate the initial draft answer."""
|
||||||
|
prompt = DRAFT_PROMPT.format(question=state["question"])
|
||||||
|
answer = llm.invoke(prompt).content.strip()
|
||||||
|
state["draft"] = answer
|
||||||
|
return state
|
||||||
|
|
||||||
|
def reflect(state: ReflectState) -> ReflectState:
|
||||||
|
"""Critique the draft and produce verdict and critique list."""
|
||||||
|
prompt = REFLECT_PROMPT.format(question=state["question"], draft=state["draft"])
|
||||||
|
response = llm.invoke(prompt).content.strip()
|
||||||
|
try:
|
||||||
|
data = json.loads(response)
|
||||||
|
verdict = data.get("verdict", "needs_revision")
|
||||||
|
critique = data.get("critique", [])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
# Fallback if parsing fails
|
||||||
|
verdict = "needs_revision"
|
||||||
|
critique = ["Unable to parse critique."]
|
||||||
|
|
||||||
|
state["verdict"] = verdict
|
||||||
|
state["critique"] = critique
|
||||||
|
return state
|
||||||
|
|
||||||
|
def rewrite(state: ReflectState) -> ReflectState:
|
||||||
|
"""Rewrite the draft based on critique and increment round."""
|
||||||
|
critique_text = "\n".join(f"- {c}" for c in state["critique"])
|
||||||
|
prompt = REWRITE_PROMPT.format(draft=state["draft"], critique=critique_text)
|
||||||
|
new_draft = llm.invoke(prompt).content.strip()
|
||||||
|
state["draft"] = new_draft
|
||||||
|
state["round"] += 1
|
||||||
|
return state
|
||||||
|
|
||||||
|
def decide(state: ReflectState) -> str:
|
||||||
|
"""Decide whether to end or rewrite."""
|
||||||
|
if state["verdict"] == "ok":
|
||||||
|
return "end"
|
||||||
|
if state["round"] >= state["max_rounds"]:
|
||||||
|
return "end"
|
||||||
|
return "rewrite"
|
||||||
|
|
||||||
def build_graph() -> StateGraph:
|
def build_graph() -> StateGraph:
|
||||||
graph = StateGraph(BriefState)
|
graph = StateGraph(ReflectState)
|
||||||
|
|
||||||
# Add nodes
|
graph.add_node("draft_answer", draft_answer)
|
||||||
graph.add_node("outline", outline_node)
|
graph.add_node("reflect", reflect)
|
||||||
graph.add_node("research_step", research_step_node)
|
graph.add_node("rewrite", rewrite)
|
||||||
graph.add_node("synthesize", synthesize_node)
|
graph.add_node("decide", decide)
|
||||||
|
|
||||||
# Define the condition for looping research steps
|
graph.set_entry_point("draft_answer")
|
||||||
def condition(state: BriefState):
|
graph.add_edge("draft_answer", "reflect")
|
||||||
if state["step_index"] < len(state["outline"]):
|
graph.add_edge("reflect", "decide")
|
||||||
return "research_step"
|
graph.add_conditional_edges("decide", lambda state: state["verdict"] if state["verdict"] == "ok" else ("rewrite" if state["round"] < state["max_rounds"] else "end"))
|
||||||
else:
|
graph.add_edge("rewrite", "reflect")
|
||||||
return "synthesize"
|
graph.add_edge("end", END)
|
||||||
|
|
||||||
# Build edges
|
return graph
|
||||||
graph.add_edge("outline", "research_step")
|
|
||||||
graph.add_conditional_edges("research_step", condition, {
|
|
||||||
"research_step": "research_step",
|
|
||||||
"synthesize": "synthesize"
|
|
||||||
})
|
|
||||||
graph.add_edge("synthesize", "__end__")
|
|
||||||
|
|
||||||
return graph.compile()
|
|
||||||
+37
-26
@@ -1,38 +1,49 @@
|
|||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
from dotenv import load_dotenv
|
import sys
|
||||||
from src.graph import build_graph
|
|
||||||
from src.state import BriefState
|
from .graph import build_graph
|
||||||
|
from .state import ReflectState
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="LangGraph reflection agent")
|
||||||
|
parser.add_argument("question", type=str, help="The question to answer")
|
||||||
|
parser.add_argument(
|
||||||
|
"--max_rounds",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="Maximum number of rewrite rounds (default: 2)",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# Load environment variables
|
args = parse_args()
|
||||||
load_dotenv()
|
|
||||||
# Default topic
|
|
||||||
default_topic = "Как студенту безопасно подключать MCP к LangChain"
|
|
||||||
|
|
||||||
# Initial state
|
# Ensure OpenAI key is set
|
||||||
initial_state: BriefState = {
|
if not os.getenv("OPENAI_API_KEY"):
|
||||||
"topic": default_topic,
|
print("Error: OPENAI_API_KEY environment variable not set.", file=sys.stderr)
|
||||||
"outline": None,
|
sys.exit(1)
|
||||||
"step_index": 0,
|
|
||||||
"notes": [],
|
initial_state: ReflectState = {
|
||||||
"final_brief": None
|
"question": args.question,
|
||||||
|
"draft": "",
|
||||||
|
"critique": [],
|
||||||
|
"verdict": "",
|
||||||
|
"round": 0,
|
||||||
|
"max_rounds": args.max_rounds,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build and run the graph
|
|
||||||
graph = build_graph()
|
graph = build_graph()
|
||||||
final_state = graph.invoke(initial_state)
|
final_state = graph.invoke(initial_state)
|
||||||
|
|
||||||
# Print results
|
print("\n=== Final Answer ===")
|
||||||
print("\n=== Outline ===")
|
print(final_state["draft"])
|
||||||
for i, step in enumerate(final_state["outline"], 1):
|
print("\n=== Verdict ===")
|
||||||
print(f"{i}. {step}")
|
print(final_state["verdict"])
|
||||||
|
if final_state["verdict"] != "ok":
|
||||||
print("\n=== Notes ===")
|
print("\n=== Critique ===")
|
||||||
for i, note in enumerate(final_state["notes"], 1):
|
for i, point in enumerate(final_state["critique"], 1):
|
||||||
print(f"[Step {i}] {note}\n")
|
print(f"{i}. {point}")
|
||||||
|
|
||||||
print("\n=== Final Brief ===")
|
|
||||||
print(final_state["final_brief"])
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
+8
-7
@@ -1,8 +1,9 @@
|
|||||||
from typing import TypedDict, List, Optional
|
from typing import TypedDict, List
|
||||||
|
|
||||||
class BriefState(TypedDict):
|
class ReflectState(TypedDict):
|
||||||
topic: str
|
question: str
|
||||||
outline: List[str] | None
|
draft: str
|
||||||
step_index: int
|
critique: List[str]
|
||||||
notes: List[str]
|
verdict: str # "ok" | "needs_revision"
|
||||||
final_brief: str | None
|
round: int
|
||||||
|
max_rounds: int
|
||||||
Reference in New Issue
Block a user