mmap-backend / tests /test_agents_chat_workflow.py
jugalgajjar's picture
feat: chat history (multi-turn, search, rename, delete)
4ae8ec5
Raw
History Blame Contribute Delete
20.8 kB
"""Unit tests for the LangGraph chat workflow."""
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
from app.agents import chat_workflow as wf
from app.agents.verification import VerificationResult
from app.rag.graph_expansion import GraphFact, GraphRelation
from app.rag.groq_chat import GroqChatError
from app.rag.retrieval import RetrievedChunk
def _chunk(text: str = "hello") -> RetrievedChunk:
return RetrievedChunk(
chunk_id=str(uuid4()),
document_id=str(uuid4()),
chunk_index=0,
score=0.9,
text=text,
)
def _fact() -> GraphFact:
return GraphFact(
name="Qdrant",
type="Technology",
description="vector DB",
relations=[
GraphRelation(
relation="uses",
other="Cosine Distance",
distance=1,
relation_chain=["uses"],
)
],
)
@pytest.fixture(autouse=True)
def _stub_classifier_to_chat():
"""Default every test to the 'chat' route so we don't hit Groq."""
with patch.object(wf, "classify_intent", new=AsyncMock(return_value="chat")):
yield
@pytest.mark.asyncio
async def test_run_chat_returns_answer_chunks_and_facts():
chunks = [_chunk("Qdrant is a vector DB")]
facts = [_fact()]
with (
patch.object(wf, "retrieve", return_value=chunks),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=facts)),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="42")),
# Without this patch the real verifier runs (live Groq when .env has
# a key) and the strict gate would legitimately refuse "42".
patch.object(
wf,
"verify_answer",
new=AsyncMock(
return_value=VerificationResult(verdict="verified", groundedness_score=1.0)
),
),
):
state = await wf.run_chat(query="what is qdrant?", user_id=uuid4())
assert state["answer"] == "42"
assert state["chunks"] == chunks
assert state["graph_facts"] == facts
assert state["used_context"] is True
assert state["used_graph"] is True
assert state["intent"] == "chat"
@pytest.mark.asyncio
async def test_run_chat_marks_no_context_when_retrieval_returns_empty():
with (
patch.object(wf, "retrieve", return_value=[]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="I don't know.")),
):
state = await wf.run_chat(query="anything", user_id=uuid4())
assert state["used_context"] is False
assert state["used_graph"] is False
assert state["chunks"] == []
assert state["graph_facts"] == []
@pytest.mark.asyncio
async def test_run_chat_uses_no_context_fallback_system_prompt(monkeypatch):
captured: dict = {}
async def fake_chat(*, messages, **kwargs):
captured["system"] = messages[0]["content"]
return "ok"
monkeypatch.setattr(wf, "chat_completion", fake_chat)
monkeypatch.setattr(wf, "retrieve", lambda **kwargs: [])
monkeypatch.setattr(wf, "_safe_expand", AsyncMock(return_value=[]))
await wf.run_chat(query="hi", user_id=uuid4())
from app.rag.prompts import NO_CONTEXT_FALLBACK_SYSTEM, SYSTEM_PROMPT
assert captured["system"] == NO_CONTEXT_FALLBACK_SYSTEM
assert captured["system"] != SYSTEM_PROMPT
@pytest.mark.asyncio
async def test_run_chat_propagates_groq_errors():
with (
patch.object(wf, "retrieve", return_value=[_chunk()]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(
wf,
"chat_completion",
new=AsyncMock(side_effect=GroqChatError(429, {"detail": "rate limit"})),
),
pytest.raises(GroqChatError) as exc,
):
await wf.run_chat(query="hi", user_id=uuid4())
assert exc.value.status_code == 429
@pytest.mark.asyncio
async def test_safe_expand_swallows_graph_errors():
with patch.object(
wf,
"expand_with_graph",
new=AsyncMock(side_effect=RuntimeError("neo4j down")),
):
result = await wf._safe_expand("q", [], uuid4())
assert result == []
@pytest.mark.asyncio
async def test_workflow_passes_top_k_and_document_ids_to_retrieve():
captured: dict = {}
def fake_retrieve(*, query, user_id, top_k, document_ids):
captured.update(
{"query": query, "user_id": user_id, "top_k": top_k, "document_ids": document_ids}
)
return []
doc_id = uuid4()
with (
patch.object(wf, "retrieve", side_effect=fake_retrieve),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="ok")),
):
await wf.run_chat(
query="explicit",
user_id=uuid4(),
top_k=7,
document_ids=[doc_id],
)
assert captured["query"] == "explicit"
assert captured["top_k"] == 7
assert captured["document_ids"] == [doc_id]
@pytest.mark.asyncio
async def test_compiled_workflow_executes_nodes_in_order():
chunks = [_chunk()]
with (
patch.object(wf, "retrieve", return_value=chunks),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="done")),
patch.object(
wf,
"verify_answer",
new=AsyncMock(
return_value=VerificationResult(verdict="verified", groundedness_score=1.0)
),
),
):
compiled = wf.build_chat_workflow()
final = await compiled.ainvoke({"query": "go", "user_id": uuid4(), "top_k": 3})
assert final["answer"] == "done"
assert final["chunks"] == chunks
@pytest.mark.asyncio
async def test_workflow_runs_verify_node_after_respond():
chunks = [_chunk("Qdrant is a vector DB")]
captured: dict = {}
async def fake_verify(*, answer, chunks, graph_facts, web_results=None):
captured["answer"] = answer
captured["chunks"] = chunks
return VerificationResult(
verdict="partial",
groundedness_score=0.5,
total_claims=2,
supported_claims=1,
unsupported_claims=["fabricated bit"],
)
with (
patch.object(wf, "retrieve", return_value=chunks),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="Qdrant is great.")),
patch.object(wf, "verify_answer", new=fake_verify),
):
state = await wf.run_chat(query="what?", user_id=uuid4())
assert captured["answer"] == "Qdrant is great."
assert captured["chunks"] == chunks
v = state["verification"]
assert v.verdict == "partial"
assert v.unsupported_claims == ["fabricated bit"]
@pytest.mark.asyncio
async def test_route_after_classify_picks_the_right_branch():
assert wf._route_after_classify({"intent": "chat"}) == "retrieve_for_chat"
assert wf._route_after_classify({"intent": "summarize"}) == "fetch_summaries"
assert wf._route_after_classify({"intent": "explain_graph"}) == "retrieve_for_graph"
# Missing intent defaults to chat.
assert wf._route_after_classify({}) == "retrieve_for_chat"
@pytest.mark.asyncio
async def test_summarize_intent_uses_summaries_branch():
"""When the classifier returns 'summarize', the workflow must fetch the
user's stored doc summaries instead of running vector retrieval."""
summaries = [
{
"id": "d-1",
"filename": "paper.pdf",
"tldr": "The paper covers vector RAG.",
"key_points": ["uses Qdrant", "uses bge-small"],
"topics": ["RAG"],
}
]
with (
patch.object(wf, "classify_intent", new=AsyncMock(return_value="summarize")),
patch.object(wf, "list_summaries_for_user", new=AsyncMock(return_value=summaries)),
patch.object(wf, "retrieve") as retrieve_spy,
patch.object(wf, "chat_completion", new=AsyncMock(return_value="summary answer")),
patch.object(
wf,
"verify_answer",
new=AsyncMock(
return_value=VerificationResult(verdict="verified", groundedness_score=1.0)
),
),
):
state = await wf.run_chat(query="recap my docs", user_id=uuid4())
assert state["intent"] == "summarize"
assert state["doc_summaries"] == summaries
assert state["used_context"] is True
retrieve_spy.assert_not_called()
@pytest.mark.asyncio
async def test_explain_graph_intent_skips_vector_retrieval():
facts = [_fact()]
with (
patch.object(wf, "classify_intent", new=AsyncMock(return_value="explain_graph")),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=facts)) as expand_spy,
patch.object(wf, "retrieve") as retrieve_spy,
patch.object(wf, "chat_completion", new=AsyncMock(return_value="graph answer")),
patch.object(
wf,
"verify_answer",
new=AsyncMock(
return_value=VerificationResult(verdict="verified", groundedness_score=1.0)
),
),
):
state = await wf.run_chat(query="what relates to qdrant", user_id=uuid4())
assert state["intent"] == "explain_graph"
assert state["chunks"] == []
assert state["graph_facts"] == facts
assert state["used_graph"] is True
retrieve_spy.assert_not_called()
# Expansion still gets called (with empty chunks) so it walks from seeds.
expand_spy.assert_called_once()
@pytest.mark.asyncio
async def test_classifier_failure_falls_back_to_chat_branch():
"""Even when classify_intent crashes upstream, the workflow falls back
to the safe `chat` branch — the user always gets an answer."""
# The autouse fixture is the seam: replace it with a failing AsyncMock.
# The router's own catch is exercised by test_classify_intent_falls_back_*;
# this test asserts the wf surfaces the error if the classifier crashes
# outside its own catch (defense in depth).
chunks = [_chunk("text")]
with (
patch.object(wf, "classify_intent", new=AsyncMock(side_effect=RuntimeError("boom"))),
patch.object(wf, "retrieve", return_value=chunks),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="answer")),
patch.object(
wf,
"verify_answer",
new=AsyncMock(
return_value=VerificationResult(verdict="verified", groundedness_score=1.0)
),
),
pytest.raises(RuntimeError),
):
await wf.run_chat(query="hi", user_id=uuid4())
# ---------------------------------------------------------------------------
# RAG toggle, web search, strict gate
# ---------------------------------------------------------------------------
def _web_result(content: str = "fresh web fact"):
from app.rag.tavily import WebResult
return WebResult(title="W", url="https://w.com", content=content, score=0.9)
_VERIFIED = VerificationResult(verdict="verified", groundedness_score=1.0)
@pytest.mark.asyncio
async def test_rag_off_skips_classifier_and_retrieval():
captured: dict = {}
async def fake_chat(*, messages, **kwargs):
captured["system"] = messages[0]["content"]
return "from my own knowledge"
with (
patch.object(wf, "classify_intent", new=AsyncMock(return_value="chat")) as classify_spy,
patch.object(wf, "retrieve") as retrieve_spy,
patch.object(wf, "chat_completion", new=fake_chat),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=_VERIFIED)),
):
state = await wf.run_chat(query="hi", user_id=uuid4(), use_rag=False)
from app.rag.prompts import PURE_KNOWLEDGE_SYSTEM
classify_spy.assert_not_awaited()
retrieve_spy.assert_not_called()
assert state["intent"] == "chat"
assert state["chunks"] == []
assert state["used_context"] is False
assert captured["system"] == PURE_KNOWLEDGE_SYSTEM
@pytest.mark.asyncio
async def test_rag_off_web_on_uses_web_only_prompt_and_user_max():
captured: dict = {}
async def fake_chat(*, messages, **kwargs):
captured["system"] = messages[0]["content"]
captured["user"] = messages[1]["content"]
return "web answer"
with (
patch.object(wf, "retrieve") as retrieve_spy,
patch.object(
wf, "search_web", new=AsyncMock(return_value=[_web_result("nemotron rocks")])
) as web_spy,
patch.object(wf, "chat_completion", new=fake_chat),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=_VERIFIED)),
):
state = await wf.run_chat(
query="latest news?",
user_id=uuid4(),
use_rag=False,
use_web=True,
web_max_results=7,
)
from app.rag.prompts import WEB_ONLY_SYSTEM
retrieve_spy.assert_not_called()
web_spy.assert_awaited_once_with(query="latest news?", max_results=7)
assert state["used_web"] is True
assert captured["system"] == WEB_ONLY_SYSTEM
assert "nemotron rocks" in captured["user"]
assert "[W1]" in captured["user"]
@pytest.mark.asyncio
async def test_route_after_classify_returns_skip_retrieval_when_rag_off():
assert wf._route_after_classify({"use_rag": False}) == "skip_retrieval"
assert wf._route_after_classify({"use_rag": False, "intent": "summarize"}) == "skip_retrieval"
assert wf._route_after_classify({"use_rag": True, "intent": "chat"}) == "retrieve_for_chat"
@pytest.mark.asyncio
async def test_web_search_failure_is_non_blocking():
with (
patch.object(wf, "retrieve", return_value=[]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "search_web", new=AsyncMock(side_effect=RuntimeError("tavily down"))),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="still answered")),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=_VERIFIED)),
):
state = await wf.run_chat(query="q", user_id=uuid4(), use_web=True)
assert state["answer"] == "still answered"
assert state["used_web"] is False
assert state["web_results"] == []
@pytest.mark.asyncio
async def test_strict_gate_replaces_low_groundedness_answer():
low = VerificationResult(verdict="unsupported", groundedness_score=0.3)
with (
patch.object(wf, "retrieve", return_value=[_chunk()]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="dubious claim")),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=low)),
):
state = await wf.run_chat(query="q", user_id=uuid4(), rag_mode="strict")
from app.rag.prompts import STRICT_REFUSAL_MESSAGE
assert state["answer"] == STRICT_REFUSAL_MESSAGE
assert state["strict_refusal"] == STRICT_REFUSAL_MESSAGE
# The verification result itself stays honest.
assert state["verification"].groundedness_score == 0.3
@pytest.mark.asyncio
async def test_regular_mode_keeps_low_groundedness_answer():
low = VerificationResult(verdict="unsupported", groundedness_score=0.3)
with (
patch.object(wf, "retrieve", return_value=[_chunk()]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="hybrid answer")),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=low)),
):
state = await wf.run_chat(query="q", user_id=uuid4(), rag_mode="regular")
assert state["answer"] == "hybrid answer"
assert state["strict_refusal"] is None
@pytest.mark.asyncio
async def test_regular_mode_with_context_uses_regular_system_prompt():
captured: dict = {}
async def fake_chat(*, messages, **kwargs):
captured["system"] = messages[0]["content"]
return "ok"
with (
patch.object(wf, "retrieve", return_value=[_chunk()]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=fake_chat),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=_VERIFIED)),
):
await wf.run_chat(query="q", user_id=uuid4(), rag_mode="regular")
from app.rag.prompts import REGULAR_MODE_SYSTEM
assert captured["system"] == REGULAR_MODE_SYSTEM
@pytest.mark.asyncio
@pytest.mark.parametrize("use_rag", [True, False])
@pytest.mark.parametrize("use_web", [True, False])
@pytest.mark.parametrize("intent", ["chat", "summarize", "explain_graph"])
async def test_prepare_context_state_parity_with_graph(use_rag, use_web, intent):
"""The streaming path's context must match the graph's pre-respond state
for every (intent, use_rag, use_web) combination — drift guard."""
chunks = [_chunk("parity")] if intent == "chat" else []
web = [_web_result()] if use_web else []
summaries = [{"id": "d", "filename": "f.pdf", "tldr": "t", "key_points": []}]
patches = (
patch.object(wf, "classify_intent", new=AsyncMock(return_value=intent)),
patch.object(wf, "retrieve", return_value=chunks),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "list_summaries_for_user", new=AsyncMock(return_value=summaries)),
patch.object(wf, "search_web", new=AsyncMock(return_value=web)),
patch.object(wf, "chat_completion", new=AsyncMock(return_value="a")),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=_VERIFIED)),
)
kwargs = dict(
query="q",
user_id=uuid4(),
use_rag=use_rag,
use_web=use_web,
web_max_results=3,
history=[{"role": "user", "content": "prior q"}],
)
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5], patches[6]:
full = await wf.run_chat(**kwargs)
# Re-create patches (they're single-use context managers).
with (
patch.object(wf, "classify_intent", new=AsyncMock(return_value=intent)),
patch.object(wf, "retrieve", return_value=chunks),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "list_summaries_for_user", new=AsyncMock(return_value=summaries)),
patch.object(wf, "search_web", new=AsyncMock(return_value=web)),
):
prepared = await wf.prepare_context_state(**kwargs)
for key in (
"intent",
"chunks",
"graph_facts",
"web_results",
"used_context",
"used_graph",
"used_web",
):
assert prepared.get(key) == full.get(key), f"{key} drifted for {kwargs}"
# ---------------------------------------------------------------------------
# Multi-turn history
# ---------------------------------------------------------------------------
def test_build_respond_messages_inserts_history_between_system_and_user():
history = [
{"role": "user", "content": "first question"},
{"role": "assistant", "content": "first answer"},
]
state = {"query": "follow-up?", "history": history, "chunks": [_chunk("ctx")]}
messages = wf.build_respond_messages(state) # type: ignore[arg-type]
assert messages[0]["role"] == "system"
assert messages[1:3] == history
assert messages[-1]["role"] == "user"
assert "follow-up?" in messages[-1]["content"]
assert len(messages) == 4
def test_build_respond_messages_without_history_keeps_two_messages():
state = {"query": "q", "chunks": [_chunk("ctx")]}
messages = wf.build_respond_messages(state) # type: ignore[arg-type]
assert len(messages) == 2
@pytest.mark.asyncio
async def test_history_flows_through_run_chat_to_respond_prompt():
captured: dict = {}
async def fake_chat(*, messages, **kwargs):
captured["messages"] = messages
return "ok"
history = [
{"role": "user", "content": "what is qdrant?"},
{"role": "assistant", "content": "a vector database"},
]
with (
patch.object(wf, "retrieve", return_value=[]),
patch.object(wf, "_safe_expand", new=AsyncMock(return_value=[])),
patch.object(wf, "chat_completion", new=fake_chat),
patch.object(wf, "verify_answer", new=AsyncMock(return_value=_VERIFIED)),
):
await wf.run_chat(query="and weaviate?", user_id=uuid4(), history=history)
assert captured["messages"][1:3] == history