Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e
This commit is contained in:
commit
7ca726caf2
659 files changed
+98171
No files matched your search
@@ -0,0 +1,632 @@
|
||||
"""Integration gate: the real ``IngestUploadHandler`` against a real Qdrant
|
||||
server, searched through the real ``RetrievalPipeline``/``ProductionSearchExecutor``.
|
||||
|
||||
(This is the *handler-level* integration gate. The system-level gate that
|
||||
goes through HTTP upload -> PostgreSQL job -> worker lease -> this handler ->
|
||||
HTTP search lives in ``tests/system/``.)
|
||||
|
||||
upload payload -> security gate -> parser -> chunker -> dense embedding
|
||||
-> sparse encoding -> Qdrant upsert -> /v1/search execution path
|
||||
|
||||
Proves, against the live index, that:
|
||||
|
||||
1. the document is processed and indexed;
|
||||
2. every stored point carries a dense vector;
|
||||
3. every stored point carries a sparse vector;
|
||||
4. the document is retrievable via dense search (exact-vector query);
|
||||
5. the document is retrievable via sparse search (identifier-token query);
|
||||
6. RRF fuses both backends (both ranks present, no degradation);
|
||||
7. language / document_id filters constrain the results;
|
||||
8. **source identity**: the source UUID the *caller* supplied at upload time
|
||||
-- never a value read back from the index -- is what the ``source_ids``
|
||||
filter matches and what every candidate's provenance reports: source A's
|
||||
two documents come back for ``source_ids=[A]``, source B's one document
|
||||
for ``[B]``, neither leaks into the other, and an ad hoc upload without a
|
||||
source id belongs to the space's built-in Uploads source.
|
||||
|
||||
Embeddings use a deterministic hash-based mock transport: the point of this
|
||||
gate is the indexing/search contract, not embedding quality, and determinism
|
||||
is what makes the dense exact-match assertion (cosine similarity 1.0 with an
|
||||
identical query vector) a real proof that dense retrieval works end to end.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client import AsyncQdrantClient
|
||||
|
||||
from ragcore.api.v1.search.service import (
|
||||
SearchExecutionRequest,
|
||||
SearchFilterData,
|
||||
SearchQuery,
|
||||
)
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.jobs.models import Job, JobContext
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
from ragcore.application.sources import SourceRecord
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.authorization import Principal, PrincipalType
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
|
||||
from ragcore.domain.sources import upload_source_id
|
||||
from ragcore.infrastructure.blob.filesystem import FilesystemBlobStorage
|
||||
from ragcore.infrastructure.ingestion.handler import IngestUploadHandler
|
||||
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.infrastructure.parsers.publisher import FilesystemArtifactPublisher
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
from ragcore.infrastructure.retrieval.production_executor import (
|
||||
ProductionSearchExecutor,
|
||||
RetrievalPipeline,
|
||||
)
|
||||
from ragcore.infrastructure.security import (
|
||||
ContentSecurityGateService,
|
||||
production_content_security_policy,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
SOURCE_A = uuid4()
|
||||
SOURCE_B = uuid4()
|
||||
DIMENSIONS = 8
|
||||
|
||||
ENGLISH_DOCUMENT = (
|
||||
"Gateway Failover Runbook\n\n"
|
||||
"When the payment gateway loses power, cycle breaker BRK-4417 in rack two.\n"
|
||||
"Wait ten seconds before restoring the feed and confirm the status light.\n"
|
||||
)
|
||||
DUTCH_DOCUMENT = (
|
||||
"Firewall Segmentatie Handleiding\n\n"
|
||||
"Dit document beschrijft de netwerksegmentatie van de firewall voor het\n"
|
||||
"productienetwerk. De regels worden per zone beheerd en moeten na iedere\n"
|
||||
"wijziging opnieuw worden gecontroleerd door een beheerder van het team.\n"
|
||||
)
|
||||
SOURCE_B_DOCUMENT = (
|
||||
"Certificate Rotation Procedure\n\n"
|
||||
"Rotate the edge certificate CERT-8820 every ninety days using the vault job.\n"
|
||||
"Confirm the fingerprint on the load balancer after the rotation completes.\n"
|
||||
)
|
||||
AD_HOC_DOCUMENT = (
|
||||
"Lunch Menu\n\nThursday soup SOUP-001 is tomato basil, served from noon in the canteen.\n"
|
||||
)
|
||||
|
||||
|
||||
def _deterministic_vector(text: str) -> list[float]:
|
||||
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
||||
raw = [digest[index] / 255.0 - 0.5 for index in range(DIMENSIONS)]
|
||||
norm = math.sqrt(sum(value * value for value in raw)) or 1.0
|
||||
return [value / norm for value in raw]
|
||||
|
||||
|
||||
def _embedding_transport() -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
vectors = [_deterministic_vector(text) for text in body["input"]]
|
||||
return httpx.Response(200, json={"embeddings": vectors})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
class MemoryDocumentRepository:
|
||||
"""Just enough of DocumentRepository for the handler's happy path."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
self.versions: dict[UUID, DocumentVersionRecord] = {}
|
||||
self.published: list[UUID] = []
|
||||
|
||||
async def create_document(self, document: DocumentRecord) -> DocumentRecord:
|
||||
self.documents.append(document)
|
||||
return document
|
||||
|
||||
async def create_version(self, version: DocumentVersionRecord) -> DocumentVersionRecord:
|
||||
self.versions[version.id] = version
|
||||
return version
|
||||
|
||||
async def get_version(self, version_id: UUID) -> DocumentVersionRecord | None:
|
||||
return self.versions.get(version_id)
|
||||
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: UUID, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
return next(
|
||||
(
|
||||
document
|
||||
for document in self.documents
|
||||
if document.workspace_id == workspace_id
|
||||
and document.source_key == source_key
|
||||
and document.external_id == external_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def publish_validated(self, version_id, artifacts, chunks): # type: ignore[no-untyped-def]
|
||||
self.versions[version_id] = replace(
|
||||
self.versions[version_id], state=DocumentVersionState.PUBLISHED
|
||||
)
|
||||
self.published.append(version_id)
|
||||
return self.versions[version_id]
|
||||
|
||||
async def reactivate_source(self, document_id: UUID) -> None:
|
||||
raise AssertionError("not exercised by this gate")
|
||||
|
||||
|
||||
class MemorySourceRegistry:
|
||||
"""Enough of ``SourceRegistry`` for ingestion that no sync run owns.
|
||||
|
||||
The handler reports every finished ingest back to the registry so a sync
|
||||
waiting on its children can settle. These uploads have no remembered item,
|
||||
so both marks legitimately match nothing and the convergence query is
|
||||
never reached -- which is exactly the branch this gate should exercise.
|
||||
|
||||
The marks used to be absent altogether. The handler's reporting step
|
||||
catches broadly (a lost convergence signal must never mask an ingest
|
||||
result), so the missing methods raised ``AttributeError`` into that catch
|
||||
and the gate quietly ran the failure path on every document while looking
|
||||
green.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: dict[UUID, SourceRecord] = {}
|
||||
self.marks: list[tuple[str, UUID, str]] = []
|
||||
|
||||
async def ensure_source(self, record: SourceRecord) -> SourceRecord:
|
||||
return self.records.setdefault(record.id, record)
|
||||
|
||||
async def mark_item_ingested(
|
||||
self,
|
||||
source_id: UUID,
|
||||
external_id: str,
|
||||
*,
|
||||
fingerprint: str,
|
||||
document_id: UUID | None,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
self.marks.append(("ingested", source_id, external_id))
|
||||
return False
|
||||
|
||||
async def mark_item_failed(
|
||||
self,
|
||||
source_id: UUID,
|
||||
external_id: str,
|
||||
*,
|
||||
fingerprint: str,
|
||||
failure_code: str,
|
||||
retryable: bool,
|
||||
now: datetime,
|
||||
job_id: UUID | None = None,
|
||||
) -> bool:
|
||||
self.marks.append(("failed", source_id, external_id))
|
||||
return False
|
||||
|
||||
async def summarize_items(self, source_id: UUID) -> object:
|
||||
raise AssertionError("no remembered item matched, so nothing should be summarized")
|
||||
|
||||
async def converge_sync_state(self, source_id: UUID, state: object, **kwargs: object) -> None:
|
||||
raise AssertionError("no remembered item matched, so no source should be settled")
|
||||
|
||||
|
||||
class NullAudit:
|
||||
async def record(self, **event: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def qdrant_client() -> AsyncIterator[AsyncQdrantClient]:
|
||||
client = AsyncQdrantClient(
|
||||
url=os.getenv("RAGCORE_QDRANT_URL", "http://qdrant:6333"),
|
||||
api_key=os.getenv("RAGCORE_QDRANT_API_KEY", "replace-me"),
|
||||
timeout=60,
|
||||
)
|
||||
yield client
|
||||
await client.close()
|
||||
|
||||
|
||||
async def _active_profile_store(tmp_path: Path, profile_name: str) -> JsonEmbeddingProfileStore:
|
||||
store = JsonEmbeddingProfileStore(tmp_path / "profiles.json")
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile=profile_name,
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="gate-embed:v1",
|
||||
model_digest="b" * 64,
|
||||
dimensions=DIMENSIONS,
|
||||
context_limit=2048,
|
||||
tokenizer="gate",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store
|
||||
|
||||
|
||||
def _job_context(payload: dict[str, object]) -> JobContext:
|
||||
return JobContext(
|
||||
job=Job(
|
||||
id=uuid4(),
|
||||
job_key=f"gate-{uuid4().hex}",
|
||||
operation="ingest_upload",
|
||||
target_key="gate",
|
||||
profile_version="upload-v1",
|
||||
payload=payload,
|
||||
available_at=datetime.now(UTC),
|
||||
),
|
||||
attempt_number=1,
|
||||
idempotency_key=uuid4().hex,
|
||||
cancellation=asyncio.Event(),
|
||||
)
|
||||
|
||||
|
||||
def _build_handler(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
profile_store: JsonEmbeddingProfileStore,
|
||||
repository: MemoryDocumentRepository,
|
||||
registry: MemorySourceRegistry,
|
||||
embedding_client: httpx.AsyncClient,
|
||||
qdrant_client: AsyncQdrantClient,
|
||||
) -> IngestUploadHandler:
|
||||
return IngestUploadHandler(
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
blob_storage=FilesystemBlobStorage(tmp_path / "blobs"),
|
||||
artifact_publisher=FilesystemArtifactPublisher(tmp_path / "parsed"),
|
||||
embedding_profile_store=profile_store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
qdrant_store=QdrantRebuildStore(qdrant_client),
|
||||
source_registry=registry,
|
||||
security_gate=ContentSecurityGateService(
|
||||
NullAudit(), policy=production_content_security_policy()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _ingest(
|
||||
handler: IngestUploadHandler,
|
||||
blob_storage: FilesystemBlobStorage,
|
||||
*,
|
||||
content: bytes,
|
||||
name: str,
|
||||
source_id: UUID | None = None,
|
||||
external_id: str | None = None,
|
||||
locale: str | None = None,
|
||||
media_type: str = "text/plain",
|
||||
) -> None:
|
||||
async def _bytes() -> AsyncIterator[bytes]:
|
||||
yield content
|
||||
|
||||
reference = await blob_storage.put(_bytes(), media_type=media_type, original_name=name)
|
||||
payload: dict[str, object] = {
|
||||
"blob_digest": reference.digest,
|
||||
"media_type": media_type,
|
||||
"original_name": name,
|
||||
"space_id": str(SPACE_ID),
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
}
|
||||
if source_id is not None:
|
||||
payload["source_id"] = str(source_id)
|
||||
payload["external_id"] = external_id or name
|
||||
if locale is not None:
|
||||
payload["locale"] = locale
|
||||
await handler(_job_context(payload))
|
||||
|
||||
|
||||
def _search_request(
|
||||
query: str, *, filters: SearchFilterData | None = None
|
||||
) -> SearchExecutionRequest:
|
||||
return SearchExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=SearchQuery(query=query, max_results=10, filters=filters or SearchFilterData()),
|
||||
scope=AuthorizedRetrievalScope(WORKSPACE_ID, frozenset({SPACE_ID})),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class _TitleLookup:
|
||||
async def titles(self, document_ids: set[UUID]) -> dict[UUID, str]:
|
||||
return dict.fromkeys(document_ids, "Gate Document")
|
||||
|
||||
|
||||
def _doc_ids(result) -> set[UUID]: # type: ignore[no-untyped-def]
|
||||
return {hit.citation.document_id for hit in result.hits}
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_to_hybrid_search_gate(
|
||||
tmp_path: Path, qdrant_client: AsyncQdrantClient
|
||||
) -> None:
|
||||
profile_name = f"gate-{uuid4().hex[:10]}"
|
||||
profile_store = await _active_profile_store(tmp_path, profile_name)
|
||||
blob_storage = FilesystemBlobStorage(tmp_path / "blobs")
|
||||
embedding_client = httpx.AsyncClient(transport=_embedding_transport(), follow_redirects=False)
|
||||
repository = MemoryDocumentRepository()
|
||||
registry = MemorySourceRegistry()
|
||||
handler = _build_handler(
|
||||
tmp_path,
|
||||
profile_store=profile_store,
|
||||
repository=repository,
|
||||
registry=registry,
|
||||
embedding_client=embedding_client,
|
||||
qdrant_client=qdrant_client,
|
||||
)
|
||||
collection_name = f"rag_dense_{profile_name}_v1"
|
||||
try:
|
||||
# 1. Ingest: source A owns two documents, source B one, and one ad
|
||||
# hoc upload names no source at all. The identities below are the
|
||||
# *inputs*; nothing is read back from Qdrant to build the filters.
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=ENGLISH_DOCUMENT.encode(),
|
||||
name="runbook.txt",
|
||||
source_id=SOURCE_A,
|
||||
external_id="runbooks/gateway",
|
||||
)
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=DUTCH_DOCUMENT.encode(),
|
||||
name="firewall.txt",
|
||||
source_id=SOURCE_A,
|
||||
external_id="handleidingen/firewall",
|
||||
)
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=SOURCE_B_DOCUMENT.encode(),
|
||||
name="certificates.txt",
|
||||
source_id=SOURCE_B,
|
||||
external_id="procedures/certificates",
|
||||
)
|
||||
await _ingest(handler, blob_storage, content=AD_HOC_DOCUMENT.encode(), name="menu.txt")
|
||||
assert len(repository.published) == 4
|
||||
documents_by_source: dict[UUID, set[UUID]] = {}
|
||||
for document in repository.documents:
|
||||
documents_by_source.setdefault(document.source_id, set()).add(document.id)
|
||||
assert len(documents_by_source[SOURCE_A]) == 2
|
||||
assert len(documents_by_source[SOURCE_B]) == 1
|
||||
uploads_source = upload_source_id(SPACE_ID)
|
||||
assert len(documents_by_source[uploads_source]) == 1
|
||||
assert set(registry.records) == {SOURCE_A, SOURCE_B, uploads_source}
|
||||
|
||||
# 2 + 3. Every stored point has both a dense and a sparse vector, and
|
||||
# stores exactly the caller's source identity.
|
||||
records, _ = await qdrant_client.scroll(
|
||||
collection_name=collection_name,
|
||||
limit=100,
|
||||
with_payload=True,
|
||||
with_vectors=True,
|
||||
)
|
||||
assert records
|
||||
stored_sources: set[str] = set()
|
||||
for record in records:
|
||||
vectors = record.vector
|
||||
assert isinstance(vectors, dict)
|
||||
assert len(vectors["dense"]) == DIMENSIONS
|
||||
sparse = vectors["sparse"]
|
||||
indices = getattr(sparse, "indices", None) or sparse["indices"]
|
||||
assert len(indices) > 0
|
||||
assert record.payload is not None
|
||||
stored_sources.add(str(record.payload["source_id"]))
|
||||
assert stored_sources == {str(SOURCE_A), str(SOURCE_B), str(uploads_source)}
|
||||
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=qdrant_client,
|
||||
embedding_profile_store=profile_store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, _TitleLookup()) # type: ignore[arg-type]
|
||||
|
||||
# 5 + 6. Sparse retrieval: the identifier token only the English
|
||||
# runbook contains must surface it, fused from both backends without
|
||||
# degradation.
|
||||
sparse_led = await executor.execute(_search_request("cycle breaker BRK-4417"))
|
||||
assert sparse_led.degradation_codes == ()
|
||||
assert sparse_led.hits
|
||||
top = sparse_led.hits[0]
|
||||
assert "BRK-4417" in top.text
|
||||
assert top.fused.sparse_rank is not None
|
||||
assert top.sparse_score is not None
|
||||
# Provenance reports the caller's source identity, not a synthetic one.
|
||||
assert top.fused.candidate.provenance.source_id == SOURCE_A
|
||||
assert top.citation.source_uri == f"ragcore://source/{SOURCE_A}"
|
||||
|
||||
# 4. Dense retrieval: querying with the exact stored chunk text embeds
|
||||
# to the identical vector, so it must be the dense top hit.
|
||||
dense_led = await executor.execute(_search_request(top.text))
|
||||
assert dense_led.hits
|
||||
assert dense_led.hits[0].fused.dense_rank == 1
|
||||
assert dense_led.hits[0].dense_score == pytest.approx(1.0, abs=1e-5)
|
||||
assert dense_led.hits[0].citation.chunk_id == top.citation.chunk_id
|
||||
|
||||
# 7a. Language filter: detected Dutch content is retrievable as nl,
|
||||
# and the nl filter excludes the English runbook.
|
||||
dutch_only = await executor.execute(
|
||||
_search_request(
|
||||
"firewall segmentatie productienetwerk",
|
||||
filters=SearchFilterData(languages=("nl",)),
|
||||
)
|
||||
)
|
||||
assert dutch_only.hits
|
||||
assert all("BRK-4417" not in hit.text for hit in dutch_only.hits)
|
||||
|
||||
# 7b. document_ids filter constrains to exactly one document.
|
||||
target_document = top.citation.document_id
|
||||
only_document = await executor.execute(
|
||||
_search_request(
|
||||
"runbook firewall breaker segmentatie certificate",
|
||||
filters=SearchFilterData(document_ids=(target_document,)),
|
||||
)
|
||||
)
|
||||
assert only_document.hits
|
||||
assert _doc_ids(only_document) == {target_document}
|
||||
|
||||
# 8. Source filter on the ORIGINAL input identities.
|
||||
broad_query = "runbook firewall breaker segmentatie certificate rotation soup menu"
|
||||
from_a = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(SOURCE_A,)))
|
||||
)
|
||||
assert from_a.hits
|
||||
assert _doc_ids(from_a) == documents_by_source[SOURCE_A]
|
||||
assert {hit.fused.candidate.provenance.source_id for hit in from_a.hits} == {SOURCE_A}
|
||||
|
||||
from_b = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(SOURCE_B,)))
|
||||
)
|
||||
assert from_b.hits
|
||||
assert _doc_ids(from_b) == documents_by_source[SOURCE_B]
|
||||
|
||||
# That source B's content is really reachable is proven the way the
|
||||
# identifier assertions above prove it: with the one token that only
|
||||
# the wanted chunk contains.
|
||||
#
|
||||
# It has to be *only* that token. This document chunks into a heading
|
||||
# ("Certificate Rotation Procedure") and a body, both under the same
|
||||
# parent, and near-duplicate suppression in `fuse_candidates` returns
|
||||
# exactly one per parent. A query that also says "certificate" or
|
||||
# "rotation" hands the heading -- three words, every one of them a
|
||||
# match -- the better sparse score, and the body loses its own
|
||||
# identifier. The heading carries no "CERT-8820", so this query can
|
||||
# only be answered by the chunk under test.
|
||||
certificates = await executor.execute(
|
||||
_search_request("CERT-8820", filters=SearchFilterData(source_ids=(SOURCE_B,)))
|
||||
)
|
||||
assert certificates.hits
|
||||
assert any("CERT-8820" in hit.text for hit in certificates.hits)
|
||||
|
||||
from_uploads = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(uploads_source,)))
|
||||
)
|
||||
assert from_uploads.hits
|
||||
assert _doc_ids(from_uploads) == documents_by_source[uploads_source]
|
||||
|
||||
# Source + document filters compose; a document of another source is
|
||||
# excluded even when named explicitly.
|
||||
[doc_b] = documents_by_source[SOURCE_B]
|
||||
crossed = await executor.execute(
|
||||
_search_request(
|
||||
broad_query,
|
||||
filters=SearchFilterData(source_ids=(SOURCE_A,), document_ids=(doc_b,)),
|
||||
)
|
||||
)
|
||||
assert crossed.hits == ()
|
||||
|
||||
# A filter for a foreign source excludes everything.
|
||||
nothing = await executor.execute(
|
||||
_search_request(broad_query, filters=SearchFilterData(source_ids=(uuid4(),)))
|
||||
)
|
||||
assert nothing.hits == ()
|
||||
finally:
|
||||
await qdrant_client.delete_collection(collection_name)
|
||||
await embedding_client.aclose()
|
||||
|
||||
|
||||
def _minimal_pdf() -> bytes:
|
||||
"""A one-page PDF with a real text layer, built without external tools."""
|
||||
|
||||
content_stream = (
|
||||
b"BT /F1 18 Tf 72 720 Td (Backup Restore Procedure) Tj"
|
||||
b" 0 -30 Td (Verify snapshot SNAP-9911 before restoring the archive.) Tj ET"
|
||||
)
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R"
|
||||
b" /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length %d >>\nstream\n" % len(content_stream) + content_stream + b"\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
out = b"%PDF-1.4\n"
|
||||
offsets = []
|
||||
for index, body in enumerate(objects, 1):
|
||||
offsets.append(len(out))
|
||||
out += b"%d 0 obj\n" % index + body + b"\nendobj\n"
|
||||
xref = len(out)
|
||||
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objects) + 1)
|
||||
for offset in offsets:
|
||||
out += b"%010d 00000 n \n" % offset
|
||||
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (
|
||||
len(objects) + 1,
|
||||
xref,
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.asyncio
|
||||
async def test_pdf_upload_to_citation_gate(
|
||||
tmp_path: Path, qdrant_client: AsyncQdrantClient
|
||||
) -> None:
|
||||
"""Gate B: a real PDF flows through the security gate and Docling to
|
||||
page-located citations."""
|
||||
|
||||
pytest.importorskip("docling")
|
||||
profile_name = f"gate-{uuid4().hex[:10]}"
|
||||
profile_store = await _active_profile_store(tmp_path, profile_name)
|
||||
blob_storage = FilesystemBlobStorage(tmp_path / "blobs")
|
||||
embedding_client = httpx.AsyncClient(transport=_embedding_transport(), follow_redirects=False)
|
||||
repository = MemoryDocumentRepository()
|
||||
handler = _build_handler(
|
||||
tmp_path,
|
||||
profile_store=profile_store,
|
||||
repository=repository,
|
||||
registry=MemorySourceRegistry(),
|
||||
embedding_client=embedding_client,
|
||||
qdrant_client=qdrant_client,
|
||||
)
|
||||
collection_name = f"rag_dense_{profile_name}_v1"
|
||||
try:
|
||||
await _ingest(
|
||||
handler,
|
||||
blob_storage,
|
||||
content=_minimal_pdf(),
|
||||
name="restore.pdf",
|
||||
media_type="application/pdf",
|
||||
)
|
||||
assert len(repository.published) == 1
|
||||
[version] = repository.versions.values()
|
||||
assert version.parser_version.startswith("docling-")
|
||||
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=qdrant_client,
|
||||
embedding_profile_store=profile_store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, _TitleLookup()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request("snapshot SNAP-9911 restore archive"))
|
||||
assert result.degradation_codes == ()
|
||||
assert result.hits
|
||||
top = result.hits[0]
|
||||
assert "SNAP-9911" in top.text
|
||||
# The citation carries the page the chunk came from.
|
||||
assert top.citation.location.get("page_start") == 1
|
||||
assert top.citation.location.get("page_end") == 1
|
||||
finally:
|
||||
await qdrant_client.delete_collection(collection_name)
|
||||
await embedding_client.aclose()
|
||||
Reference in new issue
Block a user