Merge branch 'main' into anydoc-support

Conflicts, and how each was taken:

- application/core/settings.py — ours. The renamed OCR_ENABLED /
  OCR_ATTACHMENTS_ENABLED / OCR_MIN_CHARS_PER_PAGE accept main's
  DOCLING_OCR_* spellings as AliasChoices, so nothing is dropped.
- application/Dockerfile — both. Main's install layers plus the
  INSTALL_DOCLING build arg.
- application/parser/file/constants.py — both imports.
- deployment/docker-compose.yaml — both. The INSTALL_DOCLING /
  INSTALL_TESSERACT build args on backend and worker, and main's
  -Q docsgpt,parsing,embeddings, which query embedding needs.
- tests/conftest.py — theirs. Both sides fixed the same pytest-postgresql
  9.0.0 autocommit= breakage; main's spelling is the one already on main.
- application/requirements.txt — the comments claimed different reasons
  torch is in core. Main's is the true one now: it removed
  sentence-transformers, so docling is torch's only remaining consumer.

Two things the merge broke without conflicting:

- onnxruntime. This branch moved it out of core into the docling extra;
  main meanwhile made it the runtime local embeddings execute on
  (fastembed). Git took the deletion, leaving fastembed with no pinned
  runtime in a repo that pins everything. Restored to core, and no longer
  pinned twice from the extra.
- The frontend copy of ATTACHMENT_PARSER_EXTENSIONS. The backend list is
  derived and picked up the anydoc suffixes; the hand-kept frontend mirror
  did not, so the composer would refuse files the API accepts.
  tests/parser/file/test_constants.py is what caught it.

ruff, pytest (9897 passed), frontend build and docs build all pass. The
image build is unverified: no Docker daemon on this machine.
This commit is contained in:
Alex committed 2026-09-04 16:42:48 +01:00
commit 3947c66cda
97 files changed
+8661 -1049

No files matched your search

