Skip to content

Commit 375a513

Browse files
committed
Added tests and CI
1 parent 4eba85d commit 375a513

12 files changed

Lines changed: 578 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- name: Check out repository
14+
uses: actions/checkout@v4
15+
16+
- name: Set up Python environment
17+
uses: astral-sh/setup-uv@v3
18+
19+
- name: Install dependencies and run tests
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: "3.11"
23+
24+
- run: uv sync --dev
25+
- run: uv run ruff check .
26+
- run: uv run pytest tests/

src/core/api/chat.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from fastapi import APIRouter, HTTPException
22
from fastapi.responses import StreamingResponse
3-
from pydantic import BaseModel
4-
from typing import Optional
53
from langchain_core.runnables import RunnableConfig
64
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
5+
from pydantic import BaseModel
6+
7+
from typing import Optional
78
import time
89
import uuid
910

src/core/ingestion.py

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,26 @@ def __init__(self):
2727
self.chunk_overlap = settings.chunk_overlap
2828
self.splitter_type = settings.splitter_type
2929

30+
def convert_to_lc_documents(self, results: list) -> list[Document]:
31+
"""Convert raw extraction results from Kreuzberg into LangChain Document objects.
32+
33+
Args:
34+
results (list): List of extraction results from Kreuzberg
35+
36+
Returns:
37+
list[Document]: List of LangChain Document objects with extracted content and metadata
38+
"""
39+
return [
40+
Document(
41+
page_content=result.content,
42+
metadata={
43+
k: str(v)
44+
for k, v in (result.metadata or {}).items()
45+
},
46+
)
47+
for result in results
48+
]
49+
3050
def load_documents_from_directory(self, input_path: Path) -> list[Document]:
3151
"""
3252
Load all files from a specified directory into LangChain Document objects.
@@ -44,24 +64,17 @@ def load_documents_from_directory(self, input_path: Path) -> list[Document]:
4464
Raises:
4565
RuntimeError: If loading or extraction fails for any reason
4666
"""
67+
if not input_path.exists() or not input_path.is_dir():
68+
raise RuntimeError(f"Input path {input_path} does not exist or is not a directory")
69+
4770
try:
4871
files: list[str | Path] = [f for f in input_path.glob("*") if f.is_file()]
4972
config = ExtractionConfig(
5073
ocr=OcrConfig(backend="tesseract", language="eng")
5174
)
5275

5376
results = batch_extract_files_sync(files, config=config)
54-
55-
return [
56-
Document(
57-
page_content=result.content,
58-
metadata={
59-
k: str(v)
60-
for k, v in (result.metadata or {}).items()
61-
},
62-
)
63-
for result in results
64-
]
77+
return self.convert_to_lc_documents(results)
6578
except Exception as e:
6679
raise RuntimeError(f"Failed to load documents from {input_path}") from e
6780

