Spaces:
Runtime error
Runtime error
Julien Simon commited on
Commit Β·
7ab7df1
1
Parent(s): c319b15
Add comprehensive test suite with 98.28% coverage
Browse files- Add 34 new high-priority tests covering critical workflows:
* Multi-turn conversations with chat history (4 tests)
* Mode switching between RAG and Vanilla LLM (4 tests)
* Network and API failure scenarios (9 tests)
* Empty vectorstore and initial state handling (8 tests)
* Input validation and edge cases (8 tests)
- Add test review document (TEST_REVIEW.md) with critical analysis
- Add coverage configuration (.coveragerc) to exclude test files
- Update pytest.ini with coverage configuration
Test Results:
- 153 tests passing (up from 119)
- 98.28% code coverage (maintained)
- All modules at 100% except qa_chain (98%) and retrievers (93%)
Covers all critical user workflows and production edge cases.
- .coveragerc +23 -0
- pytest.ini +15 -0
- tests/README.md +89 -0
- tests/TEST_REVIEW.md +370 -0
- tests/__init__.py +2 -0
- tests/conftest.py +133 -0
- tests/test_app.py +20 -0
- tests/test_cli.py +217 -0
- tests/test_config.py +56 -0
- tests/test_empty_vectorstore.py +209 -0
- tests/test_handlers.py +192 -0
- tests/test_input_validation.py +276 -0
- tests/test_integration.py +253 -0
- tests/test_mode_switching.py +245 -0
- tests/test_models.py +45 -0
- tests/test_multi_turn_conversations.py +209 -0
- tests/test_network_failures.py +242 -0
- tests/test_qa_chain.py +245 -0
- tests/test_qa_chain_edge_cases.py +169 -0
- tests/test_qa_chain_final.py +66 -0
- tests/test_qa_chain_remaining.py +120 -0
- tests/test_retrievers.py +139 -0
- tests/test_retrievers_edge_cases.py +79 -0
- tests/test_retrievers_final.py +76 -0
- tests/test_retrievers_remaining.py +133 -0
- tests/test_ui_app.py +150 -0
- tests/test_ui_components.py +106 -0
- tests/test_utils.py +133 -0
- tests/test_utils_edge_cases.py +45 -0
- tests/test_vectorstore.py +184 -0
- tests/test_vectorstore_edge_cases.py +51 -0
- tests/test_vectorstore_remaining.py +19 -0
.coveragerc
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[run]
|
| 2 |
+
source = .
|
| 3 |
+
omit =
|
| 4 |
+
*/tests/*
|
| 5 |
+
*/test_*.py
|
| 6 |
+
*/env/*
|
| 7 |
+
*/__pycache__/*
|
| 8 |
+
*/vectorstore/*
|
| 9 |
+
*/pdf/*
|
| 10 |
+
*/htmlcov/*
|
| 11 |
+
setup.py
|
| 12 |
+
*/conftest.py
|
| 13 |
+
|
| 14 |
+
[report]
|
| 15 |
+
exclude_lines =
|
| 16 |
+
pragma: no cover
|
| 17 |
+
def __repr__
|
| 18 |
+
raise AssertionError
|
| 19 |
+
raise NotImplementedError
|
| 20 |
+
if __name__ == .__main__.:
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
@abstractmethod
|
| 23 |
+
|
pytest.ini
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
testpaths = tests
|
| 3 |
+
python_files = test_*.py
|
| 4 |
+
python_classes = Test*
|
| 5 |
+
python_functions = test_*
|
| 6 |
+
addopts =
|
| 7 |
+
--cov=.
|
| 8 |
+
--cov-report=term-missing
|
| 9 |
+
--cov-report=html
|
| 10 |
+
--cov-fail-under=80
|
| 11 |
+
--cov-config=.coveragerc
|
| 12 |
+
-v
|
| 13 |
+
--tb=short
|
| 14 |
+
norecursedirs = env .git __pycache__ vectorstore pdf tests
|
| 15 |
+
|
tests/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Test Suite
|
| 2 |
+
|
| 3 |
+
This directory contains the test suite for the RAG application.
|
| 4 |
+
|
| 5 |
+
## Running Tests
|
| 6 |
+
|
| 7 |
+
### Run all tests
|
| 8 |
+
```bash
|
| 9 |
+
pytest
|
| 10 |
+
```
|
| 11 |
+
|
| 12 |
+
### Run with coverage report
|
| 13 |
+
```bash
|
| 14 |
+
pytest --cov=. --cov-report=term-missing
|
| 15 |
+
```
|
| 16 |
+
|
| 17 |
+
### Run specific test file
|
| 18 |
+
```bash
|
| 19 |
+
pytest tests/test_utils.py
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
### Run specific test
|
| 23 |
+
```bash
|
| 24 |
+
pytest tests/test_utils.py::test_format_chat_history_empty
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
## Coverage
|
| 28 |
+
|
| 29 |
+
The test suite aims for 80% code coverage. Current coverage: **84%**
|
| 30 |
+
|
| 31 |
+
### Coverage by Module
|
| 32 |
+
|
| 33 |
+
- `config.py`: 100%
|
| 34 |
+
- `models.py`: 100%
|
| 35 |
+
- `utils.py`: 95%
|
| 36 |
+
- `vectorstore.py`: 91%
|
| 37 |
+
- `retrievers.py`: 89%
|
| 38 |
+
- `qa_chain.py`: 83%
|
| 39 |
+
- `ui/handlers.py`: 100%
|
| 40 |
+
|
| 41 |
+
### Excluded from Coverage
|
| 42 |
+
|
| 43 |
+
- `app.py`: Entry point (minimal logic)
|
| 44 |
+
- `cli.py`: CLI entry point (integration testing)
|
| 45 |
+
- `ui/app.py`: UI setup (requires full Gradio integration)
|
| 46 |
+
- `ui/components.py`: UI component definitions
|
| 47 |
+
|
| 48 |
+
## Test Structure
|
| 49 |
+
|
| 50 |
+
- `conftest.py`: Shared fixtures and test configuration
|
| 51 |
+
- `test_config.py`: Configuration constants tests
|
| 52 |
+
- `test_models.py`: Model creation tests
|
| 53 |
+
- `test_utils.py`: Utility function tests
|
| 54 |
+
- `test_vectorstore.py`: Vectorstore management tests
|
| 55 |
+
- `test_retrievers.py`: Retrieval strategy tests
|
| 56 |
+
- `test_qa_chain.py`: QA chain wrapper tests
|
| 57 |
+
- `test_handlers.py`: UI handler function tests
|
| 58 |
+
|
| 59 |
+
## Fixtures
|
| 60 |
+
|
| 61 |
+
Common fixtures available in `conftest.py`:
|
| 62 |
+
|
| 63 |
+
- `mock_embeddings`: Mock embedding model
|
| 64 |
+
- `mock_vectorstore`: Mock vectorstore
|
| 65 |
+
- `sample_documents`: Sample document objects
|
| 66 |
+
- `mock_llm`: Mock language model
|
| 67 |
+
- `mock_reranker`: Mock reranker
|
| 68 |
+
- `sample_chat_history`: Sample chat history
|
| 69 |
+
- `temp_pdf_dir`: Temporary PDF directory
|
| 70 |
+
- `temp_vectorstore_dir`: Temporary vectorstore directory
|
| 71 |
+
|
| 72 |
+
## Writing New Tests
|
| 73 |
+
|
| 74 |
+
When adding new tests:
|
| 75 |
+
|
| 76 |
+
1. Follow the naming convention: `test_<function_name>`
|
| 77 |
+
2. Use descriptive docstrings
|
| 78 |
+
3. Mock external dependencies (LLMs, vectorstores, etc.)
|
| 79 |
+
4. Test both success and error cases
|
| 80 |
+
5. Aim for high coverage of business logic
|
| 81 |
+
|
| 82 |
+
## Continuous Integration
|
| 83 |
+
|
| 84 |
+
Tests should pass before committing. Run:
|
| 85 |
+
|
| 86 |
+
```bash
|
| 87 |
+
pytest --cov=. --cov-fail-under=80
|
| 88 |
+
```
|
| 89 |
+
|
tests/TEST_REVIEW.md
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Critical Test Suite Review
|
| 2 |
+
|
| 3 |
+
## Executive Summary
|
| 4 |
+
|
| 5 |
+
**Current Status:** 98.28% code coverage with 119 tests passing β
|
| 6 |
+
|
| 7 |
+
**Overall Assessment:** The test suite is comprehensive and well-structured, but several important workflows and edge cases are missing that could impact production reliability.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## β
Well-Covered Areas
|
| 12 |
+
|
| 13 |
+
1. **Core Functionality**
|
| 14 |
+
- Unit tests for all major modules (config, models, utils, vectorstore, retrievers, qa_chain)
|
| 15 |
+
- Integration tests for RAG flow
|
| 16 |
+
- Edge cases for individual components
|
| 17 |
+
|
| 18 |
+
2. **Search Strategies**
|
| 19 |
+
- MMR, Similarity, and Hybrid search types
|
| 20 |
+
- Query rewriting and re-ranking
|
| 21 |
+
- Document filtering
|
| 22 |
+
|
| 23 |
+
3. **Error Handling**
|
| 24 |
+
- LLM errors
|
| 25 |
+
- Retriever errors
|
| 26 |
+
- Empty document scenarios
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## β Missing Critical Workflows
|
| 31 |
+
|
| 32 |
+
### 1. **Multi-Turn Conversations** β οΈ HIGH PRIORITY
|
| 33 |
+
**Issue:** No tests for chat history context in RAG queries
|
| 34 |
+
|
| 35 |
+
**Missing Scenarios:**
|
| 36 |
+
- RAG query with previous conversation context
|
| 37 |
+
- Follow-up questions that reference previous answers
|
| 38 |
+
- Context accumulation across multiple turns
|
| 39 |
+
- Chat history limit enforcement (CHAT_HISTORY_LIMIT)
|
| 40 |
+
|
| 41 |
+
**Impact:** Users rely on conversational context for follow-up questions. Without testing, context may be lost or incorrectly formatted.
|
| 42 |
+
|
| 43 |
+
**Test Needed:**
|
| 44 |
+
```python
|
| 45 |
+
def test_rag_with_chat_history_context():
|
| 46 |
+
"""Test RAG query with multi-turn conversation."""
|
| 47 |
+
# First question
|
| 48 |
+
# Follow-up question that references first answer
|
| 49 |
+
# Verify context is properly included
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
---
|
| 53 |
+
|
| 54 |
+
### 2. **Mode Switching Workflows** β οΈ HIGH PRIORITY
|
| 55 |
+
**Issue:** No tests for switching between RAG and Vanilla modes
|
| 56 |
+
|
| 57 |
+
**Missing Scenarios:**
|
| 58 |
+
- Start with RAG, switch to Vanilla mid-conversation
|
| 59 |
+
- Start with Vanilla, switch to RAG mid-conversation
|
| 60 |
+
- Chat history preservation across mode switches
|
| 61 |
+
- UI state consistency during switches
|
| 62 |
+
|
| 63 |
+
**Impact:** Users may lose context or get confused when switching modes.
|
| 64 |
+
|
| 65 |
+
**Test Needed:**
|
| 66 |
+
```python
|
| 67 |
+
def test_mode_switching_preserves_history():
|
| 68 |
+
"""Test that chat history is preserved when switching modes."""
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
---
|
| 72 |
+
|
| 73 |
+
### 3. **Empty Vectorstore Scenarios** β οΈ MEDIUM PRIORITY
|
| 74 |
+
**Issue:** Limited testing of empty/initial state
|
| 75 |
+
|
| 76 |
+
**Missing Scenarios:**
|
| 77 |
+
- First-time user (no vectorstore exists)
|
| 78 |
+
- Vectorstore exists but is empty (no documents)
|
| 79 |
+
- User queries before any documents are loaded
|
| 80 |
+
- Graceful degradation when no documents available
|
| 81 |
+
|
| 82 |
+
**Impact:** Application may crash or show confusing errors to new users.
|
| 83 |
+
|
| 84 |
+
**Test Needed:**
|
| 85 |
+
```python
|
| 86 |
+
def test_rag_query_with_empty_vectorstore():
|
| 87 |
+
"""Test RAG query when no documents are available."""
|
| 88 |
+
# Should return empty results or helpful message
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
---
|
| 92 |
+
|
| 93 |
+
### 4. **Document Update Workflow** β οΈ MEDIUM PRIORITY
|
| 94 |
+
**Issue:** Limited testing of adding new documents to existing vectorstore
|
| 95 |
+
|
| 96 |
+
**Missing Scenarios:**
|
| 97 |
+
- Adding new PDFs to existing vectorstore
|
| 98 |
+
- Updating vectorstore with duplicate documents
|
| 99 |
+
- Partial document loading failures
|
| 100 |
+
- Concurrent document additions
|
| 101 |
+
|
| 102 |
+
**Impact:** Users may not see new documents or experience data corruption.
|
| 103 |
+
|
| 104 |
+
**Test Needed:**
|
| 105 |
+
```python
|
| 106 |
+
def test_vectorstore_update_with_new_documents():
|
| 107 |
+
"""Test adding new documents to existing vectorstore."""
|
| 108 |
+
# Verify new documents are indexed
|
| 109 |
+
# Verify old documents remain
|
| 110 |
+
# Verify no duplicates
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
---
|
| 114 |
+
|
| 115 |
+
### 5. **Network/API Failure Scenarios** β οΈ HIGH PRIORITY
|
| 116 |
+
**Issue:** Limited testing of external service failures
|
| 117 |
+
|
| 118 |
+
**Missing Scenarios:**
|
| 119 |
+
- LLM server unavailable/timeout
|
| 120 |
+
- Embedding model download failure
|
| 121 |
+
- ChromaDB connection failures
|
| 122 |
+
- Network interruptions during streaming
|
| 123 |
+
- Retry logic for transient failures
|
| 124 |
+
|
| 125 |
+
**Impact:** Application may hang or crash when external services fail.
|
| 126 |
+
|
| 127 |
+
**Test Needed:**
|
| 128 |
+
```python
|
| 129 |
+
def test_llm_server_unavailable():
|
| 130 |
+
"""Test graceful handling when LLM server is down."""
|
| 131 |
+
# Should show user-friendly error
|
| 132 |
+
# Should not crash application
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
---
|
| 136 |
+
|
| 137 |
+
### 6. **Input Validation & Edge Cases** β οΈ MEDIUM PRIORITY
|
| 138 |
+
**Issue:** Limited testing of invalid inputs
|
| 139 |
+
|
| 140 |
+
**Missing Scenarios:**
|
| 141 |
+
- Very long queries (>10k characters)
|
| 142 |
+
- Empty queries (whitespace only)
|
| 143 |
+
- Special characters in queries
|
| 144 |
+
- Invalid document filter selections
|
| 145 |
+
- Malformed chat history
|
| 146 |
+
- Unicode/emoji in queries
|
| 147 |
+
|
| 148 |
+
**Impact:** Application may crash or behave unexpectedly with edge case inputs.
|
| 149 |
+
|
| 150 |
+
**Test Needed:**
|
| 151 |
+
```python
|
| 152 |
+
def test_very_long_query():
|
| 153 |
+
"""Test handling of extremely long queries."""
|
| 154 |
+
|
| 155 |
+
def test_empty_whitespace_query():
|
| 156 |
+
"""Test handling of whitespace-only queries."""
|
| 157 |
+
|
| 158 |
+
def test_invalid_document_filter():
|
| 159 |
+
"""Test handling of invalid document filter selection."""
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
---
|
| 163 |
+
|
| 164 |
+
### 7. **Hybrid Search Parameter Variations** β οΈ LOW PRIORITY
|
| 165 |
+
**Issue:** Limited testing of hybrid alpha parameter
|
| 166 |
+
|
| 167 |
+
**Missing Scenarios:**
|
| 168 |
+
- Hybrid search with alpha=0.0 (pure keyword)
|
| 169 |
+
- Hybrid search with alpha=1.0 (pure semantic)
|
| 170 |
+
- Hybrid search with various alpha values (0.1, 0.5, 0.9)
|
| 171 |
+
- Score fusion correctness at boundaries
|
| 172 |
+
|
| 173 |
+
**Impact:** Users may not get optimal results with different alpha settings.
|
| 174 |
+
|
| 175 |
+
**Test Needed:**
|
| 176 |
+
```python
|
| 177 |
+
def test_hybrid_search_alpha_boundaries():
|
| 178 |
+
"""Test hybrid search with extreme alpha values."""
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
---
|
| 182 |
+
|
| 183 |
+
### 8. **Streaming Edge Cases** β οΈ MEDIUM PRIORITY
|
| 184 |
+
**Issue:** Limited testing of streaming failures
|
| 185 |
+
|
| 186 |
+
**Missing Scenarios:**
|
| 187 |
+
- Stream interruption mid-response
|
| 188 |
+
- Partial stream completion
|
| 189 |
+
- Multiple concurrent streams
|
| 190 |
+
- Stream timeout handling
|
| 191 |
+
- Memory leaks during long streams
|
| 192 |
+
|
| 193 |
+
**Impact:** Users may experience incomplete responses or resource issues.
|
| 194 |
+
|
| 195 |
+
**Test Needed:**
|
| 196 |
+
```python
|
| 197 |
+
def test_stream_interruption():
|
| 198 |
+
"""Test handling of interrupted streams."""
|
| 199 |
+
|
| 200 |
+
def test_concurrent_streams():
|
| 201 |
+
"""Test multiple concurrent streaming requests."""
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
### 9. **PDF Loading & Processing** β οΈ MEDIUM PRIORITY
|
| 207 |
+
**Issue:** Limited testing of PDF processing edge cases
|
| 208 |
+
|
| 209 |
+
**Missing Scenarios:**
|
| 210 |
+
- Corrupted PDF files
|
| 211 |
+
- Password-protected PDFs
|
| 212 |
+
- Very large PDF files (>100MB)
|
| 213 |
+
- PDFs with no extractable text
|
| 214 |
+
- PDFs with images only
|
| 215 |
+
- Multiple PDFs with same filename
|
| 216 |
+
|
| 217 |
+
**Impact:** Application may fail silently or crash when processing problematic PDFs.
|
| 218 |
+
|
| 219 |
+
**Test Needed:**
|
| 220 |
+
```python
|
| 221 |
+
def test_corrupted_pdf_handling():
|
| 222 |
+
"""Test handling of corrupted PDF files."""
|
| 223 |
+
|
| 224 |
+
def test_large_pdf_processing():
|
| 225 |
+
"""Test processing of very large PDF files."""
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
---
|
| 229 |
+
|
| 230 |
+
### 10. **Vectorstore Persistence & Recovery** β οΈ LOW PRIORITY
|
| 231 |
+
**Issue:** No tests for persistence and recovery
|
| 232 |
+
|
| 233 |
+
**Missing Scenarios:**
|
| 234 |
+
- Vectorstore corruption detection
|
| 235 |
+
- Recovery from corrupted vectorstore
|
| 236 |
+
- Backup and restore workflows
|
| 237 |
+
- Migration between vectorstore versions
|
| 238 |
+
|
| 239 |
+
**Impact:** Users may lose their indexed documents or experience data corruption.
|
| 240 |
+
|
| 241 |
+
**Test Needed:**
|
| 242 |
+
```python
|
| 243 |
+
def test_vectorstore_corruption_recovery():
|
| 244 |
+
"""Test recovery from corrupted vectorstore."""
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
---
|
| 248 |
+
|
| 249 |
+
### 11. **UI/UX Workflows** β οΈ MEDIUM PRIORITY
|
| 250 |
+
**Issue:** Limited testing of UI interaction flows
|
| 251 |
+
|
| 252 |
+
**Missing Scenarios:**
|
| 253 |
+
- Clear button functionality
|
| 254 |
+
- Example question selection
|
| 255 |
+
- RAG toggle state persistence
|
| 256 |
+
- Search type change during active query
|
| 257 |
+
- Document filter change during active query
|
| 258 |
+
- Context panel visibility toggling
|
| 259 |
+
|
| 260 |
+
**Impact:** Users may experience UI bugs or confusion.
|
| 261 |
+
|
| 262 |
+
**Test Needed:**
|
| 263 |
+
```python
|
| 264 |
+
def test_ui_clear_functionality():
|
| 265 |
+
"""Test clear button resets all state correctly."""
|
| 266 |
+
|
| 267 |
+
def test_ui_state_consistency():
|
| 268 |
+
"""Test UI state remains consistent during operations."""
|
| 269 |
+
```
|
| 270 |
+
|
| 271 |
+
---
|
| 272 |
+
|
| 273 |
+
### 12. **Performance & Scalability** β οΈ LOW PRIORITY
|
| 274 |
+
**Issue:** No performance tests
|
| 275 |
+
|
| 276 |
+
**Missing Scenarios:**
|
| 277 |
+
- Query latency with large document sets
|
| 278 |
+
- Memory usage with many documents
|
| 279 |
+
- Concurrent user handling
|
| 280 |
+
- Large chat history performance
|
| 281 |
+
|
| 282 |
+
**Impact:** Application may become slow or unresponsive with scale.
|
| 283 |
+
|
| 284 |
+
**Test Needed:**
|
| 285 |
+
```python
|
| 286 |
+
def test_query_performance_large_dataset():
|
| 287 |
+
"""Test query performance with large document sets."""
|
| 288 |
+
```
|
| 289 |
+
|
| 290 |
+
---
|
| 291 |
+
|
| 292 |
+
## π Additional Observations
|
| 293 |
+
|
| 294 |
+
### Test Organization
|
| 295 |
+
- β
Good separation of unit, integration, and edge case tests
|
| 296 |
+
- β
Clear naming conventions
|
| 297 |
+
- β οΈ Some test files could be consolidated (multiple qa_chain test files)
|
| 298 |
+
|
| 299 |
+
### Test Quality
|
| 300 |
+
- β
Good use of fixtures and mocks
|
| 301 |
+
- β
Comprehensive assertions
|
| 302 |
+
- β οΈ Some tests are too focused on implementation details rather than behavior
|
| 303 |
+
|
| 304 |
+
### Coverage Gaps
|
| 305 |
+
- **qa_chain.py lines 269-274:** Similarity search document matching (edge case)
|
| 306 |
+
- **retrievers.py lines 66, 128, 179-185:** Hybrid search edge cases
|
| 307 |
+
|
| 308 |
+
---
|
| 309 |
+
|
| 310 |
+
## π Recommended Action Items
|
| 311 |
+
|
| 312 |
+
### Immediate (Before Production)
|
| 313 |
+
1. β
Add multi-turn conversation tests
|
| 314 |
+
2. β
Add mode switching tests
|
| 315 |
+
3. β
Add empty vectorstore tests
|
| 316 |
+
4. β
Add network failure tests
|
| 317 |
+
5. β
Add input validation tests
|
| 318 |
+
|
| 319 |
+
### Short-term (Next Sprint)
|
| 320 |
+
6. β
Add document update workflow tests
|
| 321 |
+
7. β
Add streaming edge case tests
|
| 322 |
+
8. β
Add PDF processing edge case tests
|
| 323 |
+
9. β
Add UI workflow tests
|
| 324 |
+
|
| 325 |
+
### Long-term (Future Enhancements)
|
| 326 |
+
10. β
Add performance tests
|
| 327 |
+
11. β
Add vectorstore persistence tests
|
| 328 |
+
12. β
Add hybrid search parameter variation tests
|
| 329 |
+
|
| 330 |
+
---
|
| 331 |
+
|
| 332 |
+
## π― Priority Matrix
|
| 333 |
+
|
| 334 |
+
| Workflow | Priority | Impact | Effort | Status |
|
| 335 |
+
|----------|----------|--------|--------|--------|
|
| 336 |
+
| Multi-turn conversations | HIGH | HIGH | MEDIUM | β Missing |
|
| 337 |
+
| Mode switching | HIGH | HIGH | LOW | β Missing |
|
| 338 |
+
| Network failures | HIGH | HIGH | MEDIUM | β οΈ Partial |
|
| 339 |
+
| Empty vectorstore | MEDIUM | MEDIUM | LOW | β οΈ Partial |
|
| 340 |
+
| Input validation | MEDIUM | MEDIUM | LOW | β οΈ Partial |
|
| 341 |
+
| Document updates | MEDIUM | MEDIUM | MEDIUM | β οΈ Partial |
|
| 342 |
+
| Streaming edge cases | MEDIUM | MEDIUM | MEDIUM | β οΈ Partial |
|
| 343 |
+
| PDF processing | MEDIUM | MEDIUM | MEDIUM | β οΈ Partial |
|
| 344 |
+
| UI workflows | MEDIUM | LOW | LOW | β οΈ Partial |
|
| 345 |
+
| Performance | LOW | LOW | HIGH | β Missing |
|
| 346 |
+
| Vectorstore persistence | LOW | LOW | HIGH | β Missing |
|
| 347 |
+
|
| 348 |
+
---
|
| 349 |
+
|
| 350 |
+
## π‘ Recommendations
|
| 351 |
+
|
| 352 |
+
1. **Focus on User Journeys:** Add end-to-end tests that simulate real user workflows
|
| 353 |
+
2. **Error Resilience:** Expand error handling tests to cover all failure modes
|
| 354 |
+
3. **Integration Testing:** Add more integration tests that test multiple components together
|
| 355 |
+
4. **Performance Baseline:** Establish performance benchmarks for critical paths
|
| 356 |
+
5. **Test Documentation:** Document test scenarios and their business value
|
| 357 |
+
|
| 358 |
+
---
|
| 359 |
+
|
| 360 |
+
## Conclusion
|
| 361 |
+
|
| 362 |
+
The test suite provides excellent code coverage (98.28%) and covers most unit-level functionality well. However, several critical user workflows and edge cases are missing, particularly around:
|
| 363 |
+
|
| 364 |
+
- **Multi-turn conversations** (critical for RAG use case)
|
| 365 |
+
- **Mode switching** (core feature)
|
| 366 |
+
- **Error resilience** (production readiness)
|
| 367 |
+
- **Input validation** (user experience)
|
| 368 |
+
|
| 369 |
+
Addressing these gaps will significantly improve production reliability and user experience.
|
| 370 |
+
|
tests/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test suite for the RAG application."""
|
| 2 |
+
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration and shared fixtures."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from unittest.mock import MagicMock, Mock
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
from langchain_core.documents import Document
|
| 8 |
+
from langchain_core.messages import AIMessage, HumanMessage
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def mock_embeddings():
|
| 13 |
+
"""Create a mock embeddings model."""
|
| 14 |
+
mock = MagicMock()
|
| 15 |
+
mock.embed_query.return_value = [0.1] * 384
|
| 16 |
+
return mock
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@pytest.fixture
|
| 20 |
+
def mock_vectorstore():
|
| 21 |
+
"""Create a mock vectorstore."""
|
| 22 |
+
mock = MagicMock()
|
| 23 |
+
mock.get.return_value = {
|
| 24 |
+
"documents": ["Document 1", "Document 2"],
|
| 25 |
+
"metadatas": [
|
| 26 |
+
{"source": "pdf/test1.pdf", "page": 1},
|
| 27 |
+
{"source": "pdf/test2.pdf", "page": 2},
|
| 28 |
+
],
|
| 29 |
+
}
|
| 30 |
+
return mock
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@pytest.fixture
|
| 34 |
+
def sample_documents():
|
| 35 |
+
"""Create sample document objects."""
|
| 36 |
+
return [
|
| 37 |
+
Document(
|
| 38 |
+
page_content="This is a test document about machine learning.",
|
| 39 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 40 |
+
),
|
| 41 |
+
Document(
|
| 42 |
+
page_content="Another document about neural networks.",
|
| 43 |
+
metadata={"source": "pdf/test2.pdf", "page": 2},
|
| 44 |
+
),
|
| 45 |
+
Document(
|
| 46 |
+
page_content="Document in references section.",
|
| 47 |
+
metadata={"source": "pdf/test3.pdf", "page": 3, "section": "references"},
|
| 48 |
+
),
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@pytest.fixture
|
| 53 |
+
def mock_llm():
|
| 54 |
+
"""Create a mock LLM."""
|
| 55 |
+
mock = MagicMock()
|
| 56 |
+
mock.invoke.return_value = MagicMock(content="Mocked response")
|
| 57 |
+
mock.stream.return_value = [
|
| 58 |
+
MagicMock(content="Chunk "),
|
| 59 |
+
MagicMock(content="1 "),
|
| 60 |
+
MagicMock(content="2"),
|
| 61 |
+
]
|
| 62 |
+
return mock
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@pytest.fixture
|
| 66 |
+
def mock_reranker():
|
| 67 |
+
"""Create a mock reranker."""
|
| 68 |
+
mock = MagicMock()
|
| 69 |
+
mock.predict.return_value = [0.9, 0.8, 0.7]
|
| 70 |
+
return mock
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@pytest.fixture
|
| 74 |
+
def sample_chat_history():
|
| 75 |
+
"""Create sample chat history."""
|
| 76 |
+
return [
|
| 77 |
+
{"role": "user", "content": "What is RAG?"},
|
| 78 |
+
{"role": "assistant", "content": "RAG is Retrieval-Augmented Generation."},
|
| 79 |
+
{"role": "user", "content": "How does it work?"},
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@pytest.fixture
|
| 84 |
+
def sample_chat_history_tuples():
|
| 85 |
+
"""Create sample chat history as tuples."""
|
| 86 |
+
return [
|
| 87 |
+
("What is RAG?", "RAG is Retrieval-Augmented Generation."),
|
| 88 |
+
("How does it work?", "It retrieves documents and generates answers."),
|
| 89 |
+
]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@pytest.fixture
|
| 93 |
+
def temp_pdf_dir(tmp_path):
|
| 94 |
+
"""Create a temporary directory for PDF files."""
|
| 95 |
+
pdf_dir = tmp_path / "pdf"
|
| 96 |
+
pdf_dir.mkdir()
|
| 97 |
+
return str(pdf_dir)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@pytest.fixture
|
| 101 |
+
def temp_vectorstore_dir(tmp_path):
|
| 102 |
+
"""Create a temporary directory for vectorstore."""
|
| 103 |
+
vs_dir = tmp_path / "vectorstore"
|
| 104 |
+
vs_dir.mkdir()
|
| 105 |
+
return str(vs_dir)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@pytest.fixture
|
| 109 |
+
def mock_hybrid_results():
|
| 110 |
+
"""Create mock hybrid search results."""
|
| 111 |
+
doc1 = Document(
|
| 112 |
+
page_content="Test content 1",
|
| 113 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 114 |
+
)
|
| 115 |
+
doc2 = Document(
|
| 116 |
+
page_content="Test content 2",
|
| 117 |
+
metadata={"source": "pdf/test2.pdf", "page": 2},
|
| 118 |
+
)
|
| 119 |
+
return [
|
| 120 |
+
{
|
| 121 |
+
"doc": doc1,
|
| 122 |
+
"fused_score": 0.9,
|
| 123 |
+
"semantic_score": 0.85,
|
| 124 |
+
"keyword_score": 0.95,
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"doc": doc2,
|
| 128 |
+
"fused_score": 0.8,
|
| 129 |
+
"semantic_score": 0.75,
|
| 130 |
+
"keyword_score": 0.85,
|
| 131 |
+
},
|
| 132 |
+
]
|
| 133 |
+
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for app.py entry point."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@patch("app.create_app")
|
| 9 |
+
def test_app_main(mock_create_app):
|
| 10 |
+
"""Test app.py main entry point."""
|
| 11 |
+
mock_demo = MagicMock()
|
| 12 |
+
mock_create_app.return_value = mock_demo
|
| 13 |
+
|
| 14 |
+
# Test the main execution logic
|
| 15 |
+
demo = mock_create_app()
|
| 16 |
+
demo.launch(share=False, server_port=7860)
|
| 17 |
+
|
| 18 |
+
mock_create_app.assert_called_once()
|
| 19 |
+
mock_demo.launch.assert_called_once_with(share=False, server_port=7860)
|
| 20 |
+
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for cli.py entry point."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from cli import main
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@patch("cli.create_llm")
|
| 11 |
+
@patch("cli.create_qa_chain")
|
| 12 |
+
@patch("cli.load_or_create_vectorstore")
|
| 13 |
+
@patch("cli.create_embeddings")
|
| 14 |
+
def test_cli_main_vanilla_llm(
|
| 15 |
+
mock_create_embeddings,
|
| 16 |
+
mock_load_vectorstore,
|
| 17 |
+
mock_create_qa_chain,
|
| 18 |
+
mock_create_llm,
|
| 19 |
+
capsys,
|
| 20 |
+
):
|
| 21 |
+
"""Test CLI main function with vanilla LLM."""
|
| 22 |
+
# Setup mocks
|
| 23 |
+
mock_embeddings = MagicMock()
|
| 24 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 25 |
+
|
| 26 |
+
mock_vectorstore = MagicMock()
|
| 27 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 28 |
+
|
| 29 |
+
mock_qa_chain = MagicMock()
|
| 30 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 31 |
+
|
| 32 |
+
mock_llm = MagicMock()
|
| 33 |
+
mock_llm.stream.return_value = [
|
| 34 |
+
MagicMock(content="Hello "),
|
| 35 |
+
MagicMock(content="World"),
|
| 36 |
+
]
|
| 37 |
+
mock_create_llm.return_value = mock_llm
|
| 38 |
+
|
| 39 |
+
# Run main
|
| 40 |
+
main()
|
| 41 |
+
|
| 42 |
+
# Check output
|
| 43 |
+
captured = capsys.readouterr()
|
| 44 |
+
assert "Vanilla Response" in captured.out
|
| 45 |
+
assert "Hello World" in captured.out
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@patch("cli.create_llm")
|
| 49 |
+
@patch("cli.create_qa_chain")
|
| 50 |
+
@patch("cli.load_or_create_vectorstore")
|
| 51 |
+
@patch("cli.create_embeddings")
|
| 52 |
+
def test_cli_main_rag_response(
|
| 53 |
+
mock_create_embeddings,
|
| 54 |
+
mock_load_vectorstore,
|
| 55 |
+
mock_create_qa_chain,
|
| 56 |
+
mock_create_llm,
|
| 57 |
+
capsys,
|
| 58 |
+
sample_documents,
|
| 59 |
+
):
|
| 60 |
+
"""Test CLI main function with RAG response."""
|
| 61 |
+
# Setup mocks
|
| 62 |
+
mock_embeddings = MagicMock()
|
| 63 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 64 |
+
|
| 65 |
+
mock_vectorstore = MagicMock()
|
| 66 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 67 |
+
|
| 68 |
+
mock_qa_chain = MagicMock()
|
| 69 |
+
mock_qa_chain.stream.return_value = [
|
| 70 |
+
{
|
| 71 |
+
"chunk": "RAG ",
|
| 72 |
+
"source_documents": sample_documents[:2],
|
| 73 |
+
"docs_with_scores": None,
|
| 74 |
+
"rewritten_query": None,
|
| 75 |
+
"hybrid_scores": None,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"chunk": "response",
|
| 79 |
+
"source_documents": sample_documents[:2],
|
| 80 |
+
"docs_with_scores": None,
|
| 81 |
+
"rewritten_query": None,
|
| 82 |
+
"hybrid_scores": None,
|
| 83 |
+
},
|
| 84 |
+
]
|
| 85 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 86 |
+
|
| 87 |
+
mock_llm = MagicMock()
|
| 88 |
+
mock_create_llm.return_value = mock_llm
|
| 89 |
+
|
| 90 |
+
# Run main
|
| 91 |
+
main()
|
| 92 |
+
|
| 93 |
+
# Check output
|
| 94 |
+
captured = capsys.readouterr()
|
| 95 |
+
assert "RAG Response" in captured.out
|
| 96 |
+
assert "RAG response" in captured.out
|
| 97 |
+
assert "Sources:" in captured.out
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@patch("cli.create_llm")
|
| 101 |
+
@patch("cli.create_qa_chain")
|
| 102 |
+
@patch("cli.load_or_create_vectorstore")
|
| 103 |
+
@patch("cli.create_embeddings")
|
| 104 |
+
def test_cli_main_with_filter(
|
| 105 |
+
mock_create_embeddings,
|
| 106 |
+
mock_load_vectorstore,
|
| 107 |
+
mock_create_qa_chain,
|
| 108 |
+
mock_create_llm,
|
| 109 |
+
capsys,
|
| 110 |
+
sample_documents,
|
| 111 |
+
):
|
| 112 |
+
"""Test CLI main function with metadata filter."""
|
| 113 |
+
# Setup mocks
|
| 114 |
+
mock_embeddings = MagicMock()
|
| 115 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 116 |
+
|
| 117 |
+
mock_vectorstore = MagicMock()
|
| 118 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 119 |
+
|
| 120 |
+
mock_qa_chain = MagicMock()
|
| 121 |
+
mock_qa_chain.stream.return_value = [
|
| 122 |
+
{
|
| 123 |
+
"chunk": "Filtered ",
|
| 124 |
+
"source_documents": sample_documents[:1],
|
| 125 |
+
"docs_with_scores": None,
|
| 126 |
+
"rewritten_query": None,
|
| 127 |
+
"hybrid_scores": None,
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"chunk": "response",
|
| 131 |
+
"source_documents": sample_documents[:1],
|
| 132 |
+
"docs_with_scores": None,
|
| 133 |
+
"rewritten_query": None,
|
| 134 |
+
"hybrid_scores": None,
|
| 135 |
+
},
|
| 136 |
+
]
|
| 137 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 138 |
+
|
| 139 |
+
mock_llm = MagicMock()
|
| 140 |
+
mock_create_llm.return_value = mock_llm
|
| 141 |
+
|
| 142 |
+
# Run main
|
| 143 |
+
main()
|
| 144 |
+
|
| 145 |
+
# Check output
|
| 146 |
+
captured = capsys.readouterr()
|
| 147 |
+
assert "Metadata Filter" in captured.out
|
| 148 |
+
assert "Sources (filtered):" in captured.out
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@patch("cli.create_llm")
|
| 152 |
+
@patch("cli.create_qa_chain")
|
| 153 |
+
@patch("cli.load_or_create_vectorstore")
|
| 154 |
+
@patch("cli.create_embeddings")
|
| 155 |
+
def test_cli_main_vanilla_error(
|
| 156 |
+
mock_create_embeddings,
|
| 157 |
+
mock_load_vectorstore,
|
| 158 |
+
mock_create_qa_chain,
|
| 159 |
+
mock_create_llm,
|
| 160 |
+
capsys,
|
| 161 |
+
):
|
| 162 |
+
"""Test CLI main function with vanilla LLM error."""
|
| 163 |
+
# Setup mocks
|
| 164 |
+
mock_embeddings = MagicMock()
|
| 165 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 166 |
+
|
| 167 |
+
mock_vectorstore = MagicMock()
|
| 168 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 169 |
+
|
| 170 |
+
mock_qa_chain = MagicMock()
|
| 171 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 172 |
+
|
| 173 |
+
mock_llm = MagicMock()
|
| 174 |
+
mock_llm.stream.side_effect = Exception("LLM Error")
|
| 175 |
+
mock_create_llm.return_value = mock_llm
|
| 176 |
+
|
| 177 |
+
# Run main
|
| 178 |
+
main()
|
| 179 |
+
|
| 180 |
+
# Check error handling
|
| 181 |
+
captured = capsys.readouterr()
|
| 182 |
+
assert "Error" in captured.out
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@patch("cli.create_llm")
|
| 186 |
+
@patch("cli.create_qa_chain")
|
| 187 |
+
@patch("cli.load_or_create_vectorstore")
|
| 188 |
+
@patch("cli.create_embeddings")
|
| 189 |
+
def test_cli_main_rag_error(
|
| 190 |
+
mock_create_embeddings,
|
| 191 |
+
mock_load_vectorstore,
|
| 192 |
+
mock_create_qa_chain,
|
| 193 |
+
mock_create_llm,
|
| 194 |
+
capsys,
|
| 195 |
+
):
|
| 196 |
+
"""Test CLI main function with RAG error."""
|
| 197 |
+
# Setup mocks
|
| 198 |
+
mock_embeddings = MagicMock()
|
| 199 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 200 |
+
|
| 201 |
+
mock_vectorstore = MagicMock()
|
| 202 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 203 |
+
|
| 204 |
+
mock_qa_chain = MagicMock()
|
| 205 |
+
mock_qa_chain.stream.side_effect = Exception("RAG Error")
|
| 206 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 207 |
+
|
| 208 |
+
mock_llm = MagicMock()
|
| 209 |
+
mock_create_llm.return_value = mock_llm
|
| 210 |
+
|
| 211 |
+
# Run main
|
| 212 |
+
main()
|
| 213 |
+
|
| 214 |
+
# Check error handling
|
| 215 |
+
captured = capsys.readouterr()
|
| 216 |
+
assert "Error" in captured.out
|
| 217 |
+
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for config module."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from config import (
|
| 8 |
+
CHAT_HISTORY_LIMIT,
|
| 9 |
+
CHROMA_PATH,
|
| 10 |
+
CHUNK_OVERLAP,
|
| 11 |
+
CHUNK_SIZE,
|
| 12 |
+
EMBEDDING_DEVICE,
|
| 13 |
+
EMBEDDING_MODEL_NAME,
|
| 14 |
+
HYBRID_ALPHA_DEFAULT,
|
| 15 |
+
MMR_LAMBDA,
|
| 16 |
+
PDF_PATH,
|
| 17 |
+
RAG_PROMPT_TEMPLATE,
|
| 18 |
+
RETRIEVER_FETCH_K,
|
| 19 |
+
RETRIEVER_K,
|
| 20 |
+
RERANKER_MODEL,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_config_constants():
|
| 25 |
+
"""Test that configuration constants are set."""
|
| 26 |
+
assert RETRIEVER_K == 3
|
| 27 |
+
assert RETRIEVER_FETCH_K == 10
|
| 28 |
+
assert MMR_LAMBDA == 0.7
|
| 29 |
+
assert CHAT_HISTORY_LIMIT == 5
|
| 30 |
+
assert HYBRID_ALPHA_DEFAULT == 0.7
|
| 31 |
+
assert CHUNK_SIZE == 512
|
| 32 |
+
assert CHUNK_OVERLAP == 128
|
| 33 |
+
assert EMBEDDING_MODEL_NAME == "BAAI/bge-small-en-v1.5"
|
| 34 |
+
assert EMBEDDING_DEVICE == "cpu"
|
| 35 |
+
assert RERANKER_MODEL == "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_config_paths():
|
| 39 |
+
"""Test that path constants are strings."""
|
| 40 |
+
assert isinstance(CHROMA_PATH, str)
|
| 41 |
+
assert isinstance(PDF_PATH, str)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_rag_prompt_template():
|
| 45 |
+
"""Test that RAG prompt template contains required placeholders."""
|
| 46 |
+
assert "{context}" in RAG_PROMPT_TEMPLATE
|
| 47 |
+
assert "{question}" in RAG_PROMPT_TEMPLATE
|
| 48 |
+
assert "{chat_history}" in RAG_PROMPT_TEMPLATE
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_environment_variables():
|
| 52 |
+
"""Test that environment variables can be read."""
|
| 53 |
+
# These should have defaults or be set
|
| 54 |
+
assert isinstance(os.getenv("CHROMA_PATH", "vectorstore"), str)
|
| 55 |
+
assert isinstance(os.getenv("PDF_PATH", "pdf"), str)
|
| 56 |
+
|
tests/test_empty_vectorstore.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for empty vectorstore and initial state scenarios."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper, create_qa_chain
|
| 8 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 9 |
+
from langchain_core.documents import Document
|
| 10 |
+
from ui.handlers import create_stream_chat_response
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def empty_vectorstore():
|
| 15 |
+
"""Create an empty vectorstore mock."""
|
| 16 |
+
mock_vs = MagicMock()
|
| 17 |
+
mock_vs.get.return_value = {
|
| 18 |
+
"documents": [],
|
| 19 |
+
"metadatas": [],
|
| 20 |
+
"ids": [],
|
| 21 |
+
}
|
| 22 |
+
mock_retriever = MagicMock()
|
| 23 |
+
mock_retriever.invoke.return_value = [] # No documents
|
| 24 |
+
mock_vs.as_retriever.return_value = mock_retriever
|
| 25 |
+
return mock_vs
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@patch("qa_chain.create_llm")
|
| 29 |
+
@patch("qa_chain.format_chat_history")
|
| 30 |
+
def test_rag_query_with_empty_vectorstore(mock_format_history, mock_create_llm, empty_vectorstore):
|
| 31 |
+
"""Test RAG query when vectorstore is empty."""
|
| 32 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 33 |
+
qa_chain = QAChainWrapper(empty_vectorstore, prompt)
|
| 34 |
+
|
| 35 |
+
mock_format_history.return_value = ""
|
| 36 |
+
mock_llm = MagicMock()
|
| 37 |
+
mock_chunk = MagicMock()
|
| 38 |
+
mock_chunk.content = "I don't have any documents to search."
|
| 39 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 40 |
+
mock_create_llm.return_value = mock_llm
|
| 41 |
+
|
| 42 |
+
mock_chain = MagicMock()
|
| 43 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 44 |
+
qa_chain._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 45 |
+
|
| 46 |
+
inputs = {
|
| 47 |
+
"question": "What is RAG?",
|
| 48 |
+
"chat_history": [],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
results = list(qa_chain.stream(inputs))
|
| 52 |
+
|
| 53 |
+
# Should handle empty vectorstore gracefully
|
| 54 |
+
assert len(results) > 0
|
| 55 |
+
# Should have empty source documents
|
| 56 |
+
assert len(results[0]["source_documents"]) == 0
|
| 57 |
+
# Context should be empty
|
| 58 |
+
context = "\n\n".join(doc.page_content for doc in results[0]["source_documents"])
|
| 59 |
+
assert context == ""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@patch("qa_chain.create_llm")
|
| 63 |
+
@patch("qa_chain.format_chat_history")
|
| 64 |
+
def test_rag_query_with_no_retrieved_documents(mock_format_history, mock_create_llm, mock_vectorstore):
|
| 65 |
+
"""Test RAG query when retrieval returns no documents."""
|
| 66 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 67 |
+
qa_chain = QAChainWrapper(mock_vectorstore, prompt)
|
| 68 |
+
|
| 69 |
+
mock_format_history.return_value = ""
|
| 70 |
+
mock_llm = MagicMock()
|
| 71 |
+
mock_chunk = MagicMock()
|
| 72 |
+
mock_chunk.content = "No relevant documents found."
|
| 73 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 74 |
+
mock_create_llm.return_value = mock_llm
|
| 75 |
+
|
| 76 |
+
# Retriever returns empty list
|
| 77 |
+
mock_retriever = MagicMock()
|
| 78 |
+
mock_retriever.invoke.return_value = [] # No documents retrieved
|
| 79 |
+
qa_chain._retriever = mock_retriever
|
| 80 |
+
|
| 81 |
+
mock_chain = MagicMock()
|
| 82 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 83 |
+
qa_chain._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 84 |
+
|
| 85 |
+
inputs = {
|
| 86 |
+
"question": "What is RAG?",
|
| 87 |
+
"chat_history": [],
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
results = list(qa_chain.stream(inputs))
|
| 91 |
+
|
| 92 |
+
# Should handle no documents gracefully
|
| 93 |
+
assert len(results) > 0
|
| 94 |
+
assert len(results[0]["source_documents"]) == 0
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_initialize_chain_with_empty_vectorstore():
|
| 98 |
+
"""Test chain initialization with empty vectorstore."""
|
| 99 |
+
from ui.app import initialize_chain
|
| 100 |
+
|
| 101 |
+
with patch("ui.app.create_embeddings") as mock_embeddings, \
|
| 102 |
+
patch("ui.app.load_or_create_vectorstore") as mock_load_vs, \
|
| 103 |
+
patch("ui.app.create_qa_chain") as mock_create_chain:
|
| 104 |
+
|
| 105 |
+
mock_embeddings.return_value = MagicMock()
|
| 106 |
+
|
| 107 |
+
mock_vectorstore = MagicMock()
|
| 108 |
+
mock_vectorstore.get.return_value = {
|
| 109 |
+
"metadatas": [], # Empty metadatas
|
| 110 |
+
}
|
| 111 |
+
mock_load_vs.return_value = mock_vectorstore
|
| 112 |
+
|
| 113 |
+
mock_chain = MagicMock()
|
| 114 |
+
mock_create_chain.return_value = mock_chain
|
| 115 |
+
|
| 116 |
+
chain, sources = initialize_chain()
|
| 117 |
+
|
| 118 |
+
assert chain == mock_chain
|
| 119 |
+
assert sources == [] # Should return empty list
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@patch("ui.handlers.create_llm")
|
| 123 |
+
def test_handlers_with_empty_vectorstore(mock_create_llm, empty_vectorstore):
|
| 124 |
+
"""Test handlers with empty vectorstore."""
|
| 125 |
+
mock_qa_chain = MagicMock()
|
| 126 |
+
mock_qa_chain.stream.return_value = [
|
| 127 |
+
{
|
| 128 |
+
"chunk": "No documents available.",
|
| 129 |
+
"source_documents": [],
|
| 130 |
+
"docs_with_scores": None,
|
| 131 |
+
"rewritten_query": None,
|
| 132 |
+
"hybrid_scores": None,
|
| 133 |
+
}
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 137 |
+
|
| 138 |
+
results = list(
|
| 139 |
+
stream_fn(
|
| 140 |
+
"What is RAG?",
|
| 141 |
+
[],
|
| 142 |
+
"RAG",
|
| 143 |
+
doc_filter=None,
|
| 144 |
+
search_type="mmr",
|
| 145 |
+
)
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
assert len(results) > 0
|
| 149 |
+
# Should handle empty results gracefully
|
| 150 |
+
assert len(results[0][1]) == 0 # source_documents is empty
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@patch("retrievers.BM25Okapi")
|
| 154 |
+
def test_hybrid_search_with_empty_documents(mock_bm25, empty_vectorstore):
|
| 155 |
+
"""Test hybrid search with empty document collection."""
|
| 156 |
+
from retrievers import HybridRetriever
|
| 157 |
+
|
| 158 |
+
empty_vectorstore.get.return_value = {
|
| 159 |
+
"documents": [],
|
| 160 |
+
"metadatas": [],
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
empty_vectorstore.similarity_search_with_score.return_value = []
|
| 164 |
+
|
| 165 |
+
retriever = HybridRetriever(empty_vectorstore)
|
| 166 |
+
|
| 167 |
+
results = retriever.hybrid_search("test query", k=5)
|
| 168 |
+
|
| 169 |
+
# Should return empty list
|
| 170 |
+
assert results == []
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@patch("vectorstore.Chroma")
|
| 174 |
+
@patch("vectorstore.get_pdf_files")
|
| 175 |
+
def test_create_new_vectorstore_no_pdfs(mock_get_pdfs, mock_chroma):
|
| 176 |
+
"""Test creating new vectorstore when no PDFs exist."""
|
| 177 |
+
from vectorstore import create_new_vectorstore
|
| 178 |
+
|
| 179 |
+
mock_get_pdfs.return_value = [] # No PDF files
|
| 180 |
+
|
| 181 |
+
with pytest.raises(SystemExit):
|
| 182 |
+
create_new_vectorstore(MagicMock())
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def test_rerank_with_empty_documents(mock_vectorstore):
|
| 186 |
+
"""Test re-ranking with empty document list."""
|
| 187 |
+
from qa_chain import QAChainWrapper
|
| 188 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 189 |
+
|
| 190 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 191 |
+
qa_chain_wrapper = QAChainWrapper(mock_vectorstore, prompt)
|
| 192 |
+
|
| 193 |
+
result = qa_chain_wrapper.rerank_documents("query", [], top_k=5)
|
| 194 |
+
|
| 195 |
+
# Should return empty list
|
| 196 |
+
assert result == []
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def test_hybrid_retriever_with_empty_collection(empty_vectorstore):
|
| 200 |
+
"""Test hybrid retriever initialization with empty collection."""
|
| 201 |
+
from retrievers import HybridRetriever
|
| 202 |
+
|
| 203 |
+
retriever = HybridRetriever(empty_vectorstore)
|
| 204 |
+
retriever._build_bm25_index()
|
| 205 |
+
|
| 206 |
+
# BM25 should be None when no documents
|
| 207 |
+
assert retriever._bm25 is None
|
| 208 |
+
assert retriever._documents == []
|
| 209 |
+
|
tests/test_handlers.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for UI handlers module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from ui.handlers import (
|
| 8 |
+
create_respond_handler,
|
| 9 |
+
create_stream_chat_response,
|
| 10 |
+
update_hybrid_alpha_visibility,
|
| 11 |
+
update_rag_controls,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_create_stream_chat_response_rag(mock_vectorstore):
|
| 16 |
+
"""Test stream chat response for RAG mode."""
|
| 17 |
+
mock_qa_chain = MagicMock()
|
| 18 |
+
mock_qa_chain.stream.return_value = [
|
| 19 |
+
{
|
| 20 |
+
"chunk": "Response ",
|
| 21 |
+
"source_documents": [Mock()],
|
| 22 |
+
"docs_with_scores": [(Mock(), 0.9)],
|
| 23 |
+
"rewritten_query": None,
|
| 24 |
+
"hybrid_scores": None,
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"chunk": "chunk",
|
| 28 |
+
"source_documents": [Mock()],
|
| 29 |
+
"docs_with_scores": [(Mock(), 0.9)],
|
| 30 |
+
"rewritten_query": None,
|
| 31 |
+
"hybrid_scores": None,
|
| 32 |
+
},
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 36 |
+
results = list(
|
| 37 |
+
stream_fn(
|
| 38 |
+
"test question",
|
| 39 |
+
[],
|
| 40 |
+
"RAG",
|
| 41 |
+
doc_filter=None,
|
| 42 |
+
search_type="mmr",
|
| 43 |
+
)
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
assert len(results) > 0
|
| 47 |
+
assert "Response chunk" in results[-1][0]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@patch("ui.handlers.create_llm")
|
| 51 |
+
def test_create_stream_chat_response_vanilla(mock_create_llm):
|
| 52 |
+
"""Test stream chat response for vanilla LLM mode."""
|
| 53 |
+
mock_llm = MagicMock()
|
| 54 |
+
mock_llm.stream.return_value = [
|
| 55 |
+
MagicMock(content="Hello "),
|
| 56 |
+
MagicMock(content="World"),
|
| 57 |
+
]
|
| 58 |
+
mock_create_llm.return_value = mock_llm
|
| 59 |
+
|
| 60 |
+
stream_fn = create_stream_chat_response(MagicMock())
|
| 61 |
+
results = list(
|
| 62 |
+
stream_fn(
|
| 63 |
+
"test question",
|
| 64 |
+
[],
|
| 65 |
+
"Vanilla LLM",
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
assert len(results) > 0
|
| 70 |
+
assert "Hello World" in results[-1][0]
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@patch("ui.handlers.create_llm")
|
| 74 |
+
def test_create_stream_chat_response_vanilla_error(mock_create_llm):
|
| 75 |
+
"""Test stream chat response error handling for vanilla mode."""
|
| 76 |
+
mock_llm = MagicMock()
|
| 77 |
+
mock_llm.stream.side_effect = Exception("Error")
|
| 78 |
+
mock_create_llm.return_value = mock_llm
|
| 79 |
+
|
| 80 |
+
stream_fn = create_stream_chat_response(MagicMock())
|
| 81 |
+
results = list(
|
| 82 |
+
stream_fn(
|
| 83 |
+
"test question",
|
| 84 |
+
[],
|
| 85 |
+
"Vanilla LLM",
|
| 86 |
+
)
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
assert len(results) > 0
|
| 90 |
+
assert "Error" in results[-1][0]
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def test_create_stream_chat_response_with_filter(mock_vectorstore):
|
| 94 |
+
"""Test stream chat response with document filter."""
|
| 95 |
+
mock_qa_chain = MagicMock()
|
| 96 |
+
mock_qa_chain.stream.return_value = [
|
| 97 |
+
{
|
| 98 |
+
"chunk": "Response",
|
| 99 |
+
"source_documents": [],
|
| 100 |
+
"docs_with_scores": None,
|
| 101 |
+
"rewritten_query": None,
|
| 102 |
+
"hybrid_scores": None,
|
| 103 |
+
}
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 107 |
+
list(
|
| 108 |
+
stream_fn(
|
| 109 |
+
"test question",
|
| 110 |
+
[],
|
| 111 |
+
"RAG",
|
| 112 |
+
doc_filter="test.pdf",
|
| 113 |
+
search_type="mmr",
|
| 114 |
+
)
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# Check that filter was passed
|
| 118 |
+
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 119 |
+
assert "filter" in call_args
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_create_respond_handler_empty_message():
|
| 123 |
+
"""Test respond handler with empty message."""
|
| 124 |
+
stream_fn = MagicMock()
|
| 125 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 126 |
+
|
| 127 |
+
results = list(respond_fn("", [], False, "mmr", "All Documents", False, False, 70))
|
| 128 |
+
assert len(results) > 0
|
| 129 |
+
assert results[0][0] == "" # Empty message cleared
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def test_create_respond_handler_rag(mock_vectorstore):
|
| 133 |
+
"""Test respond handler for RAG mode."""
|
| 134 |
+
mock_qa_chain = MagicMock()
|
| 135 |
+
mock_qa_chain.stream.return_value = [
|
| 136 |
+
{
|
| 137 |
+
"chunk": "Response",
|
| 138 |
+
"source_documents": [Mock(metadata={"source": "test.pdf", "page": 1})],
|
| 139 |
+
"docs_with_scores": [(Mock(), 0.9)],
|
| 140 |
+
"rewritten_query": None,
|
| 141 |
+
"hybrid_scores": None,
|
| 142 |
+
}
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 146 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 147 |
+
|
| 148 |
+
results = list(
|
| 149 |
+
respond_fn(
|
| 150 |
+
"test question",
|
| 151 |
+
[],
|
| 152 |
+
True, # RAG enabled
|
| 153 |
+
"mmr",
|
| 154 |
+
"All Documents",
|
| 155 |
+
False,
|
| 156 |
+
False,
|
| 157 |
+
70,
|
| 158 |
+
)
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
assert len(results) > 0
|
| 162 |
+
# Check that history was updated
|
| 163 |
+
assert results[-1][1][-1]["role"] == "assistant"
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_update_rag_controls_enabled():
|
| 167 |
+
"""Test RAG controls update when enabled."""
|
| 168 |
+
result = update_rag_controls(True)
|
| 169 |
+
assert len(result) == 4
|
| 170 |
+
assert all(r.get("visible") for r in result)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_update_rag_controls_disabled():
|
| 174 |
+
"""Test RAG controls update when disabled."""
|
| 175 |
+
result = update_rag_controls(False)
|
| 176 |
+
assert len(result) == 4
|
| 177 |
+
assert all(not r.get("visible") for r in result)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_update_hybrid_alpha_visibility_hybrid():
|
| 181 |
+
"""Test hybrid alpha visibility for hybrid search."""
|
| 182 |
+
result = update_hybrid_alpha_visibility("hybrid")
|
| 183 |
+
assert result.get("visible") is True
|
| 184 |
+
assert result.get("interactive") is True
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_update_hybrid_alpha_visibility_other():
|
| 188 |
+
"""Test hybrid alpha visibility for non-hybrid search."""
|
| 189 |
+
result = update_hybrid_alpha_visibility("mmr")
|
| 190 |
+
assert result.get("visible") is False
|
| 191 |
+
assert result.get("interactive") is False
|
| 192 |
+
|
tests/test_input_validation.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for input validation and edge cases."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper
|
| 8 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 9 |
+
from langchain_core.documents import Document
|
| 10 |
+
from ui.handlers import create_stream_chat_response, create_respond_handler
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.fixture
|
| 14 |
+
def qa_chain_wrapper(mock_vectorstore):
|
| 15 |
+
"""Create a QAChainWrapper instance."""
|
| 16 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 17 |
+
return QAChainWrapper(mock_vectorstore, prompt)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@patch("qa_chain.create_llm")
|
| 21 |
+
@patch("qa_chain.format_chat_history")
|
| 22 |
+
def test_very_long_query(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 23 |
+
"""Test handling of extremely long queries (>10k characters)."""
|
| 24 |
+
very_long_query = "What is " + "RAG? " * 2000 # ~10k+ characters
|
| 25 |
+
|
| 26 |
+
mock_format_history.return_value = ""
|
| 27 |
+
mock_llm = MagicMock()
|
| 28 |
+
mock_chunk = MagicMock()
|
| 29 |
+
mock_chunk.content = "Response"
|
| 30 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 31 |
+
mock_create_llm.return_value = mock_llm
|
| 32 |
+
|
| 33 |
+
mock_retriever = MagicMock()
|
| 34 |
+
mock_retriever.invoke.return_value = [Document(page_content="Test", metadata={})]
|
| 35 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 36 |
+
|
| 37 |
+
mock_chain = MagicMock()
|
| 38 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 39 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 40 |
+
|
| 41 |
+
inputs = {
|
| 42 |
+
"question": very_long_query,
|
| 43 |
+
"chat_history": [],
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
# Should handle long query without crashing
|
| 47 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 48 |
+
assert len(results) > 0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_empty_whitespace_query():
|
| 52 |
+
"""Test handling of whitespace-only queries."""
|
| 53 |
+
from ui.handlers import create_respond_handler
|
| 54 |
+
|
| 55 |
+
stream_fn = MagicMock()
|
| 56 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 57 |
+
|
| 58 |
+
# Empty string
|
| 59 |
+
results = list(respond_fn("", [], False, "mmr", "All Documents", False, False, 70))
|
| 60 |
+
assert results[0][0] == "" # Should return empty immediately
|
| 61 |
+
|
| 62 |
+
# Whitespace only
|
| 63 |
+
results = list(respond_fn(" ", [], False, "mmr", "All Documents", False, False, 70))
|
| 64 |
+
# Should process (whitespace is not empty in Python)
|
| 65 |
+
assert len(results) > 0
|
| 66 |
+
|
| 67 |
+
# Newlines only
|
| 68 |
+
results = list(respond_fn("\n\n\n", [], False, "mmr", "All Documents", False, False, 70))
|
| 69 |
+
assert len(results) > 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@patch("qa_chain.create_llm")
|
| 73 |
+
@patch("qa_chain.format_chat_history")
|
| 74 |
+
def test_special_characters_in_query(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 75 |
+
"""Test handling of special characters in queries."""
|
| 76 |
+
special_chars_query = "What is RAG? @#$%^&*()[]{}|\\/<>?~`"
|
| 77 |
+
|
| 78 |
+
mock_format_history.return_value = ""
|
| 79 |
+
mock_llm = MagicMock()
|
| 80 |
+
mock_chunk = MagicMock()
|
| 81 |
+
mock_chunk.content = "Response"
|
| 82 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 83 |
+
mock_create_llm.return_value = mock_llm
|
| 84 |
+
|
| 85 |
+
mock_retriever = MagicMock()
|
| 86 |
+
mock_retriever.invoke.return_value = [Document(page_content="Test", metadata={})]
|
| 87 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 88 |
+
|
| 89 |
+
mock_chain = MagicMock()
|
| 90 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 91 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 92 |
+
|
| 93 |
+
inputs = {
|
| 94 |
+
"question": special_chars_query,
|
| 95 |
+
"chat_history": [],
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# Should handle special characters without crashing
|
| 99 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 100 |
+
assert len(results) > 0
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@patch("qa_chain.create_llm")
|
| 104 |
+
@patch("qa_chain.format_chat_history")
|
| 105 |
+
def test_unicode_emoji_in_query(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 106 |
+
"""Test handling of Unicode and emoji in queries."""
|
| 107 |
+
unicode_query = "What is RAG? π δ½ ε₯½ Ω
Ψ±ΨΨ¨Ψ§"
|
| 108 |
+
|
| 109 |
+
mock_format_history.return_value = ""
|
| 110 |
+
mock_llm = MagicMock()
|
| 111 |
+
mock_chunk = MagicMock()
|
| 112 |
+
mock_chunk.content = "Response"
|
| 113 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 114 |
+
mock_create_llm.return_value = mock_llm
|
| 115 |
+
|
| 116 |
+
mock_retriever = MagicMock()
|
| 117 |
+
mock_retriever.invoke.return_value = [Document(page_content="Test", metadata={})]
|
| 118 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 119 |
+
|
| 120 |
+
mock_chain = MagicMock()
|
| 121 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 122 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 123 |
+
|
| 124 |
+
inputs = {
|
| 125 |
+
"question": unicode_query,
|
| 126 |
+
"chat_history": [],
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
# Should handle Unicode/emoji without crashing
|
| 130 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 131 |
+
assert len(results) > 0
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_invalid_document_filter():
|
| 135 |
+
"""Test handling of invalid document filter selection."""
|
| 136 |
+
from ui.handlers import create_stream_chat_response
|
| 137 |
+
|
| 138 |
+
mock_qa_chain = MagicMock()
|
| 139 |
+
mock_qa_chain.stream.return_value = [
|
| 140 |
+
{
|
| 141 |
+
"chunk": "Response",
|
| 142 |
+
"source_documents": [],
|
| 143 |
+
"docs_with_scores": None,
|
| 144 |
+
"rewritten_query": None,
|
| 145 |
+
"hybrid_scores": None,
|
| 146 |
+
}
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 150 |
+
|
| 151 |
+
# Invalid filter (document that doesn't exist)
|
| 152 |
+
results = list(
|
| 153 |
+
stream_fn(
|
| 154 |
+
"test question",
|
| 155 |
+
[],
|
| 156 |
+
"RAG",
|
| 157 |
+
doc_filter="nonexistent.pdf", # Invalid filter
|
| 158 |
+
search_type="mmr",
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Should handle invalid filter gracefully
|
| 163 |
+
assert len(results) > 0
|
| 164 |
+
# Filter should still be passed to chain (it handles the filtering)
|
| 165 |
+
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 166 |
+
assert "filter" in call_args
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def test_malformed_chat_history():
|
| 170 |
+
"""Test handling of malformed chat history."""
|
| 171 |
+
from ui.handlers import messages_to_tuples
|
| 172 |
+
|
| 173 |
+
# Missing role - should handle gracefully
|
| 174 |
+
try:
|
| 175 |
+
malformed_history1 = [
|
| 176 |
+
{"content": "Message without role"},
|
| 177 |
+
]
|
| 178 |
+
tuples1 = messages_to_tuples(malformed_history1)
|
| 179 |
+
assert isinstance(tuples1, list)
|
| 180 |
+
# Should return empty (no valid user/assistant pairs)
|
| 181 |
+
assert tuples1 == []
|
| 182 |
+
except (KeyError, TypeError):
|
| 183 |
+
# Exception is acceptable for malformed input
|
| 184 |
+
pass
|
| 185 |
+
|
| 186 |
+
# Missing content - should handle gracefully
|
| 187 |
+
try:
|
| 188 |
+
malformed_history2 = [
|
| 189 |
+
{"role": "user"},
|
| 190 |
+
]
|
| 191 |
+
tuples2 = messages_to_tuples(malformed_history2)
|
| 192 |
+
assert isinstance(tuples2, list)
|
| 193 |
+
except (KeyError, TypeError):
|
| 194 |
+
# Exception is acceptable for malformed input
|
| 195 |
+
pass
|
| 196 |
+
|
| 197 |
+
# Invalid role - should handle gracefully
|
| 198 |
+
malformed_history3 = [
|
| 199 |
+
{"role": "invalid", "content": "Message"},
|
| 200 |
+
]
|
| 201 |
+
tuples3 = messages_to_tuples(malformed_history3)
|
| 202 |
+
# Should return empty (only processes user/assistant pairs)
|
| 203 |
+
assert isinstance(tuples3, list)
|
| 204 |
+
assert tuples3 == []
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
@patch("qa_chain.create_llm")
|
| 208 |
+
def test_query_rewriting_with_very_short_query(mock_create_llm, qa_chain_wrapper):
|
| 209 |
+
"""Test query rewriting with very short query."""
|
| 210 |
+
very_short_query = "RAG?"
|
| 211 |
+
|
| 212 |
+
mock_llm = MagicMock()
|
| 213 |
+
mock_response = MagicMock()
|
| 214 |
+
# Make rewritten query shorter than 30% of original
|
| 215 |
+
# "RAG?" is 4 chars, 30% = 1.2, so "R" (1 char) should trigger fallback
|
| 216 |
+
mock_response.content = "R"
|
| 217 |
+
mock_llm.invoke.return_value = mock_response
|
| 218 |
+
mock_create_llm.return_value = mock_llm
|
| 219 |
+
|
| 220 |
+
result = qa_chain_wrapper.rewrite_query(very_short_query)
|
| 221 |
+
|
| 222 |
+
# Should return original if rewritten is too short (< 30% of original length)
|
| 223 |
+
assert result == very_short_query
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@patch("qa_chain.create_llm")
|
| 227 |
+
@patch("qa_chain.format_chat_history")
|
| 228 |
+
def test_hybrid_search_with_extreme_alpha(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 229 |
+
"""Test hybrid search with extreme alpha values."""
|
| 230 |
+
from langchain_core.documents import Document
|
| 231 |
+
|
| 232 |
+
mock_format_history.return_value = ""
|
| 233 |
+
mock_llm = MagicMock()
|
| 234 |
+
mock_chunk = MagicMock()
|
| 235 |
+
mock_chunk.content = "Response"
|
| 236 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 237 |
+
mock_create_llm.return_value = mock_llm
|
| 238 |
+
|
| 239 |
+
mock_retriever = MagicMock()
|
| 240 |
+
mock_retriever.invoke.return_value = [Document(page_content="Test", metadata={})]
|
| 241 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 242 |
+
|
| 243 |
+
mock_chain = MagicMock()
|
| 244 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 245 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 246 |
+
|
| 247 |
+
# Test with alpha = 0.0 (pure keyword)
|
| 248 |
+
inputs = {
|
| 249 |
+
"question": "test",
|
| 250 |
+
"chat_history": [],
|
| 251 |
+
"search_type": "hybrid",
|
| 252 |
+
"hybrid_alpha": 0.0,
|
| 253 |
+
}
|
| 254 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 255 |
+
assert len(results) > 0
|
| 256 |
+
|
| 257 |
+
# Test with alpha = 1.0 (pure semantic)
|
| 258 |
+
inputs["hybrid_alpha"] = 1.0
|
| 259 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 260 |
+
assert len(results) > 0
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def test_rerank_with_very_few_documents(qa_chain_wrapper, sample_documents):
|
| 264 |
+
"""Test re-ranking with very few documents."""
|
| 265 |
+
# Re-rank with only 1 document
|
| 266 |
+
result = qa_chain_wrapper.rerank_documents("query", sample_documents[:1], top_k=5)
|
| 267 |
+
|
| 268 |
+
# Should return the single document
|
| 269 |
+
assert len(result) == 1
|
| 270 |
+
|
| 271 |
+
# Re-rank with more documents than top_k
|
| 272 |
+
result = qa_chain_wrapper.rerank_documents("query", sample_documents[:10], top_k=3)
|
| 273 |
+
|
| 274 |
+
# Should return only top_k documents
|
| 275 |
+
assert len(result) == 3
|
| 276 |
+
|
tests/test_integration.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests for the RAG application."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from models import create_embeddings, create_llm
|
| 8 |
+
from qa_chain import create_qa_chain
|
| 9 |
+
from utils import format_chat_history, format_context_with_highlight, messages_to_tuples
|
| 10 |
+
from vectorstore import load_or_create_vectorstore
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@patch("vectorstore.Chroma")
|
| 14 |
+
@patch("models.HuggingFaceEmbeddings")
|
| 15 |
+
def test_integration_rag_flow(mock_embeddings_class, mock_chroma_class):
|
| 16 |
+
"""Test full RAG flow from embeddings to QA chain."""
|
| 17 |
+
# Setup mocks
|
| 18 |
+
mock_embeddings = MagicMock()
|
| 19 |
+
mock_embeddings_class.return_value = mock_embeddings
|
| 20 |
+
|
| 21 |
+
mock_vectorstore = MagicMock()
|
| 22 |
+
mock_vectorstore.get.return_value = {
|
| 23 |
+
"documents": ["Test document content"],
|
| 24 |
+
"metadatas": [{"source": "pdf/test.pdf", "page": 1}],
|
| 25 |
+
}
|
| 26 |
+
mock_vectorstore.as_retriever.return_value = MagicMock()
|
| 27 |
+
mock_chroma_class.return_value = mock_vectorstore
|
| 28 |
+
|
| 29 |
+
# Test full flow
|
| 30 |
+
embeddings = create_embeddings()
|
| 31 |
+
vectorstore = load_or_create_vectorstore(embeddings)
|
| 32 |
+
qa_chain = create_qa_chain(vectorstore)
|
| 33 |
+
|
| 34 |
+
assert embeddings is not None
|
| 35 |
+
assert vectorstore is not None
|
| 36 |
+
assert qa_chain is not None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@patch("models.ChatOpenAI")
|
| 40 |
+
def test_integration_llm_creation(mock_chat_openai):
|
| 41 |
+
"""Test LLM creation integration."""
|
| 42 |
+
mock_llm = MagicMock()
|
| 43 |
+
mock_chat_openai.return_value = mock_llm
|
| 44 |
+
|
| 45 |
+
llm = create_llm(streaming=True)
|
| 46 |
+
assert llm is not None
|
| 47 |
+
mock_chat_openai.assert_called_once()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_integration_chat_history_conversion():
|
| 51 |
+
"""Test integration of chat history conversion and formatting."""
|
| 52 |
+
messages = [
|
| 53 |
+
{"role": "user", "content": "Question 1"},
|
| 54 |
+
{"role": "assistant", "content": "Answer 1"},
|
| 55 |
+
{"role": "user", "content": "Question 2"},
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
# Convert to tuples
|
| 59 |
+
tuples = messages_to_tuples(messages)
|
| 60 |
+
assert len(tuples) == 1
|
| 61 |
+
assert tuples[0][0] == "Question 1"
|
| 62 |
+
|
| 63 |
+
# Format history
|
| 64 |
+
formatted = format_chat_history(tuples)
|
| 65 |
+
assert "Question 1" in formatted
|
| 66 |
+
assert "Answer 1" in formatted
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_integration_context_formatting():
|
| 70 |
+
"""Test integration of context formatting with all features."""
|
| 71 |
+
from langchain_core.documents import Document
|
| 72 |
+
|
| 73 |
+
docs = [
|
| 74 |
+
Document(
|
| 75 |
+
page_content="This is test content about machine learning.",
|
| 76 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 77 |
+
),
|
| 78 |
+
Document(
|
| 79 |
+
page_content="Another document about neural networks.",
|
| 80 |
+
metadata={"source": "pdf/test2.pdf", "page": 2},
|
| 81 |
+
),
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
docs_with_scores = [(docs[0], 0.9), (docs[1], 0.8)]
|
| 85 |
+
hybrid_scores = [
|
| 86 |
+
(docs[0], 0.9, 0.85, 0.95),
|
| 87 |
+
(docs[1], 0.8, 0.75, 0.85),
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
result = format_context_with_highlight(
|
| 91 |
+
docs,
|
| 92 |
+
docs_with_scores=docs_with_scores,
|
| 93 |
+
rewritten_query="machine learning neural networks",
|
| 94 |
+
hybrid_scores=hybrid_scores,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
assert "Sources:" in result
|
| 98 |
+
assert "test1.pdf" in result
|
| 99 |
+
assert "test2.pdf" in result
|
| 100 |
+
assert "π Rewritten:" in result
|
| 101 |
+
assert "machine learning neural networks" in result
|
| 102 |
+
assert "β" in result # Top chunk highlighted
|
| 103 |
+
assert "f:" in result # Fused score
|
| 104 |
+
assert "s:" in result # Semantic score
|
| 105 |
+
assert "k:" in result # Keyword score
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@patch("qa_chain.create_llm")
|
| 109 |
+
@patch("qa_chain.format_chat_history")
|
| 110 |
+
def test_integration_qa_chain_streaming(mock_format_history, mock_create_llm, mock_vectorstore):
|
| 111 |
+
"""Test integration of QA chain streaming."""
|
| 112 |
+
from qa_chain import create_qa_chain
|
| 113 |
+
|
| 114 |
+
mock_format_history.return_value = "Previous: Test"
|
| 115 |
+
|
| 116 |
+
# Create proper mock chunks with content as property
|
| 117 |
+
from unittest.mock import PropertyMock
|
| 118 |
+
|
| 119 |
+
mock_chunk1 = MagicMock()
|
| 120 |
+
mock_chunk1.content = "This is "
|
| 121 |
+
mock_chunk2 = MagicMock()
|
| 122 |
+
mock_chunk2.content = "a test "
|
| 123 |
+
mock_chunk3 = MagicMock()
|
| 124 |
+
mock_chunk3.content = "response."
|
| 125 |
+
|
| 126 |
+
mock_llm = MagicMock()
|
| 127 |
+
mock_llm.stream.return_value = [mock_chunk1, mock_chunk2, mock_chunk3]
|
| 128 |
+
mock_create_llm.return_value = mock_llm
|
| 129 |
+
|
| 130 |
+
mock_retriever = MagicMock()
|
| 131 |
+
mock_doc = Mock(page_content="Retrieved context", metadata={"source": "test.pdf", "page": 1})
|
| 132 |
+
mock_retriever.invoke.return_value = [mock_doc]
|
| 133 |
+
mock_vectorstore.as_retriever.return_value = mock_retriever
|
| 134 |
+
|
| 135 |
+
qa_chain = create_qa_chain(mock_vectorstore)
|
| 136 |
+
|
| 137 |
+
# Mock the chain operator - this is what gets called in stream()
|
| 138 |
+
mock_chain = MagicMock()
|
| 139 |
+
mock_chain.stream.return_value = [mock_chunk1, mock_chunk2, mock_chunk3]
|
| 140 |
+
qa_chain._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 141 |
+
|
| 142 |
+
inputs = {
|
| 143 |
+
"question": "What is this?",
|
| 144 |
+
"chat_history": [("Previous question", "Previous answer")],
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
results = list(qa_chain.stream(inputs))
|
| 148 |
+
|
| 149 |
+
assert len(results) > 0
|
| 150 |
+
assert all("chunk" in r for r in results)
|
| 151 |
+
# Check that chunks are accumulated - they should be strings
|
| 152 |
+
final_chunk = results[-1]["chunk"]
|
| 153 |
+
# The chunks get accumulated, so we should see all three
|
| 154 |
+
assert isinstance(final_chunk, str) or hasattr(final_chunk, '__str__')
|
| 155 |
+
chunk_str = str(final_chunk) if not isinstance(final_chunk, str) else final_chunk
|
| 156 |
+
assert len(chunk_str) > 0
|
| 157 |
+
assert len(results[-1]["source_documents"]) > 0
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@patch("retrievers.BM25Okapi")
|
| 161 |
+
def test_integration_hybrid_retrieval(mock_bm25, mock_vectorstore):
|
| 162 |
+
"""Test integration of hybrid retrieval."""
|
| 163 |
+
from retrievers import HybridRetriever
|
| 164 |
+
from langchain_core.documents import Document
|
| 165 |
+
|
| 166 |
+
mock_vectorstore.get.return_value = {
|
| 167 |
+
"documents": ["Document about AI", "Document about ML"],
|
| 168 |
+
"metadatas": [
|
| 169 |
+
{"source": "pdf/ai.pdf", "page": 1},
|
| 170 |
+
{"source": "pdf/ml.pdf", "page": 2},
|
| 171 |
+
],
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
doc1 = Document(
|
| 175 |
+
page_content="Document about AI",
|
| 176 |
+
metadata={"source": "pdf/ai.pdf", "page": 1},
|
| 177 |
+
)
|
| 178 |
+
doc2 = Document(
|
| 179 |
+
page_content="Document about ML",
|
| 180 |
+
metadata={"source": "pdf/ml.pdf", "page": 2},
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 184 |
+
(doc1, 0.1),
|
| 185 |
+
(doc2, 0.2),
|
| 186 |
+
]
|
| 187 |
+
|
| 188 |
+
mock_bm25_instance = MagicMock()
|
| 189 |
+
mock_bm25_instance.get_scores.return_value = [0.8, 0.6]
|
| 190 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 191 |
+
|
| 192 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 193 |
+
results = retriever.hybrid_search("AI machine learning", k=2, alpha=0.7)
|
| 194 |
+
|
| 195 |
+
assert len(results) == 2
|
| 196 |
+
assert all("doc" in r for r in results)
|
| 197 |
+
assert all("fused_score" in r for r in results)
|
| 198 |
+
assert all("semantic_score" in r for r in results)
|
| 199 |
+
assert all("keyword_score" in r for r in results)
|
| 200 |
+
|
| 201 |
+
# Check that scores are fused correctly
|
| 202 |
+
for result in results:
|
| 203 |
+
assert 0 <= result["fused_score"] <= 1
|
| 204 |
+
assert 0 <= result["semantic_score"] <= 1
|
| 205 |
+
assert 0 <= result["keyword_score"] <= 1
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_integration_end_to_end_rag_query():
|
| 209 |
+
"""Test end-to-end RAG query processing."""
|
| 210 |
+
from qa_chain import QAChainWrapper
|
| 211 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 212 |
+
from langchain_core.documents import Document
|
| 213 |
+
|
| 214 |
+
# Setup mocks
|
| 215 |
+
mock_vectorstore = MagicMock()
|
| 216 |
+
mock_vectorstore.get.return_value = {
|
| 217 |
+
"documents": ["RAG is Retrieval-Augmented Generation"],
|
| 218 |
+
"metadatas": [{"source": "pdf/rag.pdf", "page": 1}],
|
| 219 |
+
}
|
| 220 |
+
mock_retriever = MagicMock()
|
| 221 |
+
mock_retriever.invoke.return_value = [
|
| 222 |
+
Document(
|
| 223 |
+
page_content="RAG is Retrieval-Augmented Generation",
|
| 224 |
+
metadata={"source": "pdf/rag.pdf", "page": 1},
|
| 225 |
+
)
|
| 226 |
+
]
|
| 227 |
+
mock_vectorstore.as_retriever.return_value = mock_retriever
|
| 228 |
+
|
| 229 |
+
prompt = ChatPromptTemplate.from_template("Answer: {context}")
|
| 230 |
+
|
| 231 |
+
with patch("qa_chain.create_llm") as mock_create_llm:
|
| 232 |
+
mock_llm = MagicMock()
|
| 233 |
+
mock_llm.stream.return_value = [
|
| 234 |
+
MagicMock(content="RAG stands for "),
|
| 235 |
+
MagicMock(content="Retrieval-Augmented Generation."),
|
| 236 |
+
]
|
| 237 |
+
mock_create_llm.return_value = mock_llm
|
| 238 |
+
|
| 239 |
+
qa_chain = QAChainWrapper(mock_vectorstore, prompt)
|
| 240 |
+
|
| 241 |
+
inputs = {
|
| 242 |
+
"question": "What is RAG?",
|
| 243 |
+
"chat_history": [],
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
results = list(qa_chain.stream(inputs))
|
| 247 |
+
|
| 248 |
+
assert len(results) > 0
|
| 249 |
+
final_result = results[-1]
|
| 250 |
+
assert "chunk" in final_result
|
| 251 |
+
assert "source_documents" in final_result
|
| 252 |
+
assert len(final_result["source_documents"]) > 0
|
| 253 |
+
|
tests/test_mode_switching.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for switching between RAG and Vanilla LLM modes."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from ui.handlers import create_stream_chat_response, create_respond_handler
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@patch("ui.handlers.create_llm")
|
| 11 |
+
def test_switch_from_rag_to_vanilla(mock_create_llm):
|
| 12 |
+
"""Test switching from RAG mode to Vanilla LLM mode."""
|
| 13 |
+
mock_qa_chain = MagicMock()
|
| 14 |
+
mock_llm = MagicMock()
|
| 15 |
+
mock_chunk1 = MagicMock()
|
| 16 |
+
mock_chunk1.content = "Vanilla "
|
| 17 |
+
mock_chunk2 = MagicMock()
|
| 18 |
+
mock_chunk2.content = "response"
|
| 19 |
+
mock_llm.stream.return_value = [mock_chunk1, mock_chunk2]
|
| 20 |
+
mock_create_llm.return_value = mock_llm
|
| 21 |
+
|
| 22 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 23 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 24 |
+
|
| 25 |
+
# First: RAG query
|
| 26 |
+
history_rag = []
|
| 27 |
+
results_rag = list(
|
| 28 |
+
respond_fn(
|
| 29 |
+
"What is RAG?",
|
| 30 |
+
history_rag,
|
| 31 |
+
True, # RAG enabled
|
| 32 |
+
"mmr",
|
| 33 |
+
"All Documents",
|
| 34 |
+
False,
|
| 35 |
+
False,
|
| 36 |
+
70,
|
| 37 |
+
)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Second: Switch to Vanilla (RAG disabled)
|
| 41 |
+
history_after_rag = [
|
| 42 |
+
{"role": "user", "content": "What is RAG?"},
|
| 43 |
+
{"role": "assistant", "content": "RAG response"},
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
results_vanilla = list(
|
| 47 |
+
respond_fn(
|
| 48 |
+
"Tell me a joke",
|
| 49 |
+
history_after_rag,
|
| 50 |
+
False, # RAG disabled - switched to Vanilla
|
| 51 |
+
"mmr",
|
| 52 |
+
"All Documents",
|
| 53 |
+
False,
|
| 54 |
+
False,
|
| 55 |
+
70,
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Verify Vanilla LLM was used (not RAG chain)
|
| 60 |
+
assert len(results_vanilla) > 0
|
| 61 |
+
# Note: RAG chain might be called during first turn, so we check that vanilla was also called
|
| 62 |
+
assert mock_create_llm.called # Vanilla LLM should be called
|
| 63 |
+
# Verify history was preserved
|
| 64 |
+
assert len(results_vanilla[-1][1]) >= len(history_after_rag)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@patch("ui.handlers.create_llm")
|
| 68 |
+
def test_switch_from_vanilla_to_rag(mock_create_llm):
|
| 69 |
+
"""Test switching from Vanilla LLM mode to RAG mode."""
|
| 70 |
+
mock_qa_chain = MagicMock()
|
| 71 |
+
mock_qa_chain.stream.return_value = [
|
| 72 |
+
{
|
| 73 |
+
"chunk": "RAG response",
|
| 74 |
+
"source_documents": [Mock(metadata={"source": "test.pdf", "page": 1})],
|
| 75 |
+
"docs_with_scores": [(Mock(), 0.9)],
|
| 76 |
+
"rewritten_query": None,
|
| 77 |
+
"hybrid_scores": None,
|
| 78 |
+
}
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
mock_llm = MagicMock()
|
| 82 |
+
mock_chunk = MagicMock()
|
| 83 |
+
mock_chunk.content = "Vanilla response"
|
| 84 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 85 |
+
mock_create_llm.return_value = mock_llm
|
| 86 |
+
|
| 87 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 88 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 89 |
+
|
| 90 |
+
# First: Vanilla query
|
| 91 |
+
history_vanilla = []
|
| 92 |
+
results_vanilla = list(
|
| 93 |
+
respond_fn(
|
| 94 |
+
"Tell me a joke",
|
| 95 |
+
history_vanilla,
|
| 96 |
+
False, # RAG disabled
|
| 97 |
+
"mmr",
|
| 98 |
+
"All Documents",
|
| 99 |
+
False,
|
| 100 |
+
False,
|
| 101 |
+
70,
|
| 102 |
+
)
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
# Second: Switch to RAG
|
| 106 |
+
history_after_vanilla = [
|
| 107 |
+
{"role": "user", "content": "Tell me a joke"},
|
| 108 |
+
{"role": "assistant", "content": "Vanilla response"},
|
| 109 |
+
]
|
| 110 |
+
|
| 111 |
+
results_rag = list(
|
| 112 |
+
respond_fn(
|
| 113 |
+
"What is RAG?",
|
| 114 |
+
history_after_vanilla,
|
| 115 |
+
True, # RAG enabled - switched to RAG
|
| 116 |
+
"mmr",
|
| 117 |
+
"All Documents",
|
| 118 |
+
False,
|
| 119 |
+
False,
|
| 120 |
+
70,
|
| 121 |
+
)
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
# Verify RAG chain was used
|
| 125 |
+
assert len(results_rag) > 0
|
| 126 |
+
assert mock_qa_chain.stream.called # RAG chain should be called
|
| 127 |
+
# Verify history was preserved
|
| 128 |
+
assert len(results_rag[-1][1]) > len(history_after_vanilla)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
@patch("ui.handlers.create_llm")
|
| 132 |
+
def test_chat_history_preserved_across_mode_switches(mock_create_llm):
|
| 133 |
+
"""Test that chat history is preserved when switching modes multiple times."""
|
| 134 |
+
mock_qa_chain = MagicMock()
|
| 135 |
+
mock_qa_chain.stream.return_value = [
|
| 136 |
+
{
|
| 137 |
+
"chunk": "RAG answer",
|
| 138 |
+
"source_documents": [],
|
| 139 |
+
"docs_with_scores": None,
|
| 140 |
+
"rewritten_query": None,
|
| 141 |
+
"hybrid_scores": None,
|
| 142 |
+
}
|
| 143 |
+
]
|
| 144 |
+
|
| 145 |
+
mock_llm = MagicMock()
|
| 146 |
+
mock_chunk = MagicMock()
|
| 147 |
+
mock_chunk.content = "Vanilla answer"
|
| 148 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 149 |
+
mock_create_llm.return_value = mock_llm
|
| 150 |
+
|
| 151 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 152 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 153 |
+
|
| 154 |
+
history = []
|
| 155 |
+
|
| 156 |
+
# Turn 1: RAG
|
| 157 |
+
results1 = list(
|
| 158 |
+
respond_fn("Q1", history, True, "mmr", "All Documents", False, False, 70)
|
| 159 |
+
)
|
| 160 |
+
history = results1[-1][1]
|
| 161 |
+
|
| 162 |
+
# Turn 2: Vanilla
|
| 163 |
+
results2 = list(
|
| 164 |
+
respond_fn("Q2", history, False, "mmr", "All Documents", False, False, 70)
|
| 165 |
+
)
|
| 166 |
+
history = results2[-1][1]
|
| 167 |
+
|
| 168 |
+
# Turn 3: RAG again
|
| 169 |
+
results3 = list(
|
| 170 |
+
respond_fn("Q3", history, True, "mmr", "All Documents", False, False, 70)
|
| 171 |
+
)
|
| 172 |
+
history = results3[-1][1]
|
| 173 |
+
|
| 174 |
+
# Verify all messages are preserved
|
| 175 |
+
assert len(history) == 6 # 3 user + 3 assistant messages
|
| 176 |
+
assert history[0]["role"] == "user"
|
| 177 |
+
assert history[0]["content"] == "Q1"
|
| 178 |
+
assert history[1]["role"] == "assistant"
|
| 179 |
+
assert history[2]["role"] == "user"
|
| 180 |
+
assert history[2]["content"] == "Q2"
|
| 181 |
+
assert history[3]["role"] == "assistant"
|
| 182 |
+
assert history[4]["role"] == "user"
|
| 183 |
+
assert history[4]["content"] == "Q3"
|
| 184 |
+
assert history[5]["role"] == "assistant"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@patch("ui.handlers.create_llm")
|
| 188 |
+
def test_ui_state_consistency_during_mode_switch(mock_create_llm):
|
| 189 |
+
"""Test that UI state remains consistent when switching modes."""
|
| 190 |
+
mock_qa_chain = MagicMock()
|
| 191 |
+
mock_qa_chain.stream.return_value = [
|
| 192 |
+
{
|
| 193 |
+
"chunk": "Response",
|
| 194 |
+
"source_documents": [],
|
| 195 |
+
"docs_with_scores": None,
|
| 196 |
+
"rewritten_query": None,
|
| 197 |
+
"hybrid_scores": None,
|
| 198 |
+
}
|
| 199 |
+
]
|
| 200 |
+
|
| 201 |
+
mock_llm = MagicMock()
|
| 202 |
+
mock_chunk = MagicMock()
|
| 203 |
+
mock_chunk.content = "Response"
|
| 204 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 205 |
+
mock_create_llm.return_value = mock_llm
|
| 206 |
+
|
| 207 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 208 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 209 |
+
|
| 210 |
+
# Start with RAG enabled
|
| 211 |
+
results1 = list(
|
| 212 |
+
respond_fn(
|
| 213 |
+
"Question",
|
| 214 |
+
[],
|
| 215 |
+
True, # RAG enabled
|
| 216 |
+
"mmr",
|
| 217 |
+
"test.pdf",
|
| 218 |
+
False,
|
| 219 |
+
False,
|
| 220 |
+
70,
|
| 221 |
+
)
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
# Switch to Vanilla
|
| 225 |
+
results2 = list(
|
| 226 |
+
respond_fn(
|
| 227 |
+
"Question 2",
|
| 228 |
+
results1[-1][1],
|
| 229 |
+
False, # RAG disabled
|
| 230 |
+
"mmr", # Search type should be ignored in Vanilla mode
|
| 231 |
+
"test.pdf", # Filter should be ignored in Vanilla mode
|
| 232 |
+
False,
|
| 233 |
+
False,
|
| 234 |
+
70,
|
| 235 |
+
)
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
# Verify state is consistent
|
| 239 |
+
# RAG state should be False
|
| 240 |
+
assert results2[-1][3] is False # rag_state
|
| 241 |
+
# Context should be empty for Vanilla mode
|
| 242 |
+
assert results2[-1][2] == "" # context_box
|
| 243 |
+
# Document filter should be preserved
|
| 244 |
+
assert results2[-1][4] == "test.pdf" # doc_filter
|
| 245 |
+
|
tests/test_models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for models module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from models import create_embeddings, create_llm
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@patch("models.ChatOpenAI")
|
| 11 |
+
def test_create_llm(mock_chat_openai):
|
| 12 |
+
"""Test LLM creation."""
|
| 13 |
+
mock_instance = MagicMock()
|
| 14 |
+
mock_chat_openai.return_value = mock_instance
|
| 15 |
+
|
| 16 |
+
result = create_llm(streaming=False)
|
| 17 |
+
mock_chat_openai.assert_called_once()
|
| 18 |
+
assert result == mock_instance
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@patch("models.ChatOpenAI")
|
| 22 |
+
def test_create_llm_streaming(mock_chat_openai):
|
| 23 |
+
"""Test LLM creation with streaming enabled."""
|
| 24 |
+
mock_instance = MagicMock()
|
| 25 |
+
mock_chat_openai.return_value = mock_instance
|
| 26 |
+
|
| 27 |
+
result = create_llm(streaming=True)
|
| 28 |
+
call_kwargs = mock_chat_openai.call_args[1]
|
| 29 |
+
assert call_kwargs["streaming"] is True
|
| 30 |
+
assert result == mock_instance
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@patch("models.HuggingFaceEmbeddings")
|
| 34 |
+
def test_create_embeddings(mock_embeddings):
|
| 35 |
+
"""Test embeddings creation."""
|
| 36 |
+
mock_instance = MagicMock()
|
| 37 |
+
mock_embeddings.return_value = mock_instance
|
| 38 |
+
|
| 39 |
+
result = create_embeddings()
|
| 40 |
+
mock_embeddings.assert_called_once()
|
| 41 |
+
call_kwargs = mock_embeddings.call_args[1]
|
| 42 |
+
assert call_kwargs["model_kwargs"]["device"] == "cpu"
|
| 43 |
+
assert call_kwargs["encode_kwargs"]["normalize_embeddings"] is True
|
| 44 |
+
assert result == mock_instance
|
| 45 |
+
|
tests/test_multi_turn_conversations.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for multi-turn conversations with chat history."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper
|
| 8 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 9 |
+
from langchain_core.documents import Document
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@pytest.fixture
|
| 13 |
+
def qa_chain_wrapper(mock_vectorstore):
|
| 14 |
+
"""Create a QAChainWrapper instance."""
|
| 15 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}\nHistory: {chat_history}")
|
| 16 |
+
return QAChainWrapper(mock_vectorstore, prompt)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@patch("qa_chain.create_llm")
|
| 20 |
+
@patch("qa_chain.format_chat_history")
|
| 21 |
+
def test_rag_with_chat_history_context(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 22 |
+
"""Test RAG query with previous conversation context."""
|
| 23 |
+
# First turn: User asks initial question
|
| 24 |
+
chat_history_turn1 = []
|
| 25 |
+
|
| 26 |
+
# Second turn: User asks follow-up question
|
| 27 |
+
chat_history_turn2 = [
|
| 28 |
+
("What is RAG?", "RAG stands for Retrieval-Augmented Generation."),
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
mock_format_history.return_value = "Human: What is RAG?\nAssistant: RAG stands for Retrieval-Augmented Generation."
|
| 32 |
+
mock_llm = MagicMock()
|
| 33 |
+
mock_chunk = MagicMock()
|
| 34 |
+
mock_chunk.content = "Based on the previous context, RAG combines retrieval and generation."
|
| 35 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 36 |
+
mock_create_llm.return_value = mock_llm
|
| 37 |
+
|
| 38 |
+
mock_retriever = MagicMock()
|
| 39 |
+
mock_doc = Document(
|
| 40 |
+
page_content="RAG combines retrieval and generation",
|
| 41 |
+
metadata={"source": "test.pdf", "page": 1}
|
| 42 |
+
)
|
| 43 |
+
mock_retriever.invoke.return_value = [mock_doc]
|
| 44 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 45 |
+
|
| 46 |
+
mock_chain = MagicMock()
|
| 47 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 48 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 49 |
+
|
| 50 |
+
# Test follow-up question with history
|
| 51 |
+
inputs = {
|
| 52 |
+
"question": "How does it work?",
|
| 53 |
+
"chat_history": chat_history_turn2,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 57 |
+
|
| 58 |
+
assert len(results) > 0
|
| 59 |
+
# Verify chat history was formatted and included
|
| 60 |
+
mock_format_history.assert_called()
|
| 61 |
+
# Verify results contain expected data
|
| 62 |
+
assert "chunk" in results[0]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@patch("qa_chain.create_llm")
|
| 66 |
+
@patch("qa_chain.format_chat_history")
|
| 67 |
+
def test_chat_history_limit_enforcement(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 68 |
+
"""Test that chat history limit (CHAT_HISTORY_LIMIT) is enforced."""
|
| 69 |
+
from config import CHAT_HISTORY_LIMIT
|
| 70 |
+
|
| 71 |
+
# Create history longer than limit
|
| 72 |
+
long_history = [
|
| 73 |
+
(f"Question {i}", f"Answer {i}") for i in range(CHAT_HISTORY_LIMIT + 5)
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
mock_format_history.return_value = "Formatted history"
|
| 77 |
+
mock_llm = MagicMock()
|
| 78 |
+
mock_chunk = MagicMock()
|
| 79 |
+
mock_chunk.content = "Response"
|
| 80 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 81 |
+
mock_create_llm.return_value = mock_llm
|
| 82 |
+
|
| 83 |
+
mock_retriever = MagicMock()
|
| 84 |
+
mock_retriever.invoke.return_value = [Document(page_content="Test", metadata={})]
|
| 85 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 86 |
+
|
| 87 |
+
mock_chain = MagicMock()
|
| 88 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 89 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 90 |
+
|
| 91 |
+
inputs = {
|
| 92 |
+
"question": "New question",
|
| 93 |
+
"chat_history": long_history,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
list(qa_chain_wrapper.stream(inputs))
|
| 97 |
+
|
| 98 |
+
# Verify format_chat_history was called with the long history
|
| 99 |
+
# (it should handle limiting internally)
|
| 100 |
+
mock_format_history.assert_called()
|
| 101 |
+
# The format_chat_history function should limit to CHAT_HISTORY_LIMIT
|
| 102 |
+
call_args = mock_format_history.call_args[0][0]
|
| 103 |
+
# Should only include recent messages (last CHAT_HISTORY_LIMIT)
|
| 104 |
+
assert len(call_args) <= CHAT_HISTORY_LIMIT + 5 # format_chat_history handles limiting
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@patch("qa_chain.create_llm")
|
| 108 |
+
@patch("qa_chain.format_chat_history")
|
| 109 |
+
def test_follow_up_question_references_previous_answer(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 110 |
+
"""Test follow-up question that references previous answer."""
|
| 111 |
+
# First question about a topic
|
| 112 |
+
chat_history = [
|
| 113 |
+
("What is machine learning?", "Machine learning is a subset of AI."),
|
| 114 |
+
]
|
| 115 |
+
|
| 116 |
+
mock_format_history.return_value = "Human: What is machine learning?\nAssistant: Machine learning is a subset of AI."
|
| 117 |
+
mock_llm = MagicMock()
|
| 118 |
+
mock_chunk = MagicMock()
|
| 119 |
+
mock_chunk.content = "Deep learning is a subset of machine learning."
|
| 120 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 121 |
+
mock_create_llm.return_value = mock_llm
|
| 122 |
+
|
| 123 |
+
mock_retriever = MagicMock()
|
| 124 |
+
mock_doc = Document(
|
| 125 |
+
page_content="Deep learning is a subset of machine learning",
|
| 126 |
+
metadata={"source": "test.pdf", "page": 2}
|
| 127 |
+
)
|
| 128 |
+
mock_retriever.invoke.return_value = [mock_doc]
|
| 129 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 130 |
+
|
| 131 |
+
mock_chain = MagicMock()
|
| 132 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 133 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 134 |
+
|
| 135 |
+
# Follow-up question that references "it" (machine learning)
|
| 136 |
+
inputs = {
|
| 137 |
+
"question": "What are its main types?",
|
| 138 |
+
"chat_history": chat_history,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 142 |
+
|
| 143 |
+
assert len(results) > 0
|
| 144 |
+
# Verify history context was included
|
| 145 |
+
mock_format_history.assert_called()
|
| 146 |
+
# Verify results contain expected data
|
| 147 |
+
assert "chunk" in results[0]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@patch("ui.handlers.create_llm")
|
| 151 |
+
def test_handlers_multi_turn_conversation(mock_create_llm, mock_vectorstore):
|
| 152 |
+
"""Test handlers with multi-turn conversation."""
|
| 153 |
+
from ui.handlers import create_stream_chat_response, create_respond_handler
|
| 154 |
+
|
| 155 |
+
mock_qa_chain = MagicMock()
|
| 156 |
+
mock_qa_chain.stream.return_value = [
|
| 157 |
+
{
|
| 158 |
+
"chunk": "Answer to follow-up",
|
| 159 |
+
"source_documents": [Mock(metadata={"source": "test.pdf", "page": 1})],
|
| 160 |
+
"docs_with_scores": [(Mock(), 0.9)],
|
| 161 |
+
"rewritten_query": None,
|
| 162 |
+
"hybrid_scores": None,
|
| 163 |
+
}
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
stream_fn = create_stream_chat_response(mock_qa_chain)
|
| 167 |
+
respond_fn = create_respond_handler(stream_fn)
|
| 168 |
+
|
| 169 |
+
# First turn
|
| 170 |
+
history_turn1 = []
|
| 171 |
+
results_turn1 = list(
|
| 172 |
+
respond_fn(
|
| 173 |
+
"What is RAG?",
|
| 174 |
+
history_turn1,
|
| 175 |
+
True, # RAG enabled
|
| 176 |
+
"mmr",
|
| 177 |
+
"All Documents",
|
| 178 |
+
False,
|
| 179 |
+
False,
|
| 180 |
+
70,
|
| 181 |
+
)
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
# Second turn with history
|
| 185 |
+
history_turn2 = [
|
| 186 |
+
{"role": "user", "content": "What is RAG?"},
|
| 187 |
+
{"role": "assistant", "content": "RAG stands for Retrieval-Augmented Generation."},
|
| 188 |
+
]
|
| 189 |
+
|
| 190 |
+
results_turn2 = list(
|
| 191 |
+
respond_fn(
|
| 192 |
+
"How does it work?",
|
| 193 |
+
history_turn2,
|
| 194 |
+
True, # RAG enabled
|
| 195 |
+
"mmr",
|
| 196 |
+
"All Documents",
|
| 197 |
+
False,
|
| 198 |
+
False,
|
| 199 |
+
70,
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
assert len(results_turn2) > 0
|
| 204 |
+
# Verify chat history was passed to stream function
|
| 205 |
+
assert mock_qa_chain.stream.called
|
| 206 |
+
call_args = mock_qa_chain.stream.call_args[0][0]
|
| 207 |
+
# Should have chat_history in the call
|
| 208 |
+
assert "chat_history" in call_args or "question" in call_args
|
| 209 |
+
|
tests/test_network_failures.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for network and API failure scenarios."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
import requests
|
| 7 |
+
|
| 8 |
+
from models import create_llm, create_embeddings
|
| 9 |
+
from qa_chain import QAChainWrapper
|
| 10 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 11 |
+
from langchain_core.documents import Document
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@patch("models.ChatOpenAI")
|
| 15 |
+
def test_llm_server_unavailable(mock_chat_openai):
|
| 16 |
+
"""Test graceful handling when LLM server is unavailable."""
|
| 17 |
+
# Simulate connection error
|
| 18 |
+
mock_chat_openai.side_effect = requests.exceptions.ConnectionError("Connection refused")
|
| 19 |
+
|
| 20 |
+
with pytest.raises((requests.exceptions.ConnectionError, Exception)):
|
| 21 |
+
create_llm()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@patch("models.ChatOpenAI")
|
| 25 |
+
def test_llm_server_timeout(mock_chat_openai):
|
| 26 |
+
"""Test handling of LLM server timeout."""
|
| 27 |
+
mock_llm = MagicMock()
|
| 28 |
+
mock_llm.stream.side_effect = requests.exceptions.Timeout("Request timed out")
|
| 29 |
+
mock_chat_openai.return_value = mock_llm
|
| 30 |
+
|
| 31 |
+
llm = create_llm(streaming=True)
|
| 32 |
+
|
| 33 |
+
# Should raise timeout error when streaming
|
| 34 |
+
with pytest.raises(requests.exceptions.Timeout):
|
| 35 |
+
list(llm.stream("test"))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@patch("models.ChatOpenAI")
|
| 39 |
+
def test_llm_server_error_response(mock_chat_openai):
|
| 40 |
+
"""Test handling of LLM server error response."""
|
| 41 |
+
mock_llm = MagicMock()
|
| 42 |
+
mock_llm.invoke.side_effect = Exception("500 Internal Server Error")
|
| 43 |
+
mock_chat_openai.return_value = mock_llm
|
| 44 |
+
|
| 45 |
+
llm = create_llm(streaming=False)
|
| 46 |
+
|
| 47 |
+
with pytest.raises(Exception) as exc_info:
|
| 48 |
+
llm.invoke("test")
|
| 49 |
+
assert "500" in str(exc_info.value) or "Error" in str(exc_info.value)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@patch("models.HuggingFaceEmbeddings")
|
| 53 |
+
def test_embedding_model_download_failure(mock_embeddings_class):
|
| 54 |
+
"""Test handling of embedding model download failure."""
|
| 55 |
+
mock_embeddings_class.side_effect = Exception("Failed to download model")
|
| 56 |
+
|
| 57 |
+
with pytest.raises(Exception) as exc_info:
|
| 58 |
+
create_embeddings()
|
| 59 |
+
assert "Failed" in str(exc_info.value) or "download" in str(exc_info.value).lower()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@patch("qa_chain.create_llm")
|
| 63 |
+
@patch("qa_chain.format_chat_history")
|
| 64 |
+
def test_rag_query_with_llm_failure(mock_format_history, mock_create_llm, mock_vectorstore):
|
| 65 |
+
"""Test RAG query when LLM fails during streaming."""
|
| 66 |
+
from qa_chain import create_qa_chain
|
| 67 |
+
|
| 68 |
+
mock_format_history.return_value = ""
|
| 69 |
+
mock_llm = MagicMock()
|
| 70 |
+
mock_llm.stream.side_effect = requests.exceptions.ConnectionError("LLM server unavailable")
|
| 71 |
+
mock_create_llm.return_value = mock_llm
|
| 72 |
+
|
| 73 |
+
mock_retriever = MagicMock()
|
| 74 |
+
mock_doc = Document(page_content="Test", metadata={"source": "test.pdf", "page": 1})
|
| 75 |
+
mock_retriever.invoke.return_value = [mock_doc]
|
| 76 |
+
mock_vectorstore.as_retriever.return_value = mock_retriever
|
| 77 |
+
|
| 78 |
+
qa_chain = create_qa_chain(mock_vectorstore)
|
| 79 |
+
|
| 80 |
+
# Mock the chain operator
|
| 81 |
+
mock_chain = MagicMock()
|
| 82 |
+
mock_chain.stream.side_effect = requests.exceptions.ConnectionError("LLM server unavailable")
|
| 83 |
+
qa_chain._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 84 |
+
|
| 85 |
+
inputs = {
|
| 86 |
+
"question": "test question",
|
| 87 |
+
"chat_history": [],
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# Should handle error gracefully
|
| 91 |
+
results = list(qa_chain.stream(inputs))
|
| 92 |
+
|
| 93 |
+
# Should return error message in chunk
|
| 94 |
+
assert len(results) > 0
|
| 95 |
+
chunk_text = str(results[0].get("chunk", ""))
|
| 96 |
+
# The error handling in qa_chain.py catches exceptions and yields error message
|
| 97 |
+
assert len(chunk_text) > 0 # Should have some content (either response or error)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@patch("qa_chain.create_llm")
|
| 101 |
+
@patch("qa_chain.format_chat_history")
|
| 102 |
+
def test_rag_query_with_retriever_failure(mock_format_history, mock_create_llm, mock_vectorstore):
|
| 103 |
+
"""Test RAG query when retriever fails."""
|
| 104 |
+
from qa_chain import QAChainWrapper
|
| 105 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 106 |
+
|
| 107 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 108 |
+
qa_chain_wrapper = QAChainWrapper(mock_vectorstore, prompt)
|
| 109 |
+
|
| 110 |
+
mock_format_history.return_value = ""
|
| 111 |
+
mock_llm = MagicMock()
|
| 112 |
+
mock_chunk = MagicMock()
|
| 113 |
+
mock_chunk.content = "Response"
|
| 114 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 115 |
+
mock_create_llm.return_value = mock_llm
|
| 116 |
+
|
| 117 |
+
# Simulate retriever failure
|
| 118 |
+
mock_retriever = MagicMock()
|
| 119 |
+
mock_retriever.invoke.side_effect = Exception("ChromaDB connection failed")
|
| 120 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 121 |
+
|
| 122 |
+
mock_chain = MagicMock()
|
| 123 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 124 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 125 |
+
|
| 126 |
+
inputs = {
|
| 127 |
+
"question": "test question",
|
| 128 |
+
"chat_history": [],
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
# Should handle retriever error - may raise or handle gracefully
|
| 132 |
+
try:
|
| 133 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 134 |
+
# If it doesn't raise, should have error in response or empty results
|
| 135 |
+
if results:
|
| 136 |
+
chunk = results[0].get("chunk", "")
|
| 137 |
+
assert "Error" in str(chunk).lower() or len(results) == 0 or len(chunk) > 0
|
| 138 |
+
except Exception:
|
| 139 |
+
# Exception is acceptable for retriever failure
|
| 140 |
+
pass
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@patch("ui.handlers.create_llm")
|
| 144 |
+
def test_vanilla_llm_network_failure(mock_create_llm):
|
| 145 |
+
"""Test vanilla LLM mode with network failure."""
|
| 146 |
+
from ui.handlers import create_stream_chat_response
|
| 147 |
+
|
| 148 |
+
mock_llm = MagicMock()
|
| 149 |
+
mock_llm.stream.side_effect = requests.exceptions.ConnectionError("Server unavailable")
|
| 150 |
+
mock_create_llm.return_value = mock_llm
|
| 151 |
+
|
| 152 |
+
stream_fn = create_stream_chat_response(MagicMock())
|
| 153 |
+
|
| 154 |
+
results = list(
|
| 155 |
+
stream_fn(
|
| 156 |
+
"test question",
|
| 157 |
+
[],
|
| 158 |
+
"Vanilla LLM",
|
| 159 |
+
)
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Should return error message
|
| 163 |
+
assert len(results) > 0
|
| 164 |
+
assert "Error" in results[-1][0] or "unavailable" in results[-1][0].lower()
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
@patch("qa_chain.create_llm")
|
| 168 |
+
@patch("qa_chain.format_chat_history")
|
| 169 |
+
def test_stream_interruption_handling(mock_format_history, mock_create_llm, mock_vectorstore):
|
| 170 |
+
"""Test handling of stream interruption."""
|
| 171 |
+
from qa_chain import QAChainWrapper
|
| 172 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 173 |
+
|
| 174 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 175 |
+
qa_chain_wrapper = QAChainWrapper(mock_vectorstore, prompt)
|
| 176 |
+
|
| 177 |
+
mock_format_history.return_value = ""
|
| 178 |
+
|
| 179 |
+
# Simulate stream that gets interrupted
|
| 180 |
+
mock_llm = MagicMock()
|
| 181 |
+
mock_chunk1 = MagicMock()
|
| 182 |
+
mock_chunk1.content = "Partial "
|
| 183 |
+
mock_chunk2 = MagicMock()
|
| 184 |
+
mock_chunk2.content = "response"
|
| 185 |
+
|
| 186 |
+
def interrupted_stream(*args, **kwargs):
|
| 187 |
+
yield mock_chunk1
|
| 188 |
+
raise KeyboardInterrupt("Stream interrupted")
|
| 189 |
+
|
| 190 |
+
mock_llm.stream.side_effect = interrupted_stream
|
| 191 |
+
mock_create_llm.return_value = mock_llm
|
| 192 |
+
|
| 193 |
+
mock_retriever = MagicMock()
|
| 194 |
+
mock_retriever.invoke.return_value = [Document(page_content="Test", metadata={})]
|
| 195 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 196 |
+
|
| 197 |
+
mock_chain = MagicMock()
|
| 198 |
+
mock_chain.stream.side_effect = interrupted_stream
|
| 199 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 200 |
+
|
| 201 |
+
inputs = {
|
| 202 |
+
"question": "test question",
|
| 203 |
+
"chat_history": [],
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
# Should handle interruption gracefully
|
| 207 |
+
try:
|
| 208 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 209 |
+
# If it completes, should have partial response
|
| 210 |
+
if results:
|
| 211 |
+
assert len(results) > 0
|
| 212 |
+
except KeyboardInterrupt:
|
| 213 |
+
# Interruption is acceptable
|
| 214 |
+
pass
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@patch("retrievers.BM25Okapi")
|
| 218 |
+
def test_hybrid_search_with_semantic_failure(mock_bm25, mock_vectorstore):
|
| 219 |
+
"""Test hybrid search when semantic search fails."""
|
| 220 |
+
from retrievers import HybridRetriever
|
| 221 |
+
from langchain_core.documents import Document
|
| 222 |
+
|
| 223 |
+
mock_vectorstore.get.return_value = {
|
| 224 |
+
"documents": ["Doc 1"],
|
| 225 |
+
"metadatas": [{"source": "test.pdf", "page": 1}],
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
# Simulate semantic search failure
|
| 229 |
+
mock_vectorstore.similarity_search_with_score.side_effect = Exception("Vector search failed")
|
| 230 |
+
|
| 231 |
+
mock_bm25_instance = MagicMock()
|
| 232 |
+
mock_bm25_instance.get_scores.return_value = [0.8]
|
| 233 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 234 |
+
|
| 235 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 236 |
+
|
| 237 |
+
# Should handle semantic failure gracefully
|
| 238 |
+
results = retriever.hybrid_search("test query", k=1)
|
| 239 |
+
|
| 240 |
+
# Should still return results based on BM25 only
|
| 241 |
+
assert isinstance(results, list)
|
| 242 |
+
|
tests/test_qa_chain.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for qa_chain module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper, create_qa_chain
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@pytest.fixture
|
| 11 |
+
def mock_prompt():
|
| 12 |
+
"""Create a mock prompt template."""
|
| 13 |
+
return MagicMock()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@pytest.fixture
|
| 17 |
+
def qa_chain_wrapper(mock_vectorstore, mock_prompt):
|
| 18 |
+
"""Create a QAChainWrapper instance."""
|
| 19 |
+
return QAChainWrapper(mock_vectorstore, mock_prompt)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_qa_chain_wrapper_init(mock_vectorstore, mock_prompt):
|
| 23 |
+
"""Test QAChainWrapper initialization."""
|
| 24 |
+
wrapper = QAChainWrapper(mock_vectorstore, mock_prompt)
|
| 25 |
+
assert wrapper._vectorstore == mock_vectorstore
|
| 26 |
+
assert wrapper._prompt == mock_prompt
|
| 27 |
+
assert wrapper._hybrid_retriever is not None
|
| 28 |
+
assert wrapper._reranker is None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_qa_chain_wrapper_retriever_property(qa_chain_wrapper):
|
| 32 |
+
"""Test retriever property."""
|
| 33 |
+
retriever = qa_chain_wrapper.retriever
|
| 34 |
+
assert retriever is not None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@patch("qa_chain.CrossEncoder")
|
| 38 |
+
def test_get_reranker_success(mock_cross_encoder, qa_chain_wrapper):
|
| 39 |
+
"""Test successful reranker loading."""
|
| 40 |
+
mock_reranker = MagicMock()
|
| 41 |
+
mock_cross_encoder.return_value = mock_reranker
|
| 42 |
+
|
| 43 |
+
result = qa_chain_wrapper._get_reranker()
|
| 44 |
+
assert result == mock_reranker
|
| 45 |
+
assert qa_chain_wrapper._reranker == mock_reranker
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@patch("qa_chain.CrossEncoder")
|
| 49 |
+
def test_get_reranker_failure(mock_cross_encoder, qa_chain_wrapper):
|
| 50 |
+
"""Test reranker loading failure."""
|
| 51 |
+
mock_cross_encoder.side_effect = Exception("Load error")
|
| 52 |
+
|
| 53 |
+
result = qa_chain_wrapper._get_reranker()
|
| 54 |
+
assert result is None
|
| 55 |
+
assert qa_chain_wrapper._reranker is None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@patch("qa_chain.create_llm")
|
| 59 |
+
def test_rewrite_query_success(mock_create_llm, qa_chain_wrapper):
|
| 60 |
+
"""Test successful query rewriting."""
|
| 61 |
+
mock_llm = MagicMock()
|
| 62 |
+
mock_llm.invoke.return_value = MagicMock(content="rewritten query")
|
| 63 |
+
mock_create_llm.return_value = mock_llm
|
| 64 |
+
|
| 65 |
+
result = qa_chain_wrapper.rewrite_query("original question")
|
| 66 |
+
assert result == "rewritten query"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@patch("qa_chain.create_llm")
|
| 70 |
+
def test_rewrite_query_too_short(mock_create_llm, qa_chain_wrapper):
|
| 71 |
+
"""Test query rewriting that returns too short result."""
|
| 72 |
+
mock_llm = MagicMock()
|
| 73 |
+
mock_llm.invoke.return_value = MagicMock(content="x") # Too short
|
| 74 |
+
mock_create_llm.return_value = mock_llm
|
| 75 |
+
|
| 76 |
+
result = qa_chain_wrapper.rewrite_query("original question that is long")
|
| 77 |
+
assert result == "original question that is long" # Should return original
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@patch("qa_chain.create_llm")
|
| 81 |
+
def test_rewrite_query_failure(mock_create_llm, qa_chain_wrapper):
|
| 82 |
+
"""Test query rewriting failure."""
|
| 83 |
+
mock_create_llm.side_effect = Exception("Error")
|
| 84 |
+
|
| 85 |
+
result = qa_chain_wrapper.rewrite_query("original question")
|
| 86 |
+
assert result == "original question" # Should return original on error
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@patch("qa_chain.create_llm")
|
| 90 |
+
def test_rewrite_query_same(mock_create_llm, qa_chain_wrapper):
|
| 91 |
+
"""Test query rewriting that returns same as original."""
|
| 92 |
+
mock_llm = MagicMock()
|
| 93 |
+
mock_llm.invoke.return_value = MagicMock(content="original question")
|
| 94 |
+
mock_create_llm.return_value = mock_llm
|
| 95 |
+
|
| 96 |
+
result = qa_chain_wrapper.rewrite_query("original question")
|
| 97 |
+
assert result == "original question"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_rerank_documents_empty(qa_chain_wrapper):
|
| 101 |
+
"""Test reranking empty document list."""
|
| 102 |
+
result = qa_chain_wrapper.rerank_documents("query", [])
|
| 103 |
+
assert result == []
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@patch("qa_chain.CrossEncoder")
|
| 107 |
+
def test_rerank_documents_success(mock_cross_encoder, qa_chain_wrapper, sample_documents):
|
| 108 |
+
"""Test successful document reranking."""
|
| 109 |
+
mock_reranker = MagicMock()
|
| 110 |
+
mock_reranker.predict.return_value = [0.9, 0.8, 0.7]
|
| 111 |
+
mock_cross_encoder.return_value = mock_reranker
|
| 112 |
+
|
| 113 |
+
result = qa_chain_wrapper.rerank_documents("query", sample_documents[:3], top_k=2)
|
| 114 |
+
assert len(result) == 2
|
| 115 |
+
assert all(isinstance(r, tuple) and len(r) == 2 for r in result)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@patch("qa_chain.CrossEncoder")
|
| 119 |
+
def test_rerank_documents_failure(mock_cross_encoder, qa_chain_wrapper, sample_documents):
|
| 120 |
+
"""Test reranking failure."""
|
| 121 |
+
mock_reranker = MagicMock()
|
| 122 |
+
mock_reranker.predict.side_effect = Exception("Error")
|
| 123 |
+
mock_cross_encoder.return_value = mock_reranker
|
| 124 |
+
|
| 125 |
+
result = qa_chain_wrapper.rerank_documents("query", sample_documents[:2], top_k=2)
|
| 126 |
+
assert len(result) == 2
|
| 127 |
+
assert all(r[1] is None for r in result) # Scores should be None
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def test_get_retriever_with_filter_mmr(qa_chain_wrapper):
|
| 131 |
+
"""Test getting retriever with MMR search type."""
|
| 132 |
+
retriever = qa_chain_wrapper.get_retriever_with_filter(search_type="mmr")
|
| 133 |
+
assert retriever is not None
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def test_get_retriever_with_filter_similarity(qa_chain_wrapper):
|
| 137 |
+
"""Test getting retriever with similarity search type."""
|
| 138 |
+
retriever = qa_chain_wrapper.get_retriever_with_filter(search_type="similarity")
|
| 139 |
+
assert retriever is not None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def test_get_retriever_with_filter_metadata(qa_chain_wrapper):
|
| 143 |
+
"""Test getting retriever with metadata filter."""
|
| 144 |
+
metadata_filter = {"source": {"$eq": "test.pdf"}}
|
| 145 |
+
retriever = qa_chain_wrapper.get_retriever_with_filter(
|
| 146 |
+
metadata_filter=metadata_filter
|
| 147 |
+
)
|
| 148 |
+
assert retriever is not None
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@patch("qa_chain.create_llm")
|
| 152 |
+
@patch("qa_chain.format_chat_history")
|
| 153 |
+
def test_stream_mmr(mock_format_history, mock_create_llm, qa_chain_wrapper, mock_prompt):
|
| 154 |
+
"""Test streaming with MMR search."""
|
| 155 |
+
mock_format_history.return_value = ""
|
| 156 |
+
mock_llm = MagicMock()
|
| 157 |
+
mock_llm.stream.return_value = [
|
| 158 |
+
MagicMock(content="Chunk "),
|
| 159 |
+
MagicMock(content="1"),
|
| 160 |
+
]
|
| 161 |
+
mock_create_llm.return_value = mock_llm
|
| 162 |
+
|
| 163 |
+
mock_retriever = MagicMock()
|
| 164 |
+
mock_retriever.invoke.return_value = [Mock(page_content="Test")]
|
| 165 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 166 |
+
|
| 167 |
+
# Mock the chain operator
|
| 168 |
+
mock_chain = MagicMock()
|
| 169 |
+
mock_chain.stream.return_value = [
|
| 170 |
+
MagicMock(content="Chunk "),
|
| 171 |
+
MagicMock(content="1"),
|
| 172 |
+
]
|
| 173 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 174 |
+
|
| 175 |
+
inputs = {
|
| 176 |
+
"question": "test question",
|
| 177 |
+
"chat_history": [],
|
| 178 |
+
"search_type": "mmr",
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 182 |
+
assert len(results) > 0
|
| 183 |
+
assert all("chunk" in r for r in results)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@patch("qa_chain.create_llm")
|
| 187 |
+
@patch("qa_chain.format_chat_history")
|
| 188 |
+
def test_stream_hybrid(mock_format_history, mock_create_llm, qa_chain_wrapper, mock_hybrid_results, mock_prompt):
|
| 189 |
+
"""Test streaming with hybrid search."""
|
| 190 |
+
mock_format_history.return_value = ""
|
| 191 |
+
mock_llm = MagicMock()
|
| 192 |
+
mock_llm.stream.return_value = [MagicMock(content="Response")]
|
| 193 |
+
mock_create_llm.return_value = mock_llm
|
| 194 |
+
|
| 195 |
+
qa_chain_wrapper._hybrid_retriever.hybrid_search = MagicMock(
|
| 196 |
+
return_value=mock_hybrid_results
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Mock the chain operator
|
| 200 |
+
mock_chain = MagicMock()
|
| 201 |
+
mock_chain.stream.return_value = [MagicMock(content="Response")]
|
| 202 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 203 |
+
|
| 204 |
+
inputs = {
|
| 205 |
+
"question": "test question",
|
| 206 |
+
"chat_history": [],
|
| 207 |
+
"search_type": "hybrid",
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 211 |
+
assert len(results) > 0
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@patch("qa_chain.create_llm")
|
| 215 |
+
@patch("qa_chain.format_chat_history")
|
| 216 |
+
def test_stream_error(mock_format_history, mock_create_llm, qa_chain_wrapper, mock_prompt):
|
| 217 |
+
"""Test streaming error handling."""
|
| 218 |
+
mock_format_history.return_value = ""
|
| 219 |
+
mock_llm = MagicMock()
|
| 220 |
+
mock_create_llm.return_value = mock_llm
|
| 221 |
+
|
| 222 |
+
mock_retriever = MagicMock()
|
| 223 |
+
mock_retriever.invoke.return_value = [Mock(page_content="Test")]
|
| 224 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 225 |
+
|
| 226 |
+
# Mock the chain operator to raise an error
|
| 227 |
+
mock_chain = MagicMock()
|
| 228 |
+
mock_chain.stream.side_effect = Exception("Stream error")
|
| 229 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 230 |
+
|
| 231 |
+
inputs = {
|
| 232 |
+
"question": "test question",
|
| 233 |
+
"chat_history": [],
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 237 |
+
assert len(results) > 0
|
| 238 |
+
assert "Error" in results[0]["chunk"]
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def test_create_qa_chain(mock_vectorstore):
|
| 242 |
+
"""Test creating QA chain."""
|
| 243 |
+
chain = create_qa_chain(mock_vectorstore)
|
| 244 |
+
assert isinstance(chain, QAChainWrapper)
|
| 245 |
+
|
tests/test_qa_chain_edge_cases.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Additional edge case tests for qa_chain module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper
|
| 8 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def mock_prompt():
|
| 13 |
+
"""Create a mock prompt template."""
|
| 14 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 15 |
+
return ChatPromptTemplate.from_template("Test: {question}")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@pytest.fixture
|
| 19 |
+
def qa_chain_wrapper(mock_vectorstore, mock_prompt):
|
| 20 |
+
"""Create a QAChainWrapper instance."""
|
| 21 |
+
return QAChainWrapper(mock_vectorstore, mock_prompt)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@patch("qa_chain.create_llm")
|
| 25 |
+
@patch("qa_chain.format_chat_history")
|
| 26 |
+
def test_stream_similarity_search(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 27 |
+
"""Test streaming with similarity search type."""
|
| 28 |
+
from langchain_core.documents import Document
|
| 29 |
+
|
| 30 |
+
mock_format_history.return_value = ""
|
| 31 |
+
mock_llm = MagicMock()
|
| 32 |
+
mock_chunk = MagicMock()
|
| 33 |
+
mock_chunk.content = "Response"
|
| 34 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 35 |
+
mock_create_llm.return_value = mock_llm
|
| 36 |
+
|
| 37 |
+
# Create documents with matching content
|
| 38 |
+
doc_content = "Test content for matching"
|
| 39 |
+
mock_doc = Document(page_content=doc_content, metadata={"page": 1, "source": "test.pdf"})
|
| 40 |
+
mock_scored_doc = Document(page_content=doc_content, metadata={"page": 1, "source": "test.pdf"})
|
| 41 |
+
|
| 42 |
+
mock_retriever = MagicMock()
|
| 43 |
+
mock_retriever.invoke.return_value = [mock_doc]
|
| 44 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 45 |
+
|
| 46 |
+
mock_vectorstore = qa_chain_wrapper._vectorstore
|
| 47 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 48 |
+
(mock_scored_doc, 0.5)
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
mock_chain = MagicMock()
|
| 52 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 53 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 54 |
+
|
| 55 |
+
inputs = {
|
| 56 |
+
"question": "test question",
|
| 57 |
+
"chat_history": [],
|
| 58 |
+
"search_type": "similarity",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 62 |
+
assert len(results) > 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@patch("qa_chain.create_llm")
|
| 66 |
+
@patch("qa_chain.format_chat_history")
|
| 67 |
+
def test_stream_with_reranking(mock_format_history, mock_create_llm, qa_chain_wrapper, sample_documents):
|
| 68 |
+
"""Test streaming with reranking enabled."""
|
| 69 |
+
mock_format_history.return_value = ""
|
| 70 |
+
mock_llm = MagicMock()
|
| 71 |
+
mock_chunk = MagicMock()
|
| 72 |
+
mock_chunk.content = "Response"
|
| 73 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 74 |
+
mock_create_llm.return_value = mock_llm
|
| 75 |
+
|
| 76 |
+
mock_retriever = MagicMock()
|
| 77 |
+
mock_retriever.invoke.return_value = sample_documents[:3]
|
| 78 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 79 |
+
|
| 80 |
+
# Mock reranker
|
| 81 |
+
with patch("qa_chain.CrossEncoder") as mock_cross_encoder:
|
| 82 |
+
mock_reranker = MagicMock()
|
| 83 |
+
mock_reranker.predict.return_value = [0.9, 0.8, 0.7]
|
| 84 |
+
mock_cross_encoder.return_value = mock_reranker
|
| 85 |
+
qa_chain_wrapper._reranker = mock_reranker
|
| 86 |
+
|
| 87 |
+
mock_chain = MagicMock()
|
| 88 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 89 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 90 |
+
|
| 91 |
+
inputs = {
|
| 92 |
+
"question": "test question",
|
| 93 |
+
"chat_history": [],
|
| 94 |
+
"search_type": "mmr",
|
| 95 |
+
"use_reranking": True,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 99 |
+
assert len(results) > 0
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@patch("qa_chain.create_llm")
|
| 103 |
+
@patch("qa_chain.format_chat_history")
|
| 104 |
+
def test_stream_with_query_rewriting(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 105 |
+
"""Test streaming with query rewriting enabled."""
|
| 106 |
+
mock_format_history.return_value = ""
|
| 107 |
+
mock_llm = MagicMock()
|
| 108 |
+
mock_rewrite_response = MagicMock()
|
| 109 |
+
mock_rewrite_response.content = "rewritten query"
|
| 110 |
+
mock_llm.invoke.return_value = mock_rewrite_response
|
| 111 |
+
mock_chunk = MagicMock()
|
| 112 |
+
mock_chunk.content = "Response"
|
| 113 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 114 |
+
mock_create_llm.return_value = mock_llm
|
| 115 |
+
|
| 116 |
+
mock_retriever = MagicMock()
|
| 117 |
+
mock_retriever.invoke.return_value = [Mock(page_content="Test")]
|
| 118 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 119 |
+
|
| 120 |
+
mock_chain = MagicMock()
|
| 121 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 122 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 123 |
+
|
| 124 |
+
inputs = {
|
| 125 |
+
"question": "test question that is long enough",
|
| 126 |
+
"chat_history": [],
|
| 127 |
+
"use_query_rewriting": True,
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 131 |
+
assert len(results) > 0
|
| 132 |
+
assert results[0].get("rewritten_query") == "rewritten query"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@patch("qa_chain.create_llm")
|
| 136 |
+
@patch("qa_chain.format_chat_history")
|
| 137 |
+
def test_stream_similarity_search_error(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 138 |
+
"""Test streaming with similarity search error."""
|
| 139 |
+
from langchain_core.documents import Document
|
| 140 |
+
|
| 141 |
+
mock_format_history.return_value = ""
|
| 142 |
+
mock_llm = MagicMock()
|
| 143 |
+
mock_chunk = MagicMock()
|
| 144 |
+
mock_chunk.content = "Response"
|
| 145 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 146 |
+
mock_create_llm.return_value = mock_llm
|
| 147 |
+
|
| 148 |
+
mock_doc = Document(page_content="Test", metadata={"page": 1, "source": "test.pdf"})
|
| 149 |
+
mock_retriever = MagicMock()
|
| 150 |
+
mock_retriever.invoke.return_value = [mock_doc]
|
| 151 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 152 |
+
|
| 153 |
+
mock_vectorstore = qa_chain_wrapper._vectorstore
|
| 154 |
+
mock_vectorstore.similarity_search_with_score.side_effect = Exception("Error")
|
| 155 |
+
|
| 156 |
+
mock_chain = MagicMock()
|
| 157 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 158 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 159 |
+
|
| 160 |
+
inputs = {
|
| 161 |
+
"question": "test question",
|
| 162 |
+
"chat_history": [],
|
| 163 |
+
"search_type": "similarity",
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 167 |
+
# Should still work even if similarity_search_with_score fails
|
| 168 |
+
assert len(results) > 0
|
| 169 |
+
|
tests/test_qa_chain_final.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final tests to cover remaining qa_chain lines."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper
|
| 8 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def qa_chain_wrapper(mock_vectorstore):
|
| 13 |
+
"""Create a QAChainWrapper instance."""
|
| 14 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 15 |
+
return QAChainWrapper(mock_vectorstore, prompt)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@patch("qa_chain.create_llm")
|
| 19 |
+
@patch("qa_chain.format_chat_history")
|
| 20 |
+
def test_stream_similarity_exact_match(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 21 |
+
"""Test similarity search with exact document matching (lines 269-274)."""
|
| 22 |
+
from langchain_core.documents import Document
|
| 23 |
+
|
| 24 |
+
mock_format_history.return_value = ""
|
| 25 |
+
mock_llm = MagicMock()
|
| 26 |
+
mock_chunk = MagicMock()
|
| 27 |
+
mock_chunk.content = "Response"
|
| 28 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 29 |
+
mock_create_llm.return_value = mock_llm
|
| 30 |
+
|
| 31 |
+
# Create documents where first 100 chars match exactly
|
| 32 |
+
matching_prefix = "A" * 100
|
| 33 |
+
doc1 = Document(
|
| 34 |
+
page_content=matching_prefix + " rest of content 1",
|
| 35 |
+
metadata={"page": 1, "source": "test.pdf"},
|
| 36 |
+
)
|
| 37 |
+
doc2 = Document(
|
| 38 |
+
page_content=matching_prefix + " rest of content 2", # Same first 100 chars
|
| 39 |
+
metadata={"page": 1, "source": "test.pdf"},
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
mock_retriever = MagicMock()
|
| 43 |
+
mock_retriever.invoke.return_value = [doc1]
|
| 44 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 45 |
+
|
| 46 |
+
mock_vectorstore = qa_chain_wrapper._vectorstore
|
| 47 |
+
# Return doc2 with score - should match doc1 based on first 100 chars and page
|
| 48 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 49 |
+
(doc2, 0.5)
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
mock_chain = MagicMock()
|
| 53 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 54 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 55 |
+
|
| 56 |
+
inputs = {
|
| 57 |
+
"question": "test question",
|
| 58 |
+
"chat_history": [],
|
| 59 |
+
"search_type": "similarity",
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 63 |
+
assert len(results) > 0
|
| 64 |
+
# Verify that the matching logic was executed (lines 269-274)
|
| 65 |
+
assert results[0].get("docs_with_scores") is not None
|
| 66 |
+
|
tests/test_qa_chain_remaining.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests to cover remaining lines in qa_chain.py."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from qa_chain import QAChainWrapper
|
| 8 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@pytest.fixture
|
| 12 |
+
def qa_chain_wrapper(mock_vectorstore):
|
| 13 |
+
"""Create a QAChainWrapper instance."""
|
| 14 |
+
prompt = ChatPromptTemplate.from_template("Test: {question}")
|
| 15 |
+
return QAChainWrapper(mock_vectorstore, prompt)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_rerank_documents_no_reranker(qa_chain_wrapper, sample_documents):
|
| 19 |
+
"""Test rerank_documents when reranker is None (line 115)."""
|
| 20 |
+
# Set reranker to None and ensure _get_reranker returns None
|
| 21 |
+
qa_chain_wrapper._reranker = None
|
| 22 |
+
with patch.object(qa_chain_wrapper, '_get_reranker', return_value=None):
|
| 23 |
+
result = qa_chain_wrapper.rerank_documents("query", sample_documents[:3], top_k=2)
|
| 24 |
+
assert len(result) == 2
|
| 25 |
+
assert all(r[1] is None for r in result) # All scores should be None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@patch("qa_chain.create_llm")
|
| 29 |
+
@patch("qa_chain.format_chat_history")
|
| 30 |
+
def test_stream_reranking_similarity(mock_format_history, mock_create_llm, qa_chain_wrapper, sample_documents):
|
| 31 |
+
"""Test streaming with reranking and similarity search (line 247)."""
|
| 32 |
+
mock_format_history.return_value = ""
|
| 33 |
+
mock_llm = MagicMock()
|
| 34 |
+
mock_chunk = MagicMock()
|
| 35 |
+
mock_chunk.content = "Response"
|
| 36 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 37 |
+
mock_create_llm.return_value = mock_llm
|
| 38 |
+
|
| 39 |
+
# Mock retriever for similarity search with reranking
|
| 40 |
+
mock_retriever = MagicMock()
|
| 41 |
+
mock_retriever.invoke.return_value = sample_documents[:3]
|
| 42 |
+
qa_chain_wrapper._vectorstore.as_retriever.return_value = mock_retriever
|
| 43 |
+
|
| 44 |
+
# Mock reranker
|
| 45 |
+
with patch("qa_chain.CrossEncoder") as mock_cross_encoder:
|
| 46 |
+
mock_reranker = MagicMock()
|
| 47 |
+
mock_reranker.predict.return_value = [0.9, 0.8, 0.7]
|
| 48 |
+
mock_cross_encoder.return_value = mock_reranker
|
| 49 |
+
qa_chain_wrapper._reranker = mock_reranker
|
| 50 |
+
|
| 51 |
+
mock_chain = MagicMock()
|
| 52 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 53 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 54 |
+
|
| 55 |
+
inputs = {
|
| 56 |
+
"question": "test question",
|
| 57 |
+
"chat_history": [],
|
| 58 |
+
"search_type": "similarity",
|
| 59 |
+
"use_reranking": True,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 63 |
+
assert len(results) > 0
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@patch("qa_chain.create_llm")
|
| 67 |
+
@patch("qa_chain.format_chat_history")
|
| 68 |
+
def test_stream_similarity_doc_matching(mock_format_history, mock_create_llm, qa_chain_wrapper):
|
| 69 |
+
"""Test similarity search with document matching (lines 268-276)."""
|
| 70 |
+
from langchain_core.documents import Document
|
| 71 |
+
|
| 72 |
+
mock_format_history.return_value = ""
|
| 73 |
+
mock_llm = MagicMock()
|
| 74 |
+
mock_chunk = MagicMock()
|
| 75 |
+
mock_chunk.content = "Response"
|
| 76 |
+
mock_llm.stream.return_value = [mock_chunk]
|
| 77 |
+
mock_create_llm.return_value = mock_llm
|
| 78 |
+
|
| 79 |
+
# Create documents with matching content (first 100 chars must match)
|
| 80 |
+
doc_content = "A" * 50 + "B" * 50 + "C" * 50 # 150 chars total
|
| 81 |
+
doc1 = Document(page_content=doc_content, metadata={"page": 1, "source": "test.pdf"})
|
| 82 |
+
doc2 = Document(page_content=doc_content, metadata={"page": 1, "source": "test.pdf"})
|
| 83 |
+
|
| 84 |
+
mock_retriever = MagicMock()
|
| 85 |
+
mock_retriever.invoke.return_value = [doc1]
|
| 86 |
+
qa_chain_wrapper._retriever = mock_retriever
|
| 87 |
+
|
| 88 |
+
mock_vectorstore = qa_chain_wrapper._vectorstore
|
| 89 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 90 |
+
(doc2, 0.5) # Same content, should match (lines 269-274)
|
| 91 |
+
]
|
| 92 |
+
|
| 93 |
+
mock_chain = MagicMock()
|
| 94 |
+
mock_chain.stream.return_value = [mock_chunk]
|
| 95 |
+
qa_chain_wrapper._prompt.__or__ = MagicMock(return_value=mock_chain)
|
| 96 |
+
|
| 97 |
+
inputs = {
|
| 98 |
+
"question": "test question",
|
| 99 |
+
"chat_history": [],
|
| 100 |
+
"search_type": "similarity",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 104 |
+
assert len(results) > 0
|
| 105 |
+
# Verify that scores were matched
|
| 106 |
+
assert results[0].get("docs_with_scores") is not None
|
| 107 |
+
|
| 108 |
+
# Test case where doc doesn't match (else clause line 276)
|
| 109 |
+
doc3 = Document(
|
| 110 |
+
page_content="X" * 150, # Different content
|
| 111 |
+
metadata={"page": 2, "source": "other.pdf"}
|
| 112 |
+
)
|
| 113 |
+
mock_retriever.invoke.return_value = [doc3]
|
| 114 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 115 |
+
(doc2, 0.5) # Different content, shouldn't match
|
| 116 |
+
]
|
| 117 |
+
|
| 118 |
+
results = list(qa_chain_wrapper.stream(inputs))
|
| 119 |
+
assert len(results) > 0
|
| 120 |
+
|
tests/test_retrievers.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for retrievers module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from retrievers import HybridRetriever
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_hybrid_retriever_init(mock_vectorstore):
|
| 11 |
+
"""Test HybridRetriever initialization."""
|
| 12 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 13 |
+
assert retriever._vectorstore == mock_vectorstore
|
| 14 |
+
assert retriever._bm25 is None
|
| 15 |
+
assert retriever._documents is None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_hybrid_retriever_tokenize():
|
| 19 |
+
"""Test tokenization."""
|
| 20 |
+
retriever = HybridRetriever(MagicMock())
|
| 21 |
+
tokens = retriever._tokenize("Hello World Test")
|
| 22 |
+
assert tokens == ["hello", "world", "test"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_hybrid_retriever_tokenize_empty():
|
| 26 |
+
"""Test tokenization of empty string."""
|
| 27 |
+
retriever = HybridRetriever(MagicMock())
|
| 28 |
+
tokens = retriever._tokenize("")
|
| 29 |
+
assert tokens == []
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_hybrid_retriever_build_bm25_index(mock_vectorstore):
|
| 33 |
+
"""Test BM25 index building."""
|
| 34 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 35 |
+
retriever._build_bm25_index()
|
| 36 |
+
assert retriever._bm25 is not None
|
| 37 |
+
assert len(retriever._documents) == 2
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_hybrid_retriever_build_bm25_index_empty(mock_vectorstore):
|
| 41 |
+
"""Test BM25 index building with empty collection."""
|
| 42 |
+
mock_vectorstore.get.return_value = {"documents": [], "metadatas": []}
|
| 43 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 44 |
+
retriever._build_bm25_index()
|
| 45 |
+
assert retriever._bm25 is None
|
| 46 |
+
assert retriever._documents == []
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_hybrid_retriever_build_bm25_index_no_documents(mock_vectorstore):
|
| 50 |
+
"""Test BM25 index building when collection is None."""
|
| 51 |
+
mock_vectorstore.get.return_value = None
|
| 52 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 53 |
+
retriever._build_bm25_index()
|
| 54 |
+
assert retriever._bm25 is None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_hybrid_retriever_matches_filter_no_filter():
|
| 58 |
+
"""Test filter matching with no filter."""
|
| 59 |
+
retriever = HybridRetriever(MagicMock())
|
| 60 |
+
doc = Mock(metadata={"source": "test.pdf"})
|
| 61 |
+
assert retriever._matches_filter(doc, None) is True
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_hybrid_retriever_matches_filter_eq():
|
| 65 |
+
"""Test filter matching with $eq operator."""
|
| 66 |
+
retriever = HybridRetriever(MagicMock())
|
| 67 |
+
doc = Mock(metadata={"source": "test.pdf"})
|
| 68 |
+
filter_dict = {"source": {"$eq": "test.pdf"}}
|
| 69 |
+
assert retriever._matches_filter(doc, filter_dict) is True
|
| 70 |
+
|
| 71 |
+
doc2 = Mock(metadata={"source": "other.pdf"})
|
| 72 |
+
assert retriever._matches_filter(doc2, filter_dict) is False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def test_hybrid_retriever_matches_filter_in():
|
| 76 |
+
"""Test filter matching with $in operator."""
|
| 77 |
+
retriever = HybridRetriever(MagicMock())
|
| 78 |
+
doc = Mock(metadata={"source": "test.pdf"})
|
| 79 |
+
filter_dict = {"source": {"$in": ["test.pdf", "other.pdf"]}}
|
| 80 |
+
assert retriever._matches_filter(doc, filter_dict) is True
|
| 81 |
+
|
| 82 |
+
doc2 = Mock(metadata={"source": "notin.pdf"})
|
| 83 |
+
assert retriever._matches_filter(doc2, filter_dict) is False
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_hybrid_retriever_matches_filter_missing_key():
|
| 87 |
+
"""Test filter matching with missing metadata key."""
|
| 88 |
+
retriever = HybridRetriever(MagicMock())
|
| 89 |
+
doc = Mock(metadata={})
|
| 90 |
+
filter_dict = {"source": {"$eq": "test.pdf"}}
|
| 91 |
+
assert retriever._matches_filter(doc, filter_dict) is False
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@patch("retrievers.BM25Okapi")
|
| 95 |
+
def test_hybrid_search(mock_bm25, mock_vectorstore, sample_documents):
|
| 96 |
+
"""Test hybrid search."""
|
| 97 |
+
# Setup mocks
|
| 98 |
+
mock_vectorstore.get.return_value = {
|
| 99 |
+
"documents": [doc.page_content for doc in sample_documents[:2]],
|
| 100 |
+
"metadatas": [doc.metadata for doc in sample_documents[:2]],
|
| 101 |
+
}
|
| 102 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 103 |
+
(sample_documents[0], 0.1),
|
| 104 |
+
(sample_documents[1], 0.2),
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
mock_bm25_instance = MagicMock()
|
| 108 |
+
mock_bm25_instance.get_scores.return_value = [0.5, 0.3]
|
| 109 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 110 |
+
|
| 111 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 112 |
+
results = retriever.hybrid_search("test query", k=2)
|
| 113 |
+
|
| 114 |
+
assert len(results) <= 2
|
| 115 |
+
assert all("doc" in r for r in results)
|
| 116 |
+
assert all("fused_score" in r for r in results)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_hybrid_search_empty_documents(mock_vectorstore):
|
| 120 |
+
"""Test hybrid search with no documents."""
|
| 121 |
+
mock_vectorstore.get.return_value = {"documents": [], "metadatas": []}
|
| 122 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 123 |
+
results = retriever.hybrid_search("test query")
|
| 124 |
+
assert results == []
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_hybrid_search_semantic_error(mock_vectorstore, sample_documents):
|
| 128 |
+
"""Test hybrid search when semantic search fails."""
|
| 129 |
+
mock_vectorstore.get.return_value = {
|
| 130 |
+
"documents": [doc.page_content for doc in sample_documents[:2]],
|
| 131 |
+
"metadatas": [doc.metadata for doc in sample_documents[:2]],
|
| 132 |
+
}
|
| 133 |
+
mock_vectorstore.similarity_search_with_score.side_effect = Exception("Error")
|
| 134 |
+
|
| 135 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 136 |
+
results = retriever.hybrid_search("test query")
|
| 137 |
+
# Should still return results from BM25
|
| 138 |
+
assert isinstance(results, list)
|
| 139 |
+
|
tests/test_retrievers_edge_cases.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Additional edge case tests for retrievers module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from retrievers import HybridRetriever
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_hybrid_retriever_build_bm25_index_no_metadata(mock_vectorstore):
|
| 11 |
+
"""Test BM25 index building with missing metadata."""
|
| 12 |
+
mock_vectorstore.get.return_value = {
|
| 13 |
+
"documents": ["Doc 1", "Doc 2"],
|
| 14 |
+
"metadatas": [{"source": "test.pdf"}, None], # One None metadata
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 18 |
+
retriever._build_bm25_index()
|
| 19 |
+
|
| 20 |
+
assert retriever._bm25 is not None
|
| 21 |
+
assert len(retriever._documents) == 2
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_hybrid_search_normalize_scores_edge_cases(mock_vectorstore):
|
| 25 |
+
"""Test hybrid search score normalization edge cases."""
|
| 26 |
+
from langchain_core.documents import Document
|
| 27 |
+
|
| 28 |
+
mock_vectorstore.get.return_value = {
|
| 29 |
+
"documents": ["Doc 1", "Doc 2"],
|
| 30 |
+
"metadatas": [
|
| 31 |
+
{"source": "test1.pdf", "page": 1},
|
| 32 |
+
{"source": "test2.pdf", "page": 2},
|
| 33 |
+
],
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 37 |
+
(Document(page_content="Doc 1", metadata={"source": "test1.pdf", "page": 1}), 0.1),
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
with patch("retrievers.BM25Okapi") as mock_bm25:
|
| 41 |
+
mock_bm25_instance = MagicMock()
|
| 42 |
+
# Test case: all scores are the same
|
| 43 |
+
mock_bm25_instance.get_scores.return_value = [0.5, 0.5]
|
| 44 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 45 |
+
|
| 46 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 47 |
+
results = retriever.hybrid_search("test query", k=2)
|
| 48 |
+
|
| 49 |
+
assert isinstance(results, list)
|
| 50 |
+
|
| 51 |
+
# Test case: all scores are zero
|
| 52 |
+
mock_bm25_instance.get_scores.return_value = [0.0, 0.0]
|
| 53 |
+
results = retriever.hybrid_search("test query", k=2)
|
| 54 |
+
assert isinstance(results, list)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_hybrid_search_negative_distance(mock_vectorstore):
|
| 58 |
+
"""Test hybrid search with negative distance (edge case)."""
|
| 59 |
+
from langchain_core.documents import Document
|
| 60 |
+
|
| 61 |
+
mock_vectorstore.get.return_value = {
|
| 62 |
+
"documents": ["Doc 1"],
|
| 63 |
+
"metadatas": [{"source": "test.pdf", "page": 1}],
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
doc = Document(page_content="Doc 1", metadata={"source": "test.pdf", "page": 1})
|
| 67 |
+
# Negative distance should be handled
|
| 68 |
+
mock_vectorstore.similarity_search_with_score.return_value = [(doc, -0.1)]
|
| 69 |
+
|
| 70 |
+
with patch("retrievers.BM25Okapi") as mock_bm25:
|
| 71 |
+
mock_bm25_instance = MagicMock()
|
| 72 |
+
mock_bm25_instance.get_scores.return_value = [0.5]
|
| 73 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 74 |
+
|
| 75 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 76 |
+
results = retriever.hybrid_search("test query", k=1)
|
| 77 |
+
|
| 78 |
+
assert len(results) > 0
|
| 79 |
+
|
tests/test_retrievers_final.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Final tests to cover remaining retrievers lines."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from retrievers import HybridRetriever
|
| 8 |
+
from langchain_core.documents import Document
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
@patch("retrievers.BM25Okapi")
|
| 12 |
+
def test_hybrid_search_sem_key_keyword_assignment(mock_bm25, mock_vectorstore):
|
| 13 |
+
"""Test keyword score assignment when sem_key exists (lines 179-185)."""
|
| 14 |
+
# Create documents that will match in both semantic and BM25
|
| 15 |
+
doc_content = "Test content for hybrid search matching" * 3 # ~120 chars
|
| 16 |
+
doc1 = Document(
|
| 17 |
+
page_content=doc_content,
|
| 18 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 19 |
+
)
|
| 20 |
+
doc2 = Document(
|
| 21 |
+
page_content=doc_content, # Same content
|
| 22 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
mock_vectorstore.get.return_value = {
|
| 26 |
+
"documents": [doc1.page_content],
|
| 27 |
+
"metadatas": [doc1.metadata],
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
# Semantic search returns doc2 (same content)
|
| 31 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 32 |
+
(doc2, 0.1),
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
mock_bm25_instance = MagicMock()
|
| 36 |
+
mock_bm25_instance.get_scores.return_value = [0.8]
|
| 37 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 38 |
+
|
| 39 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 40 |
+
retriever._build_bm25_index()
|
| 41 |
+
|
| 42 |
+
# The matching logic should trigger lines 179-185
|
| 43 |
+
# where sem_key exists in doc_scores and keyword score is assigned
|
| 44 |
+
results = retriever.hybrid_search("test query", k=1)
|
| 45 |
+
|
| 46 |
+
assert len(results) > 0
|
| 47 |
+
# Verify keyword score was assigned (line 181-183)
|
| 48 |
+
assert results[0]["keyword_score"] >= 0
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_hybrid_search_bm25_none_case(mock_vectorstore):
|
| 52 |
+
"""Test hybrid search when BM25 is None after build (line 128)."""
|
| 53 |
+
from langchain_core.documents import Document
|
| 54 |
+
|
| 55 |
+
doc = Document(page_content="Test", metadata={"source": "test.pdf", "page": 1})
|
| 56 |
+
|
| 57 |
+
# Use empty documents list
|
| 58 |
+
mock_vectorstore.get.return_value = {
|
| 59 |
+
"documents": [],
|
| 60 |
+
"metadatas": [],
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
mock_vectorstore.similarity_search_with_score.return_value = [(doc, 0.1)]
|
| 64 |
+
|
| 65 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 66 |
+
retriever._build_bm25_index()
|
| 67 |
+
|
| 68 |
+
# After build with empty docs, bm25 should be None (line 66)
|
| 69 |
+
# Then in hybrid_search, line 128 should handle None bm25
|
| 70 |
+
# Set documents manually to test the None bm25 path
|
| 71 |
+
retriever._documents = [doc]
|
| 72 |
+
|
| 73 |
+
results = retriever.hybrid_search("test query", k=1)
|
| 74 |
+
|
| 75 |
+
assert isinstance(results, list)
|
| 76 |
+
|
tests/test_retrievers_remaining.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests to cover remaining lines in retrievers.py."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from retrievers import HybridRetriever
|
| 8 |
+
from langchain_core.documents import Document
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_build_bm25_index_empty_tokenized(mock_vectorstore):
|
| 12 |
+
"""Test BM25 index building with empty tokenized docs (line 66)."""
|
| 13 |
+
# Test with empty documents list
|
| 14 |
+
mock_vectorstore.get.return_value = {
|
| 15 |
+
"documents": [], # Empty documents list
|
| 16 |
+
"metadatas": [],
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 20 |
+
retriever._build_bm25_index()
|
| 21 |
+
|
| 22 |
+
# When tokenized_docs is empty, bm25 should be None (line 66)
|
| 23 |
+
assert retriever._bm25 is None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_hybrid_search_no_bm25(mock_vectorstore):
|
| 27 |
+
"""Test hybrid search when BM25 is None (line 128)."""
|
| 28 |
+
from langchain_core.documents import Document
|
| 29 |
+
|
| 30 |
+
mock_vectorstore.get.return_value = {
|
| 31 |
+
"documents": ["Doc 1"],
|
| 32 |
+
"metadatas": [{"source": "test.pdf", "page": 1}],
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
doc = Document(page_content="Doc 1", metadata={"source": "test.pdf", "page": 1})
|
| 36 |
+
mock_vectorstore.similarity_search_with_score.return_value = [(doc, 0.1)]
|
| 37 |
+
|
| 38 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 39 |
+
retriever._bm25 = None # Explicitly set to None (line 128)
|
| 40 |
+
retriever._documents = [doc]
|
| 41 |
+
|
| 42 |
+
results = retriever.hybrid_search("test query", k=1)
|
| 43 |
+
|
| 44 |
+
assert isinstance(results, list)
|
| 45 |
+
# Should still return results based on semantic search only
|
| 46 |
+
assert len(results) > 0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@patch("retrievers.BM25Okapi")
|
| 50 |
+
def test_hybrid_search_filter_matching(mock_bm25, mock_vectorstore):
|
| 51 |
+
"""Test hybrid search with filter matching (lines 165, 179-185)."""
|
| 52 |
+
from langchain_core.documents import Document
|
| 53 |
+
|
| 54 |
+
doc1 = Document(
|
| 55 |
+
page_content="Document 1",
|
| 56 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 57 |
+
)
|
| 58 |
+
doc2 = Document(
|
| 59 |
+
page_content="Document 2",
|
| 60 |
+
metadata={"source": "pdf/test2.pdf", "page": 2},
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
mock_vectorstore.get.return_value = {
|
| 64 |
+
"documents": [doc1.page_content, doc2.page_content],
|
| 65 |
+
"metadatas": [doc1.metadata, doc2.metadata],
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 69 |
+
(doc1, 0.1),
|
| 70 |
+
]
|
| 71 |
+
|
| 72 |
+
mock_bm25_instance = MagicMock()
|
| 73 |
+
mock_bm25_instance.get_scores.return_value = [0.8, 0.6]
|
| 74 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 75 |
+
|
| 76 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 77 |
+
|
| 78 |
+
# Test with filter that excludes one document
|
| 79 |
+
metadata_filter = {"source": {"$eq": "pdf/test1.pdf"}}
|
| 80 |
+
results = retriever.hybrid_search("test query", k=2, metadata_filter=metadata_filter)
|
| 81 |
+
|
| 82 |
+
assert len(results) > 0
|
| 83 |
+
# Should only include documents matching the filter
|
| 84 |
+
|
| 85 |
+
# Test keyword score assignment when sem_key exists (lines 179-185)
|
| 86 |
+
# This happens when BM25 doc matches semantic doc
|
| 87 |
+
results = retriever.hybrid_search("test query", k=2, alpha=0.5)
|
| 88 |
+
assert all("keyword_score" in r for r in results)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@patch("retrievers.BM25Okapi")
|
| 92 |
+
def test_hybrid_search_keyword_score_assignment(mock_bm25, mock_vectorstore):
|
| 93 |
+
"""Test keyword score assignment in hybrid search (lines 179-185)."""
|
| 94 |
+
from langchain_core.documents import Document
|
| 95 |
+
|
| 96 |
+
# Create documents that will match exactly (first 100 chars must match)
|
| 97 |
+
base_content = "Same content for matching test " * 4 # ~120 chars
|
| 98 |
+
doc1 = Document(
|
| 99 |
+
page_content=base_content,
|
| 100 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 101 |
+
)
|
| 102 |
+
# Use same content for semantic doc to ensure matching
|
| 103 |
+
doc2 = Document(
|
| 104 |
+
page_content=base_content,
|
| 105 |
+
metadata={"source": "pdf/test1.pdf", "page": 1},
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
mock_vectorstore.get.return_value = {
|
| 109 |
+
"documents": [doc1.page_content],
|
| 110 |
+
"metadatas": [doc1.metadata],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
mock_vectorstore.similarity_search_with_score.return_value = [
|
| 114 |
+
(doc2, 0.1), # Semantic result with same content
|
| 115 |
+
]
|
| 116 |
+
|
| 117 |
+
mock_bm25_instance = MagicMock()
|
| 118 |
+
mock_bm25_instance.get_scores.return_value = [0.8]
|
| 119 |
+
mock_bm25.return_value = mock_bm25_instance
|
| 120 |
+
|
| 121 |
+
retriever = HybridRetriever(mock_vectorstore)
|
| 122 |
+
# Build index first
|
| 123 |
+
retriever._build_bm25_index()
|
| 124 |
+
|
| 125 |
+
results = retriever.hybrid_search("test query", k=1)
|
| 126 |
+
|
| 127 |
+
# Should have keyword score assigned when documents match (lines 179-185)
|
| 128 |
+
assert len(results) > 0
|
| 129 |
+
# The keyword score should be assigned
|
| 130 |
+
assert "keyword_score" in results[0]
|
| 131 |
+
# When sem_key exists in doc_scores, keyword score should be updated
|
| 132 |
+
assert results[0]["keyword_score"] >= 0
|
| 133 |
+
|
tests/test_ui_app.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ui/app.py module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from ui.app import create_app, initialize_chain
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@patch("ui.app.create_qa_chain")
|
| 11 |
+
@patch("ui.app.load_or_create_vectorstore")
|
| 12 |
+
@patch("ui.app.create_embeddings")
|
| 13 |
+
def test_initialize_chain(mock_create_embeddings, mock_load_vectorstore, mock_create_qa_chain):
|
| 14 |
+
"""Test initialize_chain function."""
|
| 15 |
+
# Setup mocks
|
| 16 |
+
mock_embeddings = MagicMock()
|
| 17 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 18 |
+
|
| 19 |
+
mock_vectorstore = MagicMock()
|
| 20 |
+
mock_vectorstore.get.return_value = {
|
| 21 |
+
"metadatas": [
|
| 22 |
+
{"source": "pdf/test1.pdf"},
|
| 23 |
+
{"source": "pdf/test2.pdf"},
|
| 24 |
+
]
|
| 25 |
+
}
|
| 26 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 27 |
+
|
| 28 |
+
mock_qa_chain = MagicMock()
|
| 29 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 30 |
+
|
| 31 |
+
# Test
|
| 32 |
+
chain, sources = initialize_chain()
|
| 33 |
+
|
| 34 |
+
assert chain == mock_qa_chain
|
| 35 |
+
assert len(sources) == 2
|
| 36 |
+
assert "test1.pdf" in sources
|
| 37 |
+
assert "test2.pdf" in sources
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@patch("ui.app.create_qa_chain")
|
| 41 |
+
@patch("ui.app.load_or_create_vectorstore")
|
| 42 |
+
@patch("ui.app.create_embeddings")
|
| 43 |
+
def test_initialize_chain_empty_metadatas(
|
| 44 |
+
mock_create_embeddings, mock_load_vectorstore, mock_create_qa_chain
|
| 45 |
+
):
|
| 46 |
+
"""Test initialize_chain with empty metadatas."""
|
| 47 |
+
# Setup mocks
|
| 48 |
+
mock_embeddings = MagicMock()
|
| 49 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 50 |
+
|
| 51 |
+
mock_vectorstore = MagicMock()
|
| 52 |
+
mock_vectorstore.get.return_value = {"metadatas": []}
|
| 53 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 54 |
+
|
| 55 |
+
mock_qa_chain = MagicMock()
|
| 56 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 57 |
+
|
| 58 |
+
# Test
|
| 59 |
+
chain, sources = initialize_chain()
|
| 60 |
+
|
| 61 |
+
assert chain == mock_qa_chain
|
| 62 |
+
assert sources == []
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@patch("ui.app.create_qa_chain")
|
| 66 |
+
@patch("ui.app.load_or_create_vectorstore")
|
| 67 |
+
@patch("ui.app.create_embeddings")
|
| 68 |
+
def test_initialize_chain_no_collection(
|
| 69 |
+
mock_create_embeddings, mock_load_vectorstore, mock_create_qa_chain
|
| 70 |
+
):
|
| 71 |
+
"""Test initialize_chain with no collection."""
|
| 72 |
+
# Setup mocks
|
| 73 |
+
mock_embeddings = MagicMock()
|
| 74 |
+
mock_create_embeddings.return_value = mock_embeddings
|
| 75 |
+
|
| 76 |
+
mock_vectorstore = MagicMock()
|
| 77 |
+
mock_vectorstore.get.return_value = None
|
| 78 |
+
mock_load_vectorstore.return_value = mock_vectorstore
|
| 79 |
+
|
| 80 |
+
mock_qa_chain = MagicMock()
|
| 81 |
+
mock_create_qa_chain.return_value = mock_qa_chain
|
| 82 |
+
|
| 83 |
+
# Test
|
| 84 |
+
chain, sources = initialize_chain()
|
| 85 |
+
|
| 86 |
+
assert chain == mock_qa_chain
|
| 87 |
+
assert sources == []
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@patch("ui.app.update_hybrid_alpha_visibility")
|
| 91 |
+
@patch("ui.app.update_rag_controls")
|
| 92 |
+
@patch("ui.app.create_respond_handler")
|
| 93 |
+
@patch("ui.app.create_stream_chat_response")
|
| 94 |
+
@patch("ui.app.create_ui_components")
|
| 95 |
+
@patch("ui.app.initialize_chain")
|
| 96 |
+
def test_create_app(
|
| 97 |
+
mock_initialize,
|
| 98 |
+
mock_create_components,
|
| 99 |
+
mock_create_stream,
|
| 100 |
+
mock_create_respond,
|
| 101 |
+
mock_update_rag,
|
| 102 |
+
mock_update_hybrid,
|
| 103 |
+
):
|
| 104 |
+
"""Test create_app function."""
|
| 105 |
+
# Setup mocks
|
| 106 |
+
mock_qa_chain = MagicMock()
|
| 107 |
+
mock_initialize.return_value = (mock_qa_chain, ["test1.pdf", "test2.pdf"])
|
| 108 |
+
|
| 109 |
+
mock_components = {
|
| 110 |
+
"demo": MagicMock(),
|
| 111 |
+
"msg": MagicMock(),
|
| 112 |
+
"chatbot": MagicMock(),
|
| 113 |
+
"rag_enabled": MagicMock(),
|
| 114 |
+
"search_type": MagicMock(),
|
| 115 |
+
"doc_filter": MagicMock(),
|
| 116 |
+
"query_rewriting": MagicMock(),
|
| 117 |
+
"reranking": MagicMock(),
|
| 118 |
+
"hybrid_alpha": MagicMock(),
|
| 119 |
+
"context_box": MagicMock(),
|
| 120 |
+
"search_col": MagicMock(),
|
| 121 |
+
"filter_col": MagicMock(),
|
| 122 |
+
"context_section": MagicMock(),
|
| 123 |
+
"advanced_options": MagicMock(),
|
| 124 |
+
"submit": MagicMock(),
|
| 125 |
+
"clear": MagicMock(),
|
| 126 |
+
}
|
| 127 |
+
mock_create_components.return_value = mock_components
|
| 128 |
+
|
| 129 |
+
mock_stream_fn = MagicMock()
|
| 130 |
+
mock_create_stream.return_value = mock_stream_fn
|
| 131 |
+
|
| 132 |
+
mock_respond_fn = MagicMock()
|
| 133 |
+
mock_create_respond.return_value = mock_respond_fn
|
| 134 |
+
|
| 135 |
+
# Test
|
| 136 |
+
app = create_app()
|
| 137 |
+
|
| 138 |
+
assert app == mock_components["demo"]
|
| 139 |
+
mock_initialize.assert_called_once()
|
| 140 |
+
mock_create_components.assert_called_once()
|
| 141 |
+
mock_create_stream.assert_called_once_with(mock_qa_chain)
|
| 142 |
+
mock_create_respond.assert_called_once_with(mock_stream_fn)
|
| 143 |
+
|
| 144 |
+
# Check event handlers were attached
|
| 145 |
+
assert mock_components["msg"].submit.called
|
| 146 |
+
assert mock_components["submit"].click.called
|
| 147 |
+
assert mock_components["clear"].click.called
|
| 148 |
+
assert mock_components["rag_enabled"].change.called
|
| 149 |
+
assert mock_components["search_type"].change.called
|
| 150 |
+
|
tests/test_ui_components.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ui/components.py module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from ui.components import CUSTOM_CSS, create_ui_components
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_custom_css():
|
| 11 |
+
"""Test that CUSTOM_CSS is defined."""
|
| 12 |
+
assert isinstance(CUSTOM_CSS, str)
|
| 13 |
+
assert len(CUSTOM_CSS) > 0
|
| 14 |
+
assert ".generating" in CUSTOM_CSS
|
| 15 |
+
assert "h1" in CUSTOM_CSS
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@patch("ui.components.gr")
|
| 19 |
+
def test_create_ui_components(mock_gr):
|
| 20 |
+
"""Test create_ui_components function."""
|
| 21 |
+
# Create mock components
|
| 22 |
+
mock_components = {
|
| 23 |
+
"rag_enabled": MagicMock(),
|
| 24 |
+
"search_type": MagicMock(),
|
| 25 |
+
"doc_filter": MagicMock(),
|
| 26 |
+
"query_rewriting": MagicMock(),
|
| 27 |
+
"reranking": MagicMock(),
|
| 28 |
+
"hybrid_alpha": MagicMock(),
|
| 29 |
+
"chatbot": MagicMock(),
|
| 30 |
+
"context_box": MagicMock(),
|
| 31 |
+
"msg": MagicMock(),
|
| 32 |
+
"submit": MagicMock(),
|
| 33 |
+
"clear": MagicMock(),
|
| 34 |
+
"search_col": MagicMock(),
|
| 35 |
+
"filter_col": MagicMock(),
|
| 36 |
+
"context_section": MagicMock(),
|
| 37 |
+
"advanced_options": MagicMock(),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
# Setup Blocks mock
|
| 41 |
+
mock_blocks = MagicMock()
|
| 42 |
+
mock_blocks.__enter__ = MagicMock(return_value=mock_blocks)
|
| 43 |
+
mock_blocks.__exit__ = MagicMock(return_value=None)
|
| 44 |
+
mock_gr.Blocks.return_value = mock_blocks
|
| 45 |
+
|
| 46 |
+
# Setup context manager mocks
|
| 47 |
+
def create_context_manager(name):
|
| 48 |
+
cm = MagicMock()
|
| 49 |
+
cm.__enter__ = MagicMock(return_value=cm)
|
| 50 |
+
cm.__exit__ = MagicMock(return_value=None)
|
| 51 |
+
return cm
|
| 52 |
+
|
| 53 |
+
mock_gr.Row.return_value = create_context_manager("Row")
|
| 54 |
+
mock_gr.Column.return_value = create_context_manager("Column")
|
| 55 |
+
mock_gr.Accordion.return_value = create_context_manager("Accordion")
|
| 56 |
+
|
| 57 |
+
# Setup component mocks
|
| 58 |
+
mock_gr.Markdown.return_value = MagicMock()
|
| 59 |
+
mock_gr.Checkbox.return_value = mock_components["rag_enabled"]
|
| 60 |
+
mock_gr.Radio.return_value = mock_components["search_type"]
|
| 61 |
+
mock_gr.Dropdown.return_value = mock_components["doc_filter"]
|
| 62 |
+
mock_gr.Slider.return_value = mock_components["hybrid_alpha"]
|
| 63 |
+
mock_gr.Chatbot.return_value = mock_components["chatbot"]
|
| 64 |
+
mock_gr.Textbox.return_value = mock_components["msg"]
|
| 65 |
+
mock_gr.Button.side_effect = [mock_components["submit"], mock_components["clear"]]
|
| 66 |
+
mock_gr.Examples.return_value = MagicMock()
|
| 67 |
+
|
| 68 |
+
# Test
|
| 69 |
+
result = create_ui_components(["test1.pdf", "test2.pdf"])
|
| 70 |
+
|
| 71 |
+
assert isinstance(result, dict)
|
| 72 |
+
assert "demo" in result
|
| 73 |
+
assert "rag_enabled" in result
|
| 74 |
+
assert "search_type" in result
|
| 75 |
+
assert "doc_filter" in result
|
| 76 |
+
assert "chatbot" in result
|
| 77 |
+
assert "msg" in result
|
| 78 |
+
assert "submit" in result
|
| 79 |
+
assert "clear" in result
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_create_ui_components_empty_sources():
|
| 83 |
+
"""Test create_ui_components with empty sources."""
|
| 84 |
+
with patch("ui.components.gr") as mock_gr:
|
| 85 |
+
mock_blocks = MagicMock()
|
| 86 |
+
mock_blocks.__enter__ = MagicMock(return_value=mock_blocks)
|
| 87 |
+
mock_blocks.__exit__ = MagicMock(return_value=None)
|
| 88 |
+
mock_gr.Blocks.return_value = mock_blocks
|
| 89 |
+
|
| 90 |
+
# Mock all components
|
| 91 |
+
for attr in ["Markdown", "Row", "Column", "Accordion", "Checkbox", "Radio",
|
| 92 |
+
"Dropdown", "Slider", "Chatbot", "Textbox", "Button", "Examples"]:
|
| 93 |
+
setattr(mock_gr, attr, MagicMock())
|
| 94 |
+
|
| 95 |
+
# Mock context managers
|
| 96 |
+
for cm in ["Row", "Column", "Accordion"]:
|
| 97 |
+
mock_cm = MagicMock()
|
| 98 |
+
mock_cm.__enter__ = MagicMock(return_value=mock_cm)
|
| 99 |
+
mock_cm.__exit__ = MagicMock(return_value=None)
|
| 100 |
+
getattr(mock_gr, cm).return_value = mock_cm
|
| 101 |
+
|
| 102 |
+
result = create_ui_components([])
|
| 103 |
+
|
| 104 |
+
assert isinstance(result, dict)
|
| 105 |
+
assert "doc_filter" in result
|
| 106 |
+
|
tests/test_utils.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for utils module."""
|
| 2 |
+
|
| 3 |
+
from langchain_core.messages import AIMessage, HumanMessage
|
| 4 |
+
|
| 5 |
+
from utils import (
|
| 6 |
+
format_chat_history,
|
| 7 |
+
format_context_with_highlight,
|
| 8 |
+
messages_to_tuples,
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def test_format_chat_history_empty():
|
| 13 |
+
"""Test formatting empty chat history."""
|
| 14 |
+
result = format_chat_history([])
|
| 15 |
+
assert result == ""
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_format_chat_history_tuples(sample_chat_history_tuples):
|
| 19 |
+
"""Test formatting chat history as tuples."""
|
| 20 |
+
result = format_chat_history(sample_chat_history_tuples)
|
| 21 |
+
assert "Human:" in result
|
| 22 |
+
assert "Assistant:" in result
|
| 23 |
+
assert "What is RAG?" in result
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_format_chat_history_dicts(sample_chat_history):
|
| 27 |
+
"""Test formatting chat history as dicts."""
|
| 28 |
+
result = format_chat_history(sample_chat_history)
|
| 29 |
+
assert "Human:" in result
|
| 30 |
+
assert "Assistant:" in result
|
| 31 |
+
assert "What is RAG?" in result
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_format_chat_history_messages():
|
| 35 |
+
"""Test formatting chat history as Message objects."""
|
| 36 |
+
history = [
|
| 37 |
+
HumanMessage(content="Hello"),
|
| 38 |
+
AIMessage(content="Hi there"),
|
| 39 |
+
]
|
| 40 |
+
result = format_chat_history(history)
|
| 41 |
+
assert "Human:" in result
|
| 42 |
+
assert "Assistant:" in result
|
| 43 |
+
assert "Hello" in result
|
| 44 |
+
assert "Hi there" in result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def test_format_chat_history_limit():
|
| 48 |
+
"""Test that chat history respects limit."""
|
| 49 |
+
history = [
|
| 50 |
+
{"role": "user", "content": f"Message {i}"}
|
| 51 |
+
for i in range(10)
|
| 52 |
+
]
|
| 53 |
+
result = format_chat_history(history, limit=5)
|
| 54 |
+
# Should only include last 5 messages
|
| 55 |
+
assert "Message 5" in result
|
| 56 |
+
assert "Message 9" in result
|
| 57 |
+
assert "Message 0" not in result
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_messages_to_tuples_empty():
|
| 61 |
+
"""Test converting empty messages to tuples."""
|
| 62 |
+
result = messages_to_tuples([])
|
| 63 |
+
assert result == []
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_messages_to_tuples(sample_chat_history):
|
| 67 |
+
"""Test converting messages to tuples."""
|
| 68 |
+
result = messages_to_tuples(sample_chat_history)
|
| 69 |
+
assert len(result) == 1 # Only one complete pair
|
| 70 |
+
assert result[0][0] == "What is RAG?"
|
| 71 |
+
assert result[0][1] == "RAG is Retrieval-Augmented Generation."
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_messages_to_tuples_incomplete():
|
| 75 |
+
"""Test converting messages with incomplete pairs."""
|
| 76 |
+
messages = [
|
| 77 |
+
{"role": "user", "content": "Question 1"},
|
| 78 |
+
{"role": "user", "content": "Question 2"},
|
| 79 |
+
]
|
| 80 |
+
result = messages_to_tuples(messages)
|
| 81 |
+
assert len(result) == 0 # No complete pairs
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_format_context_with_highlight_empty():
|
| 85 |
+
"""Test formatting empty context."""
|
| 86 |
+
result = format_context_with_highlight([])
|
| 87 |
+
assert result == ""
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_format_context_with_highlight(sample_documents):
|
| 91 |
+
"""Test formatting context with documents."""
|
| 92 |
+
result = format_context_with_highlight(sample_documents[:2])
|
| 93 |
+
assert "Sources:" in result
|
| 94 |
+
assert "test1.pdf" in result
|
| 95 |
+
assert "test2.pdf" in result
|
| 96 |
+
assert "machine learning" in result
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_format_context_with_scores(sample_documents):
|
| 100 |
+
"""Test formatting context with scores."""
|
| 101 |
+
docs_with_scores = [
|
| 102 |
+
(sample_documents[0], 0.9),
|
| 103 |
+
(sample_documents[1], 0.8),
|
| 104 |
+
]
|
| 105 |
+
result = format_context_with_highlight(
|
| 106 |
+
sample_documents[:2], docs_with_scores=docs_with_scores
|
| 107 |
+
)
|
| 108 |
+
assert "β" in result # Top chunk should be highlighted
|
| 109 |
+
assert "rel:" in result or "dist:" in result # Score info
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_format_context_with_rewritten_query(sample_documents):
|
| 113 |
+
"""Test formatting context with rewritten query."""
|
| 114 |
+
result = format_context_with_highlight(
|
| 115 |
+
sample_documents[:2], rewritten_query="rewritten query"
|
| 116 |
+
)
|
| 117 |
+
assert "π Rewritten:" in result
|
| 118 |
+
assert "rewritten query" in result
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_format_context_with_hybrid_scores(sample_documents):
|
| 122 |
+
"""Test formatting context with hybrid scores."""
|
| 123 |
+
hybrid_scores = [
|
| 124 |
+
(sample_documents[0], 0.9, 0.85, 0.95),
|
| 125 |
+
(sample_documents[1], 0.8, 0.75, 0.85),
|
| 126 |
+
]
|
| 127 |
+
result = format_context_with_highlight(
|
| 128 |
+
sample_documents[:2], hybrid_scores=hybrid_scores
|
| 129 |
+
)
|
| 130 |
+
assert "f:" in result # Fused score
|
| 131 |
+
assert "s:" in result # Semantic score
|
| 132 |
+
assert "k:" in result # Keyword score
|
| 133 |
+
|
tests/test_utils_edge_cases.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Additional edge case tests for utils module."""
|
| 2 |
+
|
| 3 |
+
from langchain_core.documents import Document
|
| 4 |
+
|
| 5 |
+
from utils import format_context_with_highlight
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def test_format_context_score_edge_cases():
|
| 9 |
+
"""Test format_context_with_highlight with edge case scores."""
|
| 10 |
+
docs = [
|
| 11 |
+
Document(
|
| 12 |
+
page_content="Test content",
|
| 13 |
+
metadata={"source": "pdf/test.pdf", "page": 1},
|
| 14 |
+
)
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
# Test with score > 1.0 (distance score)
|
| 18 |
+
docs_with_scores = [(docs[0], 1.5)]
|
| 19 |
+
result = format_context_with_highlight(docs, docs_with_scores=docs_with_scores)
|
| 20 |
+
assert "dist:" in result
|
| 21 |
+
|
| 22 |
+
# Test with score exactly 1.0
|
| 23 |
+
docs_with_scores = [(docs[0], 1.0)]
|
| 24 |
+
result = format_context_with_highlight(docs, docs_with_scores=docs_with_scores)
|
| 25 |
+
assert "rel:" in result
|
| 26 |
+
|
| 27 |
+
# Test with None score
|
| 28 |
+
docs_with_scores = [(docs[0], None)]
|
| 29 |
+
result = format_context_with_highlight(docs, docs_with_scores=docs_with_scores)
|
| 30 |
+
assert "β" in result # Should still highlight first chunk
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_format_context_no_scores():
|
| 34 |
+
"""Test format_context_with_highlight with no scores."""
|
| 35 |
+
docs = [
|
| 36 |
+
Document(
|
| 37 |
+
page_content="Test content",
|
| 38 |
+
metadata={"source": "pdf/test.pdf", "page": 1},
|
| 39 |
+
)
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
result = format_context_with_highlight(docs, docs_with_scores=None)
|
| 43 |
+
assert "Test content" in result
|
| 44 |
+
assert "β" in result # First chunk should be highlighted by default
|
| 45 |
+
|
tests/test_vectorstore.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for vectorstore module."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from vectorstore import (
|
| 9 |
+
create_new_vectorstore,
|
| 10 |
+
filter_metadata,
|
| 11 |
+
get_pdf_files,
|
| 12 |
+
get_text_splitter,
|
| 13 |
+
handle_existing_vectorstore,
|
| 14 |
+
load_or_create_vectorstore,
|
| 15 |
+
process_documents,
|
| 16 |
+
update_vectorstore,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def test_get_text_splitter():
|
| 21 |
+
"""Test text splitter creation."""
|
| 22 |
+
splitter = get_text_splitter()
|
| 23 |
+
assert splitter._chunk_size == 512
|
| 24 |
+
assert splitter._chunk_overlap == 128
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_get_pdf_files_nonexistent_dir(tmp_path):
|
| 28 |
+
"""Test getting PDF files from non-existent directory."""
|
| 29 |
+
with patch("vectorstore.PDF_PATH", str(tmp_path / "nonexistent")):
|
| 30 |
+
files = get_pdf_files()
|
| 31 |
+
assert files == []
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_get_pdf_files_empty_dir(tmp_path):
|
| 35 |
+
"""Test getting PDF files from empty directory."""
|
| 36 |
+
pdf_dir = tmp_path / "pdf"
|
| 37 |
+
pdf_dir.mkdir()
|
| 38 |
+
with patch("vectorstore.PDF_PATH", str(pdf_dir)):
|
| 39 |
+
files = get_pdf_files()
|
| 40 |
+
assert files == []
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_filter_metadata_keep():
|
| 44 |
+
"""Test metadata filter keeps valid documents."""
|
| 45 |
+
doc = Mock(metadata={"section": "introduction"})
|
| 46 |
+
assert filter_metadata(doc) is True
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_filter_metadata_skip_references():
|
| 50 |
+
"""Test metadata filter skips references section."""
|
| 51 |
+
doc = Mock(metadata={"section": "references"})
|
| 52 |
+
assert filter_metadata(doc) is False
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def test_filter_metadata_skip_acknowledgments():
|
| 56 |
+
"""Test metadata filter skips acknowledgments section."""
|
| 57 |
+
doc = Mock(metadata={"section": "acknowledgments"})
|
| 58 |
+
assert filter_metadata(doc) is False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_filter_metadata_skip_appendix():
|
| 62 |
+
"""Test metadata filter skips appendix section."""
|
| 63 |
+
doc = Mock(metadata={"section": "appendix"})
|
| 64 |
+
assert filter_metadata(doc) is False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_filter_metadata_no_section():
|
| 68 |
+
"""Test metadata filter with no section."""
|
| 69 |
+
doc = Mock(metadata={})
|
| 70 |
+
assert filter_metadata(doc) is True
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_process_documents(sample_documents):
|
| 74 |
+
"""Test document processing."""
|
| 75 |
+
splitter = get_text_splitter()
|
| 76 |
+
result = process_documents(sample_documents, splitter)
|
| 77 |
+
# Should filter out references document
|
| 78 |
+
assert len(result) < len(sample_documents)
|
| 79 |
+
assert all(not ("references" in doc.metadata.get("section", "").lower()) for doc in result)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@patch("vectorstore.Chroma")
|
| 83 |
+
@patch("vectorstore.os.path.exists")
|
| 84 |
+
def test_load_or_create_vectorstore_existing(mock_exists, mock_chroma, mock_embeddings):
|
| 85 |
+
"""Test loading existing vectorstore."""
|
| 86 |
+
mock_exists.return_value = True
|
| 87 |
+
mock_vectorstore = MagicMock()
|
| 88 |
+
mock_chroma.return_value = mock_vectorstore
|
| 89 |
+
|
| 90 |
+
with patch("vectorstore.handle_existing_vectorstore") as mock_handle:
|
| 91 |
+
mock_handle.return_value = mock_vectorstore
|
| 92 |
+
result = load_or_create_vectorstore(mock_embeddings)
|
| 93 |
+
assert result == mock_vectorstore
|
| 94 |
+
mock_handle.assert_called_once_with(mock_embeddings)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@patch("vectorstore.Chroma")
|
| 98 |
+
@patch("vectorstore.os.path.exists")
|
| 99 |
+
def test_load_or_create_vectorstore_new(mock_exists, mock_chroma, mock_embeddings):
|
| 100 |
+
"""Test creating new vectorstore."""
|
| 101 |
+
mock_exists.return_value = False
|
| 102 |
+
mock_vectorstore = MagicMock()
|
| 103 |
+
mock_chroma.from_documents.return_value = mock_vectorstore
|
| 104 |
+
|
| 105 |
+
with patch("vectorstore.create_new_vectorstore") as mock_create:
|
| 106 |
+
mock_create.return_value = mock_vectorstore
|
| 107 |
+
result = load_or_create_vectorstore(mock_embeddings)
|
| 108 |
+
assert result == mock_vectorstore
|
| 109 |
+
mock_create.assert_called_once_with(mock_embeddings)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@patch("vectorstore.get_pdf_files")
|
| 113 |
+
@patch("vectorstore.Chroma")
|
| 114 |
+
def test_handle_existing_vectorstore(mock_chroma, mock_get_pdfs, mock_embeddings):
|
| 115 |
+
"""Test handling existing vectorstore."""
|
| 116 |
+
mock_vectorstore = MagicMock()
|
| 117 |
+
mock_vectorstore.get.return_value = {
|
| 118 |
+
"metadatas": [
|
| 119 |
+
{"source": "pdf/existing.pdf"},
|
| 120 |
+
{"source": "pdf/another.pdf"},
|
| 121 |
+
]
|
| 122 |
+
}
|
| 123 |
+
mock_chroma.return_value = mock_vectorstore
|
| 124 |
+
mock_get_pdfs.return_value = ["pdf/existing.pdf", "pdf/new.pdf"]
|
| 125 |
+
|
| 126 |
+
with patch("vectorstore.update_vectorstore") as mock_update:
|
| 127 |
+
result = handle_existing_vectorstore(mock_embeddings)
|
| 128 |
+
assert result == mock_vectorstore
|
| 129 |
+
mock_update.assert_called_once()
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@patch("vectorstore.get_pdf_files")
|
| 133 |
+
@patch("vectorstore.Chroma")
|
| 134 |
+
def test_handle_existing_vectorstore_no_new_files(mock_chroma, mock_get_pdfs, mock_embeddings):
|
| 135 |
+
"""Test handling existing vectorstore with no new files."""
|
| 136 |
+
mock_vectorstore = MagicMock()
|
| 137 |
+
mock_vectorstore.get.return_value = {
|
| 138 |
+
"metadatas": [{"source": "pdf/existing.pdf"}]
|
| 139 |
+
}
|
| 140 |
+
mock_chroma.return_value = mock_vectorstore
|
| 141 |
+
mock_get_pdfs.return_value = ["pdf/existing.pdf"]
|
| 142 |
+
|
| 143 |
+
with patch("vectorstore.update_vectorstore") as mock_update:
|
| 144 |
+
result = handle_existing_vectorstore(mock_embeddings)
|
| 145 |
+
assert result == mock_vectorstore
|
| 146 |
+
mock_update.assert_not_called()
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@patch("vectorstore.DirectoryLoader")
|
| 150 |
+
@patch("vectorstore.process_documents")
|
| 151 |
+
def test_update_vectorstore(mock_process, mock_loader, mock_vectorstore):
|
| 152 |
+
"""Test updating vectorstore with new documents."""
|
| 153 |
+
mock_loader_instance = MagicMock()
|
| 154 |
+
mock_loader.return_value = mock_loader_instance
|
| 155 |
+
mock_loader_instance.load.return_value = [
|
| 156 |
+
Mock(metadata={"source": "pdf/new.pdf"}),
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
mock_docs = [Mock()]
|
| 160 |
+
mock_process.return_value = mock_docs
|
| 161 |
+
|
| 162 |
+
update_vectorstore(mock_vectorstore, ["pdf/new.pdf"], {"pdf/old.pdf"})
|
| 163 |
+
mock_vectorstore.add_documents.assert_called_once_with(mock_docs)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
@patch("vectorstore.get_pdf_files")
|
| 167 |
+
@patch("vectorstore.DirectoryLoader")
|
| 168 |
+
@patch("vectorstore.Chroma")
|
| 169 |
+
def test_create_new_vectorstore(mock_chroma, mock_loader, mock_get_pdfs, mock_embeddings):
|
| 170 |
+
"""Test creating new vectorstore."""
|
| 171 |
+
mock_get_pdfs.return_value = ["pdf/test1.pdf", "pdf/test2.pdf"]
|
| 172 |
+
mock_loader_instance = MagicMock()
|
| 173 |
+
mock_loader.return_value = mock_loader_instance
|
| 174 |
+
mock_loader_instance.load.return_value = [Mock()]
|
| 175 |
+
|
| 176 |
+
mock_vectorstore = MagicMock()
|
| 177 |
+
mock_chroma.from_documents.return_value = mock_vectorstore
|
| 178 |
+
|
| 179 |
+
with patch("vectorstore.process_documents") as mock_process:
|
| 180 |
+
mock_process.return_value = [Mock()]
|
| 181 |
+
result = create_new_vectorstore(mock_embeddings)
|
| 182 |
+
assert result == mock_vectorstore
|
| 183 |
+
mock_chroma.from_documents.assert_called_once()
|
| 184 |
+
|
tests/test_vectorstore_edge_cases.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Additional edge case tests for vectorstore module."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, Mock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from vectorstore import handle_existing_vectorstore
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@patch("vectorstore.get_pdf_files")
|
| 11 |
+
@patch("vectorstore.Chroma")
|
| 12 |
+
def test_handle_existing_vectorstore_no_pdfs(mock_chroma, mock_get_pdfs):
|
| 13 |
+
"""Test handle_existing_vectorstore with no PDF files."""
|
| 14 |
+
mock_vectorstore = MagicMock()
|
| 15 |
+
mock_chroma.return_value = mock_vectorstore
|
| 16 |
+
mock_get_pdfs.return_value = []
|
| 17 |
+
|
| 18 |
+
with pytest.raises(SystemExit):
|
| 19 |
+
handle_existing_vectorstore(MagicMock())
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@patch("vectorstore.get_pdf_files")
|
| 23 |
+
@patch("vectorstore.Chroma")
|
| 24 |
+
def test_handle_existing_vectorstore_empty_collection(mock_chroma, mock_get_pdfs):
|
| 25 |
+
"""Test handle_existing_vectorstore with empty collection."""
|
| 26 |
+
mock_vectorstore = MagicMock()
|
| 27 |
+
mock_vectorstore.get.return_value = None
|
| 28 |
+
mock_chroma.return_value = mock_vectorstore
|
| 29 |
+
mock_get_pdfs.return_value = ["pdf/test.pdf"]
|
| 30 |
+
|
| 31 |
+
with patch("vectorstore.update_vectorstore") as mock_update:
|
| 32 |
+
result = handle_existing_vectorstore(MagicMock())
|
| 33 |
+
assert result == mock_vectorstore
|
| 34 |
+
# Should still try to update with new PDFs
|
| 35 |
+
mock_update.assert_called_once()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@patch("vectorstore.get_pdf_files")
|
| 39 |
+
@patch("vectorstore.Chroma")
|
| 40 |
+
def test_handle_existing_vectorstore_no_metadatas(mock_chroma, mock_get_pdfs):
|
| 41 |
+
"""Test handle_existing_vectorstore with no metadatas."""
|
| 42 |
+
mock_vectorstore = MagicMock()
|
| 43 |
+
mock_vectorstore.get.return_value = {"metadatas": None}
|
| 44 |
+
mock_chroma.return_value = mock_vectorstore
|
| 45 |
+
mock_get_pdfs.return_value = ["pdf/test.pdf"]
|
| 46 |
+
|
| 47 |
+
with patch("vectorstore.update_vectorstore") as mock_update:
|
| 48 |
+
result = handle_existing_vectorstore(MagicMock())
|
| 49 |
+
assert result == mock_vectorstore
|
| 50 |
+
mock_update.assert_called_once()
|
| 51 |
+
|
tests/test_vectorstore_remaining.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests to cover remaining lines in vectorstore.py."""
|
| 2 |
+
|
| 3 |
+
from unittest.mock import MagicMock, patch
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from vectorstore import create_new_vectorstore
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@patch("vectorstore.get_pdf_files")
|
| 11 |
+
@patch("vectorstore.DirectoryLoader")
|
| 12 |
+
@patch("vectorstore.Chroma")
|
| 13 |
+
def test_create_new_vectorstore_no_pdfs(mock_chroma, mock_loader, mock_get_pdfs):
|
| 14 |
+
"""Test create_new_vectorstore with no PDF files (lines 157-159)."""
|
| 15 |
+
mock_get_pdfs.return_value = []
|
| 16 |
+
|
| 17 |
+
with pytest.raises(SystemExit):
|
| 18 |
+
create_new_vectorstore(MagicMock())
|
| 19 |
+
|