Vineetiitg
feat(docs+tests): add architecture diagram to README and mocked workflow unit tests
0eca2a3 | """ | |
| Unit tests for the LangGraph Self-RAG workflow. | |
| These tests mock all LLM calls and external retrieval so they run | |
| instantly without network access, API keys, or a running Qdrant instance. | |
| Strategy: patch the individual async node functions (retrieve, grade_documents, | |
| generate, evaluate_answer) rather than the raw LLM objects, because LangGraph | |
| compiles the graph at import time and the pipe operator is hard to intercept. | |
| """ | |
| import pytest | |
| from unittest.mock import AsyncMock, patch | |
| from langchain_core.documents import Document | |
| # --------------------------------------------------------------------------- | |
| # Helpers – sample data | |
| # --------------------------------------------------------------------------- | |
| SAMPLE_DOCS = [ | |
| Document( | |
| page_content="To reset your password, go to Settings > Security > Reset Password.", | |
| metadata={"source": "faq.md", "doc_id": "faq-001", "chunk_id": "c1"}, | |
| ), | |
| Document( | |
| page_content="Our support team is available 24/7 via the Help Center.", | |
| metadata={"source": "contact.md", "doc_id": "contact-001", "chunk_id": "c2"}, | |
| ), | |
| ] | |
| SAMPLE_SOURCES = [ | |
| {"source": "faq.md", "page": None, "chunk_id": "c1", "doc_id": "faq-001", | |
| "snippet": "To reset your password, go to Settings > Security > Reset Password."}, | |
| ] | |
| def _base_state(**overrides): | |
| """Build a minimal valid input state for the workflow.""" | |
| state = {"question": "How do I reset my password?", "chat_history": [], "run_count": 0} | |
| state.update(overrides) | |
| return state | |
| # --------------------------------------------------------------------------- | |
| # Fake node return values | |
| # --------------------------------------------------------------------------- | |
| async def _fake_retrieve(state): | |
| return { | |
| "documents": SAMPLE_DOCS, | |
| "sources": SAMPLE_SOURCES, | |
| "question": state["question"], | |
| "run_count": state.get("run_count", 0), | |
| } | |
| async def _fake_retrieve_empty(state): | |
| return { | |
| "documents": [], | |
| "sources": [], | |
| "question": state["question"], | |
| "run_count": state.get("run_count", 0), | |
| } | |
| async def _fake_grade_all_relevant(state): | |
| return {"documents": state.get("documents", [])} | |
| async def _fake_grade_all_irrelevant(state): | |
| return {"documents": []} | |
| async def _fake_generate(state): | |
| run_count = state.get("run_count", 0) + 1 | |
| return { | |
| "generation": "Reset your password in Settings > Security.", | |
| "sources": SAMPLE_SOURCES, | |
| "run_count": run_count, | |
| } | |
| async def _fake_evaluate_grounded(state): | |
| return {"grounded": "yes", "confidence_score": 0.95} | |
| async def _fake_evaluate_hallucinated(state): | |
| return {"grounded": "no", "confidence_score": 0.2} | |
| # --------------------------------------------------------------------------- | |
| # Tests – Happy Path | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_happy_path_returns_grounded_answer(): | |
| """Full graph: retrieve → grade(yes) → generate → evaluate(grounded) → END.""" | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \ | |
| patch("app.graph.workflow.generate", new=_fake_generate), \ | |
| patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state()) | |
| assert state["generation"] == "Reset your password in Settings > Security." | |
| assert state["confidence_score"] == 0.95 | |
| assert state["grounded"] == "yes" | |
| assert state["run_count"] == 1 | |
| assert len(state["sources"]) > 0 | |
| # --------------------------------------------------------------------------- | |
| # Tests – No Documents Retrieved | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_ends_when_no_documents_retrieved(): | |
| """When retriever returns nothing, grade filters to empty → graph ends.""" | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve_empty), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_irrelevant): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state(question="What is the meaning of life?")) | |
| # No docs → no generation | |
| assert "generation" not in state or state.get("generation") is None | |
| assert state["documents"] == [] | |
| # --------------------------------------------------------------------------- | |
| # Tests – All Documents Graded Irrelevant | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_ends_when_all_docs_graded_irrelevant(): | |
| """When grader rejects all docs, graph ends without calling generate.""" | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_irrelevant): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state(question="Tell me about quantum physics")) | |
| assert "generation" not in state or state.get("generation") is None | |
| # --------------------------------------------------------------------------- | |
| # Tests – Hallucination Retry Loop Caps at 3 | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_retries_on_hallucination_and_caps_at_max(): | |
| """ | |
| Evaluator always says 'hallucinated', so the graph loops back to | |
| generate. After run_count reaches 3, check_hallucinations routes to END. | |
| """ | |
| generate_call_count = 0 | |
| async def _counting_generate(state): | |
| nonlocal generate_call_count | |
| generate_call_count += 1 | |
| run_count = state.get("run_count", 0) + 1 | |
| return { | |
| "generation": f"Attempt {generate_call_count}", | |
| "sources": SAMPLE_SOURCES, | |
| "run_count": run_count, | |
| } | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \ | |
| patch("app.graph.workflow.generate", new=_counting_generate), \ | |
| patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_hallucinated): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state()) | |
| # Must stop at or before 3 retries | |
| assert state["run_count"] <= 3, f"Expected max 3 retries, got {state['run_count']}" | |
| assert generate_call_count <= 3, f"Generate called {generate_call_count} times, expected ≤ 3" | |
| assert state["generation"], "Should return the last attempt's answer" | |
| # --------------------------------------------------------------------------- | |
| # Tests – Single Retry Then Grounded | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_retries_once_then_succeeds(): | |
| """First evaluation says hallucinated, second says grounded → stops at run_count=2.""" | |
| eval_call_count = 0 | |
| async def _eval_fail_then_pass(state): | |
| nonlocal eval_call_count | |
| eval_call_count += 1 | |
| if eval_call_count == 1: | |
| return {"grounded": "no", "confidence_score": 0.3} | |
| return {"grounded": "yes", "confidence_score": 0.9} | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \ | |
| patch("app.graph.workflow.generate", new=_fake_generate), \ | |
| patch("app.graph.workflow.evaluate_answer", new=_eval_fail_then_pass): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state()) | |
| assert state["grounded"] == "yes" | |
| assert state["confidence_score"] == 0.9 | |
| assert eval_call_count == 2 | |
| # --------------------------------------------------------------------------- | |
| # Tests – Output State Structure | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_output_state_has_required_keys(): | |
| """Verify the final state dict contains all expected keys after a full run.""" | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \ | |
| patch("app.graph.workflow.generate", new=_fake_generate), \ | |
| patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state()) | |
| required_keys = {"question", "documents", "generation", "sources", "run_count", | |
| "confidence_score", "grounded"} | |
| assert required_keys.issubset(state.keys()), f"Missing keys: {required_keys - state.keys()}" | |
| # --------------------------------------------------------------------------- | |
| # Tests – Chat History Preservation | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_preserves_chat_history(): | |
| """Ensure chat_history is passed through the graph without corruption.""" | |
| history = [ | |
| {"role": "user", "content": "Hello"}, | |
| {"role": "assistant", "content": "Hi! How can I help?"}, | |
| ] | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \ | |
| patch("app.graph.workflow.generate", new=_fake_generate), \ | |
| patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state(chat_history=history)) | |
| assert state["chat_history"] == history, "Chat history should be preserved" | |
| # --------------------------------------------------------------------------- | |
| # Tests – Question Passthrough | |
| # --------------------------------------------------------------------------- | |
| async def test_workflow_returns_original_question(): | |
| """The original question must be present in the final state unchanged.""" | |
| question = "How do I contact support?" | |
| with patch("app.graph.workflow.retrieve", new=_fake_retrieve), \ | |
| patch("app.graph.workflow.grade_documents", new=_fake_grade_all_relevant), \ | |
| patch("app.graph.workflow.generate", new=_fake_generate), \ | |
| patch("app.graph.workflow.evaluate_answer", new=_fake_evaluate_grounded): | |
| from app.graph.workflow import compile_workflow | |
| agent = compile_workflow() | |
| state = await agent.ainvoke(_base_state(question=question)) | |
| assert state["question"] == question | |