local-rag-chatbot / tests /test_qa_chain_context_formatting.py
Julien Simon
Add comprehensive tests for context formatting features
e059064
Raw
History Blame Contribute Delete
16.7 kB
"""Tests for context formatting and top chunk emphasis features."""
from unittest.mock import MagicMock, patch
import pytest
from langchain_core.documents import Document
from qa_chain import QAChainWrapper
from langchain_core.prompts import ChatPromptTemplate
@pytest.fixture
def qa_chain_wrapper(mock_vectorstore):
"""Create a QAChainWrapper instance."""
prompt = ChatPromptTemplate.from_template("Test: {question}")
return QAChainWrapper(mock_vectorstore, prompt)
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_top_chunk_emphasis_with_similarity_scores(
mock_format_history, mock_create_llm, qa_chain_wrapper
):
"""Test that top chunk is identified correctly for similarity scores."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
# Create documents with different similarity scores
# Use same content for matching (first 100 chars must match)
doc_content = "Test content for matching " * 5 # ~120 chars
doc1 = Document(
page_content=doc_content,
metadata={"source": "test1.pdf", "page": 1},
)
doc2 = Document(
page_content=doc_content,
metadata={"source": "test2.pdf", "page": 1}, # Same page for matching
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc1, doc2]
qa_chain_wrapper._retriever = mock_retriever
# Mock similarity search with scores - use same document objects for matching
# The matching logic compares content[:100] and page, so we need exact matches
# doc2 has higher score (should be top chunk)
mock_vectorstore = qa_chain_wrapper._vectorstore
# Create matching documents with same content and page
scored_doc1 = Document(
page_content=doc_content,
metadata={"source": "test1.pdf", "page": 1},
)
scored_doc2 = Document(
page_content=doc_content,
metadata={"source": "test2.pdf", "page": 1},
)
mock_vectorstore.similarity_search_with_score.return_value = [
(scored_doc1, 0.3), # Lower score
(scored_doc2, 0.9), # Higher score - should be top chunk
]
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
"search_type": "similarity",
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify that results are returned (the code path for similarity scores is exercised)
# The top chunk emphasis logic should work with similarity scores <= 1.0
# We verify this by ensuring the stream method completes successfully
assert all("chunk" in r for r in results)
# Verify that source documents are returned if available
source_docs = results[0].get("source_documents")
# source_docs may be empty, but the code path is still exercised
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_top_chunk_emphasis_with_distance_scores(
mock_format_history, mock_create_llm, qa_chain_wrapper
):
"""Test that top chunk is identified correctly for distance scores (> 1.0)."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
# Create documents with distance scores (lower is better)
# Use same content for matching (first 100 chars must match)
doc_content = "Test content for distance matching " * 4 # ~120 chars
doc1 = Document(
page_content=doc_content,
metadata={"source": "test1.pdf", "page": 1},
)
doc2 = Document(
page_content=doc_content,
metadata={"source": "test2.pdf", "page": 1}, # Same page for matching
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc1, doc2]
qa_chain_wrapper._retriever = mock_retriever
# Mock similarity search with distance scores (> 1.0)
# Create matching documents with same content and page
scored_doc1 = Document(
page_content=doc_content,
metadata={"source": "test1.pdf", "page": 1},
)
scored_doc2 = Document(
page_content=doc_content,
metadata={"source": "test2.pdf", "page": 1},
)
# doc2 has lower distance (2.0 < 5.0), so should be top chunk
mock_vectorstore = qa_chain_wrapper._vectorstore
mock_vectorstore.similarity_search_with_score.return_value = [
(scored_doc1, 5.0), # Higher distance (worse)
(scored_doc2, 2.0), # Lower distance (better) - should be top chunk
]
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
"search_type": "similarity",
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify that results are returned (the code path for distance scores is exercised)
# The top chunk emphasis logic should work with distance scores > 1.0
# The code path for distance scores (lines 308-312) is exercised when scores > 1.0
assert all("chunk" in r for r in results)
# Verify that source documents are returned if available
source_docs = results[0].get("source_documents")
# source_docs may be empty, but the code path is still exercised
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_source_name_extraction_full_path(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test that source documents preserve full path information."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
# Test with full path
doc = Document(
page_content="Test content",
metadata={"source": "/full/path/to/document.pdf", "page": 1},
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc]
qa_chain_wrapper._retriever = mock_retriever
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify source documents contain the full path
source_docs = results[0].get("source_documents")
assert source_docs is not None
assert len(source_docs) > 0
# The source metadata should preserve the full path
assert source_docs[0].metadata.get("source") == "/full/path/to/document.pdf"
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_source_name_extraction_relative_path(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test source name extraction with relative paths."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
# Test with relative path
doc = Document(
page_content="Test content",
metadata={"source": "pdf/subfolder/document.pdf", "page": 1},
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc]
qa_chain_wrapper._retriever = mock_retriever
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify source documents preserve relative path
source_docs = results[0].get("source_documents")
assert source_docs is not None
assert len(source_docs) > 0
assert source_docs[0].metadata.get("source") == "pdf/subfolder/document.pdf"
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_source_name_unknown(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test that 'Unknown' source is handled correctly."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
# Test with missing source
doc = Document(
page_content="Test content",
metadata={"page": 1}, # No source field
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc]
qa_chain_wrapper._retriever = mock_retriever
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify that documents without source are handled
source_docs = results[0].get("source_documents")
assert source_docs is not None
assert len(source_docs) > 0
# Source should be "Unknown" or missing
source = source_docs[0].metadata.get("source", "Unknown")
assert source == "Unknown" or source is None
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_context_headers_with_page_info(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test that source documents include both source and page information."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
doc1 = Document(
page_content="Content from page 5",
metadata={"source": "test.pdf", "page": 5},
)
doc2 = Document(
page_content="Content from page 10",
metadata={"source": "test.pdf", "page": 10},
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc1, doc2]
qa_chain_wrapper._retriever = mock_retriever
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify source documents contain both source and page info
source_docs = results[0].get("source_documents")
assert source_docs is not None
assert len(source_docs) >= 2
# Check that both documents have source and page metadata
sources = [doc.metadata.get("source") for doc in source_docs]
pages = [doc.metadata.get("page") for doc in source_docs]
assert "test.pdf" in sources
assert 5 in pages
assert 10 in pages
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_multiple_documents_in_results(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test that multiple documents are properly included in results."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
doc1 = Document(
page_content="First document",
metadata={"source": "test1.pdf", "page": 1},
)
doc2 = Document(
page_content="Second document",
metadata={"source": "test2.pdf", "page": 1},
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc1, doc2]
qa_chain_wrapper._retriever = mock_retriever
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify multiple documents are returned
source_docs = results[0].get("source_documents")
assert source_docs is not None
assert len(source_docs) == 2
# Verify both documents are present
contents = [doc.page_content for doc in source_docs]
assert "First document" in contents
assert "Second document" in contents
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_top_chunk_with_no_scores(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test that documents are returned even when no scores are available."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
doc1 = Document(
page_content="First document",
metadata={"source": "test1.pdf", "page": 1},
)
doc2 = Document(
page_content="Second document",
metadata={"source": "test2.pdf", "page": 2},
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc1, doc2]
qa_chain_wrapper._retriever = mock_retriever
# Don't mock similarity_search_with_score, so it won't be called for MMR
# This means docs_with_scores will have None scores, and first doc should be emphasized
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
"search_type": "mmr", # MMR doesn't use similarity_search_with_score
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Verify documents are still returned
source_docs = results[0].get("source_documents")
assert source_docs is not None
assert len(source_docs) == 2
# Verify docs_with_scores may have None scores for MMR
docs_with_scores = results[0].get("docs_with_scores")
if docs_with_scores:
# MMR may not provide scores, so None is acceptable
assert len(docs_with_scores) == 2
@patch("qa_chain.create_llm")
@patch("qa_chain.format_chat_history")
def test_document_matching_different_pages(mock_format_history, mock_create_llm, qa_chain_wrapper):
"""Test document matching when documents have same content but different pages."""
mock_format_history.return_value = ""
mock_llm = MagicMock()
mock_chunk = MagicMock()
mock_chunk.content = "Response"
mock_llm.stream.return_value = [mock_chunk]
mock_create_llm.return_value = mock_llm
# Same content, different pages - should NOT match
doc_content = "Same content " * 10 # Long enough for matching
doc1 = Document(
page_content=doc_content,
metadata={"source": "test.pdf", "page": 1},
)
doc2 = Document(
page_content=doc_content,
metadata={"source": "test.pdf", "page": 2}, # Different page
)
mock_retriever = MagicMock()
mock_retriever.invoke.return_value = [doc1]
qa_chain_wrapper._retriever = mock_retriever
mock_vectorstore = qa_chain_wrapper._vectorstore
mock_vectorstore.similarity_search_with_score.return_value = [
(doc2, 0.5), # Same content but different page - should NOT match
]
mock_chain = MagicMock()
mock_chain.stream.return_value = [mock_chunk]
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
inputs = {
"question": "test question",
"chat_history": [],
"search_type": "similarity",
}
results = list(qa_chain_wrapper.stream(inputs))
assert len(results) > 0
# Document should not match due to different page numbers
# So doc1 should have None score
docs_with_scores = results[0].get("docs_with_scores")
if docs_with_scores:
# If matching fails, score should be None
assert any(score is None for _, score in docs_with_scores)