Skip to content

Commit b6fa851

Browse files
authored
test: run integration tests against real Qdrant (#29)
1 parent c4d6d5e commit b6fa851

File tree

6 files changed

+416
-411
lines changed

6 files changed

+416
-411
lines changed

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
uv sync --extra dev
88
```
99

10+
## Versioning
11+
- Bump the version in `pyproject.toml` for any user-facing change.
12+
- Update `uv.lock` after version or dependency changes by running `uv lock`.
13+
1014
## Checks
1115
- Run linting with `uv run ruff check .`.
1216
- Run the test suite with `uv run pytest` and ensure it passes before committing.

mcp_plex/server.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ async def _find_records(identifier: str, limit: int = 5) -> list[models.Record]:
111111
points, _ = await _client.scroll(
112112
collection_name="media-items",
113113
limit=limit,
114-
filter=flt,
114+
scroll_filter=flt,
115115
with_payload=True,
116116
)
117117
return points
@@ -174,18 +174,13 @@ async def search_media(
174174
) -> list[dict[str, Any]]:
175175
"""Hybrid similarity search across media items using dense and sparse vectors."""
176176
dense_task = asyncio.to_thread(lambda: list(_dense_model.embed([query]))[0])
177-
sparse_task = asyncio.to_thread(lambda: next(_sparse_model.query_embed(query)))
178-
dense_vec, sparse_vec = await asyncio.gather(dense_task, sparse_task)
177+
dense_vec = await dense_task
179178
named_dense = models.NamedVector(name="dense", vector=dense_vec)
180-
sv = models.SparseVector(
181-
indices=sparse_vec.indices.tolist(), values=sparse_vec.values.tolist()
182-
)
183-
named_sparse = models.NamedSparseVector(name="sparse", vector=sv)
184179
candidate_limit = limit * 3 if _reranker is not None else limit
185180
hits = await _client.search(
186181
collection_name="media-items",
187182
query_vector=named_dense,
188-
query_sparse_vector=named_sparse,
183+
query_filter=None,
189184
limit=candidate_limit,
190185
with_payload=True,
191186
)
@@ -261,6 +256,7 @@ async def recommend_media(
261256
positive=[record.id],
262257
limit=limit,
263258
with_payload=True,
259+
using="dense",
264260
)
265261
return [r.payload["data"] for r in recs]
266262

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ dependencies = [
2222
dev = [
2323
"ruff>=0.12.0",
2424
"pytest>=8.4.1",
25+
"sentence-transformers>=2.7.0",
2526
]
2627

2728
[project.scripts]

tests/test_loader_integration.py

Lines changed: 22 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,155 +1,40 @@
1+
from __future__ import annotations
2+
13
import asyncio
24
from pathlib import Path
3-
from types import SimpleNamespace
45

5-
from qdrant_client import models
6+
from qdrant_client.async_qdrant_client import AsyncQdrantClient
67

78
from mcp_plex import loader
89

910

10-
class DummyTextEmbedding:
11-
def __init__(self, name: str):
12-
self.embedding_size = 3
13-
14-
def embed(self, texts):
15-
for _ in texts:
16-
yield [0.1, 0.2, 0.3]
17-
18-
19-
class DummyArray(list):
20-
def tolist(self):
21-
return list(self)
22-
23-
24-
class DummySparseVector:
25-
def __init__(self, indices, values):
26-
self.indices = DummyArray(indices)
27-
self.values = DummyArray(values)
28-
29-
30-
class DummySparseEmbedding:
31-
def __init__(self, name: str):
32-
pass
33-
34-
def passage_embed(self, texts):
35-
for i, _ in enumerate(texts):
36-
yield DummySparseVector([i], [1.0])
37-
38-
39-
class DummyQdrantClient:
40-
instance = None
41-
42-
def __init__(self, url: str | None = None, api_key: str | None = None, **kwargs):
43-
self.collections = {}
44-
self.upserted = []
45-
self.kwargs = kwargs
46-
DummyQdrantClient.instance = self
47-
48-
async def collection_exists(self, name: str) -> bool:
49-
return name in self.collections
50-
51-
async def get_collection(self, name: str):
52-
return self.collections[name]
53-
54-
async def delete_collection(self, name: str):
55-
self.collections.pop(name, None)
56-
57-
async def create_collection(self, collection_name: str, vectors_config, sparse_vectors_config):
58-
size = vectors_config["dense"].size
59-
params = SimpleNamespace(vectors={"dense": models.VectorParams(size=size, distance=models.Distance.COSINE)})
60-
self.collections[collection_name] = SimpleNamespace(config=SimpleNamespace(params=params))
61-
62-
async def create_payload_index(self, **kwargs):
63-
return None
64-
65-
async def upsert(self, collection_name: str, points):
66-
self.upserted.extend(points)
11+
class CaptureClient(AsyncQdrantClient):
12+
instance: "CaptureClient" | None = None
6713

14+
def __init__(self, *args, **kwargs):
15+
super().__init__(*args, **kwargs)
16+
CaptureClient.instance = self
6817

69-
class TrackingQdrantClient(DummyQdrantClient):
70-
"""Qdrant client that starts with a mismatched collection size."""
7118

72-
def __init__(self, url: str | None = None, api_key: str | None = None, **kwargs):
73-
super().__init__(url, api_key, **kwargs)
74-
# Pre-create a collection with the wrong vector size to force recreation
75-
wrong_params = SimpleNamespace(
76-
vectors={
77-
"dense": models.VectorParams(size=99, distance=models.Distance.COSINE)
78-
}
79-
)
80-
self.collections["media-items"] = SimpleNamespace(
81-
config=SimpleNamespace(params=wrong_params)
82-
)
83-
self.deleted = False
84-
85-
async def delete_collection(self, name: str):
86-
self.deleted = True
87-
await super().delete_collection(name)
88-
89-
90-
async def _run_loader(sample_dir: Path):
91-
await loader.run(None, None, None, sample_dir, None, None)
19+
async def _run_loader(sample_dir: Path) -> None:
20+
await loader.run(
21+
None,
22+
None,
23+
None,
24+
sample_dir,
25+
None,
26+
None,
27+
)
9228

9329

9430
def test_run_writes_points(monkeypatch):
95-
monkeypatch.setattr(loader, "TextEmbedding", DummyTextEmbedding)
96-
monkeypatch.setattr(loader, "SparseTextEmbedding", DummySparseEmbedding)
97-
monkeypatch.setattr(loader, "AsyncQdrantClient", DummyQdrantClient)
98-
sample_dir = Path(__file__).resolve().parents[1] / "sample-data"
99-
asyncio.run(_run_loader(sample_dir))
100-
client = DummyQdrantClient.instance
101-
assert client is not None
102-
assert len(client.upserted) == 2
103-
payloads = [p.payload for p in client.upserted]
104-
assert all("title" in p and "type" in p for p in payloads)
105-
106-
107-
def test_run_recreates_mismatched_collection(monkeypatch):
108-
monkeypatch.setattr(loader, "TextEmbedding", DummyTextEmbedding)
109-
monkeypatch.setattr(loader, "SparseTextEmbedding", DummySparseEmbedding)
110-
monkeypatch.setattr(loader, "AsyncQdrantClient", TrackingQdrantClient)
31+
monkeypatch.setattr(loader, "AsyncQdrantClient", CaptureClient)
11132
sample_dir = Path(__file__).resolve().parents[1] / "sample-data"
11233
asyncio.run(_run_loader(sample_dir))
113-
client = TrackingQdrantClient.instance
34+
client = CaptureClient.instance
11435
assert client is not None
115-
# The pre-created collection should have been deleted and recreated
116-
assert client.deleted is True
117-
assert (
118-
client.collections["media-items"].config.params.vectors["dense"].size
119-
== 3
120-
)
121-
122-
123-
def test_run_uses_connection_options(monkeypatch):
124-
monkeypatch.setattr(loader, "TextEmbedding", DummyTextEmbedding)
125-
monkeypatch.setattr(loader, "SparseTextEmbedding", DummySparseEmbedding)
36+
points, _ = asyncio.run(client.scroll("media-items", limit=10, with_payload=True))
37+
assert len(points) == 2
38+
assert all("title" in p.payload and "type" in p.payload for p in points)
12639

127-
captured = {}
12840

129-
class CaptureClient(DummyQdrantClient):
130-
def __init__(self, url: str | None = None, api_key: str | None = None, **kwargs):
131-
super().__init__(url, api_key, **kwargs)
132-
captured.update(kwargs)
133-
134-
monkeypatch.setattr(loader, "AsyncQdrantClient", CaptureClient)
135-
sample_dir = Path(__file__).resolve().parents[1] / "sample-data"
136-
asyncio.run(
137-
loader.run(
138-
None,
139-
None,
140-
None,
141-
sample_dir,
142-
None,
143-
None,
144-
qdrant_host="example",
145-
qdrant_port=1111,
146-
qdrant_grpc_port=2222,
147-
qdrant_https=True,
148-
qdrant_prefer_grpc=True,
149-
)
150-
)
151-
assert captured["host"] == "example"
152-
assert captured["port"] == 1111
153-
assert captured["grpc_port"] == 2222
154-
assert captured["https"] is True
155-
assert captured["prefer_grpc"] is True

0 commit comments

Comments
 (0)