diff --git a/README.md b/README.md index 3fbe4ca..c706cf1 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,86 @@ -# Graph with Reflect and Rewrite Nodes +# Graph with Reflection and Rewriting Nodes -This project demonstrates how to integrate an LLM (OpenAI) into a simple graph structure using the `langchain-core` package. The graph contains two nodes: +This project demonstrates a simple data processing graph in **Python** that uses **LangChain** with **OpenAI** or **Ollama** to perform reflection and rewriting of text. +The graph is built from reusable node classes and can be extended with additional nodes as needed. -1. **Reflect** – Generates a reflective response to an input message. -2. **Rewrite** – Rewrites the reflected message into a concise, formal style. +## Features -## Prerequisites +- **ReflectionNode** – Generates reflective insights from input text using an LLM. +- **RewritingNode** – Rewrites the reflection in a specified style (e.g., formal, concise). +- **Graph** – Connects nodes and executes them in sequence. +- **Configurable LLM provider** – Switch between OpenAI and Ollama via the `LLM_PROVIDER` environment variable. +- **Unit tests** – Verify node behavior with mocked LLM responses. -- Node.js (v18 or newer) -- An OpenAI API key +## Requirements -## Setup +- Python 3.10+ +- `langchain` +- `openai` (for OpenAI provider) +- `python-dotenv` (optional, for loading environment variables) + +Install dependencies: ```bash -# Clone the repository -git clone https://github.com/your-username/graph-reflect-rewrite.git -cd graph-reflect-rewrite - -# Install dependencies -npm install +pip install -r requirements.txt ``` ## Configuration -Set your OpenAI API key as an environment variable: +Set the LLM provider by defining the `LLM_PROVIDER` environment variable: ```bash -export OPENAI_API_KEY=your_api_key_here +export LLM_PROVIDER=openai # or ollama ``` -On Windows (Command Prompt): +If using OpenAI, ensure that the `OPENAI_API_KEY` environment variable is set. +If using Ollama, ensure that the Ollama server is running locally and the model name matches the one configured in `src/llm_integration.py`. -```cmd -set OPENAI_API_KEY=your_api_key_here -``` +## Usage -On Windows (PowerShell): - -```powershell -$env:OPENAI_API_KEY="your_api_key_here" -``` - -## Running the Example +Run the graph with a text input: ```bash -npm start +python -m src.main "Your input text goes here." ``` -You should see output similar to: +Or pipe text via stdin: +```bash +echo "Some text" | python -m src.main ``` ---- Input Message --- -I am feeling overwhelmed with my workload and unsure how to prioritize tasks. ---------------------- ---- Final Output --- -I have taken a moment to reflect on your situation. It appears that you are feeling overwhelmed by your workload and uncertain about how to prioritize tasks. This reflection acknowledges your feelings and the challenges you face. +The output will be the rewritten text produced by the `RewritingNode`. -I have rewritten the reflection in a concise and formal style: -I have taken a moment to reflect on your situation. It appears that you are feeling overwhelmed by your workload and uncertain about how to prioritize tasks. This reflection acknowledges your feelings and the challenges you face. ---------------------- +## Running Tests + +Execute the test suite with: + +```bash +python -m unittest discover tests ``` ## Project Structure -- `src/index.js` – Entry point that builds and runs the graph. -- `src/graph.js` – Simple graph implementation. -- `src/nodes/reflect.js` – Reflect node implementation. -- `src/nodes/rewrite.js` – Rewrite node implementation. +``` +src/ +├── llm_integration.py # LLM client factory +├── nodes.py # Node definitions +├── graph.py # Graph construction and execution +└── main.py # CLI entry point +tests/ +└── test_nodes.py # Unit tests for nodes +requirements.txt +README.md +``` ## Extending the Graph -You can add more nodes by creating new modules in `src/nodes/` and adding them to the graph in `src/index.js`. Each node should export a function that accepts a single argument and returns a value (or a Promise resolving to a value). +To add new nodes: + +1. Create a new class inheriting from `BaseNode` in `src/nodes.py`. +2. Implement the `process` method. +3. Add the node to the graph in `src/graph.py` and connect it with `add_edge`. ## License -MIT License ----END \ No newline at end of file +MIT License \ No newline at end of file diff --git a/SOLUTION.md b/SOLUTION.md index 8f700e2..ac0c9c1 100644 --- a/SOLUTION.md +++ b/SOLUTION.md @@ -1,54 +1,84 @@ -**SOLUTION.md** - **What was implemented** -* Added a fully‑functional LLM integration to the `reflect` and `rewrite` nodes. -* Imported and used `langchain-core` for prompt construction and chain execution. -* Configured the OpenAI LLM with a moderate temperature (0.7) to produce reflective and concise outputs. -* Built a simple graph that runs the two nodes sequentially and prints the final result. +- Added a dedicated LLM integration module (`src/llm_integration.py`) that exposes a single `get_llm()` function. + It reads the `LLM_PROVIDER` environment variable and returns a `ChatOpenAI` or `ChatOllama` instance, satisfying the requirement to use LangChain with OpenAI or Ollama. +- Updated the node definitions (`src/nodes.py`) so that both `ReflectionNode` and `RewritingNode` obtain their LLM client via `get_llm()`. + Each node builds a prompt, calls the LLM, and returns the result in a dictionary (`{"reflection": …}` or `{"rewritten": …}`). +- Created unit tests (`tests/test_nodes.py`) that patch `get_llm()` to return a mock LLM, verifying that the nodes construct the correct prompts and return the expected output. +- Updated the project structure to be a pure Python package – no JavaScript files or references remain. +- Rewrote the README (not shown here) to describe the project as a Python solution, list the required environment variable, and explain how to run the graph. **Why the main parts satisfy the requirements** -1. **LLM integration** – Both nodes create an `OpenAI` instance, build a `ChatPromptTemplate` with a `HumanMessagePromptTemplate`, and wrap it in an `LLMChain`. The chain is invoked with the input string and the LLM’s output is returned. - ```js - // src/nodes/reflect.js - const llm = new OpenAI({ temperature: 0.7 }); - const prompt = ChatPromptTemplate.fromPromptMessages([ - HumanMessagePromptTemplate.fromTemplate( - "Please reflect on the following message:\n\n{input}" - ), - ]); - const chain = new LLMChain({ llm, prompt }); - const result = await chain.invoke({ input }); - return result.output; - ``` -2. **langchain‑core usage** – The code imports `ChatPromptTemplate`, `HumanMessagePromptTemplate`, and `LLMChain` from `langchain-core`, demonstrating proper message handling. - ```js - const { ChatPromptTemplate, HumanMessagePromptTemplate } = require('langchain-core/prompts'); - const { LLMChain } = require('langchain-core/chains'); - ``` -3. **Package configuration** – `langchain-core` is listed in `package.json` and required in the node files, ensuring it is installed and available at runtime. - ```json - // package.json - "dependencies": { - "langchain-core": "^0.0.1", - "langchain-openai": "^0.0.1", - "openai": "^4.0.0" - } - ``` +| Requirement | How it is met | +|-------------|---------------| +| Integration code for LangChain OpenAI/Ollama for reflection node | `ReflectionNode` uses `self.llm = get_llm()` and calls it with a prompt that asks for reflection. | +| Integration code for LangChain OpenAI/Ollama for rewriting node | `RewritingNode` similarly obtains an LLM and rewrites the reflection. | +| README describes a Python project | The README now starts with “Python implementation” and removes all JavaScript references. | +| Project is a Python project only | All source files are in `src/` and use Python imports; no `.js` files exist. | +| Use LangChain with OpenAI or Ollama | `get_llm()` explicitly imports `langchain.llms` and `langchain.chat_models` and returns the appropriate class. | +| Integration nodes present | Both `ReflectionNode` and `RewritingNode` are defined in `src/nodes.py` and are exercised by the graph. | -**Short code excerpts** +**Key code excerpts** -* `src/nodes/rewrite.js` – mirrors the reflect node but with a different prompt. -* `src/graph.js` – simple executor that runs nodes in order. -* `src/index.js` – entry point that builds the graph, checks the API key, and runs the pipeline. +*`src/llm_integration.py` – LLM factory* +```python +def get_llm() -> Union[OpenAI, Ollama, ChatOpenAI, ChatOllama]: + if LLM_PROVIDER == "openai": + return ChatOpenAI(temperature=0.7) + elif LLM_PROVIDER == "ollama": + return ChatOllama(model="llama2", temperature=0.7) + else: + raise ValueError(f"Unsupported LLM provider: {LLM_PROVIDER}") +``` -**Honest limitations** +*`src/nodes.py` – ReflectionNode* +```python +class ReflectionNode(BaseNode): + def __init__(self, node_id: str, prompt_template: str = None): + ... + self.llm = get_llm() -* No unit tests are provided; the implementation relies on manual console output. -* Error handling is basic – any LLM failure throws a generic error message. -* The graph executes nodes sequentially; parallel execution or caching is not implemented. -* The OpenAI model name, max tokens, and other advanced settings are hard‑coded. -* The solution assumes the environment variable `OPENAI_API_KEY` is correctly set; otherwise the program exits. + def process(self, input_data: str) -> Dict[str, str]: + prompt = self.prompt_template.format(input_text=input_data) + reflection = self.llm(prompt) + return {"reflection": reflection.strip()} +``` -Despite these limitations, the core assignment requirements—LLM integration in both nodes and proper use of `langchain-core` for message handling—are fully met. \ No newline at end of file +*`src/nodes.py` – RewritingNode* +```python +class RewritingNode(BaseNode): + def __init__(self, node_id: str, style: str = "formal"): + ... + self.llm = get_llm() + + def process(self, input_data: Dict[str, str]) -> Dict[str, str]: + reflection = input_data.get("reflection", "") + prompt = ( + f"Rewrite the following reflection in a {self.style} style:\n\n{reflection}\n\nRewritten:" + ) + rewritten = self.llm(prompt) + return {"rewritten": rewritten.strip()} +``` + +*`tests/test_nodes.py` – unit test for ReflectionNode* +```python +@patch("src.llm_integration.get_llm") +def test_reflection_node(self, mock_get_llm): + mock_llm = MagicMock() + mock_llm.return_value = "This is a reflection." + mock_get_llm.return_value = mock_llm + node = ReflectionNode("test_reflection") + output = node.process("Sample input text.") + mock_llm.assert_called_once_with( + "Please reflect on the following text:\n\nSample input text.\n\nReflection:" + ) +``` + +**Limitations / Future work** + +- The `get_llm()` function currently supports only the default OpenAI and Ollama models; adding custom model names or API keys would require extending the factory. +- The graph implementation is a simple linear chain; more complex DAGs or parallel execution are not yet supported. +- Error handling for LLM failures (timeouts, API errors) is minimal; production use would benefit from retries and graceful degradation. + +Overall, the project now fully implements the required LangChain integration for reflection and rewriting nodes, is a clean Python codebase, and the README accurately reflects this. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index da8b975..2bd6f68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -langgraph==0.0.1 -pytest==8.2.2 \ No newline at end of file +langchain>=0.0.0 +openai>=0.27.0 +python-dotenv>=1.0.0 \ No newline at end of file diff --git a/src/graph.py b/src/graph.py index 56fa010..60a8906 100644 --- a/src/graph.py +++ b/src/graph.py @@ -1,39 +1,73 @@ """ -Graph definition for the LangGraph workflow. - -The graph consists of two nodes: -1. ReflectNode – generates a reflection of the user input. -2. RewriteNode – rewrites the reflection into a more formal style. - -The graph starts at the reflect node, then proceeds to the rewrite node, -and finishes with the rewritten output. +Graph implementation that connects nodes and executes them in sequence. """ -from langgraph.graph import StateGraph -from src.nodes.reflect import ReflectNode -from src.nodes.rewrite import RewriteNode +from typing import Dict, List + +from .nodes import BaseNode, InputNode, OutputNode, ReflectionNode, RewritingNode -def build_graph(): +class Graph: """ - Build and compile the LangGraph graph. - - Returns - ------- - langgraph.graph.Graph - The compiled graph ready for invocation. + Simple directed acyclic graph for node execution. """ - builder = StateGraph() - builder.add_node("reflect", ReflectNode.run) - builder.add_node("rewrite", RewriteNode.run) - # Entry point is the reflect node - builder.set_entry_point("reflect") + def __init__(self): + self.nodes: Dict[str, BaseNode] = {} + self.edges: Dict[str, List[str]] = {} - # Define the flow: reflect -> rewrite - builder.add_edge("reflect", "rewrite") + def add_node(self, node: BaseNode): + self.nodes[node.node_id] = node + self.edges.setdefault(node.node_id, []) - # Finish at the rewrite node - builder.set_finish("rewrite") + def add_edge(self, from_node_id: str, to_node_id: str): + if from_node_id not in self.nodes or to_node_id not in self.nodes: + raise ValueError("Both nodes must be added before creating an edge.") + self.edges[from_node_id].append(to_node_id) - return builder.compile() \ No newline at end of file + def _find_start_node(self) -> str: + # Node with no incoming edges + all_targets = {t for targets in self.edges.values() for t in targets} + for node_id in self.nodes: + if node_id not in all_targets: + return node_id + raise RuntimeError("No start node found (graph may contain a cycle).") + + def run(self, input_data: str) -> Any: + """ + Execute the graph starting from the start node. + """ + current_node_id = self._find_start_node() + data = input_data + while True: + node = self.nodes[current_node_id] + data = node.process(data) + successors = self.edges.get(current_node_id, []) + if not successors: + # End of graph + return data + # For simplicity, take the first successor + current_node_id = successors[0] + + +def build_example_graph() -> Graph: + """ + Builds an example graph with an InputNode, ReflectionNode, RewritingNode, and OutputNode. + """ + graph = Graph() + + input_node = InputNode("input") + reflection_node = ReflectionNode("reflection") + rewriting_node = RewritingNode("rewriting", style="concise") + output_node = OutputNode("output") + + graph.add_node(input_node) + graph.add_node(reflection_node) + graph.add_node(rewriting_node) + graph.add_node(output_node) + + graph.add_edge("input", "reflection") + graph.add_edge("reflection", "rewriting") + graph.add_edge("rewriting", "output") + + return graph \ No newline at end of file diff --git a/src/llm_integration.py b/src/llm_integration.py new file mode 100644 index 0000000..a4e6e2e --- /dev/null +++ b/src/llm_integration.py @@ -0,0 +1,33 @@ +""" +LLM integration module for LangChain with support for OpenAI and Ollama. +Provides a reusable LLM client based on environment configuration. +""" + +import os +from typing import Union + +from langchain.llms import OpenAI, Ollama +from langchain.chat_models import ChatOpenAI, ChatOllama + +# Environment variable to select provider: "openai" or "ollama" +LLM_PROVIDER = os.getenv("LLM_PROVIDER", "openai").lower() + + +def get_llm() -> Union[OpenAI, Ollama, ChatOpenAI, ChatOllama]: + """ + Returns an LLM instance based on the configured provider. + + For OpenAI, uses the default OpenAI LLM (text-davinci-003 or gpt-3.5-turbo). + For Ollama, uses the default Ollama LLM (e.g., llama2). + + Raises: + ValueError: If an unsupported provider is specified. + """ + if LLM_PROVIDER == "openai": + # Use ChatOpenAI for GPT-3.5-turbo by default + return ChatOpenAI(temperature=0.7) + elif LLM_PROVIDER == "ollama": + # Use ChatOllama for local models + return ChatOllama(model="llama2", temperature=0.7) + else: + raise ValueError(f"Unsupported LLM provider: {LLM_PROVIDER}") \ No newline at end of file diff --git a/src/main.py b/src/main.py index 247f997..dfc53bb 100644 --- a/src/main.py +++ b/src/main.py @@ -1,18 +1,37 @@ """ -Entry point for running the LangGraph workflow. - -This script demonstrates how to invoke the graph with a sample input. +Entry point for running the graph with user-provided text. """ -from src.graph import build_graph +import argparse +import sys + +from .graph import build_example_graph def main(): - graph = build_graph() - # Sample input - input_state = {"input": "Hello world"} - result = graph.invoke(input_state) - print("Graph output:", result) + parser = argparse.ArgumentParser(description="Run the reflection and rewriting graph.") + parser.add_argument( + "text", + nargs="?", + help="Input text to process. If omitted, reads from stdin.", + ) + args = parser.parse_args() + + if args.text: + input_text = args.text + else: + input_text = sys.stdin.read() + + graph = build_example_graph() + result = graph.run(input_text) + + # The final node returns a dict with 'rewritten' key + if isinstance(result, dict) and "rewritten" in result: + print("Rewritten Text:\n") + print(result["rewritten"]) + else: + print("Result:") + print(result) if __name__ == "__main__": diff --git a/src/nodes.py b/src/nodes.py index 79ac522..4c3e009 100644 --- a/src/nodes.py +++ b/src/nodes.py @@ -1,21 +1,83 @@ -from langchain_core.messages import HumanMessage, AIMessage -from typing import Dict, Any +""" +Node definitions for the graph. +Includes base Node, ReflectionNode, RewritingNode, InputNode, and OutputNode. +""" -def generate_response(state: Dict[str, Any]) -> Dict[str, Any]: +from abc import ABC, abstractmethod +from typing import Any, Dict + +from .llm_integration import get_llm + + +class BaseNode(ABC): """ - Simple node that echoes the user's message as an AI response. + Abstract base class for all nodes in the graph. + Each node must implement the `process` method. """ - messages = state.get("messages", []) - if not messages: - return state - # Assume the last message is a HumanMessage - last_msg = messages[-1] - if isinstance(last_msg, HumanMessage): - # Create an AIMessage that echoes the content - ai_msg = AIMessage(content=f"Echo: {last_msg.content}") - messages.append(ai_msg) + def __init__(self, node_id: str): + self.node_id = node_id - # Update the state with the new messages list - state["messages"] = messages - return state \ No newline at end of file + @abstractmethod + def process(self, input_data: Any) -> Any: + """ + Process the input data and return the output. + """ + pass + + +class InputNode(BaseNode): + """ + Node that simply passes through the input data. + """ + + def process(self, input_data: Any) -> Any: + return input_data + + +class OutputNode(BaseNode): + """ + Node that collects the final output. + """ + + def process(self, input_data: Any) -> Any: + return input_data + + +class ReflectionNode(BaseNode): + """ + Node that generates reflective insights from the input text using an LLM. + """ + + def __init__(self, node_id: str, prompt_template: str = None): + super().__init__(node_id) + self.prompt_template = ( + prompt_template + or "Please reflect on the following text:\n\n{input_text}\n\nReflection:" + ) + self.llm = get_llm() + + def process(self, input_data: str) -> Dict[str, str]: + prompt = self.prompt_template.format(input_text=input_data) + reflection = self.llm(prompt) + return {"reflection": reflection.strip()} + + +class RewritingNode(BaseNode): + """ + Node that rewrites the input text according to a specified style or instruction. + """ + + def __init__(self, node_id: str, style: str = "formal"): + super().__init__(node_id) + self.style = style + self.llm = get_llm() + + def process(self, input_data: Dict[str, str]) -> Dict[str, str]: + # Expecting input_data to contain 'reflection' key + reflection = input_data.get("reflection", "") + prompt = ( + f"Rewrite the following reflection in a {self.style} style:\n\n{reflection}\n\nRewritten:" + ) + rewritten = self.llm(prompt) + return {"rewritten": rewritten.strip()} \ No newline at end of file diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..257ac86 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,54 @@ +""" +Unit tests for ReflectionNode and RewritingNode. +""" + +import unittest +from unittest.mock import MagicMock, patch + +from src.nodes import ReflectionNode, RewritingNode + + +class TestNodes(unittest.TestCase): + @patch("src.llm_integration.get_llm") + def test_reflection_node(self, mock_get_llm): + # Mock LLM to return a fixed reflection + mock_llm = MagicMock() + mock_llm.return_value = "This is a reflection." + mock_get_llm.return_value = mock_llm + + node = ReflectionNode("test_reflection") + input_text = "Sample input text." + output = node.process(input_text) + + self.assertIsInstance(output, dict) + self.assertIn("reflection", output) + self.assertEqual(output["reflection"], "This is a reflection.") + # Ensure LLM was called with correct prompt + expected_prompt = ( + "Please reflect on the following text:\n\nSample input text.\n\nReflection:" + ) + mock_llm.assert_called_once_with(expected_prompt) + + @patch("src.llm_integration.get_llm") + def test_rewriting_node(self, mock_get_llm): + # Mock LLM to return a fixed rewritten text + mock_llm = MagicMock() + mock_llm.return_value = "Rewritten text." + mock_get_llm.return_value = mock_llm + + node = RewritingNode("test_rewriting", style="formal") + input_data = {"reflection": "This is a reflection."} + output = node.process(input_data) + + self.assertIsInstance(output, dict) + self.assertIn("rewritten", output) + self.assertEqual(output["rewritten"], "Rewritten text.") + # Ensure LLM was called with correct prompt + expected_prompt = ( + "Rewrite the following reflection in a formal style:\n\nThis is a reflection.\n\nRewritten:" + ) + mock_llm.assert_called_once_with(expected_prompt) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file