homelab-codex-ws/packages/kb-retrieval/tests/test_embed.py
oskar 51998fd891 feat(kb-retrieval): add embed_batch for /api/embed (faza mailowa Krok 1)
Batched embedding client (input as a list, batch 64 measured live at ~8-18
ms/chunk vs ~150-200 ms/chunk sequential through embed_chunk's
/api/embeddings) for jobs/mail-body-ingest -- embed_chunk stays the
single-text path for kb-query and paperless cyclic ingest. Validates the
response embedding count and each item's dimension (1024), raising
EmbeddingDimensionError (abort-run) on mismatch so a bad batch never silently
writes a mismatched vector (docs/kb/modules/05-faza-mailowa-plan.md, §4,
decision 5).
2026-07-23 17:06:49 +02:00

170 lines
5.6 KiB
Python

"""Unit tests for the Ollama embedding client -- no real HTTP, no real Ollama."""
from __future__ import annotations
import pytest
from kb_retrieval.embed import (
EmbeddingDimensionError,
_vector_literal,
check_ollama_health,
embed_batch,
embed_chunk,
)
class TestVectorLiteral:
def test_formats_as_bracketed_csv(self):
assert _vector_literal([0.1, 0.2, -0.3]) == "[0.1,0.2,-0.3]"
class _FakeEmbedResponse:
def __init__(self, payload, status=200):
self._payload = payload
self._status = status
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def json(self):
return self._payload
def raise_for_status(self):
if self._status >= 400:
raise RuntimeError(f"HTTP {self._status}")
class _FakeOllamaSession:
"""Serves a fixed embedding vector for every /api/embeddings POST, or errors by prompt."""
def __init__(self, dim=1024, fail_for=None):
self._dim = dim
self._fail_for = fail_for or set()
self.requests: list[dict] = []
def post(self, url, json):
self.requests.append({"url": url, "json": json})
if json["prompt"] in self._fail_for:
return _FakeEmbedResponse({}, status=500)
return _FakeEmbedResponse({"embedding": [0.01] * self._dim})
class TestEmbedChunk:
async def test_returns_embedding_and_elapsed(self):
session = _FakeOllamaSession(dim=1024)
embedding, elapsed = await embed_chunk(session, "http://fake-ollama", "bge-m3", "hello world")
assert len(embedding) == 1024
assert elapsed >= 0
assert session.requests == [
{"url": "http://fake-ollama/api/embeddings", "json": {"model": "bge-m3", "prompt": "hello world"}}
]
async def test_missing_embedding_key_raises(self):
class _EmptyResponse(_FakeEmbedResponse):
pass
class _Session:
def post(self, url, json):
return _EmptyResponse({})
with pytest.raises(ValueError):
await embed_chunk(_Session(), "http://fake-ollama", "bge-m3", "text")
class _FakeBatchResponse(_FakeEmbedResponse):
pass
class _FakeBatchOllamaSession:
"""Serves a fixed set of embeddings for every /api/embed POST."""
def __init__(self, dim=1024, n_override=None, bad_index=None):
self._dim = dim
self._n_override = n_override
self._bad_index = bad_index
self.requests: list[dict] = []
def post(self, url, json):
self.requests.append({"url": url, "json": json})
texts = json["input"]
n = self._n_override if self._n_override is not None else len(texts)
embeddings = [[0.01] * self._dim for _ in range(n)]
if self._bad_index is not None and self._bad_index < len(embeddings):
embeddings[self._bad_index] = [0.01] * (self._dim - 1)
return _FakeBatchResponse({"embeddings": embeddings})
class TestEmbedBatch:
async def test_returns_embeddings_and_elapsed(self):
session = _FakeBatchOllamaSession(dim=1024)
texts = ["hello", "world", "third"]
embeddings, elapsed = await embed_batch(session, "http://fake-ollama", "bge-m3", texts)
assert len(embeddings) == 3
assert all(len(e) == 1024 for e in embeddings)
assert elapsed >= 0
assert session.requests == [
{"url": "http://fake-ollama/api/embed", "json": {"model": "bge-m3", "input": texts}}
]
async def test_length_mismatch_raises_dimension_error(self):
session = _FakeBatchOllamaSession(dim=1024, n_override=2)
with pytest.raises(EmbeddingDimensionError):
await embed_batch(session, "http://fake-ollama", "bge-m3", ["a", "b", "c"])
async def test_wrong_dimension_on_one_item_raises(self):
session = _FakeBatchOllamaSession(dim=1024, bad_index=1)
with pytest.raises(EmbeddingDimensionError):
await embed_batch(session, "http://fake-ollama", "bge-m3", ["a", "b", "c"])
async def test_missing_embeddings_key_raises_value_error(self):
class _Session:
def post(self, url, json):
return _FakeBatchResponse({})
with pytest.raises(ValueError):
await embed_batch(_Session(), "http://fake-ollama", "bge-m3", ["a"])
class _FakeTagsResponse:
def __init__(self, status):
self.status = status
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
class _FakeHealthSession:
def __init__(self, status=200, raises=None):
self._status = status
self._raises = raises
def get(self, url, timeout=None):
if self._raises is not None:
raise self._raises
return _FakeTagsResponse(self._status)
class TestCheckOllamaHealth:
async def test_up_on_2xx(self):
session = _FakeHealthSession(status=200)
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is True
async def test_down_on_error_status(self):
session = _FakeHealthSession(status=500)
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is False
async def test_down_on_connection_error(self):
import aiohttp
session = _FakeHealthSession(raises=aiohttp.ClientConnectionError("refused"))
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is False
async def test_down_on_timeout(self):
session = _FakeHealthSession(raises=TimeoutError())
assert await check_ollama_health(session, "http://fake-ollama", 0.5) is False