Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).
Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
* Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
* Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
* infra/llm/ollama_provider.py — OllamaProvider with health_check
* infra/docling_agent_reasoning.py — runner adapter, encapsulates the
private _rag_loop call (tracked at docling-project/docling-agent#26),
commits OLLAMA_HOST once at boot (eliminates the per-request env race),
translates upstream IndexError into ReasoningParseError
* api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
consumes app.state.reasoning_runner via the port
* main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
when REASONING_ENABLED=true and deps are importable
* Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
type RAGResult → ReasoningResult, frontend feature flag wiring,
i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
consumers in production)
* 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
serialization, R13 Protocol conformance via isinstance
* E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
* README — Live Reasoning section (env vars, archi, link to issue #26)
Bloc B — Security (audit 08, dev-only context)
* docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
flagged as dev-only with link to OpenSearch security docs
* main.py — boot warning if NEO4J_URI is set with the default 'changeme'
password, so prod operators can't silently inherit it
Bloc C — DRY frontend (audit 05)
* shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
* features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
* api/schemas.py — DOCUMENT_STATUS_UPLOADED constant
Bloc D — Quality (audits 02/06/07/09/10/12)
* domain/ports.py — DocumentConverter.supports_page_batching property
(LSP fix, replaces isinstance(ServeConverter) check)
* domain/ports.py — VectorStore.ping() (encapsulation, replaces
_vector_store._client.info() reach-around)
* api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
aligned with the user-facing terminology (URLs unchanged)
* api/documents.py — Path.read_bytes() + generate_preview() wrapped in
asyncio.to_thread, unblocks the FastAPI event loop on /preview
* infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
* src/__tests__/integration/ — cross-feature integration test relocated
out of features/history/ so feature folders stay self-contained
* Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
exact value comparisons)
Validation
* 446 backend pytest, 202 frontend vitest — all green
* ruff + ruff format + ESLint + Prettier + vue-tsc clean
* Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO
Closes #200
186 lines
6.8 KiB
Python
186 lines
6.8 KiB
Python
"""Tests for the ingestion service (services.ingestion_service)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from services.ingestion_service import IngestionConfig, IngestionService
|
|
|
|
|
|
def _make_chunks_json(count: int = 3, *, with_deleted: bool = False) -> str:
|
|
chunks = []
|
|
for i in range(count):
|
|
chunk = {
|
|
"text": f"chunk text {i}",
|
|
"headings": [f"Heading {i}"],
|
|
"sourcePage": i + 1,
|
|
"tokenCount": 10,
|
|
"bboxes": [{"page": i + 1, "bbox": [0.0, 0.0, 100.0, 50.0]}],
|
|
}
|
|
if with_deleted and i == count - 1:
|
|
chunk["deleted"] = True
|
|
chunks.append(chunk)
|
|
return json.dumps(chunks)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_embedding() -> AsyncMock:
|
|
svc = AsyncMock()
|
|
svc.embed.return_value = [[0.1, 0.2, 0.3]] * 3
|
|
return svc
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_vector_store() -> AsyncMock:
|
|
store = AsyncMock()
|
|
store.ensure_index.return_value = None
|
|
store.delete_document.return_value = 0
|
|
store.index_chunks.return_value = 3
|
|
return store
|
|
|
|
|
|
@pytest.fixture
|
|
def service(mock_embedding: AsyncMock, mock_vector_store: AsyncMock) -> IngestionService:
|
|
return IngestionService(
|
|
embedding_service=mock_embedding,
|
|
vector_store=mock_vector_store,
|
|
config=IngestionConfig(index_name="test-idx", embedding_dimension=3),
|
|
)
|
|
|
|
|
|
class TestIngest:
|
|
async def test_full_pipeline(
|
|
self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
result = await service.ingest("doc-1", "test.pdf", _make_chunks_json(3))
|
|
|
|
assert result.doc_id == "doc-1"
|
|
assert result.chunks_indexed == 3
|
|
mock_embedding.embed.assert_awaited_once()
|
|
texts = mock_embedding.embed.call_args[0][0]
|
|
assert len(texts) == 3
|
|
mock_vector_store.ensure_index.assert_awaited_once()
|
|
mock_vector_store.delete_document.assert_awaited_once_with("test-idx", "doc-1")
|
|
mock_vector_store.index_chunks.assert_awaited_once()
|
|
indexed = mock_vector_store.index_chunks.call_args[0][1]
|
|
assert len(indexed) == 3
|
|
assert indexed[0].doc_id == "doc-1"
|
|
assert indexed[0].filename == "test.pdf"
|
|
assert indexed[0].embedding == [0.1, 0.2, 0.3]
|
|
|
|
async def test_skips_deleted_chunks(
|
|
self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_embedding.embed.return_value = [[0.1, 0.2, 0.3]] * 2
|
|
mock_vector_store.index_chunks.return_value = 2
|
|
result = await service.ingest("doc-1", "test.pdf", _make_chunks_json(3, with_deleted=True))
|
|
|
|
assert result.chunks_indexed == 2
|
|
texts = mock_embedding.embed.call_args[0][0]
|
|
assert len(texts) == 2
|
|
|
|
async def test_empty_chunks(
|
|
self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
result = await service.ingest("doc-1", "test.pdf", json.dumps([]))
|
|
assert result.chunks_indexed == 0
|
|
mock_embedding.embed.assert_not_awaited()
|
|
|
|
async def test_idempotent_deletes_old(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_vector_store.delete_document.return_value = 5
|
|
await service.ingest("doc-1", "test.pdf", _make_chunks_json(3))
|
|
mock_vector_store.delete_document.assert_awaited_once_with("test-idx", "doc-1")
|
|
|
|
async def test_bbox_conversion(
|
|
self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_embedding.embed.return_value = [[0.1, 0.2, 0.3]]
|
|
mock_vector_store.index_chunks.return_value = 1
|
|
await service.ingest("doc-1", "test.pdf", _make_chunks_json(1))
|
|
indexed = mock_vector_store.index_chunks.call_args[0][1]
|
|
bbox = indexed[0].bboxes[0]
|
|
assert bbox.x == 0.0
|
|
assert bbox.y == 0.0
|
|
assert bbox.w == 100.0
|
|
assert bbox.h == 50.0
|
|
|
|
async def test_with_binary_hash(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_embedding = service._embedding
|
|
mock_embedding.embed.return_value = [[0.1]] * 1
|
|
await service.ingest("doc-1", "test.pdf", _make_chunks_json(1), binary_hash="abc123")
|
|
indexed = mock_vector_store.index_chunks.call_args[0][1]
|
|
assert indexed[0].origin is not None
|
|
assert indexed[0].origin.binary_hash == "abc123"
|
|
|
|
|
|
class TestDeleteDocument:
|
|
async def test_delegates_to_vector_store(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_vector_store.delete_document.return_value = 3
|
|
result = await service.delete_document("doc-1")
|
|
assert result == 3
|
|
|
|
|
|
class TestSearch:
|
|
async def test_embeds_and_searches(
|
|
self, service: IngestionService, mock_embedding: AsyncMock, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_embedding.embed.return_value = [[0.5, 0.6, 0.7]]
|
|
mock_vector_store.search_similar.return_value = []
|
|
await service.search("test query", k=5)
|
|
mock_embedding.embed.assert_awaited_once_with(["test query"])
|
|
mock_vector_store.search_similar.assert_awaited_once()
|
|
|
|
|
|
class TestSearchFulltext:
|
|
async def test_delegates_to_vector_store(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_vector_store.search_fulltext.return_value = []
|
|
await service.search_fulltext("hello world", k=5)
|
|
mock_vector_store.search_fulltext.assert_awaited_once_with(
|
|
"test-idx", "hello world", k=5, doc_id=None
|
|
)
|
|
|
|
async def test_filters_by_doc_id(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_vector_store.search_fulltext.return_value = []
|
|
await service.search_fulltext("hello", doc_id="doc-1")
|
|
mock_vector_store.search_fulltext.assert_awaited_once_with(
|
|
"test-idx", "hello", k=20, doc_id="doc-1"
|
|
)
|
|
|
|
|
|
class TestPing:
|
|
async def test_ping_success(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_vector_store.ping.return_value = True
|
|
result = await service.ping()
|
|
assert result is True
|
|
|
|
async def test_ping_failure(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
mock_vector_store.ping.side_effect = ConnectionError("down")
|
|
result = await service.ping()
|
|
assert result is False
|
|
|
|
|
|
class TestEnsureIndex:
|
|
async def test_calls_vector_store(
|
|
self, service: IngestionService, mock_vector_store: AsyncMock
|
|
) -> None:
|
|
await service.ensure_index()
|
|
mock_vector_store.ensure_index.assert_awaited_once()
|
|
call_args = mock_vector_store.ensure_index.call_args
|
|
assert call_args[0][0] == "test-idx"
|