+131
View File
@@ -2369,6 +2369,20 @@ class TestGetStoreAttachmentUserError:
msg = _get_store_attachment_user_error(RuntimeError("oops"))
assert msg == "Failed to process file"
@pytest.mark.unit
def test_unsupported_type_message_is_rebuilt_from_the_filename(self):
"""Nothing is read off the exception, so its text cannot reach a response."""
from application.api.user.attachments.routes import (
_get_store_attachment_user_error,
)
from application.upload_limits import UnsupportedUploadTypeError
err = UnsupportedUploadTypeError("internals: /srv/app/tmp/staged-42")
msg = _get_store_attachment_user_error(err, "clip.mp4")
assert msg == "Unsupported file type: .mp4"
assert "/srv/app" not in msg
class TestRequireLiveSttRedisUnavailable:
"""Cover lines 99-102: Redis unavailable returns 503."""
@@ -2386,3 +2400,120 @@ class TestRequireLiveSttRedisUnavailable:
result = _require_live_stt_redis()
assert hasattr(result, "status_code")
assert result.status_code == 503
MP4_BYTES = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2avc1mp41\x00\x00\x00\x08free"
class TestStoreAttachmentTypeGate:
"""Unparseable uploads are refused before anything is stored or queued."""
@patch("application.api.user.tasks.store_attachment.delay")
def test_rejects_unsupported_file_type_before_storage(
self, mock_store_attachment, flask_app
):
from application.api.user.attachments.routes import StoreAttachment
app = Flask(__name__)
mock_storage = MagicMock()
with patch("application.api.user.base.storage", mock_storage), app.test_request_context(
"/api/store_attachment",
method="POST",
data={"file": (io.BytesIO(MP4_BYTES), "clip.mp4")},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = StoreAttachment().post()
assert _get_response_status(response) == 400
body = _get_response_json(response)
assert body["success"] is False
assert body["message"] == "Unsupported file type: .mp4"
assert body["errors"] == [
{"upload_index": 0, "filename": "clip.mp4", "error": "Unsupported file type: .mp4"}
]
mock_storage.save_file.assert_not_called()
mock_store_attachment.assert_not_called()
@patch("application.api.user.tasks.store_attachment.delay")
def test_rejects_a_binary_renamed_to_a_text_suffix(
self, mock_store_attachment, flask_app
):
""".txt has no parser, so it is judged on content like any other suffix."""
from application.api.user.attachments.routes import StoreAttachment
app = Flask(__name__)
mock_storage = MagicMock()
with patch("application.api.user.base.storage", mock_storage), app.test_request_context(
"/api/store_attachment",
method="POST",
data={"file": (io.BytesIO(MP4_BYTES), "notes.txt")},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = StoreAttachment().post()
assert _get_response_status(response) == 400
assert _get_response_json(response)["message"] == "Unsupported file type: .txt"
mock_storage.save_file.assert_not_called()
mock_store_attachment.assert_not_called()
@patch("application.api.user.tasks.store_attachment.delay")
def test_accepts_a_text_file_with_no_dedicated_parser(
self, mock_store_attachment, flask_app
):
"""A .py or .log is read by the plain-text fallthrough and must stay allowed."""
from application.api.user.attachments.routes import StoreAttachment
app = Flask(__name__)
mock_storage = MagicMock()
mock_storage.save_file.return_value = {"storage_type": "local"}
mock_store_attachment.return_value = SimpleNamespace(id="task-py")
with patch("application.api.user.base.storage", mock_storage), app.test_request_context(
"/api/store_attachment",
method="POST",
data={"file": (io.BytesIO(b"def main():\n return 1\n"), "main.py")},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = StoreAttachment().post()
assert _get_response_status(response) == 200
assert _get_response_json(response)["task_id"] == "task-py"
assert mock_store_attachment.call_count == 1
@patch("application.api.user.tasks.store_attachment.delay")
def test_batch_skips_unsupported_file_and_keeps_the_rest(
self, mock_store_attachment, flask_app
):
from application.api.user.attachments.routes import StoreAttachment
app = Flask(__name__)
mock_storage = MagicMock()
mock_storage.save_file.return_value = {"storage_type": "local"}
mock_store_attachment.return_value = SimpleNamespace(id="task-notes")
with patch("application.api.user.base.storage", mock_storage), app.test_request_context(
"/api/store_attachment",
method="POST",
data={
"file": [
(io.BytesIO(MP4_BYTES), "clip.mp4"),
(io.BytesIO(b"hello"), "notes.txt"),
]
},
content_type="multipart/form-data",
):
request.decoded_token = {"sub": "test_user"}
response = StoreAttachment().post()
assert _get_response_status(response) == 200
body = _get_response_json(response)
assert [task["upload_index"] for task in body["tasks"]] == [1]
assert body["tasks"][0]["filename"] == "notes.txt"
assert body["errors"] == [
{"upload_index": 0, "filename": "clip.mp4", "error": "Unsupported file type: .mp4"}
]
assert mock_storage.save_file.call_count == 1
assert mock_store_attachment.call_count == 1
@@ -95,6 +95,12 @@ class TestCreateWikiSource:
assert row["type"] == "wiki"
assert row["config"]["kind"] == "wiki"
assert int(row["tokens"]) == 0
# Wiki pages get embedded like any other source, so the row has to name
# the model that did it. NULL reads as "the legacy model" to the boot
# mismatch check, which then reports the source as stale forever.
from application.core.settings import settings
assert row["model"] == settings.EMBEDDINGS_NAME
# No seed content → no re-embed, and never any ingest/reingest task.
mock_reembed.assert_not_called()
mock_ingest.assert_not_called()
+85
View File
@@ -769,3 +769,88 @@ class TestSubmitFeedbackHappy:
response = SubmitFeedback().post()
assert response.status_code == 400
@pytest.mark.unit
class TestSubmitFeedbackWithApiKey:
"""api_key callers carry no JWT."""
def _seed_agent_with_key(self, pg_conn, owner, key):
from application.storage.db.repositories.agents import AgentsRepository
return AgentsRepository(pg_conn).create(owner, "widget", "published", key=key)
def _post(self, app, pg_conn, payload):
from application.api.user.conversations.routes import SubmitFeedback
with _patch_conversations_db(pg_conn), app.test_request_context("/api/feedback", method="POST", json=payload):
from flask import request
request.decoded_token = None
return SubmitFeedback().post()
def test_valid_key_rates_its_own_conversation(self, app, pg_conn):
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
owner, key = "owner-fb-key", "agent-key-ok"
self._seed_agent_with_key(pg_conn, owner, key)
repo = ConversationsRepository(pg_conn)
conv_id = str(repo.create(owner, name="via widget", api_key=key)["id"])
repo.append_message(conv_id, {"prompt": "p", "response": "r"})
response = self._post(
app,
pg_conn,
{
"feedback": "LIKE",
"question_index": 0,
"conversation_id": conv_id,
"api_key": key,
},
)
assert response.status_code == 200
fb = repo.get_messages(conv_id)[0].get("feedback")
assert fb and fb.get("text") == "like"
def test_key_cannot_rate_owner_conversation_it_did_not_create(self, app, pg_conn):
from application.storage.db.repositories.conversations import (
ConversationsRepository,
)
owner, key = "owner-fb-scope", "agent-key-scope"
self._seed_agent_with_key(pg_conn, owner, key)
# Owner's conversation, not created with the key.
conv_id = _seed_conversation(pg_conn, owner, name="owner private")
ConversationsRepository(pg_conn).append_message(conv_id, {"prompt": "p", "response": "r"})
response = self._post(
app,
pg_conn,
{
"feedback": "LIKE",
"question_index": 0,
"conversation_id": conv_id,
"api_key": key,
},
)
assert response.status_code == 404
def test_unknown_key_is_unauthorized(self, app, pg_conn):
conv_id = _seed_conversation(pg_conn, "owner-fb-bad")
response = self._post(
app,
pg_conn,
{
"feedback": "LIKE",
"question_index": 0,
"conversation_id": conv_id,
"api_key": "no-such-key",
},
)
assert response.status_code == 401
+21 -4
View File
@@ -62,16 +62,18 @@ from sqlalchemy import create_engine
_ALEMBIC_INI = Path(__file__).resolve().parent.parent / "application" / "alembic.ini"
def _migrate_template_db(host, port, user, dbname, password, **_loader_options) -> None:
def _migrate_template_db(host, port, user, dbname, password, **kwargs) -> None:
"""Run alembic ``upgrade head`` into the session's template database.
Called once per session by pytest-postgresql's ``load=`` hook (against
the template DB, before any test runs). Runs in a subprocess so the
parent process never imports application settings with this URI cached.
``**_loader_options`` absorbs keyword arguments newer pytest-postgresql
releases pass to loaders (9.0.0 added ``autocommit=``); alembic manages
its own connection, so they are irrelevant here.
``**kwargs`` swallows keywords newer plugin releases hand to loaders --
9.0.0 added ``autocommit``, which broke every DB test on a plugin bump
alone. They describe the connection the caller already gave us, so
ignoring them keeps one signature working across versions instead of
pinning the plugin back.
"""
url = (
f"postgresql+psycopg://{user}:{password or ''}@{host}:{port}/{dbname}"
@@ -162,6 +164,21 @@ def _no_real_redis(monkeypatch):
"""
monkeypatch.setattr("application.cache._redis_instance", None)
monkeypatch.setattr("application.cache._redis_creation_failed", True)
@pytest.fixture(autouse=True)
def _no_worker_delegation(monkeypatch):
"""Embed in-process during tests, the way CI has no worker to embed on.
``EMBEDDINGS_DELEGATE_TO_WORKER`` ships on, so an unmocked embed would
publish to a broker nobody is consuming and block for
``EMBEDDINGS_DELEGATE_TIMEOUT`` before failing -- a minute per call, and a
pass/fail that depends on whether the developer happens to have a worker
running. Tests covering delegation patch the setting back on themselves.
"""
from application.core.settings import settings
monkeypatch.setattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False, raising=False)
monkeypatch.setattr("application.cache._pubsub_redis_instance", None)
monkeypatch.setattr("application.cache._pubsub_redis_creation_failed", True)
+21
View File
@@ -514,6 +514,27 @@ class TestEmbeddingDim:
store = GraphStore.__new__(GraphStore)
assert store._embedding_dim() == 1536
def test_none_dimension_falls_back_to_default(self, monkeypatch):
"""A remote model outside the registry reports ``None``, not nothing.
``getattr`` with a default cannot catch that -- the attribute exists --
so the width reached the DDL as ``vector(None)``.
"""
from application.vectorstore import base as base_module
monkeypatch.setattr(base_module.settings, "EMBEDDINGS_BASE_URL", None)
fake_embedding = MagicMock()
fake_embedding.dimension = None
monkeypatch.setattr(
base_module.EmbeddingsSingleton,
"get_instance",
staticmethod(lambda *a, **k: fake_embedding),
)
monkeypatch.setattr(GraphStore, "_embedding_dim", _REAL_EMBEDDING_DIM)
store = GraphStore.__new__(GraphStore)
assert store._embedding_dim() == store_module.DEFAULT_NAME_EMBEDDING_DIM
def test_falls_back_to_default_dimension(self, monkeypatch):
from application.vectorstore import base as base_module
@@ -179,8 +179,12 @@ class TestPerRoundUsageRows:
list(handler.handle_streaming(agent, first, {}, []))
assert len(rows) == 2
assert rows[0] == {"prompt_tokens": 100, "generated_tokens": 10}
assert rows[1] == {"prompt_tokens": 200, "generated_tokens": 20}
# Provider-reported counts replace the estimates; the rest of the
# call record (e.g. ``model``) rides along, hence a subset check
# rather than dict equality.
counts = lambda row: {k: row[k] for k in ("prompt_tokens", "generated_tokens")} # noqa: E731
assert counts(rows[0]) == {"prompt_tokens": 100, "generated_tokens": 10}
assert counts(rows[1]) == {"prompt_tokens": 200, "generated_tokens": 20}
class TestPreferProviderUsageClaim:
+35
View File
@@ -2347,3 +2347,38 @@ class TestBuildConversationFromMessagesEmpty:
result = handler._build_conversation_from_messages(messages)
assert result is not None
assert len(result.get("queries", [])) >= 1
class TestFinishedLogCacheBins:
"""``cached_tokens`` / ``cache_write_tokens`` ride on the finish events
only when a provider reported them, so dashboards can compute a cache
hit rate without a sentinel zero polluting providers that never report."""
def test_stream_finished_carries_cache_bins_when_given(self, caplog):
import logging as _logging
llm = StubLLM()
with caplog.at_level(_logging.INFO, logger="root"):
llm._emit_stream_finished_log(
"m1",
prompt_tokens=100,
completion_tokens=5,
latency_ms=10,
cached_tokens=80,
cache_write_tokens=7,
)
evt = next(r for r in caplog.records if r.message == "llm_stream_finished")
assert evt.cached_tokens == 80
assert evt.cache_write_tokens == 7
def test_gen_finished_omits_cache_bins_when_none(self, caplog):
import logging as _logging
llm = StubLLM()
with caplog.at_level(_logging.INFO, logger="root"):
llm._emit_gen_finished_log(
"m1", prompt_tokens=100, completion_tokens=5, latency_ms=10
)
evt = next(r for r in caplog.records if r.message == "llm_gen_finished")
assert not hasattr(evt, "cached_tokens")
assert not hasattr(evt, "cache_write_tokens")
+56
View File
@@ -1550,3 +1550,59 @@ def test_responses_gen_stream_retries_without_tools(monkeypatch):
assert len(create.calls) == 2
assert "tools" in create.calls[0]
assert "tools" not in create.calls[1]
@pytest.mark.unit
def test_record_chat_usage_captures_cache_write_bin(monkeypatch):
"""Newer OpenAI-family deployments report cache writes next to cache reads."""
llm = _make_llm(monkeypatch, ModelCapabilities(api_flavor="chat_completions"))
llm._record_chat_usage(_ns(
prompt_tokens=10,
completion_tokens=7,
total_tokens=17,
prompt_tokens_details=_ns(cached_tokens=4, cache_write_tokens=6),
completion_tokens_details=None,
))
assert llm._last_usage["prompt_tokens_details"] == {
"cached_tokens": 4,
"cache_write_tokens": 6,
}
@pytest.mark.unit
def test_record_responses_metadata_captures_cache_write_bin(monkeypatch):
llm = _make_llm(monkeypatch, _responses_caps())
llm._record_responses_metadata(_ns(
id="resp_1",
usage=_ns(
input_tokens=10,
output_tokens=7,
total_tokens=17,
input_tokens_details=_ns(cached_tokens=0, cache_write_tokens=9),
output_tokens_details=None,
),
))
# A pure cache-write turn (first request of a prefix) has reads=0 and
# writes>0; both bins were reported, so both are recorded — a reported
# zero is "no cache hits", not "unknown".
assert llm._last_usage["prompt_tokens_details"] == {
"cached_tokens": 0,
"cache_write_tokens": 9,
}
@pytest.mark.unit
def test_record_responses_metadata_omits_unreported_cache_bins(monkeypatch):
"""A provider that says nothing about caching must persist as NULL, not 0."""
llm = _make_llm(monkeypatch, _responses_caps())
llm._record_responses_metadata(_ns(
id="resp_2",
usage=_ns(
input_tokens=10,
output_tokens=7,
total_tokens=17,
input_tokens_details=None,
output_tokens_details=None,
),
))
assert "prompt_tokens_details" not in llm._last_usage
+23
View File
@@ -0,0 +1,23 @@
import pytest
@pytest.fixture(scope="session")
def _mpnet_tokenizer():
"""Real WordPiece tokenizer; skipped when the hub is unreachable."""
pytest.importorskip("tokenizers")
from tokenizers import Tokenizer
try:
tokenizer = Tokenizer.from_pretrained("sentence-transformers/all-mpnet-base-v2")
except Exception as exc: # offline CI
pytest.skip(f"tokenizer unavailable: {exc}")
tokenizer.no_padding()
tokenizer.no_truncation()
return tokenizer
@pytest.fixture
def hf_counter(_mpnet_tokenizer):
from application.parser.tokenization import HuggingFaceCounter
return HuggingFaceCounter(_mpnet_tokenizer, "sentence-transformers/all-mpnet-base-v2")
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the shared file-extension constants."""
import re
from importlib.util import find_spec
from pathlib import Path
import pytest
from application.parser.file.bulk import get_default_file_extractor
from application.parser.file.constants import (
ATTACHMENT_PARSER_EXTENSIONS,
attachment_extension,
has_attachment_parser,
SUPPORTED_SOURCE_EXTENSIONS,
)
FRONTEND_CONSTANTS = (
Path(__file__).resolve().parents[3] / "frontend/src/constants/fileUpload.ts"
)
@pytest.mark.unit
def test_parser_extensions_match_the_extractor():
"""The list is a hand-kept literal; this is what keeps it honest.
``ATTACHMENT_PARSER_EXTENSIONS`` cannot import the extractor (that would
pull docling into the API process), so a parser added to
``get_default_file_extractor`` would otherwise stay refused as an
attachment — which is how .webp, .tiff, .vtt and .xml were first missed.
A suffix listed here but *without* a parser is the opposite failure: it
skips the content check and reaches the plain-text fallthrough, which is
how a binary named notes.txt got through.
"""
parser_suffixes = set(get_default_file_extractor())
assert parser_suffixes <= set(ATTACHMENT_PARSER_EXTENSIONS), (
"parsers exist for suffixes the attachment gate refuses: "
f"{sorted(parser_suffixes - set(ATTACHMENT_PARSER_EXTENSIONS))}"
)
if find_spec("docling") is not None:
assert parser_suffixes == set(ATTACHMENT_PARSER_EXTENSIONS), (
"listed as parser-backed but nothing parses them: "
f"{sorted(set(ATTACHMENT_PARSER_EXTENSIONS) - parser_suffixes)}"
)
@pytest.mark.unit
def test_frontend_mirrors_the_parser_extensions():
"""The composer gates uploads client-side; a divergent list refuses valid files.
``ATTACHMENT_PARSER_EXTENSIONS`` in the frontend is a hand-kept copy of
the backend's. Nothing but this test connects them, and .mdx, .xhtml and
.adoc were blocked in the UI while the API accepted them before it
existed.
"""
if not FRONTEND_CONSTANTS.exists():
pytest.skip("frontend sources not present in this checkout")
source = FRONTEND_CONSTANTS.read_text(encoding="utf-8")
match = re.search(
r"ATTACHMENT_PARSER_EXTENSIONS:\s*readonly string\[\]\s*=\s*\[(.*?)\]",
source,
re.DOTALL,
)
assert match, "ATTACHMENT_PARSER_EXTENSIONS not found in fileUpload.ts"
frontend_extensions = set(re.findall(r"'(\.[^']+)'", match.group(1)))
assert frontend_extensions == set(ATTACHMENT_PARSER_EXTENSIONS), (
"frontend and backend attachment lists disagree — "
f"backend only: {sorted(set(ATTACHMENT_PARSER_EXTENSIONS) - frontend_extensions)}, "
f"frontend only: {sorted(frontend_extensions - set(ATTACHMENT_PARSER_EXTENSIONS))}"
)
@pytest.mark.unit
def test_source_ingestion_types_are_parser_backed_apart_from_text():
"""Source ingestion's own list must not smuggle a parserless suffix past the sniff."""
assert set(SUPPORTED_SOURCE_EXTENSIONS) - set(ATTACHMENT_PARSER_EXTENSIONS) == {
".txt"
}
@pytest.mark.unit
@pytest.mark.parametrize(
("filename", "expected"),
[
("Photo.JPG", ".jpg"),
("/tmp/dir.d/report.PDF", ".pdf"),
("archive.tar.gz", ".gz"),
("Dockerfile", ""),
(".env", ""),
("trailing.", "."),
(None, ""),
],
)
def test_attachment_extension(filename, expected):
assert attachment_extension(filename) == expected
@pytest.mark.unit
def test_has_attachment_parser_is_case_insensitive_and_excludes_zip():
assert has_attachment_parser("Report.PDF")
assert has_attachment_parser("scan.WebP")
# No parser: admitted (or not) on content instead, never on the suffix.
assert not has_attachment_parser("archive.zip")
assert not has_attachment_parser("main.py")
assert not has_attachment_parser("clip.mp4")
# .txt is the plain-text fallthrough itself, not a parser.
assert not has_attachment_parser("notes.txt")
+74 -3
View File
@@ -108,18 +108,89 @@ class TestSplitDocument:
result = chunker.split_document(doc)
assert len(result) > 1
# First chunk should contain header
assert "h1" in result[0].text
# Only the first: this is what duplicate_headers=False means.
assert all("h1" not in chunk.text for chunk in result[1:])
def test_split_duplicates_header(self):
"""Every chunk carries the header when the flag is set.
Asserting only the first chunk passed even while the flag did nothing:
the implementation cleared the header after the first iteration, so
duplicate_headers was unreachable.
"""
chunker = Chunker(max_tokens=50, min_tokens=5, duplicate_headers=True)
text = "h1\nh2\nh3\n" + "word " * 200
doc = Document(text=text, doc_id="doc1")
result = chunker.split_document(doc)
assert len(result) > 1
# First chunk should contain header
assert "h1" in result[0].text
assert all("h1" in chunk.text for chunk in result)
def test_oversized_header_does_not_multiply_chunks(self):
"""A header past the budget used to leave a one-token body budget.
``max(1, max_tokens - header_tokens)`` bottomed out at 1, so the body
was cut into one chunk per token -- each still over ``max_tokens``,
since the whole header was prepended to it.
"""
chunker = Chunker(max_tokens=50, min_tokens=5, duplicate_headers=True)
header = ("verylongheaderword " * 40) + "h2\nh3\n"
body = "word " * 100
doc = Document(text=f"{header}\n{body}", doc_id="doc1")
result = chunker.split_document(doc)
body_tokens = chunker.counter.count(body)
assert len(result) <= body_tokens // 10, "chunk count must track the budget"
for chunk in result:
assert chunk.extra_info["token_count"] <= chunker.max_tokens
assert "".join(chunk.text for chunk in result) == doc.text
def test_header_taking_most_of_the_budget_is_not_duplicated(self):
"""Duplication is dropped when it would leave almost no room for body.
Below a quarter of the budget every chunk is mostly repeated header,
which multiplies the chunk count without adding retrievable text.
"""
chunker = Chunker(max_tokens=50, min_tokens=5, duplicate_headers=True)
header = ("headerword " * 20) + "h2\nh3\n"
doc = Document(text=f"{header}\n" + "word " * 200, doc_id="doc1")
result = chunker.split_document(doc)
assert len(result) > 1
assert "headerword" in result[0].text
assert all("headerword" not in chunk.text for chunk in result[1:])
def test_header_only_document_is_not_dropped(self):
"""The loop only ever emitted the header attached to a body piece.
With no body there was no piece, so an entire document disappeared
from the index with no error and no log line.
"""
chunker = Chunker(max_tokens=50, min_tokens=1, duplicate_headers=False)
text = "h1\nh2\nh3\n"
doc = Document(text=text, doc_id="doc1")
result = chunker.split_document(doc)
assert len(result) == 1
assert result[0].text == text
def test_header_only_document_keeps_its_text_through_chunk(self):
"""The reachable shape: three lines that alone exceed the budget."""
chunker = Chunker(max_tokens=5, min_tokens=1, duplicate_headers=False)
text = "h1\nh2\nh3\n"
result = chunker.chunk([Document(text=text, doc_id="doc1")])
assert result, "the document must not vanish"
assert "".join(chunk.text for chunk in result) == text
def test_zero_max_tokens_does_not_split_per_token(self):
chunker = Chunker(max_tokens=0, min_tokens=1)
assert chunker.max_tokens == 1
def test_split_preserves_embedding(self):
chunker = Chunker(max_tokens=50, min_tokens=5)
+4 -2
View File
@@ -15,11 +15,13 @@ from application.parser.chunking_strategies import (
SemanticChunker,
)
from application.parser.schema.base import Document
from application.utils import get_encoding
from application.parser.tokenization import get_token_counter
def _tok(text: str) -> int:
return len(get_encoding().encode(text))
# Chunk budgets are enforced in the embedding model's tokenizer, so a
# test that measures them in cl100k measures the wrong thing.
return get_token_counter().count(text)
@pytest.mark.unit
+300
View File
@@ -0,0 +1,300 @@
"""Chunk sizes must be counted in the embedding model's units, and splitting
must never rewrite the text it splits."""
import pytest
from application.parser import tokenization
from application.parser.tokenization import (
HuggingFaceCounter,
TiktokenCounter,
get_token_counter,
)
SAMPLES = [
"Hello World: DocsGPT ANSWERS Questions.",
"The quick brown fox jumps over the lazy dog. " * 40,
"Comment configurer l'authentification avec une clé API ?",
"def embed(text: str) -> list[float]:\n return model.encode(text)\n",
"Ünïcödé — em-dashes, curly “quotes”, and 日本語 text.",
"a,b,c\n1,2,3\n4,5,6\n" * 30,
]
class _StubEncoding:
"""Whitespace tokenizer standing in for tiktoken."""
def encode_ordinary(self, text):
return [ord(c) for c in text]
def decode(self, ids):
return "".join(chr(i) for i in ids)
def decode_with_offsets(self, ids):
# One token per character, so each token starts where the last ended.
return self.decode(ids), list(range(len(ids)))
@pytest.fixture(autouse=True)
def _clear_cache():
tokenization.reset_cache()
yield
tokenization.reset_cache()
class TestSplittingPreservesText:
"""The property that protects every stored document."""
@pytest.mark.parametrize("text", SAMPLES)
def test_tiktoken_split_reassembles_exactly(self, text, monkeypatch):
monkeypatch.setattr(tokenization, "get_encoding", _StubEncoding)
counter = TiktokenCounter()
pieces = counter.split(text, 7)
assert "".join(pieces) == text
@pytest.mark.parametrize("text", SAMPLES)
def test_hf_split_reassembles_exactly(self, text, hf_counter):
"""WordPiece lowercases on decode, so splitting must slice, not decode."""
pieces = hf_counter.split(text, 7)
assert "".join(pieces) == text
def test_hf_split_does_not_lowercase(self, hf_counter):
text = "Hello World: DocsGPT ANSWERS Questions."
assert "".join(hf_counter.split(text, 3)) == text
assert "DocsGPT" in "".join(hf_counter.split(text, 3))
@pytest.mark.parametrize("text", SAMPLES)
def test_every_piece_is_within_budget(self, text, hf_counter):
budget = 10
for piece in hf_counter.split(text, budget):
# The final piece can absorb trailing characters the tokenizer
# dropped, so allow a small overshoot there only.
assert hf_counter.count(piece) <= budget + 2
def test_short_text_is_returned_whole(self, hf_counter):
assert hf_counter.split("short", 100) == ["short"]
def test_empty_text_yields_no_pieces(self, hf_counter):
assert hf_counter.split("", 10) == []
def test_zero_budget_is_clamped_not_infinite_loop(self, hf_counter):
pieces = hf_counter.split("some words here to split", 0)
assert "".join(pieces) == "some words here to split"
class TestCounting:
def test_counts_differ_between_tokenizers(self, hf_counter, monkeypatch):
"""The whole point: mpnet and cl100k disagree, so units matter."""
monkeypatch.setattr(tokenization, "get_encoding", _StubEncoding)
text = "internationalisation tokenization"
assert hf_counter.count(text) != TiktokenCounter().count(text)
def test_empty_text_counts_zero(self, hf_counter):
assert hf_counter.count("") == 0
class TestSelection:
def test_registered_model_uses_its_own_tokenizer(self):
counter = get_token_counter("huggingface_sentence-transformers/all-mpnet-base-v2")
assert isinstance(counter, HuggingFaceCounter)
assert counter.name == "sentence-transformers/all-mpnet-base-v2"
def test_openai_model_falls_back_to_cl100k(self):
"""OpenAI models are served remotely and genuinely count cl100k."""
assert isinstance(get_token_counter("openai_text-embedding-ada-002"), TiktokenCounter)
def test_unreachable_tokenizer_falls_back_rather_than_raising(self, monkeypatch):
monkeypatch.setattr(tokenization, "_load_hf_counter", lambda repo: None)
assert isinstance(get_token_counter("granite-311m"), TiktokenCounter)
def test_counter_is_cached_per_model(self):
first = get_token_counter("granite-311m")
assert get_token_counter("granite-311m") is first
class TestTiktokenSplitAgainstRealCl100k:
"""The stub above is one token per character, so it can never place a cut
inside a character. Real cl100k can, and that is the case that corrupted
text: decoding each window on its own turns a straddled multi-byte
character into U+FFFD on both sides of the cut."""
@pytest.fixture
def real_counter(self):
try:
counter = TiktokenCounter()
counter.count("probe")
except Exception as exc: # offline CI, same policy as the HF fixture
pytest.skip(f"cl100k encoding unavailable: {exc}")
return counter
# 2000 is the shipped default max_tokens, 384 mpnet's window; the small
# values place many more cuts per unit of text.
@pytest.mark.parametrize("window", [1, 2, 3, 7, 128, 384, 2000])
@pytest.mark.parametrize(
"text",
[
"日本語のテキストです。絵文字も🎉あります。",
"検索は自然言語でできます。" * 200,
"Здравствуйте, как настроить аутентификацию?",
"🎉🎊✨🚀🔥💡📚🧠" * 50,
"Ünïcödé — em-dashes, curly “quotes”, and 日本語 text.",
],
ids=["ja-short", "ja-long", "ru", "emoji", "mixed"],
)
def test_split_reassembles_exactly(self, real_counter, text, window):
pieces = real_counter.split(text, window)
assert "".join(pieces) == text
@pytest.mark.parametrize("window", [1, 3, 128, 2000])
def test_split_never_emits_a_replacement_character(self, real_counter, window):
text = "検索は自然言語でできます。絵文字も🎉あります。" * 100
assert "�" not in "".join(real_counter.split(text, window))
def test_first_window_budget_is_honoured_and_lossless(self, real_counter):
text = "日本語のテキストです。" * 50
pieces = real_counter.split(text, 20, first_max_tokens=5)
assert "".join(pieces) == text
assert real_counter.count(pieces[0]) <= 5
class TestTiktokenCounterEdges:
"""The cl100k path is the fallback, so its edges matter as much."""
@pytest.fixture
def counter(self, monkeypatch):
monkeypatch.setattr(tokenization, "get_encoding", _StubEncoding)
return TiktokenCounter()
def test_empty_text_counts_zero_and_splits_to_nothing(self, counter):
assert counter.count("") == 0
assert counter.split("", 10) == []
def test_text_within_budget_is_returned_whole(self, counter):
assert counter.split("abc", 10) == ["abc"]
def test_first_window_can_be_smaller_than_the_rest(self, counter):
"""A header eats into the first chunk's budget only."""
pieces = counter.split("abcdefghij", 4, first_max_tokens=2)
assert pieces[0] == "ab"
assert "".join(pieces) == "abcdefghij"
class TestCounterContract:
def test_base_class_requires_an_implementation(self):
base = tokenization.TokenCounter()
with pytest.raises(NotImplementedError):
base.count("x")
with pytest.raises(NotImplementedError):
base.split("x", 1)
class TestFallbackWhenTokenizerUnavailable:
def test_load_failure_returns_none_rather_than_raising(self, monkeypatch, caplog):
"""Chunking must survive an offline host or a bad repo name."""
import builtins
real_import = builtins.__import__
def boom(name, *args, **kwargs):
if name == "tokenizers":
raise ImportError("no tokenizers here")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", boom)
assert tokenization._load_hf_counter("some/repo") is None
def test_selection_falls_back_to_cl100k_on_failure(self, monkeypatch):
monkeypatch.setattr(tokenization, "_load_hf_counter", lambda repo: None)
assert isinstance(get_token_counter("granite-97m"), TiktokenCounter)
def test_reset_cache_forces_reselection(self, monkeypatch):
first = get_token_counter("granite-311m")
tokenization.reset_cache()
monkeypatch.setattr(tokenization, "_load_hf_counter", lambda repo: None)
assert get_token_counter("granite-311m") is not first
class TestOffsetsWithoutSpans:
"""Some tokenizers emit ``(0, 0)`` for specials or normalised-away chars.
Those tokens consume budget but point at no text, so the splitter has to
skip the window rather than emit an empty piece or lose the tail.
"""
class _Encoded:
def __init__(self, offsets):
self.offsets = offsets
self.ids = list(range(len(offsets)))
class _Tokenizer:
def __init__(self, offsets):
self._offsets = offsets
def encode(self, text, add_special_tokens=False):
return TestOffsetsWithoutSpans._Encoded(self._offsets)
def _counter(self, offsets):
return HuggingFaceCounter(self._Tokenizer(offsets), "stub")
def test_span_less_windows_are_skipped_not_emitted_empty(self):
# Two real tokens, then a window of pure (0, 0) padding-like entries.
counter = self._counter([(0, 2), (2, 4), (0, 0), (0, 0)])
pieces = counter.split("abcd", 2)
assert "" not in pieces
assert "".join(pieces) == "abcd"
def test_trailing_text_is_never_dropped(self):
"""Offsets that stop short of the string must not lose the remainder."""
counter = self._counter([(0, 1), (1, 2), (2, 3)])
pieces = counter.split("abcdef", 2)
assert "".join(pieces) == "abcdef"
def test_all_span_less_offsets_still_return_the_text(self):
counter = self._counter([(0, 0), (0, 0), (0, 0)])
assert "".join(counter.split("abc", 1)) == "abc"
class TestUnknownTokenCollapse:
"""A tokenizer that folds a long unbroken run into one ``[UNK]``.
WordPiece gives up on any word longer than ``max_input_chars_per_word``
and emits a single unknown token for it. Counting that as one token makes
a base64 blob or a minified bundle look tiny, so the chunker never splits
it and an oversized chunk reaches the embedding server.
"""
class _CollapsingEncoding:
"""One token per whitespace-separated word, however long the word."""
def __init__(self, text):
self.ids = []
self.offsets = []
cursor = 0
for word in text.split(" "):
if word:
self.ids.append(0)
self.offsets.append((cursor, cursor + len(word)))
cursor += len(word) + 1
class _CollapsingTokenizer:
def encode(self, text, add_special_tokens=False):
return TestUnknownTokenCollapse._CollapsingEncoding(text)
def _counter(self):
return tokenization.HuggingFaceCounter(self._CollapsingTokenizer(), "stub")
def test_long_unbroken_run_is_charged_by_its_span(self):
counter = self._counter()
assert counter.count("a" * 9000) > 100
def test_ordinary_prose_is_unaffected(self):
counter = self._counter()
text = "the quick brown fox jumps over the lazy dog"
assert counter.count(text) == 9
def test_split_bounds_a_collapsed_run(self):
counter = self._counter()
text = "a" * 9000
pieces = counter.split(text, 20)
assert "".join(pieces) == text, "split must not lose or alter text"
assert len(pieces) > 1, "a collapsed run must still be cut into pieces"
assert all(counter.count(p) <= 20 for p in pieces)
+96
View File
@@ -0,0 +1,96 @@
"""Model pre-fetching, which the Docker build runs to bake artifacts in."""
import sys
import types
from unittest.mock import MagicMock, patch
import pytest
from application.scripts import prefetch_models
from application.vectorstore.model_registry import GRANITE_311M, MPNET, OPENAI_ADA_002
@pytest.fixture
def fake_fastembed():
"""Stand in for FastEmbed so nothing is downloaded."""
text_embedding = MagicMock()
pooling = types.SimpleNamespace(CLS="CLS", MEAN="MEAN")
module = types.ModuleType("fastembed")
module.TextEmbedding = text_embedding
desc = types.ModuleType("fastembed.common.model_description")
desc.PoolingType = pooling
desc.ModelSource = lambda hf=None: {"hf": hf}
with patch.dict(
sys.modules,
{"fastembed": module, "fastembed.common.model_description": desc},
):
yield text_embedding
class TestPrefetch:
def test_defaults_cover_legacy_and_new_install(self):
"""An upgraded image must still serve mpnet; a new one needs granite."""
assert MPNET.name in prefetch_models.DEFAULT_MODELS
assert GRANITE_311M.name in prefetch_models.DEFAULT_MODELS
def test_fetches_named_models(self, fake_fastembed):
fetched = prefetch_models.prefetch([GRANITE_311M.name])
assert fetched == [GRANITE_311M.repo]
assert fake_fastembed.call_args.kwargs["model_name"] == GRANITE_311M.repo
def test_registers_with_registry_pooling_and_dim(self, fake_fastembed):
prefetch_models.prefetch([GRANITE_311M.name])
kwargs = fake_fastembed.add_custom_model.call_args.kwargs
assert kwargs["model"] == GRANITE_311M.repo
assert kwargs["pooling"] == "CLS"
assert kwargs["dim"] == GRANITE_311M.dimension
assert kwargs["model_file"] == GRANITE_311M.onnx_file
def test_mean_pooled_model_registers_as_mean(self, fake_fastembed):
prefetch_models.prefetch([MPNET.name])
assert fake_fastembed.add_custom_model.call_args.kwargs["pooling"] == "MEAN"
def test_aliases_resolve(self, fake_fastembed):
assert prefetch_models.prefetch(["granite-311m"]) == [GRANITE_311M.repo]
def test_cache_dir_is_forwarded(self, fake_fastembed):
prefetch_models.prefetch([MPNET.name], cache_dir="/models")
assert fake_fastembed.call_args.kwargs["cache_dir"] == "/models"
def test_cache_dir_omitted_when_absent(self, fake_fastembed):
prefetch_models.prefetch([MPNET.name])
assert "cache_dir" not in fake_fastembed.call_args.kwargs
def test_remote_only_model_is_skipped(self, fake_fastembed):
"""OpenAI embeddings have no local artifacts to cache."""
assert prefetch_models.prefetch([OPENAI_ADA_002.name]) == []
fake_fastembed.assert_not_called()
def test_unknown_model_fails_loudly(self, fake_fastembed):
"""A silent skip at build time is a download at run time, offline."""
with pytest.raises(SystemExit) as excinfo:
prefetch_models.prefetch(["nope/nope"])
assert "nope/nope" in str(excinfo.value)
assert MPNET.name in str(excinfo.value)
def test_several_models_in_one_run(self, fake_fastembed):
fetched = prefetch_models.prefetch([MPNET.name, GRANITE_311M.name])
assert fetched == [MPNET.repo, GRANITE_311M.repo]
class TestMain:
def test_no_args_fetches_the_defaults(self, fake_fastembed):
with patch.object(prefetch_models, "prefetch", return_value=[]) as spy:
assert prefetch_models.main([]) == 0
assert spy.call_args.args[0] == list(prefetch_models.DEFAULT_MODELS)
def test_explicit_args_override_the_defaults(self, fake_fastembed):
with patch.object(prefetch_models, "prefetch", return_value=[]) as spy:
prefetch_models.main(["granite-97m"])
assert spy.call_args.args[0] == ["granite-97m"]
def test_cache_dir_read_from_environment(self, fake_fastembed, monkeypatch):
monkeypatch.setenv("EMBEDDINGS_CACHE_DIR", "/app/models")
with patch.object(prefetch_models, "prefetch", return_value=[]) as spy:
prefetch_models.main(["granite-97m"])
assert spy.call_args.args[1] == "/app/models"
+534
View File
@@ -0,0 +1,534 @@
"""Re-embed script: CLI contract, orchestration, and the FAISS rebuild path."""
from unittest.mock import MagicMock, patch
import pytest
from application.scripts import reembed
def paginating_cursor(chunk_rows, *, graph_rows=(), graph_table=("graph_nodes",)):
"""A cursor answering the reads ``reembed_pgvector`` issues, from memory.
The chunk read is paginated by keyset, so a cursor that returns the same
page for every ``fetchall`` never terminates. Returns the cursor and the
mutable table backing it.
Args:
chunk_rows: ``(id, text)`` rows for the source's chunk table.
graph_rows: ``(id, name)`` rows for ``graph_nodes``.
graph_table: What ``to_regclass`` reports; ``(None,)`` for absent.
Returns:
``(cursor, tables)``, where ``tables["chunks"]`` and
``tables["graph"]`` can be reassigned to change what is served.
"""
tables = {"chunks": list(chunk_rows), "graph": list(graph_rows)}
pending = {"rows": []}
cursor = MagicMock()
def execute(query, params=None):
text = str(query)
if "to_regclass" in text:
pending["rows"] = [graph_table]
elif "FROM graph_nodes" in text:
pending["rows"] = list(tables["graph"])
elif "count(*)" in text:
pending["rows"] = [(len(tables["chunks"]),)]
elif "id > %s" in text:
_, after_id, limit = params
pending["rows"] = [r for r in tables["chunks"] if r[0] > after_id][:limit]
else:
pending["rows"] = list(tables["chunks"])[: params[1]]
cursor.execute.side_effect = execute
cursor.fetchall.side_effect = lambda: pending["rows"]
cursor.fetchone.side_effect = lambda: pending["rows"][0] if pending["rows"] else None
return cursor, tables
class TestCLI:
def test_defaults(self):
args = reembed.build_parser().parse_args([])
assert args.dry_run is False
assert args.sources is None
assert args.batch_size == reembed.DEFAULT_BATCH_SIZE
def test_unsupported_store_exits_without_touching_anything(self):
with patch.object(reembed.settings, "VECTOR_STORE", "qdrant", create=True):
with patch.object(reembed, "run") as run:
assert reembed.main([]) == 2
run.assert_not_called()
def test_supported_stores_are_pgvector_and_faiss(self):
assert set(reembed.SUPPORTED_STORES) == {"pgvector", "faiss"}
def test_sources_are_split_and_trimmed(self):
with patch.object(reembed.settings, "VECTOR_STORE", "faiss", create=True):
with patch.object(reembed, "run", return_value=0) as run:
reembed.main(["--sources", " a , b ,, c "])
assert run.call_args.args[1] == ["a", "b", "c"]
def test_batch_size_is_clamped_to_at_least_one(self):
with patch.object(reembed.settings, "VECTOR_STORE", "faiss", create=True):
with patch.object(reembed, "run", return_value=0) as run:
reembed.main(["--batch-size", "0"])
assert run.call_args.args[2] == 1
class TestRun:
def test_no_sources_is_a_clean_exit(self):
with patch.object(reembed, "list_source_ids", return_value=[]):
assert reembed.run("faiss", None, 8, False) == 0
def test_processes_every_discovered_source(self):
with patch.object(reembed, "list_source_ids", return_value=["a", "b"]):
with patch.object(reembed, "reembed_faiss", return_value=(3, 3)) as handler:
assert reembed.run("faiss", None, 8, False) == 0
assert [call.args[0] for call in handler.call_args_list] == ["a", "b"]
def test_explicit_sources_skip_discovery(self):
with patch.object(reembed, "list_source_ids") as discover:
with patch.object(reembed, "reembed_faiss", return_value=(1, 1)):
reembed.run("faiss", ["only-this"], 8, False)
discover.assert_not_called()
def test_one_failing_source_does_not_stop_the_others(self):
def handler(source_id, batch_size, dry_run):
if source_id == "bad":
raise RuntimeError("boom")
return (1, 1)
with patch.object(reembed, "reembed_faiss", side_effect=handler) as spy:
code = reembed.run("faiss", ["good", "bad", "also-good"], 8, False)
assert code == 1, "a failure must be reported in the exit code"
assert spy.call_count == 3, "later sources must still be attempted"
def test_dry_run_is_reported_as_success(self):
with patch.object(reembed, "reembed_faiss", return_value=(5, 0)):
assert reembed.run("faiss", ["a"], 8, True) == 0
def test_pgvector_uses_the_pgvector_handler(self):
with patch.object(reembed, "reembed_pgvector", return_value=(1, 1)) as handler:
reembed.run("pgvector", ["a"], 8, False)
handler.assert_called_once()
class TestFaissRebuild:
@pytest.fixture
def stores(self):
"""An existing store to read from and the rebuilt one written back."""
existing = MagicMock()
existing.get_chunks.return_value = [
{"doc_id": "1", "text": "alpha", "metadata": {"i": 0}},
{"doc_id": "2", "text": "beta", "metadata": {"i": 1}},
]
rebuilt = MagicMock()
with patch.object(
reembed.VectorCreator, "create_vectorstore", side_effect=[existing, rebuilt]
) as factory:
yield existing, rebuilt, factory
def test_rebuilds_from_stored_text_and_saves(self, stores):
existing, rebuilt, factory = stores
seen, written = reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
assert (seen, written) == (2, 2)
rebuilt.save_local.assert_called_once()
docs = factory.call_args_list[1].kwargs["docs_init"]
assert [d.page_content for d in docs] == ["alpha", "beta"]
assert [d.metadata for d in docs] == [{"i": 0}, {"i": 1}]
def test_embeddings_key_comes_from_settings(self, stores):
"""A placeholder here is sent as the server's bearer token."""
_, _, factory = stores
with patch.object(reembed.settings, "EMBEDDINGS_KEY", "sk-real", create=True):
reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
keys = [call.kwargs.get("embeddings_key") for call in factory.call_args_list]
assert keys == ["sk-real", "sk-real"]
def test_chunk_ids_are_preserved(self, stores):
"""Re-embedding must not renumber chunks.
Fresh ids orphan every GraphRAG ``graph_node_chunks`` row for the
source and invalidate any id a client already holds.
"""
_, _, factory = stores
reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
assert factory.call_args_list[1].kwargs["ids"] == ["1", "2"]
def test_existing_index_is_opened_past_the_dimension_check(self, stores):
"""A width change is the main reason to run this script.
``assert_embedding_dimensions`` refuses to open an index whose width
differs from the configured model -- and its error message recommends
this script, so without the opt-out the advice failed on every source.
The chunk text lives in the sidecar, so reading it needs no match.
"""
_, _, factory = stores
reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
assert factory.call_args_list[0].kwargs["skip_dimension_check"] is True
# The rebuild writes the new width, so it must still be checked.
assert "skip_dimension_check" not in factory.call_args_list[1].kwargs
def test_batch_size_is_forwarded_to_the_rebuild(self, stores):
"""On a remote embeddings server the whole index is otherwise one POST."""
_, _, factory = stores
reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
assert factory.call_args_list[1].kwargs["batch_size"] == 8
def test_existing_index_is_not_deleted(self, stores):
"""The rebuild must not destroy the old index before the new one exists."""
existing, rebuilt, _ = stores
reembed.reembed_faiss("s1", batch_size=8, dry_run=False)
existing.delete_index.assert_not_called()
def test_dry_run_reads_but_never_rebuilds(self):
existing = MagicMock()
existing.get_chunks.return_value = [{"doc_id": "1", "text": "a", "metadata": {}}]
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=existing
) as factory:
seen, written = reembed.reembed_faiss("s1", batch_size=8, dry_run=True)
assert (seen, written) == (1, 0)
assert factory.call_count == 1, "no rebuild store may be constructed"
def test_empty_index_is_a_no_op(self):
existing = MagicMock()
existing.get_chunks.return_value = []
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=existing
):
assert reembed.reembed_faiss("s1", batch_size=8, dry_run=False) == (0, 0)
class TestPgvectorWithoutTheExtension:
"""Mocked pgvector paths.
The live tests in ``test_reembed_pgvector_live`` skip wherever the cluster
has no pgvector build -- which includes CI -- so the SQL shape and the
batching contract are pinned here too.
"""
@pytest.fixture
def store(self):
"""A store whose cursor serves the paginated reads, not a fixed page.
``reembed_pgvector`` walks the source by keyset, so a cursor that
returns the same rows for every ``fetchall`` never terminates. The fake
answers each of the three queries the function issues from one in-memory
table, which tests mutate through ``rows``.
"""
cursor, table = paginating_cursor([(1, "alpha"), (2, "beta"), (3, "gamma")])
store = MagicMock()
store._table_name = "documents"
store._vector_column = "embedding"
conn = MagicMock()
conn.cursor.return_value = cursor
store._get_connection.return_value = conn
store._embedding.embed_documents.side_effect = lambda texts: [
[0.5] * 4 for _ in texts
]
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=store
), patch.object(reembed.settings, "GRAPHRAG_ENABLED", False):
yield store, conn, cursor, table
def test_reads_and_rewrites_every_chunk(self, store):
_, conn, cursor, _ = store
seen, written = reembed.reembed_pgvector("s1", batch_size=64, dry_run=False)
assert (seen, written) == (3, 3)
cursor.executemany.assert_called_once()
conn.commit.assert_called()
def test_pages_are_bounded_by_batch_size(self, store):
"""The read is paginated, so a huge source never lands in memory at once."""
_, _, cursor, table = store
table["chunks"] = [(i, f"chunk-{i}") for i in range(1, 11)]
seen, written = reembed.reembed_pgvector("s1", batch_size=3, dry_run=False)
assert (seen, written) == (10, 10)
selects = [
call for call in cursor.execute.call_args_list
if "SELECT id, text" in str(call.args[0])
]
# Four pages of at most 3, then one empty page to end the walk.
assert len(selects) == 5
assert all(call.args[1][-1] == 3 for call in selects)
def test_dry_run_counts_without_reading_the_text(self, store):
fake_store, _, cursor, _ = store
seen, written = reembed.reembed_pgvector("s1", batch_size=64, dry_run=True)
assert (seen, written) == (3, 0)
fake_store._embedding.embed_documents.assert_not_called()
cursor.executemany.assert_not_called()
assert any(
"count(*)" in str(call.args[0]) for call in cursor.execute.call_args_list
)
def test_batches_commit_separately(self, store):
_, conn, cursor, _ = store
reembed.reembed_pgvector("s1", batch_size=2, dry_run=False)
# 3 rows at batch 2 is two write transactions.
assert cursor.executemany.call_count == 2
assert conn.commit.call_count == 2
def test_failed_batch_rolls_back_and_raises(self, store):
_, conn, cursor, _ = store
cursor.executemany.side_effect = RuntimeError("write failed")
with pytest.raises(RuntimeError):
reembed.reembed_pgvector("s1", batch_size=64, dry_run=False)
conn.rollback.assert_called_once()
def test_connection_is_returned_even_on_failure(self, store):
fake_store, _, cursor, _ = store
cursor.executemany.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError):
reembed.reembed_pgvector("s1", batch_size=64, dry_run=False)
fake_store.close.assert_called_once()
def test_null_text_does_not_crash_the_embed_call(self, store):
fake_store, _, _, table = store
table["chunks"] = [(1, None), (2, "beta")]
seen, written = reembed.reembed_pgvector("s1", batch_size=64, dry_run=False)
assert (seen, written) == (2, 2)
assert fake_store._embedding.embed_documents.call_args.args[0] == ["", "beta"]
def test_empty_source_is_a_no_op(self, store):
fake_store, _, _, table = store
table["chunks"] = []
assert reembed.reembed_pgvector("s1", batch_size=64, dry_run=False) == (0, 0)
fake_store._embedding.embed_documents.assert_not_called()
def test_source_discovery_returns_sorted_ids(self):
store = MagicMock()
store._table_name = "documents"
cursor = MagicMock()
cursor.fetchall.return_value = [("b",), ("a",)]
conn = MagicMock()
conn.cursor.return_value = cursor
store._get_connection.return_value = conn
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=store
):
assert reembed.list_source_ids("pgvector") == ["b", "a"]
class TestFaissSourceDiscovery:
def test_source_ids_come_from_index_directories(self):
storage = MagicMock()
storage.list_files.return_value = [
"indexes/src-a/index.faiss",
"indexes/src-a/index.pkl",
"indexes/src-b/index.faiss",
]
with patch(
"application.storage.storage_creator.StorageCreator.get_storage",
return_value=storage,
):
assert reembed.list_source_ids("faiss") == ["src-a", "src-b"]
def test_storage_failure_is_reported_as_a_usable_error(self):
storage = MagicMock()
storage.list_files.side_effect = OSError("permission denied")
with patch(
"application.storage.storage_creator.StorageCreator.get_storage",
return_value=storage,
):
with pytest.raises(reembed.ReembedError, match="permission denied"):
reembed.list_source_ids("faiss")
def test_unsupported_store_error_names_the_alternatives(self):
with patch.object(reembed.settings, "VECTOR_STORE", "milvus", create=True):
assert reembed.main([]) == 2
class TestGraphNodeReembedding:
"""``graph_nodes.name_embedding`` seeds every graph traversal.
It is written once at extraction time and never revisited, so rewriting
only the chunk table leaves the graph seeding from the previous model --
and since mpnet and granite-311m are both 768-dimensional, the column
accepts the mismatch and nothing reports it.
"""
@pytest.fixture
def graph(self):
store = MagicMock()
store._embedding.embed_documents.side_effect = lambda texts: [
[0.5] * 4 for _ in texts
]
cursor = MagicMock()
cursor.fetchone.return_value = ("graph_nodes",)
cursor.fetchall.return_value = [
("n1", "Alpha"),
("n2", "Beta"),
("n3", "Gamma"),
]
conn = MagicMock()
conn.cursor.return_value = cursor
return store, conn, cursor
def test_rewrites_every_node_name(self, graph):
store, conn, cursor = graph
written = reembed.reembed_graph_nodes(
store, conn, "s1", batch_size=64, dry_run=False
)
assert written == 3
store._embedding.embed_documents.assert_called_once_with(
["Alpha", "Beta", "Gamma"]
)
statement = cursor.executemany.call_args.args[0]
assert "graph_nodes" in statement and "name_embedding" in statement
conn.commit.assert_called()
def test_dry_run_counts_without_embedding(self, graph):
store, conn, cursor = graph
assert reembed.reembed_graph_nodes(store, conn, "s1", 64, dry_run=True) == 3
store._embedding.embed_documents.assert_not_called()
cursor.executemany.assert_not_called()
def test_missing_table_is_a_no_op(self, graph):
store, conn, cursor = graph
cursor.fetchone.return_value = (None,)
assert reembed.reembed_graph_nodes(store, conn, "s1", 64, dry_run=False) == 0
store._embedding.embed_documents.assert_not_called()
def test_batches_commit_separately(self, graph):
store, conn, cursor = graph
reembed.reembed_graph_nodes(store, conn, "s1", batch_size=2, dry_run=False)
assert cursor.executemany.call_count == 2
assert conn.commit.call_count == 2
def test_failed_batch_rolls_back_and_raises(self, graph):
store, conn, cursor = graph
cursor.executemany.side_effect = RuntimeError("write failed")
with pytest.raises(RuntimeError, match="write failed"):
reembed.reembed_graph_nodes(store, conn, "s1", 64, dry_run=False)
conn.rollback.assert_called_once()
def test_pgvector_run_skips_the_graph_when_disabled(self):
store, conn, cursor = self._pgvector_mocks()
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=store
), patch.object(reembed.settings, "GRAPHRAG_ENABLED", False):
reembed.reembed_pgvector("s1", batch_size=64, dry_run=False)
assert not self._graph_statements(cursor)
def test_pgvector_run_reembeds_the_graph_when_enabled(self):
store, conn, cursor = self._pgvector_mocks()
with patch.object(
reembed.VectorCreator, "create_vectorstore", return_value=store
), patch.object(reembed.settings, "GRAPHRAG_ENABLED", True):
reembed.reembed_pgvector("s1", batch_size=64, dry_run=False)
assert self._graph_statements(cursor)
@staticmethod
def _pgvector_mocks():
cursor, _ = paginating_cursor(
[(1, "alpha"), (2, "beta")],
graph_rows=[("n1", "Alpha"), ("n2", "Beta")],
)
store = MagicMock()
store._table_name = "documents"
store._vector_column = "embedding"
conn = MagicMock()
conn.cursor.return_value = cursor
store._get_connection.return_value = conn
store._embedding.embed_documents.side_effect = lambda texts: [
[0.5] * 4 for _ in texts
]
return store, conn, cursor
@staticmethod
def _graph_statements(cursor):
return [
call
for call in cursor.executemany.call_args_list
if "graph_nodes" in str(call.args[0])
]
class TestEmbedsInProcess:
"""A batch job should not round-trip every chunk through the broker."""
def test_delegation_is_turned_off_for_the_run(self, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True, raising=False)
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector", raising=False)
seen = {}
with patch.object(reembed, "run", side_effect=lambda *a, **k: seen.setdefault(
"delegating", settings.EMBEDDINGS_DELEGATE_TO_WORKER
) or 0):
assert reembed.main(["--dry-run"]) == 0
assert seen["delegating"] is False
class TestRecordsTheModel:
"""``sources.model`` is what the boot mismatch check reads."""
def test_a_re_embedded_source_is_stamped(self, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "EMBEDDINGS_NAME", "new/model", raising=False)
conn = MagicMock()
session = MagicMock()
session.__enter__ = MagicMock(return_value=conn)
session.__exit__ = MagicMock(return_value=False)
with patch("application.storage.db.session.db_session", return_value=session):
reembed.record_source_model("src-1")
params = conn.execute.call_args.args[1]
assert params == {"model": "new/model", "id": "src-1"}
def test_a_dry_run_stamps_nothing(self, monkeypatch):
with patch.object(reembed, "record_source_model") as record, patch.object(
reembed, "list_source_ids", return_value=["a"]
), patch.object(reembed, "reembed_pgvector", return_value=(3, 0)):
reembed.run("pgvector", None, 64, True)
record.assert_not_called()
def test_a_real_run_stamps_each_source(self, monkeypatch):
with patch.object(reembed, "record_source_model") as record, patch.object(
reembed, "list_source_ids", return_value=["a", "b"]
), patch.object(reembed, "reembed_pgvector", return_value=(3, 3)):
reembed.run("pgvector", None, 64, False)
assert [c.args[0] for c in record.call_args_list] == ["a", "b"]
def test_a_failed_source_is_not_stamped(self):
with patch.object(reembed, "record_source_model") as record, patch.object(
reembed, "list_source_ids", return_value=["a"]
), patch.object(reembed, "reembed_pgvector", side_effect=RuntimeError("boom")):
assert reembed.run("pgvector", None, 64, False) == 1
record.assert_not_called()
class TestThePinIsResolved:
"""The script must embed with the model the installation is pinned to.
``resolve_embeddings_pin`` runs in ``application.app``, which this script
never imports. An install pinned in ``app_metadata`` with no
``EMBEDDINGS_NAME`` in the environment -- every stock Kubernetes
deployment, whose manifests carry no embedding config -- would otherwise
rewrite its whole index with the legacy code default and stamp
``sources.model`` to match, creating the cross-model index this script
exists to repair.
"""
def test_main_resolves_the_pin_before_reading_the_store(self):
order = []
with patch(
"application.storage.db.embeddings_pin.resolve_embeddings_pin",
side_effect=lambda *a, **k: order.append("pin"),
), patch.object(reembed.settings, "VECTOR_STORE", "pgvector", create=True), patch.object(
reembed, "run", side_effect=lambda *a, **k: (order.append("run"), 0)[1]
):
assert reembed.main([]) == 0
assert order == ["pin", "run"], "the pin must resolve before anything embeds"
def test_an_unsupported_store_still_resolved_the_pin_first(self):
with patch(
"application.storage.db.embeddings_pin.resolve_embeddings_pin"
) as pin, patch.object(reembed.settings, "VECTOR_STORE", "qdrant", create=True):
assert reembed.main([]) == 2
pin.assert_called_once()
+297
View File
@@ -0,0 +1,297 @@
"""Live pgvector run of the re-embed script.
Uses the ephemeral pytest-postgresql cluster with a stub embeddings model, so
a real ``UPDATE ... ::vector`` round trip is exercised without downloading a
model. Skips when the cluster has no pgvector build.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from application.scripts import reembed
from application.vectorstore import pgvector as pgvector_module
from application.vectorstore.pgvector import PGVectorStore
pytestmark = pytest.mark.integration
DIM = 8
class _Embeddings:
"""Returns a distinct constant per generation, so a rewrite is visible."""
dimension = DIM
def __init__(self, seed: float):
self.seed = seed
self.calls = 0
def embed_documents(self, texts):
self.calls += 1
return [[self.seed] + [0.0] * (DIM - 1) for _ in texts]
def embed_query(self, query):
return [self.seed] + [0.0] * (DIM - 1)
def _dsn(info) -> str:
password = f":{info.password}" if info.password else ""
return f"postgresql://{info.user}{password}@{info.host}:{info.port}/{info.dbname}"
@pytest.fixture(autouse=True)
def _close_pools():
"""Never leak a pool into another test; the DSN dies with the test DB."""
yield
for dsn, pool in list(pgvector_module._POOLS.items()):
try:
pool.close()
except Exception:
# Teardown only: the ephemeral cluster may already be gone, and a
# failure to close a pool for a dead DSN must not fail the test
# that just passed. Dropping the entry below is what matters.
pass
pgvector_module._POOLS.pop(dsn, None)
@pytest.fixture
def live_dsn(postgresql, monkeypatch):
try:
with postgresql.cursor() as cursor:
cursor.execute("CREATE EXTENSION vector;")
postgresql.rollback()
except Exception as exc:
postgresql.rollback()
pytest.skip(f"pgvector extension unavailable: {exc}")
dsn = _dsn(postgresql.info)
from application.core import settings as settings_module
settings = settings_module.settings
monkeypatch.setattr(settings, "VECTOR_STORE", "pgvector", raising=False)
monkeypatch.setattr(settings, "PGVECTOR_CONNECTION_STRING", dsn, raising=False)
monkeypatch.setattr(settings, "GRAPHRAG_ENABLED", False, raising=False)
monkeypatch.setattr(settings, "PGVECTOR_IVFFLAT_PROBES", None, raising=False)
monkeypatch.setattr(settings, "PGVECTOR_POOL_MAX_SIZE", 4, raising=False)
monkeypatch.setattr(settings, "EMBEDDINGS_NAME", "granite-311m", raising=False)
return dsn
def _seed(dsn, source_id, texts, embeddings):
"""Create the schema and insert ``texts`` embedded by ``embeddings``."""
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=embeddings,
):
store = PGVectorStore(source_id=source_id, connection_string=dsn)
conn = store._get_connection()
PGVectorStore.create_schema(conn, dimension=DIM)
conn.commit()
store.add_texts(list(texts), metadatas=[{"i": i} for i in range(len(texts))])
store.close()
def _as_list(vector):
"""Normalise a stored vector to a list of floats.
Depending on whether pgvector's adapter is registered on the reading
connection, the value comes back as a ``Vector`` or as its text form
``'[1,0,...]'``. Both are valid; the assertions should not care.
"""
if vector is None:
return []
if hasattr(vector, "to_list"):
return list(vector.to_list())
if isinstance(vector, str):
return [float(part) for part in vector.strip("[]").split(",") if part]
return list(vector)
def _vectors(dsn, source_id):
store = PGVectorStore(source_id=source_id, connection_string=dsn)
conn = store._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT text, embedding FROM documents WHERE source_id = %s ORDER BY id",
(source_id,),
)
# pgvector hands back a ``Vector``; normalise to a plain list so the
# assertions read the same whichever adapter is registered.
return [(text, _as_list(vector)) for text, vector in cursor.fetchall()]
finally:
cursor.close()
store.close()
TEXTS = ["alpha document", "beta document", "gamma document"]
class TestReembedPgvectorLive:
def test_rewrites_vectors_and_preserves_text(self, live_dsn):
_seed(live_dsn, "src-a", TEXTS, _Embeddings(1.0))
before = _vectors(live_dsn, "src-a")
assert [row[0] for row in before] == TEXTS
assert all(row[1][0] == pytest.approx(1.0) for row in before)
new_model = _Embeddings(9.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=new_model,
):
seen, written = reembed.reembed_pgvector("src-a", batch_size=2, dry_run=False)
assert (seen, written) == (3, 3)
after = _vectors(live_dsn, "src-a")
# Text is untouched; only the vectors moved.
assert [row[0] for row in after] == TEXTS
assert all(row[1][0] == pytest.approx(9.0) for row in after)
def test_dry_run_counts_without_writing(self, live_dsn):
_seed(live_dsn, "src-b", TEXTS, _Embeddings(1.0))
new_model = _Embeddings(9.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=new_model,
):
seen, written = reembed.reembed_pgvector("src-b", batch_size=2, dry_run=True)
assert (seen, written) == (3, 0)
assert new_model.calls == 0, "dry run must not embed"
assert all(row[1][0] == pytest.approx(1.0) for row in _vectors(live_dsn, "src-b"))
def test_batches_are_respected(self, live_dsn):
_seed(live_dsn, "src-c", TEXTS, _Embeddings(1.0))
new_model = _Embeddings(9.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=new_model,
):
reembed.reembed_pgvector("src-c", batch_size=2, dry_run=False)
assert new_model.calls == 2, "3 chunks at batch 2 is two embed calls"
def test_only_the_named_source_is_touched(self, live_dsn):
_seed(live_dsn, "src-d", TEXTS, _Embeddings(1.0))
_seed(live_dsn, "src-e", TEXTS, _Embeddings(1.0))
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=_Embeddings(9.0),
):
reembed.reembed_pgvector("src-d", batch_size=64, dry_run=False)
assert all(row[1][0] == pytest.approx(9.0) for row in _vectors(live_dsn, "src-d"))
assert all(row[1][0] == pytest.approx(1.0) for row in _vectors(live_dsn, "src-e"))
def test_source_discovery_lists_every_source(self, live_dsn):
_seed(live_dsn, "src-f", TEXTS, _Embeddings(1.0))
_seed(live_dsn, "src-g", TEXTS, _Embeddings(1.0))
assert reembed.list_source_ids("pgvector") == ["src-f", "src-g"]
GRAPH_SOURCE = "11111111-2222-3333-4444-555555555555"
def _seed_graph_node(dsn, source_id, name, seed):
"""Insert one graph node carrying a name embedding at ``seed``."""
from application.graphrag.store import GraphStore
store = PGVectorStore(source_id=source_id, connection_string=dsn)
conn = store._get_connection()
GraphStore.create_schema(conn, dimension=DIM)
cursor = conn.cursor()
try:
cursor.execute(
"INSERT INTO graph_nodes (id, source_id, name, normalized_name, type, "
"description, degree, doc_freq, name_embedding) "
"VALUES (gen_random_uuid(), %s, %s, %s, 'ENTITY', '', 0, 0, %s::vector)",
(source_id, name, name.lower(), str([seed] + [0.0] * (DIM - 1))),
)
conn.commit()
finally:
cursor.close()
store.close()
def _node_vectors(dsn, source_id):
store = PGVectorStore(source_id=source_id, connection_string=dsn)
conn = store._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT name, name_embedding FROM graph_nodes "
"WHERE source_id = %s ORDER BY name",
(source_id,),
)
return [(name, _as_list(vector)) for name, vector in cursor.fetchall()]
finally:
cursor.close()
store.close()
class TestGraphNodeReembedLive:
"""``graph_nodes.name_embedding`` seeds every graph traversal.
Rewriting only the chunk table leaves it in the previous model's space,
and because mpnet and granite-311m share a width the column accepts the
mismatch silently -- exactly the failure the script exists to prevent.
"""
def test_node_names_are_re_embedded(self, live_dsn, monkeypatch):
from application.core import settings as settings_module
monkeypatch.setattr(
settings_module.settings, "GRAPHRAG_ENABLED", True, raising=False
)
_seed(live_dsn, GRAPH_SOURCE, TEXTS, _Embeddings(1.0))
_seed_graph_node(live_dsn, GRAPH_SOURCE, "Alpha", 1.0)
assert all(v[0] == pytest.approx(1.0) for _, v in _node_vectors(live_dsn, GRAPH_SOURCE))
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=_Embeddings(9.0),
):
reembed.reembed_pgvector(GRAPH_SOURCE, batch_size=64, dry_run=False)
after = _node_vectors(live_dsn, GRAPH_SOURCE)
assert [name for name, _ in after] == ["Alpha"]
assert all(v[0] == pytest.approx(9.0) for _, v in after), (
"graph node names must move with the chunk vectors"
)
def test_graph_is_left_alone_when_graphrag_is_off(self, live_dsn, monkeypatch):
from application.core import settings as settings_module
monkeypatch.setattr(
settings_module.settings, "GRAPHRAG_ENABLED", False, raising=False
)
_seed(live_dsn, GRAPH_SOURCE, TEXTS, _Embeddings(1.0))
_seed_graph_node(live_dsn, GRAPH_SOURCE, "Alpha", 1.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=_Embeddings(9.0),
):
reembed.reembed_pgvector(GRAPH_SOURCE, batch_size=64, dry_run=False)
assert all(v[0] == pytest.approx(1.0) for _, v in _node_vectors(live_dsn, GRAPH_SOURCE))
def test_dry_run_leaves_node_vectors_untouched(self, live_dsn, monkeypatch):
from application.core import settings as settings_module
monkeypatch.setattr(
settings_module.settings, "GRAPHRAG_ENABLED", True, raising=False
)
_seed(live_dsn, GRAPH_SOURCE, TEXTS, _Embeddings(1.0))
_seed_graph_node(live_dsn, GRAPH_SOURCE, "Alpha", 1.0)
with patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=_Embeddings(9.0),
):
reembed.reembed_pgvector(GRAPH_SOURCE, batch_size=64, dry_run=True)
assert all(v[0] == pytest.approx(1.0) for _, v in _node_vectors(live_dsn, GRAPH_SOURCE))
@@ -290,3 +290,32 @@ class TestBucketedTotals:
)
assert len(rows) == 1
assert rows[0]["prompt_tokens"] == 10
class TestCacheBins:
def _row(self, pg_conn, user_id):
return pg_conn.execute(
text(
"SELECT cached_tokens, cache_write_tokens FROM token_usage "
"WHERE user_id = :u ORDER BY id DESC LIMIT 1"
),
{"u": user_id},
).fetchone()
def test_persists_cache_bins(self, pg_conn):
repo = TokenUsageRepository(pg_conn)
repo.insert(
user_id="cache-user",
prompt_tokens=1000,
generated_tokens=10,
cached_tokens=800,
cache_write_tokens=100,
)
assert tuple(self._row(pg_conn, "cache-user")) == (800, 100)
def test_cache_bins_default_to_null_not_zero(self, pg_conn):
"""NULL means "provider did not report"; 0 means "reported no cache
activity". Keeping them distinct is what makes a hit-rate query honest."""
repo = TokenUsageRepository(pg_conn)
repo.insert(user_id="cache-user-2", prompt_tokens=10, generated_tokens=1)
assert tuple(self._row(pg_conn, "cache-user-2")) == (None, None)
@@ -74,8 +74,8 @@ class TestEnsureVectorSchemaCreates:
cursor = MagicMock()
conn.cursor.return_value = cursor
with patch("psycopg.connect", return_value=conn) as connect, patch(
"application.vectorstore.base.get_embeddings",
return_value=_embeddings(dimension),
"application.vectorstore.model_registry.dimension_for",
return_value=dimension,
), patch(
"application.vectorstore.pgvector.PGVectorStore.create_schema"
) as vector_schema, patch(
@@ -130,8 +130,8 @@ class TestEnsureVectorSchemaDimensionCheck:
):
conn = MagicMock()
with patch("psycopg.connect", return_value=conn), patch(
"application.vectorstore.base.get_embeddings",
return_value=_embeddings(1536),
"application.vectorstore.model_registry.dimension_for",
return_value=1536,
), patch("application.vectorstore.pgvector.PGVectorStore.create_schema"), patch(
"application.vectorstore.pgvector.PGVectorStore.table_dimension",
return_value=768,
@@ -226,3 +226,164 @@ class TestBootGating:
assert 'os.environ.setdefault("AUTO_VECTOR_SCHEMA", "false")' in (
conftest.read_text()
)
@pytest.mark.unit
class TestBootDoesNotLoadTheModel:
"""The hook needs an integer, not an inference session.
It used to build the embeddings instance to read ``.dimension`` off it,
loading several hundred MB of ONNX into every API and worker process at
import. For a model the registry describes that is a lookup.
"""
def _run(self, registry_dim, loader):
conn = MagicMock()
conn.cursor.return_value = MagicMock()
with patch("psycopg.connect", return_value=conn), patch(
"application.vectorstore.model_registry.dimension_for",
return_value=registry_dim,
), patch(
"application.vectorstore.base.build_local_embeddings", loader
), patch(
"application.vectorstore.pgvector.PGVectorStore.create_schema"
) as vector_schema, patch(
"application.vectorstore.pgvector.PGVectorStore.table_dimension",
return_value=None,
):
ensure_vector_schema()
return vector_schema
def test_a_registered_model_is_never_constructed(self, vector_settings):
loader = MagicMock()
vector_schema = self._run(768, loader)
loader.assert_not_called()
assert vector_schema.call_args.kwargs["dimension"] == 768
def test_an_unregistered_model_still_falls_back_to_loading(self, vector_settings):
loader = MagicMock(return_value=_embeddings(1024))
vector_schema = self._run(None, loader)
loader.assert_called_once()
assert vector_schema.call_args.kwargs["dimension"] == 1024
@pytest.mark.unit
class TestUnknownWidthIsProbed:
"""A remote server's width is only knowable by asking it.
``RemoteEmbeddings`` reports ``None`` until its first call, so sizing the
table from the attribute alone fell back to 768 and skipped the mismatch
check — the silent ``vector(768)`` column this hook exists to prevent.
"""
def _run(self, remote, table_dimension=1024):
conn = MagicMock()
conn.cursor.return_value = MagicMock()
with patch("psycopg.connect", return_value=conn), patch(
"application.vectorstore.model_registry.dimension_for", return_value=None
), patch(
"application.vectorstore.base.build_local_embeddings", return_value=remote
), patch(
"application.vectorstore.pgvector.PGVectorStore.create_schema"
) as vector_schema, patch(
"application.vectorstore.pgvector.PGVectorStore.table_dimension",
return_value=table_dimension,
):
try:
ensure_vector_schema()
raised = False
except RuntimeError:
raised = True
return vector_schema, raised
@staticmethod
def _remote(width=None, error=None):
remote = MagicMock()
remote.dimension = None
remote.embed_query.side_effect = error or (lambda _text: [0.0] * width)
return remote
def test_the_table_is_sized_from_the_probe(self, vector_settings):
remote = self._remote(width=1024)
vector_schema, _ = self._run(remote, table_dimension=1024)
remote.embed_query.assert_called_once()
assert vector_schema.call_args.kwargs["dimension"] == 1024
def test_the_probe_restores_the_mismatch_check(self, vector_settings):
_, raised = self._run(self._remote(width=768), table_dimension=1024)
assert raised, "a 768-dim model against a vector(1024) table must fail loudly"
def test_an_unreachable_server_does_not_block_boot(self, vector_settings):
vector_schema, raised = self._run(
self._remote(error=ConnectionError("server down")), table_dimension=1024
)
assert not raised
assert vector_schema.call_args.kwargs["dimension"] == 768
def test_a_model_that_knows_its_width_is_not_probed(self, vector_settings):
local = MagicMock()
local.dimension = 384
vector_schema, _ = self._run(local, table_dimension=384)
local.embed_query.assert_not_called()
assert vector_schema.call_args.kwargs["dimension"] == 384
@pytest.mark.unit
class TestBootLoadedModelIsReleased:
"""The width probe must not leave a model resident in a delegating process.
Reading ``.dimension`` off an unregistered model means loading it, and
``EmbeddingsSingleton`` caches what it builds. In an API that delegates
every embed to the worker that cached copy is never called again — it is
several hundred megabytes held for the life of the process, which is the
cost ``EMBEDDINGS_DELEGATE_TO_WORKER`` exists to avoid.
"""
def _run(self, vector_settings, *, delegate, base_url=None):
from application.vectorstore.base import EmbeddingsSingleton
monkeyed = _embeddings(1024)
conn = MagicMock()
conn.cursor.return_value = MagicMock()
EmbeddingsSingleton._instances.pop("test-model", None)
def _build(*_args, **_kwargs):
EmbeddingsSingleton._instances["test-model"] = monkeyed
return monkeyed
with patch.object(
vector_settings, "EMBEDDINGS_DELEGATE_TO_WORKER", delegate
), patch.object(
vector_settings, "EMBEDDINGS_BASE_URL", base_url
), patch("psycopg.connect", return_value=conn), patch(
"application.vectorstore.model_registry.dimension_for", return_value=None
), patch(
"application.vectorstore.base.build_local_embeddings", side_effect=_build
), patch(
"application.vectorstore.pgvector.PGVectorStore.create_schema"
) as vector_schema, patch(
"application.vectorstore.pgvector.PGVectorStore.table_dimension",
return_value=1024,
):
ensure_vector_schema()
try:
return vector_schema, "test-model" in EmbeddingsSingleton._instances
finally:
EmbeddingsSingleton._instances.pop("test-model", None)
def test_a_delegating_process_does_not_retain_it(self, vector_settings):
vector_schema, retained = self._run(vector_settings, delegate=True)
assert not retained, "a delegating API must not hold the model it probed"
assert vector_schema.call_args.kwargs["dimension"] == 1024
def test_a_process_that_embeds_locally_keeps_it(self, vector_settings):
_, retained = self._run(vector_settings, delegate=False)
assert retained, "without delegation the model is used, so evicting it "\
"would only force a rebuild on the first query"
def test_a_remote_client_is_kept(self, vector_settings):
_, retained = self._run(
vector_settings, delegate=True, base_url="http://embeddings:8080"
)
assert retained, "a RemoteEmbeddings holds no model and is what the "\
"process goes on to use"
+131
View File
@@ -0,0 +1,131 @@
"""Which embedding model an installation is pinned to."""
from unittest.mock import MagicMock, patch
import pytest
from application.storage.db import embeddings_pin
from application.storage.db.embeddings_pin import (
NOTICE_KEY,
PIN_KEY,
resolve_embeddings_pin,
)
from application.vectorstore.model_registry import DEFAULT_LEGACY, DEFAULT_NEW_INSTALL
@pytest.fixture
def store():
"""An in-memory stand-in for the ``app_metadata`` key/value table."""
data = {}
repo = MagicMock()
repo.get.side_effect = data.get
repo.set.side_effect = lambda k, v: data.__setitem__(k, v)
repo.setdefault.side_effect = lambda k, v: data.setdefault(k, v)
repo._data = data
return repo
def _run(store, *, has_sources, env_pinned=False, name="unset"):
session = MagicMock()
session.__enter__ = MagicMock(return_value=MagicMock())
session.__exit__ = MagicMock(return_value=False)
fields = {"EMBEDDINGS_NAME"} if env_pinned else set()
with patch.object(embeddings_pin.settings, "EMBEDDINGS_NAME", name), patch.object(
type(embeddings_pin.settings), "model_fields_set", property(lambda self: fields)
), patch.object(embeddings_pin, "db_session", return_value=session), patch.object(
embeddings_pin, "AppMetadataRepository", return_value=store
), patch.object(
embeddings_pin, "_has_sources", return_value=has_sources
):
resolve_embeddings_pin()
return embeddings_pin.settings.EMBEDDINGS_NAME
class TestFreshInstall:
def test_an_empty_installation_is_pinned_to_the_current_model(self, store):
assert _run(store, has_sources=False) == DEFAULT_NEW_INSTALL
assert store._data[PIN_KEY] == DEFAULT_NEW_INSTALL
def test_no_legacy_notice_is_printed(self, store, capsys):
_run(store, has_sources=False)
assert "reembed" not in capsys.readouterr().out
assert NOTICE_KEY not in store._data
class TestExistingInstall:
"""An index built by the old model must keep being read by it."""
def test_an_installation_with_sources_is_pinned_to_the_legacy_model(self, store):
assert _run(store, has_sources=True) == DEFAULT_LEGACY
assert store._data[PIN_KEY] == DEFAULT_LEGACY
def test_the_notice_names_the_migration_command(self, store, capsys):
_run(store, has_sources=True)
out = capsys.readouterr().out
assert "application.scripts.reembed" in out
assert DEFAULT_NEW_INSTALL in out
def test_the_notice_is_shown_only_once(self, store, capsys):
_run(store, has_sources=True)
capsys.readouterr()
store._data.pop(PIN_KEY) # force the decision again
_run(store, has_sources=True)
assert "reembed" not in capsys.readouterr().out
class TestPrecedence:
def test_a_stored_pin_survives_new_sources(self, store):
store._data[PIN_KEY] = DEFAULT_NEW_INSTALL
assert _run(store, has_sources=True) == DEFAULT_NEW_INSTALL
def test_the_environment_wins_and_nothing_is_stored(self, store):
assert _run(store, has_sources=True, env_pinned=True, name="my/model") == "my/model"
assert store._data == {}
def test_an_unreachable_database_leaves_the_default_alone(self):
with patch.object(embeddings_pin.settings, "EMBEDDINGS_NAME", "fallback"), patch.object(
type(embeddings_pin.settings), "model_fields_set", property(lambda self: set())
), patch.object(embeddings_pin, "db_session", side_effect=OSError("no db")):
resolve_embeddings_pin()
assert embeddings_pin.settings.EMBEDDINGS_NAME == "fallback"
class TestSourceModelMismatch:
"""The only signal that an index is being queried by the wrong model."""
def _run(self, rows, active, log):
conn = MagicMock()
conn.execute.side_effect = [
MagicMock(scalar=MagicMock(return_value="sources")),
MagicMock(fetchall=MagicMock(return_value=rows)),
]
session = MagicMock()
session.__enter__ = MagicMock(return_value=conn)
session.__exit__ = MagicMock(return_value=False)
with patch.object(embeddings_pin.settings, "EMBEDDINGS_NAME", active), patch.object(
embeddings_pin, "db_session", return_value=session
):
embeddings_pin.warn_on_source_model_mismatch(log)
def test_a_different_model_is_reported_with_counts(self):
log = MagicMock()
self._run([(DEFAULT_LEGACY, 28)], DEFAULT_NEW_INSTALL, log)
message = log.warning.call_args.args[0] % log.warning.call_args.args[1:]
assert "28 built with" in message
assert "application.scripts.reembed" in message
def test_an_alias_is_not_a_mismatch(self):
"""A stored alias and the canonical name are the same model."""
log = MagicMock()
self._run([("sentence-transformers/all-mpnet-base-v2", 28)], DEFAULT_LEGACY, log)
log.warning.assert_not_called()
def test_a_matching_model_says_nothing(self):
log = MagicMock()
self._run([(DEFAULT_NEW_INSTALL, 5)], DEFAULT_NEW_INSTALL, log)
log.warning.assert_not_called()
def test_two_unregistered_names_that_differ_are_a_mismatch(self):
log = MagicMock()
self._run([("some/other-model", 3)], "my/custom-model", log)
log.warning.assert_called_once()
+100
View File
@@ -0,0 +1,100 @@
"""Migration round-trip test for 0031_token_usage_cache_tokens."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
import pytest
from sqlalchemy import text
pytestmark = pytest.mark.integration
def _alembic_ini() -> Path:
return Path(__file__).resolve().parents[3] / "application" / "alembic.ini"
def _run_alembic(url: str, *args: str) -> None:
subprocess.check_call(
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
timeout=60,
env={**os.environ, "POSTGRES_URI": url},
)
def _alembic_heads(url: str) -> list[str]:
out = subprocess.check_output(
[sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), "heads"],
timeout=60,
env={**os.environ, "POSTGRES_URI": url},
text=True,
)
return [line for line in out.splitlines() if line.strip()]
def _alembic_version(conn) -> str:
return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column_exists(conn, table: str, column: str) -> bool:
row = conn.execute(
text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :t AND column_name = :c AND table_schema = 'public'"
),
{"t": table, "c": column},
).fetchone()
return row is not None
_0031 = "0031_token_usage_cache_tokens"
_0030 = "0030_superseded_messages"
class TestMigration0031RoundTrip:
def test_single_head(self, pg_engine):
url = pg_engine.url.render_as_string(hide_password=False)
assert len(_alembic_heads(url)) == 1
def test_head_has_cache_columns(self, pg_engine):
with pg_engine.connect() as conn:
assert _alembic_version(conn) >= _0031
assert _column_exists(conn, "token_usage", "cached_tokens")
assert _column_exists(conn, "token_usage", "cache_write_tokens")
def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
url = pg_engine.url.render_as_string(hide_password=False)
_run_alembic(url, "downgrade", _0030)
with pg_engine.connect() as conn:
assert _alembic_version(conn) == _0030
assert not _column_exists(conn, "token_usage", "cached_tokens")
assert not _column_exists(conn, "token_usage", "cache_write_tokens")
_run_alembic(url, "upgrade", "head")
with pg_engine.connect() as conn:
assert _alembic_version(conn) >= _0031
assert _column_exists(conn, "token_usage", "cached_tokens")
def test_existing_rows_read_null_cache_bins(self, pg_engine):
"""Rows written before 0031 must read NULL (unknown), not 0."""
url = pg_engine.url.render_as_string(hide_password=False)
_run_alembic(url, "downgrade", _0030)
with pg_engine.begin() as conn:
conn.execute(
text(
"INSERT INTO token_usage (user_id, prompt_tokens, generated_tokens) "
"VALUES ('u-mig31', 10, 1)"
)
)
_run_alembic(url, "upgrade", "head")
with pg_engine.connect() as conn:
row = conn.execute(
text(
"SELECT cached_tokens, cache_write_tokens FROM token_usage "
"WHERE user_id = 'u-mig31'"
)
).fetchone()
assert tuple(row) == (None, None)
+81 -37
View File
@@ -16,6 +16,13 @@ def local_storage(temp_base_dir):
return LocalStorage(base_dir=temp_base_dir)
@pytest.fixture
def real_storage(tmp_path):
"""Storage over a real directory, for the write paths worth exercising."""
base = os.path.realpath(str(tmp_path))
return LocalStorage(base_dir=base), base
@pytest.mark.unit
class TestLocalStorageInitialization:
@@ -37,50 +44,87 @@ class TestLocalStorageInitialization:
with pytest.raises(ValueError, match="Path traversal detected"):
local_storage._get_full_path("/absolute/path/test.txt")
@patch("os.makedirs")
@patch("builtins.open", new_callable=mock_open)
@patch("shutil.copyfileobj")
def test_save_file_creates_directory_and_saves(
self, mock_copyfileobj, mock_file, mock_makedirs, local_storage
):
file_data = io.BytesIO(b"test content")
path = "documents/test.txt"
def test_save_file_creates_directory_and_saves(self, real_storage):
storage, base = real_storage
result = storage.save_file(io.BytesIO(b"test content"), "documents/test.txt")
result = local_storage.save_file(file_data, path)
expected_dir = os.path.join(os.path.realpath("/tmp/test_storage"), "documents")
expected_file = os.path.join(os.path.realpath("/tmp/test_storage"), "documents/test.txt")
assert mock_makedirs.call_count == 1
assert os.path.normpath(mock_makedirs.call_args[0][0]) == os.path.normpath(
expected_dir
)
assert mock_makedirs.call_args[1] == {"exist_ok": True}
assert mock_file.call_count == 1
assert os.path.normpath(mock_file.call_args[0][0]) == os.path.normpath(
expected_file
)
assert mock_file.call_args[0][1] == "wb"
mock_copyfileobj.assert_called_once_with(file_data, mock_file())
written = os.path.join(base, "documents/test.txt")
assert os.path.isfile(written)
with open(written, "rb") as f:
assert f.read() == b"test content"
assert result == {"storage_type": "local"}
@patch("os.makedirs")
def test_save_file_with_save_method(self, mock_makedirs, local_storage):
file_data = MagicMock()
file_data.save = MagicMock()
path = "documents/test.txt"
def test_save_file_with_save_method(self, real_storage):
"""Werkzeug's ``FileStorage.save`` accepts the destination handle."""
storage, base = real_storage
result = local_storage.save_file(file_data, path)
class _Uploaded:
def save(self, dst):
dst.write(b"from save()")
expected_file = os.path.join(os.path.realpath("/tmp/test_storage"), "documents/test.txt")
assert file_data.save.call_count == 1
assert os.path.normpath(file_data.save.call_args[0][0]) == os.path.normpath(
expected_file
)
result = storage.save_file(_Uploaded(), "documents/test.txt")
with open(os.path.join(base, "documents/test.txt"), "rb") as f:
assert f.read() == b"from save()"
assert result == {"storage_type": "local"}
def test_save_file_replaces_existing_content(self, real_storage):
storage, base = real_storage
storage.save_file(io.BytesIO(b"first"), "a/b.bin")
storage.save_file(io.BytesIO(b"second"), "a/b.bin")
with open(os.path.join(base, "a/b.bin"), "rb") as f:
assert f.read() == b"second"
def test_failed_write_leaves_the_previous_file_intact(self, real_storage):
"""The whole point of writing through a temp file.
``reembed`` rewrites an index in place; a half-written ``index.faiss``
loads at neither the old width nor the new one, so an interrupted write
must leave the previous bytes untouched rather than truncate them.
"""
storage, base = real_storage
storage.save_file(io.BytesIO(b"the good index"), "indexes/s1/index.faiss")
class _DiesHalfway:
def __init__(self):
self._served = False
def read(self, size=-1):
if self._served:
raise OSError("connection reset")
self._served = True
return b"corrupt"
with pytest.raises(OSError, match="connection reset"):
storage.save_file(_DiesHalfway(), "indexes/s1/index.faiss")
with open(os.path.join(base, "indexes/s1/index.faiss"), "rb") as f:
assert f.read() == b"the good index"
def test_failed_write_leaves_no_temp_file_behind(self, real_storage):
storage, base = real_storage
class _Explodes:
def read(self, size=-1):
raise OSError("boom")
with pytest.raises(OSError):
storage.save_file(_Explodes(), "indexes/s1/index.faiss")
assert os.listdir(os.path.join(base, "indexes/s1")) == []
def test_save_file_keeps_the_existing_permissions(self, real_storage):
"""``mkstemp`` is 0600; the replacement must not silently narrow access."""
storage, base = real_storage
storage.save_file(io.BytesIO(b"one"), "a/b.bin")
target = os.path.join(base, "a/b.bin")
os.chmod(target, 0o640)
storage.save_file(io.BytesIO(b"two"), "a/b.bin")
assert os.stat(target).st_mode & 0o777 == 0o640
def test_save_file_with_absolute_path_outside_base_raises(self, local_storage):
file_data = io.BytesIO(b"test content")
path = "/absolute/path/test.txt"
+51 -1
View File
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import pytest
from application.celery_init import make_celery
@@ -222,3 +222,53 @@ def test_unparseable_file_raises_the_non_retryable_type():
with pytest.raises(DocumentParseError, match="No text could be extracted"):
embed_and_store_documents([], "/tmp", "src", None)
@pytest.mark.unit
class TestReclaimIsSkippedForEmbeds:
"""The post-task heap reclaim must not run on the query hot path.
``_reclaim_memory_after_task`` exists for the large transient allocations
docling/torch parsing makes. Query embedding became a Celery task, and a
full generational collect on a worker holding the ONNX model measured ~86 ms
against ~8 ms for the embed itself -- a 9x slowdown of the round trip for a
task that allocates a few kilobytes.
"""
@staticmethod
def _collects(task_name):
from application.celery_init import _reclaim_memory_after_task
task = MagicMock()
task.name = task_name
with patch("application.celery_init.gc.collect") as collect, patch(
"application.celery_init._trim_native_heap"
):
_reclaim_memory_after_task(task=task, task_id="t", state="SUCCESS")
return collect.called
def test_the_embed_task_is_skipped(self):
assert not self._collects("application.vectorstore.embeddings_tasks.embed_texts")
def test_parsing_still_reclaims(self):
assert self._collects("application.api.user.tasks.parse_document")
def test_ingest_still_reclaims(self):
assert self._collects("application.api.user.tasks.ingest")
def test_an_unnamed_sender_still_reclaims(self):
"""Unknown callers keep the old behaviour rather than silently skipping."""
from application.celery_init import _reclaim_memory_after_task
with patch("application.celery_init.gc.collect") as collect, patch(
"application.celery_init._trim_native_heap"
):
_reclaim_memory_after_task(task_id="t", state="SUCCESS")
assert collect.called
def test_the_skip_list_names_the_real_task(self):
"""A renamed task must not silently start paying the collect again."""
from application.celery_init import _NO_RECLAIM_TASKS
from application.vectorstore.embeddings_delegated import EMBED_TASK
assert EMBED_TASK in _NO_RECLAIM_TASKS
+220
View File
@@ -1,5 +1,6 @@
"""Tests for bounded user-upload stream helpers."""
import codecs
import io
import pytest
@@ -37,3 +38,222 @@ def test_limited_text_read_decodes_incrementally_and_rejects_overflow():
with pytest.raises(UploadTooLargeError):
read_text_upload_limited(_ShortReadStream(b"12345"), max_bytes=4)
# --- attachment type gate -------------------------------------------------
#
# ``SimpleDirectoryReader`` falls through to a plain-text ``open()`` for any
# suffix without a parser. That is what a .py or a .log attachment relies on,
# and it is also how a phone-uploaded video used to be "parsed" into
# megabytes of binary garbage, truncated, and stored with
# ``extraction.status == "ok"``. So a suffix with no parser is admitted on
# content: text in, binary out.
MP4_HEADER = b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2avc1mp41"
@pytest.mark.parametrize(
("filename", "content"),
[
("clip.mp4", MP4_HEADER),
("movie.MOV", MP4_HEADER),
("archive.zip", b"PK\x03\x04\x14\x00\x00\x00\x08\x00" + bytes(range(32))),
("binary", bytes(range(32)) * 8),
("trailing.", b"\x00\x01\x02\x03"),
("x.tar.gz", b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03"),
],
)
def test_enforce_parseable_attachment_rejects_binary_without_a_parser(
filename, content, tmp_path
):
from application.upload_limits import (
enforce_parseable_attachment,
UnsupportedUploadTypeError,
unsupported_upload_message,
)
path = tmp_path / "staged.bin"
path.write_bytes(content)
with pytest.raises(UnsupportedUploadTypeError) as excinfo:
enforce_parseable_attachment(path, filename)
assert str(excinfo.value) == unsupported_upload_message(filename)
assert str(excinfo.value).startswith("Unsupported file type")
def test_enforce_parseable_attachment_rejects_binary_named_as_text(tmp_path):
""".txt has no parser — it *is* the fallthrough — so it is sniffed like any suffix.
Renaming a video to notes.txt would otherwise walk straight back into the
bug this gate exists for.
"""
from application.upload_limits import (
enforce_parseable_attachment,
UnsupportedUploadTypeError,
)
path = tmp_path / "notes.txt"
path.write_bytes(MP4_HEADER + bytes(range(256)) * 4)
with pytest.raises(UnsupportedUploadTypeError) as excinfo:
enforce_parseable_attachment(path, "notes.txt")
assert str(excinfo.value) == "Unsupported file type: .txt"
@pytest.mark.parametrize(
"content",
[
codecs.BOM_UTF8 + "hello — Unicode\n".encode(),
codecs.BOM_UTF16_LE + "hello\n".encode("utf-16-le"),
codecs.BOM_UTF16_BE + "hello\n".encode("utf-16-be"),
codecs.BOM_UTF32_BE + "hello\n".encode("utf-32-be"),
],
)
def test_enforce_parseable_attachment_accepts_bom_marked_unicode_text(
content, tmp_path
):
"""A UTF-16 .txt is half NUL bytes and still ordinary text — the BOM says so."""
from application.upload_limits import enforce_parseable_attachment
path = tmp_path / "notes.txt"
path.write_bytes(content)
enforce_parseable_attachment(path, "notes.txt")
@pytest.mark.parametrize(
"bom",
[codecs.BOM_UTF8, codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE, codecs.BOM_UTF32_BE],
)
def test_enforce_parseable_attachment_rejects_binary_behind_a_bom(bom, tmp_path):
"""A BOM says which encoding to read, not that the content is text.
Otherwise three prepended bytes buy any binary a pass.
"""
from application.upload_limits import (
enforce_parseable_attachment,
UnsupportedUploadTypeError,
)
path = tmp_path / "notes.txt"
path.write_bytes(bom + MP4_HEADER + bytes(range(256)) * 8)
with pytest.raises(UnsupportedUploadTypeError):
enforce_parseable_attachment(path, "notes.txt")
def test_enforce_parseable_attachment_uses_the_extractor_it_is_given(tmp_path):
"""The worker holds the live parser table; a trimmed install must not admit on trust.
Without docling the fallback extractor has no .webp handler, so a .webp
would otherwise skip the content check and be read as plain text.
"""
from application.upload_limits import (
enforce_parseable_attachment,
UnsupportedUploadTypeError,
)
path = tmp_path / "scan.webp"
path.write_bytes(b"RIFF\x00\x00\x00\x00WEBPVP8 " + bytes(range(256)))
# Default list: .webp is parser-backed, admitted on its name.
enforce_parseable_attachment(path, "scan.webp")
# The extractor actually loaded has no .webp parser.
with pytest.raises(UnsupportedUploadTypeError):
enforce_parseable_attachment(path, "scan.webp", {".pdf", ".docx"})
@pytest.mark.parametrize(
"filename",
[
"Report.PDF",
"photo.JPG",
"slides.pptx",
"voice.ogg",
"page.xhtml",
"doc.adoc",
"scan.webp",
"fax.tiff",
"subs.vtt",
"feed.xml",
],
)
def test_enforce_parseable_attachment_accepts_parser_backed_types(filename, tmp_path):
"""A parser-backed suffix is admitted on its name — a PDF is binary and parses fine."""
from application.upload_limits import enforce_parseable_attachment
path = tmp_path / "staged.bin"
path.write_bytes(MP4_HEADER)
enforce_parseable_attachment(path, filename)
@pytest.mark.parametrize(
"filename",
[
"notes.txt",
"main.py",
"server.log",
"config.yaml",
"query.sql",
"Dockerfile",
"notes.unknown",
],
)
def test_enforce_parseable_attachment_accepts_text_without_a_parser(filename, tmp_path):
"""The plain-text fallthrough reads these correctly, so they must stay allowed."""
from application.upload_limits import enforce_parseable_attachment
path = tmp_path / "staged.txt"
path.write_text("def main():\n\treturn 'café — ok'\n", encoding="utf-8")
enforce_parseable_attachment(path, filename)
@pytest.mark.parametrize(
("sample", "expected"),
[
(b"", True),
(b"plain text\n", True),
("café — em dash\n".encode(), True),
(b"\x1b[31mred log line\x1b[0m\n", True),
(codecs.BOM_UTF16_LE + "hi\n".encode("utf-16-le"), True),
(codecs.BOM_UTF8 + b"hi\n", True),
(codecs.BOM_UTF32_LE + "hi\n".encode("utf-32-le"), True),
# A BOM in front of binary is still binary.
(codecs.BOM_UTF8 + b"\x00\x01\x02", False),
(codecs.BOM_UTF16_LE + MP4_HEADER, False),
(b"text\x00with nul", False),
(bytes(range(32)) * 4, False),
(b"\x7f\x7f\x7f\x7f" + b"a" * 16, False),
],
)
def test_looks_like_text(sample, expected):
from application.upload_limits import looks_like_text
assert looks_like_text(sample) is expected
def test_file_looks_like_text_only_samples_the_head(tmp_path):
"""Binary past the sampled head is the parser's problem, not the gate's."""
from application.upload_limits import file_looks_like_text
path = tmp_path / "staged.log"
path.write_bytes(b"a" * 9000 + b"\x00" * 100)
assert file_looks_like_text(path) is True
def test_file_looks_like_text_allows_an_unreadable_file(tmp_path):
from application.upload_limits import file_looks_like_text
assert file_looks_like_text(tmp_path / "missing.txt") is True
def test_unsupported_upload_message_names_the_extension():
from application.upload_limits import unsupported_upload_message
assert unsupported_upload_message("clip.mp4") == "Unsupported file type: .mp4"
assert unsupported_upload_message("Clip.MP4") == "Unsupported file type: .mp4"
assert unsupported_upload_message("binary") == "Unsupported file type: (no extension)"
+112
View File
@@ -600,3 +600,115 @@ class TestCountPromptTokens:
]
tokens = _count_prompt_tokens(messages, tools=None)
assert tokens > 0
# ── prompt-cache breakdown ────────────────────────────────────────────────────
#
# Providers report ``cached_tokens`` (and, on newer OpenAI-family models,
# ``cache_write_tokens``) as breakdowns of ``prompt_tokens``. The prompt bin
# stays the provider total (never subtract the details back out); the two
# sub-bins ride alongside so persistence and the finish events can chart a
# cache hit rate.
class _ReportingLLM:
decoded_token = {"sub": "user_1"}
user_api_key = None
agent_id = None
token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
def __init__(self, details):
self._last_usage = {
"prompt_tokens": 1000,
"completion_tokens": 10,
"total_tokens": 1010,
}
if details is not None:
self._last_usage["prompt_tokens_details"] = details
self._last_usage_claimed = False
self.emitted = []
def _emit_gen_finished_log(self, model, **kwargs):
self.emitted.append(kwargs)
@pytest.mark.unit
def test_prefer_provider_usage_carries_cache_bins():
from application.usage import _prefer_provider_usage
llm = _ReportingLLM({"cached_tokens": 800, "cache_write_tokens": 100})
usage = _prefer_provider_usage(llm, {"prompt_tokens": 1, "generated_tokens": 1})
assert usage["prompt_tokens"] == 1000
assert usage["generated_tokens"] == 10
assert usage["cached_tokens"] == 800
assert usage["cache_write_tokens"] == 100
@pytest.mark.unit
def test_prefer_provider_usage_maps_anthropic_cache_creation_to_writes():
from application.usage import _prefer_provider_usage
llm = _ReportingLLM({"cached_tokens": 3, "cache_creation_tokens": 9})
usage = _prefer_provider_usage(llm, {"prompt_tokens": 1, "generated_tokens": 1})
assert usage["cached_tokens"] == 3
assert usage["cache_write_tokens"] == 9
@pytest.mark.unit
def test_prefer_provider_usage_without_details_has_no_cache_keys():
from application.usage import _prefer_provider_usage
llm = _ReportingLLM(None)
usage = _prefer_provider_usage(llm, {"prompt_tokens": 1, "generated_tokens": 1})
assert "cached_tokens" not in usage
assert "cache_write_tokens" not in usage
@pytest.mark.unit
def test_prefer_provider_usage_keeps_the_rest_of_the_call_record():
from application.usage import _prefer_provider_usage
llm = _ReportingLLM(None)
usage = _prefer_provider_usage(
llm, {"prompt_tokens": 1, "generated_tokens": 1, "model": "m"}
)
assert usage["model"] == "m"
@pytest.mark.unit
def test_decorator_persists_and_emits_cache_bins(monkeypatch):
_install_fake_token_repo(monkeypatch)
llm = _ReportingLLM({"cached_tokens": 800, "cache_write_tokens": 100})
@gen_token_usage
def wrapped(self, model, messages, stream, tools, **kwargs):
_ = (model, messages, stream, tools, kwargs)
return "ok"
wrapped(llm, "m", [{"role": "user", "content": "hi"}], False, None)
row = _FakeTokenUsageRepo.last_instance.inserted[0]
assert row["prompt_tokens"] == 1000
assert row["cached_tokens"] == 800
assert row["cache_write_tokens"] == 100
assert llm.emitted[0]["cached_tokens"] == 800
assert llm.emitted[0]["cache_write_tokens"] == 100
@pytest.mark.unit
def test_decorator_omits_cache_bins_when_provider_reports_none(monkeypatch):
_install_fake_token_repo(monkeypatch)
llm = _ReportingLLM(None)
@gen_token_usage
def wrapped(self, model, messages, stream, tools, **kwargs):
_ = (model, messages, stream, tools, kwargs)
return "ok"
wrapped(llm, "m", [{"role": "user", "content": "hi"}], False, None)
row = _FakeTokenUsageRepo.last_instance.inserted[0]
assert row["cached_tokens"] is None
assert row["cache_write_tokens"] is None
assert llm.emitted[0]["cached_tokens"] is None
assert llm.emitted[0]["cache_write_tokens"] is None
+68 -23
View File
@@ -263,11 +263,11 @@ class TestEmbeddingsSingleton:
def test_get_instance_hf_ignores_positional_key(
self, mock_get_wrapper, mock_settings
):
"""A stray key must not reach the zero-arg HuggingFace factory.
"""A stray key must not reach the wrapper for a registered model.
The factories are ``lambda: EmbeddingsWrapper(...)``, so a caller that
passed ``settings.EMBEDDINGS_KEY`` positionally used to blow up with
``TypeError: <lambda>() takes 0 positional arguments``.
Registered models take their whole configuration from the registry, so
a caller that passes ``settings.EMBEDDINGS_KEY`` positionally (as the
vector stores do) must have it dropped rather than forwarded.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_wrapper_cls = Mock()
@@ -278,9 +278,8 @@ class TestEmbeddingsSingleton:
result = EmbeddingsSingleton.get_instance(HF_MPNET, None)
assert result is mock_instance
mock_wrapper_cls.assert_called_once_with(
"sentence-transformers/all-mpnet-base-v2"
)
# The configured name is passed through; the registry maps it to a repo.
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base._get_embeddings_wrapper")
@@ -293,9 +292,7 @@ class TestEmbeddingsSingleton:
EmbeddingsSingleton.get_instance(HF_MPNET, openai_api_key="sk-nope")
mock_wrapper_cls.assert_called_once_with(
"sentence-transformers/all-mpnet-base-v2"
)
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
# --- BaseVectorStore ---
@@ -401,12 +398,15 @@ class TestBaseVectorStore:
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
@patch("os.path.exists")
def test_get_embeddings_huggingface_local_model(
self, mock_exists, mock_get_instance, mock_settings
def test_get_embeddings_registered_model_passes_configured_name(
self, mock_get_instance, mock_settings
):
"""No bundled-path branch any more: the name goes straight through.
FastEmbed resolves artifacts through its own cache (warmed in the
image), so the old ``/app/models/...`` probe has no job to do.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_exists.side_effect = lambda p: p == "/app/models/all-mpnet-base-v2"
mock_emb = Mock()
mock_get_instance.return_value = mock_emb
@@ -415,7 +415,9 @@ class TestBaseVectorStore:
"huggingface_sentence-transformers/all-mpnet-base-v2"
)
assert result is mock_emb
mock_get_instance.assert_called_with("/app/models/all-mpnet-base-v2")
mock_get_instance.assert_called_with(
"huggingface_sentence-transformers/all-mpnet-base-v2"
)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
@@ -518,18 +520,18 @@ class TestGetEmbeddingsResolver:
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base._get_embeddings_wrapper")
@patch("os.path.exists")
def test_uses_local_model_path_and_caches_it(
self, mock_exists, mock_get_wrapper, mock_settings
def test_repeated_resolution_loads_one_model(
self, mock_get_wrapper, mock_settings
):
"""With the bundled model present, the instance is keyed by its path.
"""A second call must not load a second copy of the model.
A second call must not load a second copy of the model.
The instance is keyed by the configured name. It used to be keyed by a
bundled filesystem path when one happened to exist, which meant the
same model could be cached twice under two keys.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_settings.EMBEDDINGS_NAME = HF_MPNET
mock_settings.EMBEDDINGS_KEY = None
mock_exists.side_effect = lambda path: path == LOCAL_MPNET
mock_wrapper_cls = Mock()
mock_wrapper_cls.return_value = Mock()
mock_get_wrapper.return_value = mock_wrapper_cls
@@ -538,8 +540,8 @@ class TestGetEmbeddingsResolver:
second = get_embeddings()
assert first is second
assert set(EmbeddingsSingleton._instances) == {LOCAL_MPNET}
mock_wrapper_cls.assert_called_once_with(LOCAL_MPNET)
assert set(EmbeddingsSingleton._instances) == {HF_MPNET}
mock_wrapper_cls.assert_called_once_with(HF_MPNET)
@patch("application.vectorstore.base.settings")
def test_remote_when_base_url_configured(self, mock_settings):
@@ -589,6 +591,49 @@ class TestGetEmbeddingsResolver:
"openai_text-embedding-ada-002", model="embed-deploy"
)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
def test_openai_alias_also_reaches_the_azure_deployment(
self, mock_get_instance, mock_settings
):
"""The registry accepts the bare alias, so the key handling must too.
Matching on the canonical string alone sent the alias down the generic
branch, where the deployment name is never passed and Azure answers
every embed with DeploymentNotFound.
"""
mock_settings.EMBEDDINGS_BASE_URL = None
mock_settings.OPENAI_API_BASE = "https://azure.openai.com"
mock_settings.OPENAI_API_VERSION = "2023-05-15"
mock_settings.AZURE_DEPLOYMENT_NAME = "deploy"
mock_settings.AZURE_EMBEDDINGS_DEPLOYMENT_NAME = "embed-deploy"
mock_settings.EMBEDDINGS_NAME = "text-embedding-ada-002"
mock_settings.EMBEDDINGS_KEY = "sk-key"
get_embeddings()
mock_get_instance.assert_called_once_with(
"text-embedding-ada-002", model="embed-deploy"
)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
def test_openai_name_is_matched_case_insensitively(
self, mock_get_instance, mock_settings
):
mock_settings.EMBEDDINGS_BASE_URL = None
mock_settings.OPENAI_API_BASE = None
mock_settings.OPENAI_API_VERSION = None
mock_settings.AZURE_DEPLOYMENT_NAME = None
mock_settings.EMBEDDINGS_NAME = "OpenAI_Text-Embedding-Ada-002"
mock_settings.EMBEDDINGS_KEY = "sk-from-settings"
get_embeddings()
mock_get_instance.assert_called_once_with(
"OpenAI_Text-Embedding-Ada-002", openai_api_key="sk-from-settings"
)
@patch("application.vectorstore.base.settings")
@patch("application.vectorstore.base.EmbeddingsSingleton.get_instance")
def test_explicit_arguments_win_over_settings(
@@ -0,0 +1,380 @@
"""Query embedding runs on the worker so the API holds no model."""
import threading
import time
from unittest.mock import MagicMock, patch
import pytest
from application.vectorstore import base
from application.vectorstore.embeddings_delegated import EMBED_TASK, DelegatedEmbeddings
@pytest.fixture(autouse=True)
def _clear_singleton():
base.EmbeddingsSingleton._instances.clear()
yield
base.EmbeddingsSingleton._instances.clear()
@pytest.fixture
def not_in_worker():
with patch("application.vectorstore.embeddings_delegated._in_worker", return_value=False):
yield
class TestDispatch:
def test_query_is_embedded_on_the_worker(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.return_value = [[0.1, 0.2, 0.3]]
with patch("application.celery_init.celery", celery):
vector = DelegatedEmbeddings("some/model").embed_query("hello")
assert vector == [0.1, 0.2, 0.3]
assert celery.send_task.call_args.args[0] == EMBED_TASK
assert celery.send_task.call_args.kwargs["args"] == [["hello"], "some/model"]
def test_routed_to_the_embeddings_queue(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.return_value = [[0.0]]
with patch("application.celery_init.celery", celery):
with patch.object(base.settings, "EMBEDDINGS_QUEUE", "embeddings"):
DelegatedEmbeddings("some/model").embed_query("hi")
assert celery.send_task.call_args.kwargs["queue"] == "embeddings"
def test_no_worker_gives_an_actionable_error(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.side_effect = TimeoutError("no worker")
with patch("application.celery_init.celery", celery):
with pytest.raises(RuntimeError) as excinfo:
DelegatedEmbeddings("some/model").embed_query("hi")
message = str(excinfo.value)
assert "EMBEDDINGS_DELEGATE_TO_WORKER=false" in message
assert "EMBEDDINGS_BASE_URL" in message
def test_empty_input_never_reaches_the_broker(self, not_in_worker):
celery = MagicMock()
with patch("application.celery_init.celery", celery):
assert DelegatedEmbeddings("some/model").embed_documents([]) == []
celery.send_task.assert_not_called()
class TestInsideAWorker:
"""Dispatching from inside a task would queue work behind itself."""
def test_a_running_task_embeds_locally(self):
local = MagicMock()
local.embed_documents.return_value = [[1.0, 2.0]]
celery = MagicMock()
with patch("application.vectorstore.embeddings_delegated._in_worker", return_value=True):
with patch("application.vectorstore.base.build_local_embeddings", return_value=local):
with patch("application.celery_init.celery", celery):
vector = DelegatedEmbeddings("some/model").embed_query("hi")
assert vector == [1.0, 2.0]
celery.send_task.assert_not_called()
def test_the_local_model_is_built_once(self):
local = MagicMock()
local.embed_documents.return_value = [[1.0]]
builder = MagicMock(return_value=local)
client = DelegatedEmbeddings("some/model")
with patch("application.vectorstore.embeddings_delegated._in_worker", return_value=True):
with patch("application.vectorstore.base.build_local_embeddings", builder):
client.embed_query("a")
client.embed_query("b")
builder.assert_called_once()
class TestDimension:
def test_registry_width_costs_no_round_trip(self):
celery = MagicMock()
with patch("application.celery_init.celery", celery):
client = DelegatedEmbeddings("ibm-granite/granite-embedding-311m-multilingual-r2")
assert client.dimension == 768
celery.send_task.assert_not_called()
def test_unknown_width_is_probed_once(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.return_value = [[0.0] * 1024]
with patch("application.celery_init.celery", celery):
client = DelegatedEmbeddings("some/unregistered")
assert client.dimension == 1024
assert client.dimension == 1024
celery.send_task.assert_called_once()
def test_an_unreachable_worker_reports_no_width(self, not_in_worker):
celery = MagicMock()
celery.send_task.return_value.get.side_effect = TimeoutError("down")
with patch("application.celery_init.celery", celery):
assert DelegatedEmbeddings("some/unregistered").dimension is None
class TestGetEmbeddingsDispatch:
def test_delegates_when_enabled(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", None):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True):
assert isinstance(base.get_embeddings("some/model"), DelegatedEmbeddings)
def test_remote_url_wins_over_delegation(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", "http://embed.local"):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True):
assert isinstance(base.get_embeddings("some/model"), base.RemoteEmbeddings)
def test_disabled_loads_in_process(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", None):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False):
with patch.object(base.EmbeddingsSingleton, "get_instance") as get_instance:
base.get_embeddings("some/model")
get_instance.assert_called_once()
def test_the_delegating_client_is_shared(self):
with patch.object(base.settings, "EMBEDDINGS_BASE_URL", None):
with patch.object(base.settings, "EMBEDDINGS_DELEGATE_TO_WORKER", True):
assert base.get_embeddings("some/model") is base.get_embeddings("some/model")
class TestFailureCooldown:
"""One dead-worker timeout per retrieval, not one per source.
``fanout.embed_questions`` swallows a dispatch failure and lets every store
embed its own query, so without a latch a single chat request pays
``EMBEDDINGS_DELEGATE_TIMEOUT`` once in the fan-out and again per source.
A missing worker is a property of the deployment, not of the call.
"""
@staticmethod
def _celery(side_effect):
result = MagicMock()
result.get.side_effect = side_effect
celery = MagicMock()
celery.send_task.return_value = result
return celery, result
def test_only_the_first_call_waits_out_the_timeout(self, not_in_worker):
celery, _ = self._celery(TimeoutError("no worker"))
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
for _ in range(4):
with pytest.raises(RuntimeError):
embeddings.embed_query("q")
assert celery.send_task.call_count == 1
def test_the_fast_failure_still_names_the_remedy(self, not_in_worker):
celery, _ = self._celery(TimeoutError("no worker"))
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
with pytest.raises(RuntimeError):
embeddings.embed_query("q")
with pytest.raises(RuntimeError, match="EMBEDDINGS_DELEGATE_TO_WORKER=false"):
embeddings.embed_query("q")
def test_the_latch_clears_once_the_worker_answers(self, not_in_worker):
celery, result = self._celery(TimeoutError("no worker"))
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
with pytest.raises(RuntimeError):
embeddings.embed_query("q")
embeddings._failed_at = None # stand in for the cooldown elapsing
result.get.side_effect = None
result.get.return_value = [[0.5, 0.5]]
assert embeddings.embed_query("q") == [0.5, 0.5]
assert embeddings._cooldown_remaining() == 0.0
def test_a_healthy_worker_is_never_latched(self, not_in_worker):
celery, result = self._celery(None)
result.get.return_value = [[0.1, 0.2]]
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
for _ in range(3):
assert embeddings.embed_query("q") == [0.1, 0.2]
assert celery.send_task.call_count == 3
class TestTheConcurrentFirstWave:
"""The latch cannot cover requests already in flight beside the first one.
Nothing is latched until that first ``get()`` returns, so every thread in
the opening wave would otherwise block for the full
``EMBEDDINGS_DELEGATE_TIMEOUT`` at once -- at the shipped 60s across a 96
thread WSGI pool, an API that serves nothing at all.
"""
@staticmethod
def _blocking_celery(release, outcome):
"""A worker whose ``get`` blocks until ``release`` is set."""
def get(timeout=None):
release.wait(5)
if isinstance(outcome, Exception):
raise outcome
return outcome
result = MagicMock()
result.get.side_effect = get
celery = MagicMock()
celery.send_task.return_value = result
return celery
def _race(self, celery, embeddings, release, threads=8):
"""Start ``threads`` embeds, let them pile up, then unblock the prober."""
errors, values = [], []
started = threading.Barrier(threads + 1)
def call():
started.wait(5)
try:
values.append(embeddings.embed_query("q"))
except Exception as exc: # noqa: BLE001 -- recorded for the assertions
errors.append(exc)
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
workers = [threading.Thread(target=call) for _ in range(threads)]
for worker in workers:
worker.start()
started.wait(5)
time.sleep(0.1) # let the followers reach the probe gate
release.set()
for worker in workers:
worker.join(10)
return values, errors
def test_only_one_caller_waits_on_an_unproven_worker(self, not_in_worker):
release = threading.Event()
celery = self._blocking_celery(release, TimeoutError("no worker"))
embeddings = DelegatedEmbeddings("granite-311m")
with patch(
"application.vectorstore.embeddings_delegated._PROBE_WAIT", 0.05
):
values, errors = self._race(celery, embeddings, release)
assert values == []
assert len(errors) == 8
# One probe published; the rest gave up without their own round trip.
assert celery.send_task.call_count == 1
assert sum("still unanswered" in str(e) for e in errors) == 7
def test_the_fast_failure_still_names_the_remedy(self, not_in_worker):
release = threading.Event()
celery = self._blocking_celery(release, TimeoutError("no worker"))
embeddings = DelegatedEmbeddings("granite-311m")
with patch("application.vectorstore.embeddings_delegated._PROBE_WAIT", 0.05):
_, errors = self._race(celery, embeddings, release, threads=3)
assert all("EMBEDDINGS_DELEGATE_TO_WORKER=false" in str(e) for e in errors)
def test_a_healthy_worker_serves_the_whole_wave(self, not_in_worker):
release = threading.Event()
celery = self._blocking_celery(release, [[0.1, 0.2]])
embeddings = DelegatedEmbeddings("granite-311m")
values, errors = self._race(celery, embeddings, release)
assert errors == []
assert values == [[0.1, 0.2]] * 8
# The probe proves the worker, then every follower dispatches for real.
assert celery.send_task.call_count == 8
assert embeddings._verified is True
def test_a_proven_worker_adds_no_gate(self, not_in_worker):
"""After one success the probe is out of the path entirely."""
release = threading.Event()
release.set()
celery = self._blocking_celery(release, [[0.3]])
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
embeddings.embed_query("warm")
assert embeddings._verified is True
with patch.object(embeddings, "_state_lock") as lock:
with patch.dict(
"sys.modules", {"application.celery_init": MagicMock(celery=celery)}
):
embeddings.embed_query("q")
lock.__enter__.assert_not_called()
def test_a_proven_worker_that_dies_is_gated_again(self, not_in_worker):
"""The proof must not outlive the worker that supplied it.
A worker that is redeployed or OOM-killed is the failure that actually
happens in production, and it is the one the probe gate stopped
covering: ``_verified`` short-circuits ahead of it. The wave in flight
when the worker dies cannot be saved -- every caller is already past
the check -- but every wave after it must be gated again.
"""
warm = threading.Event()
warm.set()
healthy = self._blocking_celery(warm, [[0.4]])
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict(
"sys.modules", {"application.celery_init": MagicMock(celery=healthy)}
):
embeddings.embed_query("warm")
assert embeddings._verified is True
# No cooldown, so anything that gates the second wave can only be the
# probe -- which engages only because the failure cleared _verified.
with patch(
"application.vectorstore.embeddings_delegated._FAILURE_COOLDOWN", 0.0
), patch("application.vectorstore.embeddings_delegated._PROBE_WAIT", 0.05):
dying = threading.Event()
died = self._blocking_celery(dying, TimeoutError("worker went away"))
self._race(died, embeddings, dying)
# The wave that was already in flight all dispatched, as it must.
assert died.send_task.call_count == 8
assert embeddings._verified is False
again = threading.Event()
still_dead = self._blocking_celery(again, TimeoutError("still gone"))
values, errors = self._race(still_dead, embeddings, again)
assert values == []
assert len(errors) == 8
# One probe pays the timeout; the other seven fail fast.
assert still_dead.send_task.call_count == 1
assert sum("still unanswered" in str(e) for e in errors) == 7
class TestTheResultIsForgotten:
"""A query vector must not outlive the query that asked for it.
``result_expires`` is 7 days and ``embed_texts`` stores its result, but the
key is ``celery-task-meta-<uuid>`` -- minted per dispatch, never derived
from the text -- so nothing reads it back and a repeated query mints
another. Without ``forget()`` every search leaks ~17 KB into the Redis the
broker shares for a week.
"""
@staticmethod
def _celery(side_effect=None, value=None):
result = MagicMock()
result.get.side_effect = side_effect
result.get.return_value = value
celery = MagicMock()
celery.send_task.return_value = result
return celery, result
def test_a_successful_embed_forgets_its_result(self, not_in_worker):
celery, result = self._celery(value=[[0.1, 0.2]])
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
assert embeddings.embed_query("q") == [0.1, 0.2]
result.forget.assert_called_once()
def test_a_failed_embed_still_forgets(self, not_in_worker):
celery, result = self._celery(side_effect=TimeoutError("no worker"))
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
with pytest.raises(RuntimeError):
embeddings.embed_query("q")
result.forget.assert_called_once()
def test_a_backend_that_cannot_delete_does_not_fail_the_query(self, not_in_worker):
celery, result = self._celery(value=[[0.3, 0.4]])
result.forget.side_effect = ConnectionError("backend down")
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
assert embeddings.embed_query("q") == [0.3, 0.4]
def test_forgetting_does_not_mask_the_dispatch_failure(self, not_in_worker):
celery, result = self._celery(side_effect=TimeoutError("no worker"))
result.forget.side_effect = ConnectionError("backend down")
embeddings = DelegatedEmbeddings("granite-311m")
with patch.dict("sys.modules", {"application.celery_init": MagicMock(celery=celery)}):
with pytest.raises(RuntimeError, match="timed out or failed"):
embeddings.embed_query("q")
+350 -113
View File
@@ -1,156 +1,393 @@
from unittest.mock import MagicMock, Mock, patch
"""Local embeddings run through FastEmbed, configured from the model registry."""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from application.vectorstore import embeddings_local
from application.vectorstore.embeddings_local import EmbeddingsWrapper
from application.vectorstore.model_registry import GRANITE_97M, MPNET
@pytest.mark.unit
class TestEmbeddingsWrapper:
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_success(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_embedding_dimension.return_value = 768
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
@pytest.fixture(autouse=True)
def _clear_registration():
"""``add_custom_model`` writes to a FastEmbed global; keep tests isolated."""
embeddings_local._registered.clear()
yield
embeddings_local._registered.clear()
wrapper = EmbeddingsWrapper("test-model")
mock_st_cls.assert_called_once()
assert wrapper.dimension == 768
@pytest.fixture(autouse=True)
def _no_hub_reads():
"""Keep unit tests off the network.
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_falls_back_to_legacy_dimension_api(self, mock_st_cls):
"""sentence-transformers < 5.4 only exposes get_sentence_embedding_dimension."""
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
del mock_model.get_embedding_dimension
mock_model.get_sentence_embedding_dimension.return_value = 768
mock_st_cls.return_value = mock_model
``_spec_for`` now asks a repository how it pools; without this every test
naming an unregistered model would reach the Hugging Face hub. ``None`` is
the "declares nothing" answer, which is the behaviour these tests were
written against. Tests that exercise the metadata patch it themselves.
"""
with patch.object(embeddings_local, "_read_repo_json", return_value=None):
yield
from application.vectorstore.embeddings_local import EmbeddingsWrapper
wrapper = EmbeddingsWrapper("test-model")
@pytest.fixture
def fake_fastembed():
"""Patch FastEmbed so no model is downloaded or run."""
text_embedding = MagicMock()
instance = MagicMock()
instance.embed.return_value = iter([np.array([0.1, 0.2, 0.3])])
text_embedding.return_value = instance
# Registration checks this before calling ``add_custom_model``; an empty
# list means "no built-in collides", which is the case for every name in
# our registry.
text_embedding.list_supported_models.return_value = []
with patch("fastembed.TextEmbedding", text_embedding):
yield text_embedding, instance
assert wrapper.dimension == 768
mock_model.get_sentence_embedding_dimension.assert_called_once()
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_failure(self, mock_st_cls):
mock_st_cls.side_effect = Exception("model not found")
class TestBuiltinModelRegistration:
"""FastEmbed ships ~30 models of its own and refuses to re-register any of
them, so registering unconditionally broke every natively-supported name."""
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_builtin_name_is_not_re_registered(self, fake_fastembed):
text_embedding, _ = fake_fastembed
text_embedding.list_supported_models.return_value = [
{"model": "BAAI/bge-small-en-v1.5"}
]
EmbeddingsWrapper("BAAI/bge-small-en-v1.5")
text_embedding.add_custom_model.assert_not_called()
assert text_embedding.call_args.kwargs["model_name"] == "BAAI/bge-small-en-v1.5"
with pytest.raises(Exception, match="model not found"):
EmbeddingsWrapper("bad-model")
def test_builtin_match_ignores_case(self, fake_fastembed):
text_embedding, _ = fake_fastembed
text_embedding.list_supported_models.return_value = [
{"model": "baai/BGE-Small-EN-v1.5"}
]
EmbeddingsWrapper("BAAI/bge-small-en-v1.5")
text_embedding.add_custom_model.assert_not_called()
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_none_model(self, mock_st_cls):
mock_st_cls.return_value = None
def test_unknown_name_is_still_registered(self, fake_fastembed):
text_embedding, _ = fake_fastembed
text_embedding.list_supported_models.return_value = [
{"model": "BAAI/bge-small-en-v1.5"}
]
EmbeddingsWrapper("some-org/custom-embedder")
text_embedding.add_custom_model.assert_called_once()
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_real_fastembed_accepts_its_own_builtin(self):
"""Runs against the installed FastEmbed, not the MagicMock.
with pytest.raises((ValueError, AttributeError)):
EmbeddingsWrapper("bad-model")
The mocked tests above cannot catch this: the failure was
``add_custom_model`` raising, and a MagicMock never raises.
"""
fastembed = pytest.importorskip("fastembed")
builtins = [m["model"] for m in fastembed.TextEmbedding.list_supported_models()]
assert builtins, "expected FastEmbed to ship built-in models"
spec = embeddings_local._spec_for(builtins[0])
# Must not raise ValueError("... is already registered ...").
embeddings_local._register(spec)
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_init_null_first_module(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = None
mock_st_cls.return_value = mock_model
from application.vectorstore.embeddings_local import EmbeddingsWrapper
class TestRegistryDrivenLoading:
def test_registered_model_loads_by_repo_not_by_configured_name(self, fake_fastembed):
text_embedding, _ = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["model_name"] == MPNET.repo
assert wrapper.dimension == MPNET.dimension
with pytest.raises(ValueError, match="failed to load properly"):
EmbeddingsWrapper("bad-model")
def test_legacy_alias_resolves_to_the_same_model(self, fake_fastembed):
text_embedding, _ = fake_fastembed
EmbeddingsWrapper("huggingface_sentence-transformers-all-mpnet-base-v2")
assert text_embedding.call_args.kwargs["model_name"] == MPNET.repo
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_embed_query(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1, 0.2, 0.3]))
mock_st_cls.return_value = mock_model
def test_dimension_comes_from_registry_without_running_the_model(self, fake_fastembed):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(GRANITE_97M.name)
assert wrapper.dimension == 384
instance.embed.assert_not_called()
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_unknown_model_is_treated_as_a_hf_repo(self, fake_fastembed):
text_embedding, _ = fake_fastembed
wrapper = EmbeddingsWrapper("some-org/custom-embedder")
assert text_embedding.call_args.kwargs["model_name"] == "some-org/custom-embedder"
# No registry entry means no known width, so it must be probed.
assert wrapper.dimension == 3
wrapper = EmbeddingsWrapper("model")
result = wrapper.embed_query("hello world")
def test_load_failure_names_the_model_and_the_known_ones(self):
with patch("fastembed.TextEmbedding", side_effect=OSError("no such repo")):
with pytest.raises(RuntimeError) as excinfo:
EmbeddingsWrapper("broken/model")
message = str(excinfo.value)
assert "broken/model" in message
assert MPNET.name in message
mock_model.encode.assert_called_once_with("hello world")
assert result == [0.1, 0.2, 0.3]
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_embed_documents(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(
tolist=Mock(return_value=[[0.1, 0.2], [0.3, 0.4]])
class TestSettingsPassthrough:
def test_threads_forwarded_when_configured(self, fake_fastembed):
text_embedding, _ = fake_fastembed
with patch.object(embeddings_local.settings, "EMBEDDINGS_THREADS", 2, create=True):
EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["threads"] == 2
def test_threads_omitted_when_unset(self, fake_fastembed):
text_embedding, _ = fake_fastembed
with patch.object(embeddings_local.settings, "EMBEDDINGS_THREADS", None, create=True):
EmbeddingsWrapper(MPNET.name)
assert "threads" not in text_embedding.call_args.kwargs
def test_cache_dir_forwarded_when_configured(self, fake_fastembed):
text_embedding, _ = fake_fastembed
with patch.object(embeddings_local.settings, "EMBEDDINGS_CACHE_DIR", "/models", create=True):
EmbeddingsWrapper(MPNET.name)
assert text_embedding.call_args.kwargs["cache_dir"] == "/models"
class TestEmbedding:
def test_embed_documents_returns_plain_lists(self, fake_fastembed):
_, instance = fake_fastembed
instance.embed.return_value = iter([np.array([1.0, 2.0]), np.array([3.0, 4.0])])
wrapper = EmbeddingsWrapper(MPNET.name)
assert wrapper.embed_documents(["a", "b"]) == [[1.0, 2.0], [3.0, 4.0]]
def test_embed_documents_short_circuits_on_empty_input(self, fake_fastembed):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
instance.embed.reset_mock()
assert wrapper.embed_documents([]) == []
instance.embed.assert_not_called()
def test_embed_query_returns_a_single_vector(self, fake_fastembed):
_, instance = fake_fastembed
instance.embed.return_value = iter([np.array([0.5, 0.6])])
wrapper = EmbeddingsWrapper(MPNET.name)
assert wrapper.embed_query("hello") == [0.5, 0.6]
def test_call_dispatches_on_input_type(self, fake_fastembed):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
instance.embed.return_value = iter([np.array([1.0])])
assert wrapper("text") == [1.0]
instance.embed.return_value = iter([np.array([1.0]), np.array([2.0])])
assert wrapper(["a", "b"]) == [[1.0], [2.0]]
def test_call_rejects_other_types(self, fake_fastembed):
wrapper = EmbeddingsWrapper(MPNET.name)
with pytest.raises(ValueError):
wrapper(42)
class TestRegistrationIsIdempotent:
def test_model_registered_once_per_process(self, fake_fastembed):
text_embedding, _ = fake_fastembed
EmbeddingsWrapper(MPNET.name)
EmbeddingsWrapper(MPNET.name)
assert text_embedding.add_custom_model.call_count == 1
class TestLengthSortedBatching:
"""Grouping by length is a throughput/memory win, but order is a contract."""
def _wrapper(self, fake_fastembed, batch_size):
_, instance = fake_fastembed
wrapper = EmbeddingsWrapper(MPNET.name)
instance.embed.side_effect = lambda texts, batch_size=None: iter(
[np.array([float(len(t))]) for t in texts]
)
mock_st_cls.return_value = mock_model
return wrapper, instance
from application.vectorstore.embeddings_local import EmbeddingsWrapper
def test_output_order_matches_input_order(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 2, create=True):
wrapper, _ = self._wrapper(fake_fastembed, 2)
texts = ["dddd", "a", "ccc", "bb", "eeeee"]
out = wrapper.embed_documents(texts)
# Each stub vector encodes its own text length, so a reordered result
# is immediately visible.
assert out == [[4.0], [1.0], [3.0], [2.0], [5.0]]
wrapper = EmbeddingsWrapper("model")
result = wrapper.embed_documents(["doc1", "doc2"])
def test_inputs_are_grouped_by_length_before_batching(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 2, create=True):
wrapper, instance = self._wrapper(fake_fastembed, 2)
wrapper.embed_documents(["dddd", "a", "ccc", "bb", "eeeee"])
sent = instance.embed.call_args.args[0]
assert [len(t) for t in sent] == [1, 2, 3, 4, 5]
mock_model.encode.assert_called_with(["doc1", "doc2"])
assert result == [[0.1, 0.2], [0.3, 0.4]]
def test_single_batch_is_not_reordered(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 32, create=True):
wrapper, instance = self._wrapper(fake_fastembed, 32)
texts = ["dddd", "a", "ccc"]
out = wrapper.embed_documents(texts)
assert instance.embed.call_args.args[0] == texts
assert out == [[4.0], [1.0], [3.0]]
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_call_with_string(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(tolist=Mock(return_value=[0.1]))
mock_st_cls.return_value = mock_model
def test_duplicate_texts_are_handled(self, fake_fastembed):
with patch.object(embeddings_local.settings, "EMBEDDINGS_MODEL_BATCH_SIZE", 2, create=True):
wrapper, _ = self._wrapper(fake_fastembed, 2)
out = wrapper.embed_documents(["aa", "b", "aa", "ccc"])
assert out == [[2.0], [1.0], [2.0], [3.0]]
from application.vectorstore.embeddings_local import EmbeddingsWrapper
wrapper = EmbeddingsWrapper("model")
result = wrapper("hello")
assert result == [0.1]
class TestTokenizerPadding:
"""A fixed padding width in ``tokenizer.json`` makes mixed batches ragged.
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_call_with_list(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_model.encode.return_value = MagicMock(
tolist=Mock(return_value=[[0.1], [0.2]])
FastEmbed calls ``enable_padding`` only when the tokenizer declares none,
so mpnet's fixed ``length: 128`` survives loading. Any batch mixing an
input longer than 128 tokens with a shorter one then produces rows of
different widths and ONNX rejects the tensor.
"""
def _tokenizer(self, padding):
tokenizer = MagicMock()
tokenizer.padding = padding
return tokenizer
def test_fixed_width_padding_is_reset_to_batch_longest(self, fake_fastembed):
_, instance = fake_fastembed
tokenizer = self._tokenizer(
{
"length": 128,
"pad_id": 1,
"pad_token": "<pad>",
"pad_type_id": 0,
"direction": "right",
"pad_to_multiple_of": None,
}
)
mock_st_cls.return_value = mock_model
instance.model.tokenizer = tokenizer
from application.vectorstore.embeddings_local import EmbeddingsWrapper
EmbeddingsWrapper(MPNET.name)
wrapper = EmbeddingsWrapper("model")
result = wrapper(["a", "b"])
assert result == [[0.1], [0.2]]
kwargs = tokenizer.enable_padding.call_args.kwargs
assert kwargs["length"] is None, "padding must follow the longest input"
# The model's own pad token must survive the reset.
assert kwargs["pad_id"] == 1
assert kwargs["pad_token"] == "<pad>"
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_call_with_invalid_type(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 3
mock_st_cls.return_value = mock_model
def test_dynamic_padding_is_left_alone(self, fake_fastembed):
_, instance = fake_fastembed
tokenizer = self._tokenizer({"length": None, "pad_id": 0, "pad_token": "<pad>"})
instance.model.tokenizer = tokenizer
from application.vectorstore.embeddings_local import EmbeddingsWrapper
EmbeddingsWrapper(GRANITE_97M.name)
wrapper = EmbeddingsWrapper("model")
with pytest.raises(ValueError, match="Input must be a string or a list"):
wrapper(123)
tokenizer.enable_padding.assert_not_called()
@patch("application.vectorstore.embeddings_local.SentenceTransformer")
def test_trust_remote_code_default(self, mock_st_cls):
mock_model = MagicMock()
mock_model._first_module.return_value = MagicMock()
mock_model.get_sentence_embedding_dimension.return_value = 768
mock_st_cls.return_value = mock_model
def test_tokenizer_that_cannot_be_reached_is_not_fatal(self, fake_fastembed):
_, instance = fake_fastembed
instance.model = None
EmbeddingsWrapper(GRANITE_97M.name)
from application.vectorstore.embeddings_local import EmbeddingsWrapper
EmbeddingsWrapper("model")
def _repo_json(pooling_file, modules_file):
"""Stub ``_read_repo_json`` returning canned repository metadata."""
call_kwargs = mock_st_cls.call_args[1]
assert call_kwargs["trust_remote_code"] is True
def read(repo, filename):
return pooling_file if filename == embeddings_local._POOLING_CONFIG else modules_file
return read
class TestPoolingReadFromTheRepository:
"""A model's pooling is a fact its repository states, not a default.
Assuming mean pooling for a CLS model returns vectors at cosine ~0.95 to
the correct ones: no error, no dimension mismatch, just quietly worse
retrieval. These cover the shapes seen on the hub.
"""
def test_cls_pooling_is_read_rather_than_assumed(self):
with patch.object(
embeddings_local,
"_read_repo_json",
_repo_json(
{"pooling_mode_cls_token": True, "word_embedding_dimension": 384},
[{"type": "sentence_transformers.models.Transformer"},
{"type": "sentence_transformers.models.Pooling"},
{"type": "sentence_transformers.models.Normalize"}],
),
):
spec = embeddings_local._spec_for("BAAI/bge-small-en-v1.5")
assert spec.pooling == "cls"
assert spec.normalize is True
# Declared width, so no probe run is needed to learn it.
assert spec.dimension == 384
def test_missing_normalize_module_means_unnormalised(self):
"""multi-qa-mpnet-base-dot-v1 is trained on unnormalised vectors."""
with patch.object(
embeddings_local,
"_read_repo_json",
_repo_json(
{"pooling_mode_cls_token": True, "word_embedding_dimension": 768},
[{"type": "sentence_transformers.models.Transformer"},
{"type": "sentence_transformers.models.Pooling"}],
),
):
spec = embeddings_local._spec_for("sentence-transformers/multi-qa-mpnet-base-dot-v1")
assert spec.pooling == "cls"
assert spec.normalize is False
def test_dense_projection_head_is_refused(self):
"""FastEmbed would skip the projection and emit the wrong vectors."""
with patch.object(
embeddings_local,
"_read_repo_json",
_repo_json(
{"pooling_mode_cls_token": True, "word_embedding_dimension": 768},
[{"type": "sentence_transformers.models.Transformer"},
{"type": "sentence_transformers.models.Pooling"},
{"type": "sentence_transformers.models.Dense"},
{"type": "sentence_transformers.models.Normalize"}],
),
):
with pytest.raises(RuntimeError) as excinfo:
embeddings_local._spec_for("sentence-transformers/LaBSE")
message = str(excinfo.value)
assert "LaBSE" in message
assert "Dense" in message
def test_unsupported_pooling_mode_falls_back_rather_than_lying(self):
with patch.object(
embeddings_local,
"_read_repo_json",
_repo_json({"pooling_mode_max_tokens": True}, []),
):
spec = embeddings_local._spec_for("some-org/max-pooled")
assert spec.pooling == embeddings_local._FALLBACK_POOLING
assert spec.dimension == 0
def test_repository_without_metadata_keeps_the_assumption(self):
spec = embeddings_local._spec_for("some-org/plain-onnx-export")
assert spec.pooling == embeddings_local._FALLBACK_POOLING
assert spec.normalize is True
assert spec.dimension == 0
def test_registry_wins_over_the_repository(self):
"""A described model is never re-read; the registry is the answer."""
read = MagicMock()
with patch.object(embeddings_local, "_read_repo_json", read):
spec = embeddings_local._spec_for(MPNET.name)
assert spec is MPNET
read.assert_not_called()
class TestPoolingOverrides:
def test_settings_override_what_the_repository_declares(self):
with patch.object(
embeddings_local,
"_read_repo_json",
_repo_json(
{"pooling_mode_mean_tokens": True, "word_embedding_dimension": 768},
[{"type": "sentence_transformers.models.Normalize"}],
),
):
with patch.object(embeddings_local.settings, "EMBEDDINGS_POOLING", "cls"), \
patch.object(embeddings_local.settings, "EMBEDDINGS_NORMALIZE", False):
spec = embeddings_local._spec_for("some-org/mislabelled")
assert spec.pooling == "cls"
assert spec.normalize is False
def test_a_meaningless_override_is_ignored(self):
with patch.object(embeddings_local.settings, "EMBEDDINGS_POOLING", "banana"):
spec = embeddings_local._spec_for("some-org/plain-onnx-export")
assert spec.pooling == embeddings_local._FALLBACK_POOLING
+68 -7
View File
@@ -5,11 +5,13 @@ than asserting that calls were forwarded to a mock.
"""
import json
from unittest.mock import Mock, patch
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
import pytest
from application.storage.local import LocalStorage
from application.vectorstore.faiss import FaissStore
class _FakeEmbeddings:
@@ -185,15 +187,20 @@ class TestFaissStoreAssertEmbeddingDimensions:
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
populated.assert_embedding_dimensions(Mock(dimension=768))
def test_missing_dimension_attr_raises(self, populated):
def test_unknown_dimension_defers_rather_than_raising(self, populated):
"""A remote model reports no width until its first call.
Refusing to open the index in that window would break startup for a
perfectly valid remote configuration, so an unknown width is deferred,
not treated as a mismatch.
"""
with patch("application.vectorstore.faiss.settings") as mock_settings:
mock_settings.EMBEDDINGS_NAME = (
"huggingface_sentence-transformers/all-mpnet-base-v2"
)
embeddings = Mock()
del embeddings.dimension
with pytest.raises(AttributeError, match="'dimension' attribute not found"):
populated.assert_embedding_dimensions(embeddings)
embeddings.dimension = None
assert populated.assert_embedding_dimensions(embeddings) is None
def test_dimension_match_passes(self, populated):
with patch("application.vectorstore.faiss.settings") as mock_settings:
@@ -202,10 +209,22 @@ class TestFaissStoreAssertEmbeddingDimensions:
)
assert populated.assert_embedding_dimensions(Mock(dimension=3)) is None
def test_non_huggingface_skips_dimension_check(self, populated):
def test_mismatch_is_caught_for_every_model_not_just_mpnet(self, populated):
"""The check used to run only when EMBEDDINGS_NAME was mpnet.
That skipped exactly the case it exists for: an index built with one
model being opened under a different one.
"""
with patch("application.vectorstore.faiss.settings") as mock_settings:
mock_settings.EMBEDDINGS_NAME = "openai_text-embedding-ada-002"
assert populated.assert_embedding_dimensions(Mock(dimension=1536)) is None
with pytest.raises(ValueError, match="Embedding dimension mismatch"):
populated.assert_embedding_dimensions(Mock(dimension=1536))
def test_mismatch_message_points_at_the_reembed_script(self, populated):
with patch("application.vectorstore.faiss.settings") as mock_settings:
mock_settings.EMBEDDINGS_NAME = "granite-311m"
with pytest.raises(ValueError, match="application.scripts.reembed"):
populated.assert_embedding_dimensions(Mock(dimension=768))
@pytest.mark.unit
@@ -226,3 +245,45 @@ class TestGetVectorstore:
with pytest.raises(ValueError, match="Invalid source_id path"):
get_vectorstore(bad)
class TestBuildFromDocumentsBatching:
"""The full rebuild path: honour caller ids and embed in batches."""
def _docs(self, n):
return [
SimpleNamespace(page_content=f"chunk {i}", metadata={"i": i})
for i in range(n)
]
def _store(self, embed):
store = FaissStore.__new__(FaissStore)
store.embeddings = MagicMock()
store.embeddings.embed_documents.side_effect = embed
store.documents = {}
store.index_to_docstore_id = {}
store.index = None
return store
def test_supplied_ids_are_used(self):
store = self._store(lambda texts: [[float(len(texts))] * 2 for _ in texts])
store._build_from_documents(self._docs(3), ids=["a", "b", "c"])
assert list(store.documents) == ["a", "b", "c"]
def test_embedding_is_split_into_batches(self):
sizes = []
def embed(texts):
sizes.append(len(texts))
return [[1.0, 2.0] for _ in texts]
store = self._store(embed)
store._build_from_documents(self._docs(5), batch_size=2)
assert sizes == [2, 2, 1]
assert len(store.index_to_docstore_id) == 5
def test_defaults_are_unchanged(self):
store = self._store(lambda texts: [[1.0, 2.0] for _ in texts])
store._build_from_documents(self._docs(3))
assert len(store.documents) == 3
assert all(len(k) == 36 for k in store.documents), "uuid4 ids by default"
+117
View File
@@ -0,0 +1,117 @@
"""The registry is the single source of truth for embedding-model facts."""
import os
import pytest
from application.vectorstore import model_registry as reg
class TestResolve:
def test_resolves_canonical_name(self):
assert reg.resolve(reg.MPNET.name) is reg.MPNET
@pytest.mark.parametrize(
"alias",
[
"huggingface_sentence-transformers-all-mpnet-base-v2",
"sentence-transformers/all-mpnet-base-v2",
"all-mpnet-base-v2",
],
)
def test_resolves_legacy_spellings_of_mpnet(self, alias):
"""Every spelling the old factory dict accepted must still work."""
assert reg.resolve(alias) is reg.MPNET
def test_resolution_is_case_insensitive_and_trims(self):
assert reg.resolve(" GRANITE-311M ") is reg.GRANITE_311M
def test_unknown_name_is_none_not_an_error(self):
"""Unknown names are a valid configuration: an arbitrary HF repo."""
assert reg.resolve("some-org/some-model") is None
def test_empty_and_none_resolve_to_none(self):
assert reg.resolve(None) is None
assert reg.resolve("") is None
class TestModelFacts:
def test_granite_311m_matches_the_existing_column_width(self):
"""768 is what makes granite a drop-in for an mpnet index."""
assert reg.GRANITE_311M.dimension == reg.MPNET.dimension == 768
def test_granite_97m_is_narrower(self):
assert reg.GRANITE_97M.dimension == 384
def test_pooling_and_normalisation_are_recorded(self):
assert reg.MPNET.pooling == "mean"
assert reg.GRANITE_311M.pooling == "cls"
assert all(m.normalize for m in reg.MODELS)
def test_granite_context_is_much_wider_than_mpnet(self):
assert reg.GRANITE_311M.max_input_tokens == 32768
assert reg.MPNET.max_input_tokens == 384
def test_local_runners_carry_a_repo_and_onnx_file(self):
for model in reg.MODELS:
if model.provider == "fastembed":
assert model.repo, f"{model.name} has no repo"
assert model.onnx_file, f"{model.name} has no onnx_file"
def test_openai_model_needs_no_local_artifacts(self):
assert reg.OPENAI_ADA_002.provider == "openai"
assert reg.OPENAI_ADA_002.repo is None
class TestHelpers:
def test_dimension_for_known_and_unknown(self):
assert reg.dimension_for(reg.GRANITE_97M.name) == 384
assert reg.dimension_for("nope/nope") is None
def test_max_input_tokens_for_known_and_unknown(self):
assert reg.max_input_tokens_for("granite-311m") == 32768
assert reg.max_input_tokens_for("nope/nope") is None
def test_known_names_lists_canonical_spellings(self):
names = reg.known_names()
assert reg.MPNET.name in names
assert reg.GRANITE_311M.name in names
def test_defaults_point_at_the_intended_models(self):
"""Existing installs stay on mpnet; new installs get granite."""
assert reg.DEFAULT_LEGACY == reg.MPNET.name
assert reg.DEFAULT_NEW_INSTALL == reg.GRANITE_311M.name
def test_no_alias_collisions_between_models(self):
seen = {}
for model in reg.MODELS:
for key in (model.name, *model.aliases):
assert key.lower() not in seen, f"{key} claimed twice"
seen[key.lower()] = model
class TestRegistryMatchesTheHub:
"""Every registry entry restates facts the model's repository already holds.
Restating them is what makes an offline install work, but a restatement can
drift from its source and nothing else would notice: wrong pooling is not a
crash, only worse retrieval. Opt in with ``DOCSGPT_HUB_TESTS=1``; needs
network.
"""
@pytest.mark.skipif(
os.environ.get("DOCSGPT_HUB_TESTS") != "1",
reason="set DOCSGPT_HUB_TESTS=1 to check the registry against the hub",
)
@pytest.mark.parametrize(
"model", [m for m in reg.MODELS if m.provider == "fastembed"]
)
def test_entry_matches_repository_metadata(self, model):
from application.vectorstore.embeddings_local import _describe_from_repo
described = _describe_from_repo(model.repo)
assert described is not None, f"{model.repo} declares no pooling metadata"
assert described.pooling == model.pooling
assert described.normalize == model.normalize
if described.dimension:
assert described.dimension == model.dimension
@@ -103,9 +103,20 @@ def live_dsn(postgresql, monkeypatch):
@pytest.fixture
def stub_embeddings():
"""Stand in for the configured model everywhere its width is read.
``ensure_vector_schema`` takes the width from the registry rather than by
constructing the model, so patching only the constructors would leave the
boot hook sizing the table from whatever EMBEDDINGS_NAME happens to be.
"""
stub = _StubEmbeddings()
with patch(
"application.vectorstore.base.get_embeddings", return_value=stub
), patch(
"application.vectorstore.base.build_local_embeddings", return_value=stub
), patch(
"application.vectorstore.model_registry.dimension_for",
return_value=STUB_DIM,
), patch(
"application.vectorstore.base.BaseVectorStore._get_embeddings",
return_value=stub,
@@ -140,6 +151,9 @@ class TestBootHookCreatesTheSchema:
wide = _WideStubEmbeddings()
with patch(
"application.vectorstore.base.get_embeddings", return_value=wide
), patch(
"application.vectorstore.model_registry.dimension_for",
return_value=wide.dimension,
):
with pytest.raises(RuntimeError) as excinfo:
ensure_vector_schema()
@@ -83,3 +83,115 @@ def test_query_path_is_truncated(monkeypatch):
sent = captured["payload"]["input"]
assert sent == enc.decode(enc.encode(long_text)[:10])
class TestInputLimitResolution:
"""The cap falls back to the model's own context window."""
def _remote(self, model_name):
from application.vectorstore.base import RemoteEmbeddings
return RemoteEmbeddings(
api_url="http://embeddings", model_name=model_name, api_key=None
)
def test_explicit_setting_wins(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 123)
assert self._remote("granite-311m")._resolve_input_limit() == 123
def test_registered_model_supplies_its_own_ceiling(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
monkeypatch.setattr(base, "_embeddings_name_is_explicit", lambda: True)
assert self._remote("granite-311m")._resolve_input_limit() == 32768
# mpnet genuinely stops at 384; sending more is paid for and discarded.
assert self._remote("all-mpnet-base-v2")._resolve_input_limit() == 384
def test_default_model_name_lends_the_server_no_ceiling(self, monkeypatch):
"""An unset EMBEDDINGS_NAME describes nothing about the remote server.
The name is only forwarded as the ``model`` field. Letting the
settings default contribute mpnet's 384-token window would clip every
chunk on a server that may well serve a 32k-context model.
"""
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
monkeypatch.setattr(base, "_embeddings_name_is_explicit", lambda: False)
assert self._remote("all-mpnet-base-v2")._resolve_input_limit() is None
def test_explicit_setting_still_wins_over_an_unset_name(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 512)
monkeypatch.setattr(base, "_embeddings_name_is_explicit", lambda: False)
assert self._remote("all-mpnet-base-v2")._resolve_input_limit() == 512
def test_unknown_model_stays_unlimited(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
assert self._remote("some-org/mystery")._resolve_input_limit() is None
def test_non_positive_setting_falls_through_to_the_registry(self, monkeypatch):
from application.vectorstore import base
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", 0)
monkeypatch.setattr(base, "_embeddings_name_is_explicit", lambda: True)
assert self._remote("granite-97m")._resolve_input_limit() == 32768
def test_dimension_is_taken_from_the_registry(self):
assert self._remote("granite-97m").dimension == 384
assert self._remote("granite-311m").dimension == 768
def test_unknown_model_dimension_is_probed_not_assumed(self):
"""The old hardcoded 768 made the probe below unreachable."""
assert self._remote("some-org/mystery").dimension is None
class TestEmbeddingsNameIsExplicit:
"""Which names count as chosen decides whether a remote server inherits a
context window it may not have.
The tests above monkeypatch the predicate, so they cannot see it being
wrong. These drive the real one.
"""
def _default(self):
from application.core.settings import Settings
return Settings.model_fields["EMBEDDINGS_NAME"].default
def test_the_default_name_is_not_a_choice(self, monkeypatch):
"""Every setup script has always written this value unconditionally.
Reading it as deliberate lends the server mpnet's 384-token window and
clips ~80% off every chunk, silently, on upgrade. ``model_fields_set``
could not tell the difference: pydantic marks a field set for anything
that reached it, ``.env`` included.
"""
monkeypatch.setattr(base.settings, "EMBEDDINGS_NAME", self._default())
assert base._embeddings_name_is_explicit() is False
def test_a_different_name_is_a_choice(self, monkeypatch):
monkeypatch.setattr(
base.settings,
"EMBEDDINGS_NAME",
"ibm-granite/granite-embedding-311m-multilingual-r2",
)
assert base._embeddings_name_is_explicit() is True
def test_dotenv_written_default_does_not_clip(self, monkeypatch):
"""End to end: the common upgrade path must send the full chunk."""
monkeypatch.setattr(base.settings, "EMBEDDINGS_MAX_INPUT_TOKENS", None)
monkeypatch.setattr(base.settings, "EMBEDDINGS_NAME", self._default())
captured = _capture_post(monkeypatch)
long_text = " ".join(["word"] * 1000)
emb = RemoteEmbeddings(api_url="http://embeddings", model_name=self._default())
emb.embed_documents([long_text])
assert captured["payload"]["input"][0] == long_text
+130
View File
@@ -326,3 +326,133 @@ class TestAttachmentZipBombGuard:
# BadZipFile → return quietly; the format parser surfaces a clean error.
worker._reject_attachment_zip_bomb(str(path))
@pytest.mark.unit
class TestAttachmentTypeGuard:
"""A binary with no parser must fail rather than be read as text.
``SimpleDirectoryReader`` falls through to a plain-text ``open()`` for a
suffix it has no parser for, which is how a phone-uploaded ``.mp4`` was
once stored as 100k tokens of binary with ``extraction.status == "ok"``.
The same fallthrough is what makes a ``.py`` or ``.log`` attachment work,
so the guard admits those on content.
"""
def test_binary_without_a_parser_fails_instead_of_being_read_as_text(
self, pg_conn, patch_worker_db, task_self, monkeypatch, tmp_path
):
from application import worker
local_path = tmp_path / "clip.mp4"
local_path.write_bytes(b"\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomavc1")
events = []
fake_storage = MagicMock(name="storage")
fake_storage.process_file.side_effect = lambda path, callback: callback(
str(local_path)
)
monkeypatch.setattr(worker.StorageCreator, "get_storage", lambda: fake_storage)
monkeypatch.setattr(
worker,
"get_default_file_extractor",
lambda ocr_enabled=False, pdf_text_fast_path=False: {},
)
monkeypatch.setattr(
worker,
"publish_user_event",
lambda user, name, payload, **kwargs: events.append((name, payload)),
)
file_info = {
"filename": "clip.mp4",
"attachment_id": "507f1f77bcf86cd799439012",
"path": "uploads/user1/attachments/clip.mp4",
"metadata": {"source": "chat"},
}
with pytest.raises(
worker.AttachmentRejectedError, match=r"Unsupported file type: \.mp4"
):
worker.attachment_worker(task_self, file_info, "user1")
failed = [payload for name, payload in events if name == "attachment.failed"]
assert failed and failed[0]["error"] == "Unsupported file type: .mp4"
def test_suffix_the_loaded_extractor_cannot_parse_is_refused(
self, pg_conn, patch_worker_db, task_self, monkeypatch, tmp_path
):
"""The guard judges against the parser table actually loaded.
Without docling the fallback extractor has no .webp parser, so a
.webp the route admitted on its name would otherwise be opened as
plain text here.
"""
from application import worker
local_path = tmp_path / "scan.webp"
local_path.write_bytes(b"RIFF\x00\x00\x00\x00WEBPVP8 " + bytes(range(256)))
events = []
fake_storage = MagicMock(name="storage")
fake_storage.process_file.side_effect = lambda path, callback: callback(
str(local_path)
)
monkeypatch.setattr(worker.StorageCreator, "get_storage", lambda: fake_storage)
# The docling-less fallback table: images are handled, .webp is not.
monkeypatch.setattr(
worker,
"get_default_file_extractor",
lambda ocr_enabled=False, pdf_text_fast_path=False: {".png": object()},
)
monkeypatch.setattr(
worker,
"publish_user_event",
lambda user, name, payload, **kwargs: events.append((name, payload)),
)
file_info = {
"filename": "scan.webp",
"attachment_id": "507f1f77bcf86cd799439014",
"path": "uploads/user1/attachments/scan.webp",
"metadata": {"source": "chat"},
}
with pytest.raises(
worker.AttachmentRejectedError, match=r"Unsupported file type: \.webp"
):
worker.attachment_worker(task_self, file_info, "user1")
failed = [payload for name, payload in events if name == "attachment.failed"]
assert failed and failed[0]["error"] == "Unsupported file type: .webp"
def test_text_without_a_parser_is_parsed(
self, pg_conn, patch_worker_db, task_self, monkeypatch, tmp_path
):
from application import worker
local_path = tmp_path / "server.log"
local_path.write_text("2026-09-02 ERROR boom\n", encoding="utf-8")
fake_storage = MagicMock(name="storage")
fake_storage.process_file.side_effect = lambda path, callback: callback(
str(local_path)
)
monkeypatch.setattr(worker.StorageCreator, "get_storage", lambda: fake_storage)
monkeypatch.setattr(
worker,
"get_default_file_extractor",
lambda ocr_enabled=False, pdf_text_fast_path=False: {},
)
file_info = {
"filename": "server.log",
"attachment_id": "507f1f77bcf86cd799439013",
"path": "uploads/user1/attachments/server.log",
"metadata": {"source": "chat"},
}
result = worker.attachment_worker(task_self, file_info, "user1")
assert result["filename"] == "server.log"
assert result["token_count"] > 0
+40
View File
@@ -100,6 +100,46 @@ class TestReembedWikiPageWorker:
)
assert result == {"status": "embedded", "added": 2, "deleted": 3}
def test_a_reembed_stamps_the_model_on_the_source(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
"""Wiki sources are created with ``model`` NULL, which the boot mismatch
check reads as the legacy model and reports as stale on every startup.
Stamping here heals a source created before it was recorded.
"""
from application import worker
from application.core.settings import settings
source_id = _seed_source(pg_conn)
assert SourcesRepository(pg_conn).get_any(source_id, "alice")["model"] is None
_patch_store(monkeypatch, MagicMock(name="vector_store"))
_patch_repo(monkeypatch, {"content": "body", "title": "T"})
_patch_chunker(monkeypatch, [Document(text="c1")])
worker.reembed_wiki_page_worker(
task_self, source_id, "p.md", "hash-1", "alice"
)
row = SourcesRepository(pg_conn).get_any(source_id, "alice")
assert row["model"] == settings.EMBEDDINGS_NAME
def test_a_purge_leaves_the_model_alone(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
"""Deleting a page embeds nothing, so it claims nothing about the model."""
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(monkeypatch, MagicMock(name="vector_store"))
_patch_repo(monkeypatch, None)
worker.reembed_wiki_page_worker(
task_self, source_id, "gone.md", "hash-x", "alice"
)
assert SourcesRepository(pg_conn).get_any(source_id, "alice")["model"] is None
def test_page_missing_purges(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):