Agentic-Support-Copilot / tests /test_workflow.py
Vineetiitg
feat(docs+tests): add architecture diagram to README and mocked workflow unit tests
0eca2a3
Raw
History Blame Contribute Delete
11.2 kB
"""
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
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