src/core/pipeline.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,6 @@ class RAGManager:
1717
vector_store (QdrantVectorRepository): Manages vector storage and retrieval
1818
"""
1919
def __init__(self):
20-
"""
21-
Initialize a RAGManager instance.
22-
23-
Sets up the document processor and vector store connection using
24-
a vector database.
25-
"""
2620
self.document_manager = DocumentProcessor()
2721
self.vector_store = QdrantVectorRepository()
2822

src/core/storage.py

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,29 @@
1717

1818
class QdrantVectorRepository:
1919
"""
20-
Manages document storage and retrieval using Qdrant as a vector database.
21-
22-
This class provides an interface for storing document chunks as vector embeddings
23-
in Qdrant, and retrieving relevant chunks using semantic search. It supports
24-
operations like adding, deleting, and querying documents from the vector store.
25-
20+
Handles storage and retrieval of document chunks in a Qdrant vector database.
21+
22+
This class provides an interface to:
23+
- Store document chunks as vector embeddings in Qdrant.
24+
- Retrieve relevant chunks using semantic search.
25+
- Add, delete, or query documents from the vector store.
26+
27+
Designed with dependency injection in mind, it allows passing a custom
28+
Qdrant client for testing or alternative environments.
29+
2630
Attributes:
27-
index_name (str): Name of the Qdrant collection
28-
qdrant_url (str): Connection URL for the Qdrant server
29-
client (QdrantClient): Qdrant client instance
31+
index_name (str): Name of the Qdrant collection.
32+
qdrant_url (str): URL of the Qdrant server.
33+
client (QdrantClient): Qdrant client instance used for all operations.
3034
"""
31-
def __init__(self, index_name: str = "my_docs"):
32-
"""
33-
Initialize a Qdrant vector repository instance.
34-
35-
Args:
36-
index_name (str): Name of the collection to use (default: "my_docs")
37-
"""
38-
self.index_name = index_name
39-
self.qdrant_url = settings.qdrant_url
40-
self.client = QdrantClient(url=self.qdrant_url)
35+
def __init__(
36+
self, client: QdrantClient | None = None,
37+
index_name: str = "my_docs",
38+
url: str | None = None
39+
):
40+
self.index_name = index_name
41+
self.qdrant_url = url or settings.qdrant_url
42+
self.client = client or QdrantClient(url=self.qdrant_url)
4143

4244
@property
4345
def embedder(self) -> CacheBackedEmbeddings:

tests/mock_docs/mock1.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis mi nibh, tincidunt lobortis nulla vitae, sollicitudin dapibus purus. Integer sit amet tortor iaculis, viverra ligula at, mollis diam. Integer vulputate libero maximus elit fermentum, at placerat velit mattis. Cras sed dui in neque fringilla faucibus. Sed dui tortor, cursus venenatis varius quis, tincidunt et diam. Nulla lacus velit, accumsan et metus vitae, mattis egestas lorem. Sed consectetur convallis ornare. Aliquam posuere est purus, et condimentum nunc condimentum in. Nullam eu sapien non mauris vestibulum ultricies. Aenean interdum metus in varius convallis.

tests/mock_docs/mock2.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis mi nibh, tincidunt lobortis nulla vitae, sollicitudin dapibus purus. Integer sit amet tortor iaculis, viverra ligula at, mollis diam. Integer vulputate libero maximus elit fermentum, at placerat velit mattis. Cras sed dui in neque fringilla faucibus. Sed dui tortor, cursus venenatis varius quis, tincidunt et diam. Nulla lacus velit, accumsan et metus vitae, mattis egestas lorem. Sed consectetur convallis ornare. Aliquam posuere est purus, et condimentum nunc condimentum in. Nullam eu sapien non mauris vestibulum ultricies. Aenean interdum metus in varius convallis.
2+
3+
Mauris vulputate metus eu sapien tempus maximus. Morbi nec diam consequat, vulputate augue at, gravida eros. Sed sed consectetur augue. Suspendisse nec nulla id mauris molestie eleifend id commodo mauris. Nulla congue a eros rhoncus viverra. Pellentesque vulputate felis quis nisl tempus faucibus. Pellentesque nisi purus, scelerisque non ligula sodales, feugiat dignissim magna. Sed eget leo ac tortor commodo maximus ut sit amet justo. Sed vitae libero eleifend nulla euismod posuere ac at nunc. Ut commodo nisl odio, eget cursus nisl fringilla facilisis. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.

tests/mock_docs/mock3.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis mi nibh, tincidunt lobortis nulla vitae, sollicitudin dapibus purus. Integer sit amet tortor iaculis, viverra ligula at, mollis diam. Integer vulputate libero maximus elit fermentum, at placerat velit mattis. Cras sed dui in neque fringilla faucibus. Sed dui tortor, cursus venenatis varius quis, tincidunt et diam. Nulla lacus velit, accumsan et metus vitae, mattis egestas lorem. Sed consectetur convallis ornare. Aliquam posuere est purus, et condimentum nunc condimentum in. Nullam eu sapien non mauris vestibulum ultricies. Aenean interdum metus in varius convallis.
2+
3+
Mauris vulputate metus eu sapien tempus maximus. Morbi nec diam consequat, vulputate augue at, gravida eros. Sed sed consectetur augue. Suspendisse nec nulla id mauris molestie eleifend id commodo mauris. Nulla congue a eros rhoncus viverra. Pellentesque vulputate felis quis nisl tempus faucibus. Pellentesque nisi purus, scelerisque non ligula sodales, feugiat dignissim magna. Sed eget leo ac tortor commodo maximus ut sit amet justo. Sed vitae libero eleifend nulla euismod posuere ac at nunc. Ut commodo nisl odio, eget cursus nisl fringilla facilisis. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
4+
5+
Fusce at tincidunt nulla. Mauris libero justo, maximus in mi a, mattis eleifend enim. Donec porta placerat tristique. Nam in gravida magna. Sed laoreet tortor tellus, eget varius justo fringilla eu. Ut ac massa arcu. Etiam dapibus aliquam ligula sed scelerisque. Suspendisse arcu purus, consequat vitae sem eget, ornare ultrices tellus. Nunc fringilla et arcu eget pretium. Pellentesque viverra lectus id velit pulvinar porta. Pellentesque sed pulvinar velit, quis euismod nibh.

tests/test_api.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import pytest
2+
from fastapi.testclient import TestClient
3+
from unittest.mock import MagicMock, patch
4+
from langgraph.checkpoint.memory import MemorySaver
5+
6+
import sys
7+
import os
8+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
9+
10+
# Mock the cache before importing the app to avoid Redis connection
11+
with patch('src.core.cache.init_llm_cache'):
12+
from src.app import app
13+
from src.core.api.chat import init_chat_dependencies, threads_store, threads_list
14+
15+
16+
# Mock agent for testing
17+
class MockAgent:
18+
async def astream_events(self, *args, **kwargs):
19+
yield {
20+
"event": "on_chat_model_stream",
21+
"data": {"chunk": MagicMock(content="Test response")},
22+
"metadata": {"langgraph_node": "model"}
23+
}
24+
25+
26+
@pytest.fixture(scope="function")
27+
def client():
28+
mock_agent = MockAgent()
29+
mock_checkpointer = MemorySaver()
30+
31+
init_chat_dependencies(mock_agent, mock_checkpointer)
32+
33+
# Create test client
34+
with TestClient(app) as c:
35+
yield c
36+
37+
38+
def test_status_endpoint(client):
39+
"""Test the status endpoint"""
40+
response = client.get("/status")
41+
assert response.status_code == 200
42+
assert response.json() == {"status": "connected"}
43+
44+
45+
def test_chat_endpoint_success(client):
46+
"""Test the chat endpoint with valid input"""
47+
# Clear any existing threads for clean test
48+
threads_store.clear()
49+
threads_list.clear()
50+
51+
payload = {
52+
"message": "Hello, how are you?",
53+
"thread_id": "test-thread-123"
54+
}
55+
56+
response = client.post("/chat", json=payload)
57+
assert response.status_code == 200
58+
assert "text/plain" in response.headers["content-type"]
59+
60+
# Check if thread was created
61+
assert "test-thread-123" in threads_store
62+
63+
64+
def test_create_thread(client):
65+
"""Test creating a new thread"""
66+
payload = {
67+
"title": "Test Thread"
68+
}
69+
70+
response = client.post("/threads", json=payload)
71+
assert response.status_code == 200
72+
73+
data = response.json()
74+
assert "id" in data
75+
assert data["title"] == "Test Thread"
76+
assert "created_at" in data
77+
assert "updated_at" in data
78+
assert data["message_count"] == 0
79+
80+
# Verify thread exists in store
81+
assert data["id"] in threads_store
82+
83+
84+
def test_get_thread(client):
85+
"""Test getting a specific thread"""
86+
# First create a thread
87+
payload = {"title": "Get Thread Test"}
88+
create_response = client.post("/threads", json=payload)
89+
thread_data = create_response.json()
90+
thread_id = thread_data["id"]
91+
92+
assert create_response.status_code == 200
93+
94+
# Get the thread
95+
response = client.get(f"/threads/{thread_id}")
96+
assert response.status_code == 200
97+
98+
data = response.json()
99+
assert data["id"] == thread_id
100+
assert data["title"] == "Get Thread Test"
101+
102+
103+
def test_get_nonexistent_thread(client):
104+
"""Test getting a non-existent thread"""
105+
response = client.get("/threads/nonexistent-thread-id")
106+
assert response.status_code == 404
107+
assert "Thread not found" in response.text
108+
109+
110+
def test_list_threads(client):
111+
"""Test listing threads"""
112+
# Clear existing threads
113+
threads_store.clear()
114+
threads_list.clear()
115+
116+
# Create a few threads
117+
for i in range(3):
118+
payload = {"title": f"Test Thread {i}"}
119+
client.post("/threads", json=payload)
120+
121+
response = client.get("/threads")
122+
assert response.status_code == 200
123+
124+
data = response.json()
125+
assert len(data) <= 3 # Should have at most 3 threads
126+
127+
titles = [t["title"] for t in data]
128+
for i in range(min(3, len(data))):
129+
assert f"Test Thread {i}" in titles
130+
131+
132+
def test_delete_thread(client):
133+
"""Test deleting a thread"""
134+
# Create a thread
135+
payload = {"title": "Delete Test Thread"}
136+
create_response = client.post("/threads", json=payload)
137+
thread_data = create_response.json()
138+
thread_id = thread_data["id"]
139+
140+
assert create_response.status_code == 200
141+
assert thread_id in threads_store
142+
143+
# Delete the thread
144+
response = client.delete(f"/threads/{thread_id}")
145+
assert response.status_code == 200
146+
assert response.json() == {"success": True}
147+
148+
# Verify thread is deleted
149+
assert thread_id not in threads_store
150+
assert thread_id not in threads_list
151+
152+
153+
def test_delete_nonexistent_thread(client):
154+
"""Test deleting a non-existent thread"""
155+
response = client.delete("/threads/nonexistent-thread-id")
156+
assert response.status_code == 404
157+
assert "Thread not found" in response.text
158+
159+
160+
def test_get_thread_messages(client):
161+
"""Test getting messages from a thread"""
162+
# Create a thread
163+
payload = {"title": "Messages Test Thread"}
164+
create_response = client.post("/threads", json=payload)
165+
thread_data = create_response.json()
166+
thread_id = thread_data["id"]
167+
168+
assert create_response.status_code == 200
169+
170+
# Get messages from the thread (should be empty initially)
171+
response = client.get(f"/threads/{thread_id}/messages")
172+
assert response.status_code == 200
173+
assert response.json() == []
174+

0 commit comments

Comments
 (0)