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,171 @@
|
||||
"""Batch byte-quota boundaries: exact limit, one byte over, and fail-closed I/O."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.indexes.models import point_wire_bytes
|
||||
from ragcore.infrastructure.qdrant import adapter as adapter_module
|
||||
from ragcore.infrastructure.qdrant.adapter import QdrantIndexAdapter
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
class _RefusingClient:
|
||||
"""Proves a quota rejection happens before any network mutation is attempted."""
|
||||
|
||||
async def upsert(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("upsert must not run once the batch quota is exceeded")
|
||||
|
||||
async def retrieve(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("retrieve must not run once the batch quota is exceeded")
|
||||
|
||||
async def set_payload(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("set_payload must not run once the batch quota is exceeded")
|
||||
|
||||
|
||||
class _RecordingClient:
|
||||
"""A minimal working double so an at-quota batch can complete normally."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.upserted_ids: list[str] = []
|
||||
|
||||
async def upsert(self, *, points: Any, **kwargs: Any) -> None:
|
||||
self.upserted_ids = [point.id for point in points]
|
||||
|
||||
async def retrieve(self, *, ids: list[str], **kwargs: Any) -> list[SimpleNamespace]:
|
||||
return [
|
||||
SimpleNamespace(
|
||||
id=point_id,
|
||||
vector={
|
||||
"dense": [0.1, 0.2, 0.3, 0.4],
|
||||
"sparse": SimpleNamespace(indices=[1], values=[0.5]),
|
||||
},
|
||||
)
|
||||
for point_id in ids
|
||||
]
|
||||
|
||||
async def set_payload(self, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="quota",
|
||||
version="v1",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _point(profile: CollectionProfile, *, text: str = "x" * 1000) -> IndexPoint:
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(1, 2),
|
||||
sparse_values=(0.5, 0.5),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": 1},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=text,
|
||||
excerpt="short excerpt",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_batch_exactly_at_the_byte_limit_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
profile = _profile()
|
||||
point = _point(profile)
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(point))
|
||||
adapter = QdrantIndexAdapter(_RecordingClient()) # type: ignore[arg-type]
|
||||
|
||||
await adapter.upsert_points(profile, (point,))
|
||||
|
||||
|
||||
async def test_batch_one_byte_over_the_limit_is_rejected_with_no_partial_mutation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
point = _point(profile)
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(point) - 1)
|
||||
adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await adapter.upsert_points(profile, (point,))
|
||||
|
||||
|
||||
async def test_batch_quota_counts_every_metadata_field_not_only_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
small = _point(profile, text="short")
|
||||
large_location = replace(
|
||||
small.payload, location={f"k{i}": f"v{i}" for i in range(500)}
|
||||
)
|
||||
large_metadata_point = replace(small, payload=large_location)
|
||||
|
||||
# Set the limit strictly between the two sizes: only the metadata-heavy point
|
||||
# must be rejected, proving the estimate is not text/excerpt-only.
|
||||
threshold = (point_wire_bytes(small) + point_wire_bytes(large_metadata_point)) // 2
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", threshold)
|
||||
|
||||
accepting_adapter = QdrantIndexAdapter(_RecordingClient()) # type: ignore[arg-type]
|
||||
await accepting_adapter.upsert_points(profile, (small,))
|
||||
|
||||
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await refusing_adapter.upsert_points(profile, (large_metadata_point,))
|
||||
|
||||
|
||||
async def test_batch_quota_counts_multibyte_unicode_by_real_utf8_bytes(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
ascii_point = _point(profile, text="a" * 300)
|
||||
multibyte_point = _point(profile, text="漢" * 300)
|
||||
|
||||
assert point_wire_bytes(multibyte_point) > point_wire_bytes(ascii_point)
|
||||
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", point_wire_bytes(ascii_point))
|
||||
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await refusing_adapter.upsert_points(profile, (multibyte_point,))
|
||||
|
||||
|
||||
async def test_batch_quota_is_cumulative_across_many_small_points(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
points = tuple(_point(profile, text=f"passage {i}") for i in range(20))
|
||||
per_point = point_wire_bytes(points[0])
|
||||
# Allow 5 points' worth of bytes; the 6th must trip the cumulative check even
|
||||
# though no single point is anywhere near the limit on its own.
|
||||
monkeypatch.setattr(adapter_module, "MAX_BATCH_BYTES", per_point * 5 + per_point // 2)
|
||||
|
||||
refusing_adapter = QdrantIndexAdapter(_RefusingClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError, match="serialized batch limit"):
|
||||
await refusing_adapter.upsert_points(profile, points)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Cumulative per-build byte quota: many small points must not evade it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
import ragcore.application.indexes.service as service_module
|
||||
from ragcore.application.indexes import models as models_module
|
||||
from ragcore.application.indexes.models import IndexBuildError
|
||||
from ragcore.application.indexes.service import _stream_build
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="build-quota",
|
||||
version="v1",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _point(profile: CollectionProfile, index: int) -> IndexPoint:
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(),
|
||||
sparse_values=(),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": index},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=f"passage {index}",
|
||||
excerpt=f"passage {index}",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class _RecordingStore:
|
||||
def __init__(self) -> None:
|
||||
self.upserted: list[IndexPoint] = []
|
||||
|
||||
async def upsert_points(self, profile: CollectionProfile, points: object) -> None:
|
||||
self.upserted.extend(points) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class _ListSource:
|
||||
def __init__(self, points: tuple[IndexPoint, ...]) -> None:
|
||||
self._points = points
|
||||
|
||||
async def iter_points(self, profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self._points:
|
||||
yield point
|
||||
|
||||
|
||||
async def test_many_small_points_are_bounded_by_the_cumulative_build_quota(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
points = tuple(_point(profile, index) for index in range(10))
|
||||
per_point_bytes = models_module.point_wire_bytes(points[0])
|
||||
# _stream_build resolves MAX_BUILD_BYTES against service.py's own module
|
||||
# globals (bound at import time), so it must be patched there, not on models.
|
||||
monkeypatch.setattr(
|
||||
service_module, "MAX_BUILD_BYTES", per_point_bytes * 5 + per_point_bytes // 2
|
||||
)
|
||||
|
||||
store = _RecordingStore()
|
||||
with pytest.raises(IndexBuildError, match="byte quota"):
|
||||
await _stream_build(store, profile, _ListSource(points))
|
||||
|
||||
# Fewer than all ten points may have been accepted; none beyond the quota.
|
||||
assert len(store.upserted) < len(points)
|
||||
|
||||
|
||||
async def test_build_quota_is_not_evaded_by_many_tiny_points(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
profile = _profile()
|
||||
points = tuple(_point(profile, index) for index in range(50))
|
||||
per_point_bytes = models_module.point_wire_bytes(points[0])
|
||||
|
||||
monkeypatch.setattr(service_module, "MAX_BUILD_BYTES", per_point_bytes * 10)
|
||||
|
||||
store = _RecordingStore()
|
||||
with pytest.raises(IndexBuildError, match="byte quota"):
|
||||
await _stream_build(store, profile, _ListSource(points))
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Pure unit coverage for the point_wire_bytes byte-quoting estimator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.application.indexes.models import point_wire_bytes
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="quota-test",
|
||||
version="v1",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _point(profile: CollectionProfile, *, text: str = "safe passage") -> IndexPoint:
|
||||
chunk_id = uuid4()
|
||||
return IndexPoint(
|
||||
point_id=chunk_id,
|
||||
dense_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
sparse_indices=(1, 2),
|
||||
sparse_values=(0.5, 0.5),
|
||||
payload=IndexPayload(
|
||||
workspace_id=uuid4(),
|
||||
space_id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=None,
|
||||
source_id=uuid4(),
|
||||
status="published",
|
||||
content_type="text/markdown",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
location={"page": 1, "block": "heading"},
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version=profile.version,
|
||||
embedding_model_digest=profile.model_digest,
|
||||
text=text,
|
||||
excerpt=text,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_wire_bytes_grows_with_non_text_metadata_fields() -> None:
|
||||
profile = _profile()
|
||||
small = _point(profile)
|
||||
large_location = replace(
|
||||
small.payload, location={f"key_{index}": f"value_{index}" for index in range(200)}
|
||||
)
|
||||
large = replace(small, payload=large_location)
|
||||
|
||||
assert point_wire_bytes(large) > point_wire_bytes(small)
|
||||
|
||||
|
||||
def test_wire_bytes_grows_with_sparse_vector_size() -> None:
|
||||
profile = _profile()
|
||||
small = _point(profile)
|
||||
large = replace(
|
||||
small,
|
||||
sparse_indices=tuple(range(500)),
|
||||
sparse_values=tuple(0.1 for _ in range(500)),
|
||||
)
|
||||
|
||||
assert point_wire_bytes(large) > point_wire_bytes(small)
|
||||
|
||||
|
||||
def test_wire_bytes_counts_real_utf8_bytes_for_multibyte_text() -> None:
|
||||
profile = _profile()
|
||||
ascii_point = _point(profile, text="a" * 100)
|
||||
multibyte_point = _point(profile, text="漢" * 100) # each character is 3 UTF-8 bytes
|
||||
|
||||
assert point_wire_bytes(multibyte_point) > point_wire_bytes(ascii_point) + 150
|
||||
|
||||
|
||||
def test_wire_bytes_includes_fingerprint_field_overhead() -> None:
|
||||
import json
|
||||
|
||||
profile = _profile()
|
||||
point = _point(profile)
|
||||
raw_payload_bytes = len(
|
||||
json.dumps(dict(point.payload.as_dict()), sort_keys=True, separators=(",", ":")).encode(
|
||||
"utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
# The adapter attaches three 64-character fingerprint fields after the payload is
|
||||
# built; the estimate must already include their size so quota cannot be evaded
|
||||
# by omitting them.
|
||||
assert point_wire_bytes(point) > raw_payload_bytes + 3 * 64
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Incremental ingest and authoritative rebuild must agree on every logical
|
||||
payload field -- the one canonical ``IndexableChunk`` mapping, exercised from
|
||||
both ends.
|
||||
|
||||
The handler indexes a document (fake Qdrant store records the points, fake
|
||||
repository records the chunk rows exactly as PostgreSQL would store them).
|
||||
The rebuild source then reads those *stored* chunk rows back as
|
||||
``PublishedChunkRecord``s -- the same shape ``iter_published_content`` yields --
|
||||
and re-embeds them. Apart from the embedding vector (a different profile
|
||||
would produce a different one) every payload field must be identical: source
|
||||
identity, document/version/chunk identity, language, content type, trust
|
||||
level, section, page/line ranges, structure path, parser version.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.documents import (
|
||||
ChunkRecord,
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
PublishedChunkRecord,
|
||||
)
|
||||
from ragcore.application.jobs.models import Job, JobContext
|
||||
from ragcore.application.sources import SourceRecord
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider
|
||||
from ragcore.infrastructure.blob.filesystem import FilesystemBlobStorage
|
||||
from ragcore.infrastructure.indexes import ProductionAuthoritativeIndexSource
|
||||
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.models import CollectionProfile, IndexPoint
|
||||
from ragcore.infrastructure.security import (
|
||||
ContentSecurityGateService,
|
||||
production_content_security_policy,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
DIMENSIONS = 4
|
||||
|
||||
|
||||
class RecordingRepository:
|
||||
"""Stores documents, versions and chunk rows; replays them as published content."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
self.versions: dict[UUID, DocumentVersionRecord] = {}
|
||||
self.chunks: dict[UUID, tuple[ChunkRecord, ...]] = {}
|
||||
|
||||
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 None
|
||||
|
||||
async def publish_validated(self, version_id, artifacts, chunks): # type: ignore[no-untyped-def]
|
||||
self.chunks[version_id] = tuple(chunks)
|
||||
version = self.versions[version_id]
|
||||
self.versions[version_id] = DocumentVersionRecord(
|
||||
id=version.id,
|
||||
document_id=version.document_id,
|
||||
source_fingerprint=version.source_fingerprint,
|
||||
content_hash=version.content_hash,
|
||||
parser_version=version.parser_version,
|
||||
chunk_profile_version=version.chunk_profile_version,
|
||||
state=DocumentVersionState.PUBLISHED,
|
||||
job_id=version.job_id,
|
||||
)
|
||||
return self.versions[version_id]
|
||||
|
||||
async def iter_published_content(self) -> AsyncIterator[PublishedChunkRecord]:
|
||||
for version_id, chunk_rows in self.chunks.items():
|
||||
version = self.versions[version_id]
|
||||
document = next(d for d in self.documents if d.id == version.document_id)
|
||||
for chunk in chunk_rows:
|
||||
yield PublishedChunkRecord(
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
source_id=document.source_id,
|
||||
document_id=document.id,
|
||||
document_version_id=version.id,
|
||||
chunk_id=chunk.id,
|
||||
parent_id=chunk.parent_id,
|
||||
structure_path=chunk.structure_path,
|
||||
content=chunk.content,
|
||||
parser_version=version.parser_version,
|
||||
metadata=dict(chunk.metadata),
|
||||
)
|
||||
|
||||
|
||||
class RecordingQdrant:
|
||||
def __init__(self) -> None:
|
||||
self.points: list[IndexPoint] = []
|
||||
self.alias_target: str | None = None
|
||||
|
||||
async def current_alias_target(self, alias_name: str) -> str | None:
|
||||
return self.alias_target
|
||||
|
||||
async def create_shadow(self, profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
async def activate(self, profile: CollectionProfile) -> None:
|
||||
self.alias_target = profile.collection_name
|
||||
|
||||
async def upsert_points(self, profile: CollectionProfile, points: list[IndexPoint]) -> None:
|
||||
self.points.extend(points)
|
||||
|
||||
|
||||
class NullRegistry:
|
||||
async def ensure_source(self, record: SourceRecord) -> SourceRecord:
|
||||
return record
|
||||
|
||||
|
||||
class NullAudit:
|
||||
async def record(self, **event: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _embed(request: httpx.Request) -> httpx.Response:
|
||||
body = json.loads(request.content)
|
||||
return httpx.Response(200, json={"embeddings": [[0.5, 0.5, 0.5, 0.5] for _ in body["input"]]})
|
||||
|
||||
|
||||
async def _profile_store(tmp_path: Path) -> tuple[JsonEmbeddingProfileStore, EmbeddingProfile]:
|
||||
store = JsonEmbeddingProfileStore(tmp_path / "profiles.json")
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="parity-embed",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="parity:v1",
|
||||
model_digest="d" * 64,
|
||||
dimensions=DIMENSIONS,
|
||||
context_limit=2048,
|
||||
tokenizer="test",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store, profile
|
||||
|
||||
|
||||
async def _bytes(data: bytes) -> AsyncIterator[bytes]:
|
||||
yield data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incremental_ingest_and_authoritative_rebuild_produce_identical_payloads(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store, profile = await _profile_store(tmp_path)
|
||||
repository = RecordingRepository()
|
||||
qdrant = RecordingQdrant()
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_embed), follow_redirects=False)
|
||||
adapter_factory = create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://ollama:11434"), http_client=client
|
||||
)
|
||||
handler = IngestUploadHandler(
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
blob_storage=FilesystemBlobStorage(tmp_path / "blobs"),
|
||||
artifact_publisher=FilesystemArtifactPublisher(tmp_path / "parsed"),
|
||||
embedding_profile_store=store,
|
||||
embedding_adapter_factory=adapter_factory,
|
||||
qdrant_store=qdrant, # type: ignore[arg-type]
|
||||
source_registry=NullRegistry(),
|
||||
security_gate=ContentSecurityGateService(
|
||||
NullAudit(), policy=production_content_security_policy()
|
||||
),
|
||||
)
|
||||
blob = FilesystemBlobStorage(tmp_path / "blobs")
|
||||
reference = await blob.put(
|
||||
_bytes(
|
||||
b"# Backup Runbook\n\n"
|
||||
b"## Nightly snapshot\n\n"
|
||||
b"Run the snapshot job SNAP-22 before midnight and verify the checksum.\n\n"
|
||||
b"## Restore drill\n\n"
|
||||
b"Restore into the disposable database and compare row counts with production.\n"
|
||||
),
|
||||
media_type="text/markdown",
|
||||
original_name="backup.md",
|
||||
)
|
||||
job = Job(
|
||||
id=uuid4(),
|
||||
job_key="parity",
|
||||
operation="ingest_upload",
|
||||
target_key=reference.digest,
|
||||
profile_version="upload-v1",
|
||||
payload={
|
||||
"blob_digest": reference.digest,
|
||||
"media_type": "text/markdown",
|
||||
"original_name": "backup.md",
|
||||
"space_id": str(SPACE_ID),
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"source_id": str(SOURCE_ID),
|
||||
"external_id": "runbooks/backup.md",
|
||||
"locale": "en",
|
||||
"trust_level": "trusted_internal",
|
||||
},
|
||||
available_at=datetime.now(UTC),
|
||||
)
|
||||
await handler(
|
||||
JobContext(job=job, attempt_number=1, idempotency_key="k", cancellation=asyncio.Event())
|
||||
)
|
||||
ingested = {point.point_id: point for point in qdrant.points}
|
||||
assert ingested
|
||||
|
||||
rebuilt_source = ProductionAuthoritativeIndexSource(
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
embedding_profile=profile,
|
||||
embedding_http_client=client,
|
||||
embedding_base_url="http://ollama:11434",
|
||||
)
|
||||
collection = CollectionProfile(
|
||||
kind="dense", # type: ignore[arg-type]
|
||||
profile=profile.profile,
|
||||
version=profile.version,
|
||||
dimensions=profile.dimensions,
|
||||
model_digest=profile.model_digest,
|
||||
)
|
||||
rebuilt = {point.point_id: point async for point in rebuilt_source.iter_points(collection)}
|
||||
|
||||
assert set(rebuilt) == set(ingested), "rebuild must cover exactly the ingested child chunks"
|
||||
for point_id, original in ingested.items():
|
||||
again = rebuilt[point_id]
|
||||
assert isinstance(again, IndexPoint)
|
||||
assert again.payload.as_dict() == original.payload.as_dict(), point_id
|
||||
assert again.sparse_indices == original.sparse_indices
|
||||
assert again.sparse_values == original.sparse_values
|
||||
# And the provenance that matters is really there, not vacuously equal.
|
||||
payload = again.payload
|
||||
assert payload.source_id == SOURCE_ID
|
||||
assert payload.trust_level == "trusted_internal"
|
||||
assert payload.language == "en"
|
||||
assert payload.content_type == "text/markdown"
|
||||
assert payload.location["structure_path"]
|
||||
assert isinstance(payload.location.get("line_start"), int)
|
||||
assert isinstance(payload.location.get("line_end"), int)
|
||||
sections = {point.payload.location.get("section") for point in rebuilt.values()}
|
||||
assert sections & {"Nightly snapshot", "Restore drill"}, sections
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.application.indexes.space_reconcile import (
|
||||
MAX_DELETE_BATCH,
|
||||
SpaceIndexReconciler,
|
||||
SpacePoint,
|
||||
)
|
||||
|
||||
|
||||
class _Profile:
|
||||
collection_name = "rag_dense_test_v1"
|
||||
alias_name = "rag_dense_test_active"
|
||||
version = "v1"
|
||||
model_digest = "a" * 64
|
||||
|
||||
|
||||
class FakeDocuments:
|
||||
def __init__(self) -> None:
|
||||
self.documents: list[DocumentRecord] = []
|
||||
self.versions: dict[UUID, list[DocumentVersionRecord]] = {}
|
||||
|
||||
def add(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
|
||||
self.documents.append(document)
|
||||
self.versions[document.id] = list(versions)
|
||||
|
||||
async def list_documents_by_space(self, space_id: UUID) -> tuple[DocumentRecord, ...]:
|
||||
return tuple(d for d in self.documents if d.space_id == space_id)
|
||||
|
||||
async def list_searchable_versions(
|
||||
self, document_id: UUID
|
||||
) -> tuple[DocumentVersionRecord, ...]:
|
||||
document = next(d for d in self.documents if d.id == document_id)
|
||||
if document.source_deleted:
|
||||
return ()
|
||||
return tuple(
|
||||
v
|
||||
for v in self.versions.get(document_id, ())
|
||||
if v.state is DocumentVersionState.PUBLISHED
|
||||
)
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, points: dict[UUID, list[SpacePoint]]) -> None:
|
||||
self.points = points
|
||||
self.deleted: list[list[UUID]] = []
|
||||
|
||||
async def list_space_points(self, profile: object, space_id: UUID) -> tuple[SpacePoint, ...]:
|
||||
return tuple(self.points.get(space_id, ()))
|
||||
|
||||
async def delete_points(self, profile: object, point_ids: Sequence[UUID]) -> None:
|
||||
self.deleted.append(list(point_ids))
|
||||
|
||||
|
||||
def _document(space_id: UUID, *, retired: bool = False) -> DocumentRecord:
|
||||
return DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
space_id=space_id,
|
||||
source_key=str(uuid4()),
|
||||
external_id=f"record-{uuid4()}",
|
||||
title="record",
|
||||
source_deleted=retired,
|
||||
)
|
||||
|
||||
|
||||
def _version(document_id: UUID, state: DocumentVersionState) -> DocumentVersionRecord:
|
||||
return DocumentVersionRecord(
|
||||
id=uuid4(),
|
||||
document_id=document_id,
|
||||
source_fingerprint="a" * 64,
|
||||
content_hash="b" * 64,
|
||||
parser_version="text-v1",
|
||||
chunk_profile_version="prose-v1",
|
||||
state=state,
|
||||
)
|
||||
|
||||
|
||||
def _points(document: DocumentRecord, version: DocumentVersionRecord, n: int) -> list[SpacePoint]:
|
||||
return [
|
||||
SpacePoint(point_id=uuid4(), document_id=document.id, document_version_id=version.id)
|
||||
for _ in range(n)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_drops_superseded_and_retired_points_only_in_the_space() -> None:
|
||||
space, other_space = uuid4(), uuid4()
|
||||
documents = FakeDocuments()
|
||||
current = _document(space)
|
||||
published = _version(current.id, DocumentVersionState.PUBLISHED)
|
||||
superseded = _version(current.id, DocumentVersionState.SUPERSEDED)
|
||||
documents.add(current, published, superseded)
|
||||
retired = _document(space, retired=True)
|
||||
retired_version = _version(retired.id, DocumentVersionState.PUBLISHED)
|
||||
documents.add(retired, retired_version)
|
||||
foreign = _document(other_space)
|
||||
foreign_version = _version(foreign.id, DocumentVersionState.PUBLISHED)
|
||||
documents.add(foreign, foreign_version)
|
||||
|
||||
keep = _points(current, published, 3)
|
||||
stale = _points(current, superseded, 2)
|
||||
gone = _points(retired, retired_version, 4)
|
||||
store = FakeStore(
|
||||
{space: keep + stale + gone, other_space: _points(foreign, foreign_version, 5)}
|
||||
)
|
||||
|
||||
report = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
|
||||
|
||||
deleted = {point_id for batch in store.deleted for point_id in batch}
|
||||
assert deleted == {p.point_id for p in stale + gone}
|
||||
assert report.points_before == 9
|
||||
assert report.deleted_points == 6
|
||||
assert report.points_after == 3
|
||||
assert report.searchable_versions == 1
|
||||
assert report.documents_total == 2
|
||||
assert report.documents_retired == 1
|
||||
assert report.orphaned_documents == 2
|
||||
# The other space was never touched.
|
||||
assert store.points[other_space] and all(
|
||||
p.point_id not in deleted for p in store.points[other_space]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_is_idempotent_and_batches_deletes() -> None:
|
||||
space = uuid4()
|
||||
documents = FakeDocuments()
|
||||
document = _document(space)
|
||||
old = _version(document.id, DocumentVersionState.SUPERSEDED)
|
||||
new = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
documents.add(document, old, new)
|
||||
stale = _points(document, old, MAX_DELETE_BATCH + 10)
|
||||
store = FakeStore({space: stale + _points(document, new, 1)})
|
||||
|
||||
first = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
|
||||
assert first.deleted_points == MAX_DELETE_BATCH + 10
|
||||
assert [len(batch) for batch in store.deleted] == [MAX_DELETE_BATCH, 10]
|
||||
|
||||
store.points[space] = [p for p in store.points[space] if p.document_version_id == new.id]
|
||||
second = await SpaceIndexReconciler(store, _Profile()).reconcile(space, documents) # type: ignore[arg-type]
|
||||
assert second.deleted_points == 0
|
||||
assert second.points_after == 1
|
||||
Reference in new issue
Block a user