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,73 @@
|
||||
"""TelemetryMiddleware: a real HTTP trace and request metric per request."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
from ragcore.api.middleware import TelemetryMiddleware
|
||||
from ragcore.platform.logging import RequestCorrelationMiddleware
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
|
||||
def _app() -> tuple[FastAPI, InMemorySpanExporter, InMemoryMetricReader]:
|
||||
span_exporter = InMemorySpanExporter()
|
||||
tracer_provider = TracerProvider(resource=Resource.create({"service.name": "test"}))
|
||||
tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||||
|
||||
metric_reader = InMemoryMetricReader()
|
||||
meter_provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[metric_reader]
|
||||
)
|
||||
metrics = TelemetryMetrics(meter_provider=meter_provider)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/ping")
|
||||
async def ping() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
# Mirrors the real app's middleware order: TelemetryMiddleware must run
|
||||
# after RequestCorrelationMiddleware to read the request_id it attaches.
|
||||
app.add_middleware(TelemetryMiddleware, metrics=metrics, tracer_provider=tracer_provider)
|
||||
app.add_middleware(RequestCorrelationMiddleware)
|
||||
return app, span_exporter, metric_reader
|
||||
|
||||
|
||||
def _metric_names(reader: InMemoryMetricReader) -> set[str]:
|
||||
data = reader.get_metrics_data()
|
||||
if data is None:
|
||||
return set()
|
||||
return {
|
||||
metric.name
|
||||
for resource_metric in data.resource_metrics
|
||||
for scope_metric in resource_metric.scope_metrics
|
||||
for metric in scope_metric.metrics
|
||||
}
|
||||
|
||||
|
||||
def test_a_request_produces_one_correlated_http_trace_span() -> None:
|
||||
app, span_exporter, _ = _app()
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/ping", headers={"X-Request-ID": "req-telemetry-1"})
|
||||
|
||||
assert response.status_code == 200
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert spans[0].name == "http_request"
|
||||
assert spans[0].attributes["correlation_id"] == "req-telemetry-1"
|
||||
assert spans[0].attributes["http.path"] == "/ping"
|
||||
|
||||
|
||||
def test_a_request_produces_the_http_request_duration_metric() -> None:
|
||||
app, _, metric_reader = _app()
|
||||
with TestClient(app) as client:
|
||||
client.get("/ping")
|
||||
|
||||
assert "ragcore_request_duration_seconds" in _metric_names(metric_reader)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""BackupService orchestration against fake ports: atomicity, verification, retention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.backup.models import (
|
||||
ArtifactChecksum,
|
||||
BackupManifest,
|
||||
BackupStatus,
|
||||
BackupVerificationError,
|
||||
)
|
||||
from ragcore.application.backup.service import MANIFEST_FILENAME, BackupService
|
||||
|
||||
|
||||
def _write_and_checksum(path: Path, content: bytes) -> ArtifactChecksum:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
return ArtifactChecksum(
|
||||
relative_path=path.name, sha256=hashlib.sha256(content).hexdigest(), size_bytes=len(content)
|
||||
)
|
||||
|
||||
|
||||
class _FakePostgresDumper:
|
||||
def __init__(self, *, content: bytes = b"pg-dump-bytes", revision: str = "abc123") -> None:
|
||||
self._content = content
|
||||
self._revision = revision
|
||||
self.dump_calls = 0
|
||||
|
||||
async def dump(self, *, destination: Path) -> ArtifactChecksum:
|
||||
self.dump_calls += 1
|
||||
return _write_and_checksum(destination, self._content)
|
||||
|
||||
async def schema_revision(self) -> str:
|
||||
return self._revision
|
||||
|
||||
|
||||
class _FakeQdrantSnapshotter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
collections: Mapping[str, bytes] | None = None,
|
||||
aliases: Mapping[str, str] | None = None,
|
||||
) -> None:
|
||||
self._collections = collections if collections is not None else {"docs": b"snapshot-bytes"}
|
||||
self._aliases = aliases if aliases is not None else {"docs-live": "docs"}
|
||||
|
||||
async def snapshot_collections(
|
||||
self, *, collection_names: Sequence[str], destination_dir: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = []
|
||||
for name in collection_names:
|
||||
content = self._collections[name]
|
||||
checksums.append(_write_and_checksum(destination_dir / f"{name}.snapshot", content))
|
||||
return tuple(checksums)
|
||||
|
||||
async def collection_aliases(self) -> Mapping[str, str]:
|
||||
return self._aliases
|
||||
|
||||
|
||||
class _FakeFileCopier:
|
||||
def __init__(self, *, files: Mapping[str, bytes] | None = None) -> None:
|
||||
self._files = files if files is not None else {"a.bin": b"blob-a", "sub/b.bin": b"blob-b"}
|
||||
|
||||
async def copy_tree(self, *, source: Path, destination: Path) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = []
|
||||
for relative_path, content in self._files.items():
|
||||
target = destination / relative_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
checksums.append(
|
||||
ArtifactChecksum(
|
||||
relative_path=relative_path,
|
||||
sha256=hashlib.sha256(content).hexdigest(),
|
||||
size_bytes=len(content),
|
||||
)
|
||||
)
|
||||
return tuple(checksums)
|
||||
|
||||
|
||||
def _service(
|
||||
*,
|
||||
postgres: _FakePostgresDumper | None = None,
|
||||
qdrant: _FakeQdrantSnapshotter | None = None,
|
||||
files: _FakeFileCopier | None = None,
|
||||
clock: object = None,
|
||||
id_factory: object = None,
|
||||
) -> BackupService:
|
||||
kwargs: dict[str, object] = {
|
||||
"postgres": postgres or _FakePostgresDumper(),
|
||||
"qdrant": qdrant or _FakeQdrantSnapshotter(),
|
||||
"files": files or _FakeFileCopier(),
|
||||
}
|
||||
if clock is not None:
|
||||
kwargs["clock"] = clock
|
||||
if id_factory is not None:
|
||||
kwargs["id_factory"] = id_factory
|
||||
return BackupService(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def test_run_produces_a_complete_manifest_pinning_schema_and_profiles(tmp_path: Path) -> None:
|
||||
service = _service(id_factory=lambda: "backup-1")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={"embedding": "hybrid-v1:v3", "generation": "default:v1"},
|
||||
)
|
||||
|
||||
assert manifest.status is BackupStatus.COMPLETE
|
||||
assert manifest.backup_id == "backup-1"
|
||||
assert manifest.schema_revision == "abc123"
|
||||
assert manifest.active_profiles == {"embedding": "hybrid-v1:v3", "generation": "default:v1"}
|
||||
assert manifest.postgres_dump is not None
|
||||
assert manifest.postgres_dump.relative_path == "postgres.dump"
|
||||
assert len(manifest.qdrant_snapshots) == 1
|
||||
assert manifest.qdrant_snapshots[0].relative_path == "qdrant/docs.snapshot"
|
||||
assert manifest.collection_aliases == {"docs-live": "docs"}
|
||||
assert {c.relative_path for c in manifest.blob_artifacts} == {"blobs/a.bin", "blobs/sub/b.bin"}
|
||||
assert {c.relative_path for c in manifest.parsed_artifacts} == {
|
||||
"parsed/a.bin",
|
||||
"parsed/sub/b.bin",
|
||||
}
|
||||
assert manifest.counts == {"qdrant_snapshots": 1, "blob_artifacts": 2, "parsed_artifacts": 2}
|
||||
|
||||
|
||||
async def test_run_filters_out_aliases_for_collections_not_in_this_backup(tmp_path: Path) -> None:
|
||||
"""collection_aliases() reports every alias in the whole Qdrant cluster;
|
||||
only the ones for collections this backup actually snapshotted may end
|
||||
up in the manifest, or restore would later look for a snapshot of
|
||||
someone else's unrelated collection."""
|
||||
|
||||
service = _service(
|
||||
qdrant=_FakeQdrantSnapshotter(
|
||||
collections={"docs": b"snapshot-bytes"},
|
||||
aliases={"docs-live": "docs", "unrelated-live": "unrelated-collection"},
|
||||
),
|
||||
id_factory=lambda: "backup-1",
|
||||
)
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
assert manifest.collection_aliases == {"docs-live": "docs"}
|
||||
|
||||
|
||||
async def test_run_writes_every_artifact_relative_to_backup_dir_on_disk(tmp_path: Path) -> None:
|
||||
service = _service(id_factory=lambda: "backup-1")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
backup_dir = tmp_path / "backup-1"
|
||||
assert manifest.postgres_dump is not None
|
||||
for checksum in (
|
||||
manifest.postgres_dump,
|
||||
*manifest.qdrant_snapshots,
|
||||
*manifest.blob_artifacts,
|
||||
*manifest.parsed_artifacts,
|
||||
):
|
||||
assert (backup_dir / checksum.relative_path).is_file()
|
||||
|
||||
|
||||
async def test_manifest_on_disk_is_incomplete_before_run_finishes(tmp_path: Path) -> None:
|
||||
"""The atomic-completion invariant: an in-flight backup must never look done."""
|
||||
|
||||
seen_status: list[str] = []
|
||||
real_dump = _FakePostgresDumper()
|
||||
|
||||
class _ObservingDumper(_FakePostgresDumper):
|
||||
async def dump(self, *, destination: Path) -> ArtifactChecksum:
|
||||
manifest_path = destination.parent / MANIFEST_FILENAME
|
||||
seen_status.append(json.loads(manifest_path.read_text())["status"])
|
||||
return await real_dump.dump(destination=destination)
|
||||
|
||||
async def schema_revision(self) -> str:
|
||||
return await real_dump.schema_revision()
|
||||
|
||||
service = _service(postgres=_ObservingDumper(), id_factory=lambda: "backup-2")
|
||||
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
assert seen_status == ["incomplete"]
|
||||
final_status = json.loads((tmp_path / "backup-2" / MANIFEST_FILENAME).read_text())["status"]
|
||||
assert final_status == "complete"
|
||||
|
||||
|
||||
async def test_a_partial_backup_is_never_promoted_to_complete(tmp_path: Path) -> None:
|
||||
class _FailingFileCopier(_FakeFileCopier):
|
||||
async def copy_tree(
|
||||
self, *, source: Path, destination: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
raise RuntimeError("disk full")
|
||||
|
||||
service = _service(files=_FailingFileCopier(), id_factory=lambda: "backup-3")
|
||||
|
||||
with pytest.raises(RuntimeError, match="disk full"):
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
manifest_path = tmp_path / "backup-3" / MANIFEST_FILENAME
|
||||
assert json.loads(manifest_path.read_text())["status"] == "incomplete"
|
||||
|
||||
|
||||
async def test_run_raises_if_a_checksum_is_corrupted_before_verification(tmp_path: Path) -> None:
|
||||
"""Simulates on-disk corruption between write and the re-verify step."""
|
||||
|
||||
class _CorruptingFileCopier(_FakeFileCopier):
|
||||
async def copy_tree(
|
||||
self, *, source: Path, destination: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = await super().copy_tree(source=source, destination=destination)
|
||||
first = destination / checksums[0].relative_path
|
||||
first.write_bytes(b"corrupted")
|
||||
return checksums
|
||||
|
||||
service = _service(files=_CorruptingFileCopier(), id_factory=lambda: "backup-4")
|
||||
|
||||
with pytest.raises(BackupVerificationError, match="mismatch"):
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
manifest_path = tmp_path / "backup-4" / MANIFEST_FILENAME
|
||||
assert json.loads(manifest_path.read_text())["status"] == "incomplete"
|
||||
|
||||
|
||||
async def test_run_raises_if_an_artifact_is_missing_before_verification(tmp_path: Path) -> None:
|
||||
class _DeletingFileCopier(_FakeFileCopier):
|
||||
async def copy_tree(
|
||||
self, *, source: Path, destination: Path
|
||||
) -> tuple[ArtifactChecksum, ...]:
|
||||
checksums = await super().copy_tree(source=source, destination=destination)
|
||||
(destination / checksums[0].relative_path).unlink()
|
||||
return checksums
|
||||
|
||||
service = _service(files=_DeletingFileCopier(), id_factory=lambda: "backup-5")
|
||||
|
||||
with pytest.raises(BackupVerificationError, match="missing"):
|
||||
await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={},
|
||||
)
|
||||
|
||||
|
||||
async def test_manifest_never_contains_settings_style_secret_fields(tmp_path: Path) -> None:
|
||||
service = _service(id_factory=lambda: "backup-6")
|
||||
|
||||
manifest = await service.run(
|
||||
backup_root=tmp_path,
|
||||
blob_root=tmp_path / "blobs-src",
|
||||
parsed_root=tmp_path / "parsed-src",
|
||||
collection_names=["docs"],
|
||||
active_profiles={"embedding": "hybrid-v1:v3"},
|
||||
)
|
||||
|
||||
serialized = json.dumps(manifest.to_json_dict())
|
||||
for forbidden in ("database_url", "api_key", "secret", "password", "token"):
|
||||
assert forbidden not in serialized.lower()
|
||||
|
||||
|
||||
def _manifest(backup_id: str, status: BackupStatus, created_at: datetime) -> BackupManifest:
|
||||
return BackupManifest(
|
||||
backup_id=backup_id,
|
||||
status=status,
|
||||
created_at=created_at,
|
||||
software_version="0.0.0",
|
||||
schema_revision="rev",
|
||||
)
|
||||
|
||||
|
||||
def _write_manifest(backup_root: Path, manifest: BackupManifest) -> None:
|
||||
backup_dir = backup_root / manifest.backup_id
|
||||
backup_dir.mkdir(parents=True)
|
||||
(backup_dir / MANIFEST_FILENAME).write_text(json.dumps(manifest.to_json_dict()))
|
||||
|
||||
|
||||
def test_prune_keeps_only_the_newest_complete_backups(tmp_path: Path) -> None:
|
||||
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
_write_manifest(tmp_path, _manifest("oldest", BackupStatus.COMPLETE, base))
|
||||
_write_manifest(tmp_path, _manifest("middle", BackupStatus.COMPLETE, base + timedelta(days=1)))
|
||||
_write_manifest(tmp_path, _manifest("newest", BackupStatus.COMPLETE, base + timedelta(days=2)))
|
||||
|
||||
service = _service()
|
||||
removed = service.prune(backup_root=tmp_path, keep_complete=2)
|
||||
|
||||
assert removed == ("oldest",)
|
||||
assert not (tmp_path / "oldest").exists()
|
||||
assert (tmp_path / "middle").exists()
|
||||
assert (tmp_path / "newest").exists()
|
||||
|
||||
|
||||
def test_prune_never_removes_an_incomplete_backup(tmp_path: Path) -> None:
|
||||
base = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
_write_manifest(tmp_path, _manifest("stale-incomplete", BackupStatus.INCOMPLETE, base))
|
||||
_write_manifest(
|
||||
tmp_path, _manifest("complete-one", BackupStatus.COMPLETE, base + timedelta(days=1))
|
||||
)
|
||||
|
||||
service = _service()
|
||||
removed = service.prune(backup_root=tmp_path, keep_complete=0)
|
||||
|
||||
assert removed == ("complete-one",)
|
||||
assert (tmp_path / "stale-incomplete").exists()
|
||||
|
||||
|
||||
def test_prune_rejects_negative_retention(tmp_path: Path) -> None:
|
||||
service = _service()
|
||||
with pytest.raises(ValueError, match="keep_complete"):
|
||||
service.prune(backup_root=tmp_path, keep_complete=-1)
|
||||
|
||||
|
||||
def test_prune_on_nonexistent_backup_root_is_a_noop(tmp_path: Path) -> None:
|
||||
service = _service()
|
||||
removed = service.prune(backup_root=tmp_path / "does-not-exist", keep_complete=5)
|
||||
assert removed == ()
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Unit tests for the document read/retire use cases a sync client drives:
|
||||
look a document up by its own (source_id, external_id) identity, list every
|
||||
version, and soft-retire a document that's disappeared at the source."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.control.api import ControlPlaneApplication
|
||||
from ragcore.application.documents import (
|
||||
DocumentRecord,
|
||||
DocumentVersionRecord,
|
||||
DocumentVersionState,
|
||||
)
|
||||
from ragcore.domain.authorization import AuthorizationError, Principal, PrincipalType
|
||||
from ragcore.domain.control import Role
|
||||
|
||||
|
||||
class FakeDocumentRepository:
|
||||
def __init__(self) -> None:
|
||||
self.documents: dict[object, DocumentRecord] = {}
|
||||
self.versions: dict[object, list[DocumentVersionRecord]] = {}
|
||||
self.retired: list[object] = []
|
||||
|
||||
def seed(self, document: DocumentRecord, *versions: DocumentVersionRecord) -> None:
|
||||
self.documents[document.id] = document
|
||||
self.versions[document.id] = list(versions)
|
||||
|
||||
async def get_document(self, document_id: object) -> DocumentRecord | None:
|
||||
return self.documents.get(document_id)
|
||||
|
||||
async def get_document_by_source(
|
||||
self, workspace_id: object, source_key: str, external_id: str
|
||||
) -> DocumentRecord | None:
|
||||
return next(
|
||||
(
|
||||
document
|
||||
for document in self.documents.values()
|
||||
if document.workspace_id == workspace_id
|
||||
and document.source_key == source_key
|
||||
and document.external_id == external_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
async def list_versions(self, document_id: object) -> tuple[DocumentVersionRecord, ...]:
|
||||
return tuple(self.versions.get(document_id, ()))
|
||||
|
||||
async def list_searchable_versions(
|
||||
self, document_id: object
|
||||
) -> tuple[DocumentVersionRecord, ...]:
|
||||
document = self.documents[document_id]
|
||||
if document.source_deleted:
|
||||
return ()
|
||||
return tuple(
|
||||
version
|
||||
for version in self.versions.get(document_id, ())
|
||||
if version.state is DocumentVersionState.PUBLISHED
|
||||
)
|
||||
|
||||
async def mark_source_deleted(self, document_id: object) -> None:
|
||||
self.retired.append(document_id)
|
||||
document = self.documents[document_id]
|
||||
self.documents[document_id] = DocumentRecord(
|
||||
id=document.id,
|
||||
workspace_id=document.workspace_id,
|
||||
space_id=document.space_id,
|
||||
source_key=document.source_key,
|
||||
external_id=document.external_id,
|
||||
title=document.title,
|
||||
source_deleted=True,
|
||||
)
|
||||
|
||||
|
||||
def _principal(workspace_id: object, role: Role = Role.EDITOR) -> Principal:
|
||||
return Principal(uuid4(), workspace_id, PrincipalType.SERVICE_ACCOUNT, frozenset({role}))
|
||||
|
||||
|
||||
def _document(workspace_id: object, *, source_deleted: bool = False) -> DocumentRecord:
|
||||
return DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
space_id=uuid4(),
|
||||
source_key=str(uuid4()),
|
||||
external_id="wiki-page-1",
|
||||
title="Wiki page",
|
||||
source_deleted=source_deleted,
|
||||
)
|
||||
|
||||
|
||||
def _version(document_id: object, 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 _service(repository: FakeDocumentRepository) -> ControlPlaneApplication:
|
||||
return ControlPlaneApplication(
|
||||
repository=None, # type: ignore[arg-type]
|
||||
authorization=AuthorizationService(),
|
||||
document_repository=repository, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_returns_the_active_version() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
published = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
repository.seed(document, published)
|
||||
service = _service(repository)
|
||||
|
||||
found, active = await service.get_document_by_source(
|
||||
_principal(workspace_id),
|
||||
source_id=UUID(document.source_key),
|
||||
external_id=document.external_id,
|
||||
)
|
||||
|
||||
assert found == document
|
||||
assert active == published
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_unknown_pair_raises_lookup_error() -> None:
|
||||
service = _service(FakeDocumentRepository())
|
||||
with pytest.raises(LookupError):
|
||||
await service.get_document_by_source(
|
||||
_principal(uuid4()), source_id=uuid4(), external_id="missing"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_wrong_workspace_raises_lookup_error() -> None:
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(uuid4())
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
await service.get_document_by_source(
|
||||
_principal(uuid4()),
|
||||
source_id=UUID(document.source_key),
|
||||
external_id=document.external_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_document_by_source_insufficient_role_raises_authorization_error() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(AuthorizationError):
|
||||
await service.get_document_by_source(
|
||||
_principal(workspace_id, role=Role.AUDITOR),
|
||||
source_id=UUID(document.source_key),
|
||||
external_id=document.external_id,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_document_versions_returns_every_state() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
superseded = _version(document.id, DocumentVersionState.SUPERSEDED)
|
||||
published = _version(document.id, DocumentVersionState.PUBLISHED)
|
||||
repository.seed(document, superseded, published)
|
||||
service = _service(repository)
|
||||
|
||||
versions = await service.list_document_versions(_principal(workspace_id), document.id)
|
||||
|
||||
assert {version.id for version in versions} == {superseded.id, published.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_document_versions_wrong_workspace_raises_lookup_error() -> None:
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(uuid4())
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
await service.list_document_versions(_principal(uuid4()), document.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_marks_source_deleted() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
retired = await service.retire_document(_principal(workspace_id), document.id)
|
||||
|
||||
assert retired.source_deleted is True
|
||||
assert repository.retired == [document.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_is_idempotent() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id, source_deleted=True)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
retired = await service.retire_document(_principal(workspace_id), document.id)
|
||||
|
||||
assert retired.source_deleted is True
|
||||
assert repository.retired == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_wrong_workspace_raises_lookup_error() -> None:
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(uuid4())
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
await service.retire_document(_principal(uuid4()), document.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retire_document_requires_source_manage_permission() -> None:
|
||||
workspace_id = uuid4()
|
||||
repository = FakeDocumentRepository()
|
||||
document = _document(workspace_id)
|
||||
repository.seed(document)
|
||||
service = _service(repository)
|
||||
|
||||
with pytest.raises(AuthorizationError):
|
||||
await service.retire_document(_principal(workspace_id, role=Role.AUDITOR), document.id)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Unit tests for control-plane space and grant use cases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.control import (
|
||||
EffectiveSpaceResolver,
|
||||
GrantService,
|
||||
KnowledgeSpaceLifecycle,
|
||||
)
|
||||
from ragcore.domain.control import (
|
||||
DataPolicy,
|
||||
ProviderKind,
|
||||
Role,
|
||||
SpaceOperation,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
|
||||
def setup_control_plane() -> tuple[Workspace, object, object, object]:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
application = workspace.create_application(
|
||||
id=uuid4(),
|
||||
name="Client",
|
||||
scopes=(),
|
||||
default_knowledge_space_ids=frozenset(),
|
||||
)
|
||||
private = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Private", slug="private", data_policy=DataPolicy.LOCAL_ONLY
|
||||
)
|
||||
shared = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Shared", slug="shared", data_policy=DataPolicy.LOCAL_PREFERRED
|
||||
)
|
||||
return workspace, application, private, shared
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("policy", "provider", "operation", "allowed"),
|
||||
[
|
||||
(DataPolicy.LOCAL_ONLY, ProviderKind.LOCAL, SpaceOperation.ANSWER, True),
|
||||
(DataPolicy.LOCAL_ONLY, ProviderKind.CLOUD, SpaceOperation.SEARCH, False),
|
||||
(DataPolicy.LOCAL_PREFERRED, ProviderKind.CLOUD, SpaceOperation.ANSWER, True),
|
||||
(DataPolicy.RETRIEVAL_ONLY, ProviderKind.LOCAL, SpaceOperation.SEARCH, True),
|
||||
(DataPolicy.NO_GENERATION, ProviderKind.CLOUD, SpaceOperation.CONTEXT, True),
|
||||
(DataPolicy.NO_GENERATION, ProviderKind.LOCAL, SpaceOperation.ANSWER, False),
|
||||
],
|
||||
)
|
||||
def test_data_policy_excludes_incompatible_provider_operations(
|
||||
policy: DataPolicy, provider: ProviderKind, operation: SpaceOperation, allowed: bool
|
||||
) -> None:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Policy", slug="policy", data_policy=policy
|
||||
)
|
||||
|
||||
assert space.allows(operation=operation, provider=provider) is allowed
|
||||
|
||||
|
||||
def test_effective_spaces_are_requested_grants_and_policy_intersection() -> None:
|
||||
workspace, application, private, shared = setup_control_plane()
|
||||
user_id = uuid4()
|
||||
grants = GrantService()
|
||||
app_grant = grants.application_grant(
|
||||
workspace, id=uuid4(), application=application, space=private, role=Role.VIEWER
|
||||
)
|
||||
user_grant = grants.user_grant(
|
||||
workspace, id=uuid4(), user_id=user_id, space=shared, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, shared),
|
||||
grants=(app_grant,),
|
||||
user_grants=(user_grant,),
|
||||
application=application,
|
||||
user_id=user_id,
|
||||
requested_space_ids=(private.id, shared.id),
|
||||
)
|
||||
|
||||
assert effective == frozenset({private.id, shared.id})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "operation"),
|
||||
[
|
||||
(Role.VIEWER, SpaceOperation.CONTEXT),
|
||||
(Role.VIEWER, SpaceOperation.ANSWER),
|
||||
(Role.AUDITOR, SpaceOperation.SEARCH),
|
||||
],
|
||||
)
|
||||
def test_grant_role_limits_effective_space_operations(
|
||||
role: Role, operation: SpaceOperation
|
||||
) -> None:
|
||||
workspace, application, private, _ = setup_control_plane()
|
||||
grant = GrantService().application_grant(
|
||||
workspace, id=uuid4(), application=application, space=private, role=role
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private,),
|
||||
grants=(grant,),
|
||||
application=application,
|
||||
requested_space_ids=(private.id,),
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
assert effective == frozenset()
|
||||
def test_default_spaces_never_expand_grants() -> None:
|
||||
workspace, application, private, shared = setup_control_plane()
|
||||
application = workspace.create_application(
|
||||
id=application.id,
|
||||
name=application.name,
|
||||
scopes=(),
|
||||
default_knowledge_space_ids=frozenset({private.id, shared.id}),
|
||||
)
|
||||
grant = GrantService().application_grant(
|
||||
workspace, id=uuid4(), application=application, space=private, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, shared),
|
||||
grants=(grant,),
|
||||
application=application,
|
||||
)
|
||||
|
||||
assert effective == frozenset({private.id})
|
||||
|
||||
|
||||
def test_inactive_spaces_and_grants_are_excluded() -> None:
|
||||
workspace, application, private, shared = setup_control_plane()
|
||||
inactive_space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Inactive", slug="inactive", active=False
|
||||
)
|
||||
grants = GrantService()
|
||||
private_grant = grants.application_grant(
|
||||
workspace,
|
||||
id=uuid4(),
|
||||
application=application,
|
||||
space=private,
|
||||
role=Role.VIEWER,
|
||||
active=False,
|
||||
)
|
||||
inactive_grant = grants.application_grant(
|
||||
workspace, id=uuid4(), application=application, space=inactive_space, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, shared, inactive_space),
|
||||
grants=(private_grant, inactive_grant),
|
||||
application=application,
|
||||
requested_space_ids=(private.id, inactive_space.id),
|
||||
)
|
||||
|
||||
assert effective == frozenset()
|
||||
|
||||
|
||||
def test_cross_workspace_spaces_and_grants_are_excluded() -> None:
|
||||
workspace, application, private, _ = setup_control_plane()
|
||||
other = Workspace(uuid4(), "Other", "other")
|
||||
other_space = other.create_knowledge_space(id=uuid4(), name="Other", slug="other")
|
||||
cross_grant = other.grant_user(
|
||||
id=uuid4(), user_id=uuid4(), knowledge_space=other_space, role=Role.VIEWER
|
||||
)
|
||||
|
||||
effective = EffectiveSpaceResolver().resolve(
|
||||
workspace_id=workspace.id,
|
||||
spaces=(private, other_space),
|
||||
user_grants=(cross_grant,),
|
||||
application=application,
|
||||
user_id=cross_grant.user_id,
|
||||
requested_space_ids=(private.id, other_space.id),
|
||||
)
|
||||
|
||||
assert effective == frozenset()
|
||||
|
||||
|
||||
def test_lifecycle_transitions_preserve_identity_without_mutating_source() -> None:
|
||||
_, _, private, _ = setup_control_plane()
|
||||
lifecycle = KnowledgeSpaceLifecycle()
|
||||
|
||||
inactive = lifecycle.deactivate(private)
|
||||
cloud = lifecycle.set_data_policy(inactive, DataPolicy.CLOUD_ALLOWED)
|
||||
active = lifecycle.activate(cloud)
|
||||
|
||||
assert private.active is True
|
||||
assert inactive.active is False
|
||||
assert cloud.data_policy is DataPolicy.CLOUD_ALLOWED
|
||||
assert active.active is True
|
||||
assert active.id == private.id
|
||||
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,161 @@
|
||||
"""JobWorker: ingestion job outcomes and claim age are really recorded."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
from ragcore.application.jobs.models import (
|
||||
Job,
|
||||
JobClaim,
|
||||
JobContext,
|
||||
JobError,
|
||||
RetryableJobError,
|
||||
TerminalJobError,
|
||||
)
|
||||
from ragcore.application.jobs.worker import JobWorker
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
|
||||
def _telemetry() -> tuple[TelemetryMetrics, InMemoryMetricReader]:
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[reader]
|
||||
)
|
||||
return TelemetryMetrics(meter_provider=provider), reader
|
||||
|
||||
|
||||
def _metric_points(reader: InMemoryMetricReader, name: str) -> list[object]:
|
||||
data = reader.get_metrics_data()
|
||||
if data is None:
|
||||
return []
|
||||
for resource_metric in data.resource_metrics:
|
||||
for scope_metric in resource_metric.scope_metrics:
|
||||
for metric in scope_metric.metrics:
|
||||
if metric.name == name:
|
||||
return list(metric.data.data_points)
|
||||
return []
|
||||
|
||||
|
||||
class _FakeJobRepository:
|
||||
"""Enough of ``JobRepository`` for one claim/complete or claim/fail cycle."""
|
||||
|
||||
def __init__(self, job: Job) -> None:
|
||||
self._job = job
|
||||
self._claimed = False
|
||||
self.completed: list[JobClaim] = []
|
||||
self.failed: list[tuple[JobClaim, JobError]] = []
|
||||
|
||||
async def enqueue(self, job: Job) -> Job:
|
||||
raise NotImplementedError
|
||||
|
||||
async def claim_next(
|
||||
self, worker_id: str, *, now: datetime, lease_duration: timedelta
|
||||
) -> JobClaim | None:
|
||||
if self._claimed:
|
||||
return None
|
||||
self._claimed = True
|
||||
return JobClaim(
|
||||
job=self._job, worker_id=worker_id, attempt_number=1, lease_until=now + lease_duration
|
||||
)
|
||||
|
||||
async def heartbeat(
|
||||
self, claim: JobClaim, *, now: datetime, lease_duration: timedelta
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def complete(self, claim: JobClaim, *, now: datetime) -> None:
|
||||
self.completed.append(claim)
|
||||
|
||||
async def fail(
|
||||
self,
|
||||
claim: JobClaim,
|
||||
error: JobError,
|
||||
*,
|
||||
now: datetime,
|
||||
retry_after: timedelta = timedelta(0),
|
||||
) -> None:
|
||||
self.failed.append((claim, error))
|
||||
|
||||
|
||||
def _job(*, available_at: datetime) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
job_key=f"job-{uuid4()}",
|
||||
operation="ingest_upload",
|
||||
target_key="target",
|
||||
profile_version="v1",
|
||||
available_at=available_at,
|
||||
)
|
||||
|
||||
|
||||
async def test_successful_job_records_claimed_age_and_succeeded_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
available_at = datetime.now(UTC) - timedelta(seconds=5)
|
||||
repository = _FakeJobRepository(_job(available_at=available_at))
|
||||
|
||||
async def handler(ctx: JobContext) -> None:
|
||||
return None
|
||||
|
||||
worker = JobWorker(
|
||||
repository, {"ingest_upload": handler}, worker_id="worker-1", metrics=telemetry
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
|
||||
age_points = _metric_points(reader, "ragcore_job_age_seconds")
|
||||
assert len(age_points) == 1
|
||||
assert age_points[0].sum == pytest.approx(5.0, abs=1.0)
|
||||
|
||||
result_points = _metric_points(reader, "ragcore_ingestion_job_result_total")
|
||||
assert len(result_points) == 1
|
||||
assert dict(result_points[0].attributes) == {
|
||||
"operation": "ingest_upload",
|
||||
"result": "succeeded",
|
||||
}
|
||||
assert repository.completed
|
||||
|
||||
|
||||
async def test_retryable_failure_records_retryable_failed_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
repository = _FakeJobRepository(_job(available_at=datetime.now(UTC)))
|
||||
|
||||
async def handler(ctx: JobContext) -> None:
|
||||
raise RetryableJobError("transient", retry_after_seconds=1.0)
|
||||
|
||||
worker = JobWorker(
|
||||
repository, {"ingest_upload": handler}, worker_id="worker-1", metrics=telemetry
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
|
||||
result_points = _metric_points(reader, "ragcore_ingestion_job_result_total")
|
||||
assert len(result_points) == 1
|
||||
assert dict(result_points[0].attributes) == {
|
||||
"operation": "ingest_upload",
|
||||
"result": "retryable_failed",
|
||||
}
|
||||
assert repository.failed
|
||||
|
||||
|
||||
async def test_terminal_failure_records_terminal_failed_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
repository = _FakeJobRepository(_job(available_at=datetime.now(UTC)))
|
||||
|
||||
async def handler(ctx: JobContext) -> None:
|
||||
raise TerminalJobError("unrecoverable")
|
||||
|
||||
worker = JobWorker(
|
||||
repository, {"ingest_upload": handler}, worker_id="worker-1", metrics=telemetry
|
||||
)
|
||||
assert await worker.run_once() is True
|
||||
|
||||
result_points = _metric_points(reader, "ragcore_ingestion_job_result_total")
|
||||
assert len(result_points) == 1
|
||||
assert dict(result_points[0].attributes) == {
|
||||
"operation": "ingest_upload",
|
||||
"result": "terminal_failed",
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
"""A worker process runs more than one job at a time, or it does not scale.
|
||||
|
||||
``claim_next`` has always used ``FOR UPDATE SKIP LOCKED``, so several claim
|
||||
loops can safely draw from the same queue -- but nothing ever ran more than
|
||||
one. A folder source with a few thousand files was therefore ingested strictly
|
||||
one document after another.
|
||||
|
||||
The test below cannot pass on a serial pool: each handler waits until every
|
||||
other one has started before it returns, so if the loops take turns the whole
|
||||
thing deadlocks instead of finishing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.jobs.models import Job, JobClaim, JobContext, JobError
|
||||
from ragcore.application.jobs.worker import run_worker_pool
|
||||
|
||||
CONCURRENCY = 3
|
||||
|
||||
|
||||
def _job(index: int) -> Job:
|
||||
return Job(
|
||||
id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
job_key=f"job-{index}",
|
||||
operation="ingest_upload",
|
||||
target_key="blob:abc",
|
||||
profile_version="upload-v1",
|
||||
available_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
class _QueueRepository:
|
||||
"""Hands every job out exactly once, to whoever asks first."""
|
||||
|
||||
def __init__(self, jobs: list[Job]) -> None:
|
||||
self._pending = list(jobs)
|
||||
self._expected = len(jobs)
|
||||
self.completed: list[Job] = []
|
||||
self.claimed_by: list[str] = []
|
||||
self.drained = asyncio.Event()
|
||||
|
||||
async def claim_next(
|
||||
self, worker_id: str, *, now: datetime, lease_duration: timedelta
|
||||
) -> JobClaim | None:
|
||||
if not self._pending:
|
||||
return None
|
||||
job = self._pending.pop(0)
|
||||
self.claimed_by.append(worker_id)
|
||||
return JobClaim(job=job, worker_id=worker_id, attempt_number=1, lease_until=now)
|
||||
|
||||
async def heartbeat(self, claim: JobClaim, **kwargs: object) -> bool:
|
||||
return True
|
||||
|
||||
async def complete(self, claim: JobClaim, *, now: datetime) -> None:
|
||||
self.completed.append(claim.job)
|
||||
if len(self.completed) == self._expected:
|
||||
self.drained.set()
|
||||
|
||||
async def fail(self, claim: JobClaim, error: JobError, **kwargs: object) -> None:
|
||||
raise AssertionError(f"no job should fail here: {error}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_worker_pool_runs_jobs_at_the_same_time() -> None:
|
||||
repository = _QueueRepository([_job(index) for index in range(CONCURRENCY)])
|
||||
started = asyncio.Barrier(CONCURRENCY)
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def handler(context: JobContext) -> None:
|
||||
# Returns only once every other handler has also started. A pool that
|
||||
# runs one job at a time can never get past this.
|
||||
await started.wait()
|
||||
|
||||
async def drain() -> None:
|
||||
await repository.drained.wait()
|
||||
stop.set()
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
run_worker_pool(
|
||||
repository, # type: ignore[arg-type]
|
||||
{"ingest_upload": handler},
|
||||
stop=stop,
|
||||
concurrency=CONCURRENCY,
|
||||
worker_id_prefix="pool-test",
|
||||
poll_interval=0.01,
|
||||
),
|
||||
drain(),
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert len(repository.completed) == CONCURRENCY
|
||||
# Every loop identifies itself: "which worker holds this lease" is the
|
||||
# first question an operator asks about a stuck job.
|
||||
assert len(set(repository.claimed_by)) == CONCURRENCY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_pool_of_one_is_the_previous_behaviour() -> None:
|
||||
repository = _QueueRepository([_job(0)])
|
||||
stop = asyncio.Event()
|
||||
|
||||
async def handler(context: JobContext) -> None:
|
||||
stop.set()
|
||||
|
||||
await asyncio.wait_for(
|
||||
run_worker_pool(
|
||||
repository, # type: ignore[arg-type]
|
||||
{"ingest_upload": handler},
|
||||
stop=stop,
|
||||
concurrency=1,
|
||||
worker_id_prefix="pool-test",
|
||||
poll_interval=0.01,
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert len(repository.completed) == 1
|
||||
assert repository.claimed_by == ["pool-test-0"]
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models.bootstrap import (
|
||||
EmbeddingBootstrapError,
|
||||
EmbeddingBootstrapRequest,
|
||||
ensure_embedding_profile,
|
||||
)
|
||||
from ragcore.application.models.registry import (
|
||||
EmbeddingProfileNotFoundError,
|
||||
EmbeddingProfileRegistry,
|
||||
)
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, NormalizationMode
|
||||
from ragcore.infrastructure.models.memory import InMemoryEmbeddingProfileStore
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, responses: list[httpx.Response]) -> None:
|
||||
self.responses = responses
|
||||
self.requests: list[str] = []
|
||||
|
||||
async def post(self, url: str, **kwargs: object) -> httpx.Response:
|
||||
self.requests.append(url)
|
||||
return self.responses.pop(0)
|
||||
|
||||
|
||||
def _response(status: int, body: object) -> httpx.Response:
|
||||
return httpx.Response(status, json=body, request=httpx.Request("POST", "http://test"))
|
||||
|
||||
|
||||
def _request() -> EmbeddingBootstrapRequest:
|
||||
return EmbeddingBootstrapRequest(
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:1.5",
|
||||
base_url="http://ollama",
|
||||
)
|
||||
|
||||
|
||||
def _existing_profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hand-registered",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="mxbai-embed-large:1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=3,
|
||||
context_limit=4096,
|
||||
tokenizer="sentencepiece-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
normalization=NormalizationMode.NONE,
|
||||
)
|
||||
|
||||
|
||||
async def test_noop_when_a_profile_is_already_active() -> None:
|
||||
store = InMemoryEmbeddingProfileStore()
|
||||
registry = EmbeddingProfileRegistry(store)
|
||||
existing = _existing_profile()
|
||||
await registry.register(existing)
|
||||
await registry.activate(existing.id)
|
||||
|
||||
client = FakeClient([])
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
|
||||
assert client.requests == []
|
||||
assert (await registry.active()).id == existing.id
|
||||
|
||||
|
||||
async def test_noop_and_no_raise_when_no_request_and_no_active_profile() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient([])
|
||||
|
||||
await ensure_embedding_profile(registry, None, http_client=client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(EmbeddingProfileNotFoundError):
|
||||
await registry.active()
|
||||
|
||||
|
||||
async def test_bootstraps_a_real_profile_from_provider_and_model_probes() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient(
|
||||
[
|
||||
_response(200, {"digest": "sha256:" + "b" * 64}),
|
||||
_response(200, {"embeddings": [[0.1, 0.2, 0.3, 0.4]]}),
|
||||
]
|
||||
)
|
||||
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
|
||||
active = await registry.active()
|
||||
assert active.model_identifier == "nomic-embed-text:1.5"
|
||||
assert active.model_digest == "b" * 64
|
||||
assert active.dimensions == 4
|
||||
assert client.requests == ["http://ollama/api/show", "http://ollama/api/embed"]
|
||||
|
||||
|
||||
async def test_bootstrap_raises_when_the_model_is_unreachable() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient([_response(500, {"error": "down"})])
|
||||
|
||||
with pytest.raises(EmbeddingBootstrapError):
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(EmbeddingProfileNotFoundError):
|
||||
await registry.active()
|
||||
|
||||
|
||||
async def test_bootstrap_raises_on_malformed_digest() -> None:
|
||||
registry = EmbeddingProfileRegistry(InMemoryEmbeddingProfileStore())
|
||||
client = FakeClient([_response(200, {"digest": "not-a-digest"})])
|
||||
|
||||
with pytest.raises(EmbeddingBootstrapError):
|
||||
await ensure_embedding_profile(registry, _request(), http_client=client) # type: ignore[arg-type]
|
||||
@@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import replace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models.selection import (
|
||||
EmbeddingCutoverEvidence,
|
||||
EmbeddingCutoverRejected,
|
||||
EmbeddingRollbackEvidence,
|
||||
EmbeddingSelectionConflict,
|
||||
EmbeddingSelectionIntegrityError,
|
||||
EmbeddingSelectionNotInitialized,
|
||||
EmbeddingSelectionOperationResult,
|
||||
EmbeddingSelectionService,
|
||||
EmbeddingSelectionState,
|
||||
EmbeddingSelectionUnavailable,
|
||||
EmbeddingSelectionVerificationResult,
|
||||
)
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import (
|
||||
EmbeddingProfile,
|
||||
EmbeddingProvider,
|
||||
EmbeddingSelection,
|
||||
NormalizationMode,
|
||||
)
|
||||
from ragcore.infrastructure.models.memory import InMemoryEmbeddingProfileStore
|
||||
|
||||
|
||||
def _profile(
|
||||
*,
|
||||
profile: str,
|
||||
version: str,
|
||||
provider: EmbeddingProvider = EmbeddingProvider.MODELFORGE,
|
||||
digest: str = "a" * 64,
|
||||
space_id: UUID | None = None,
|
||||
) -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile=profile,
|
||||
version=version,
|
||||
provider=provider,
|
||||
model_identifier=(
|
||||
"rag.embedding@1"
|
||||
if provider is EmbeddingProvider.MODELFORGE
|
||||
else "nomic-embed-text:1.5"
|
||||
),
|
||||
model_digest=digest,
|
||||
dimensions=4,
|
||||
context_limit=4096,
|
||||
tokenizer="tokenizer-v1",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
normalization=NormalizationMode.L2,
|
||||
embedding_space_id=space_id,
|
||||
)
|
||||
|
||||
|
||||
class MemorySelectionStore:
|
||||
"""Small lock-backed fake with the same sentinel, CAS, and retry semantics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.state: EmbeddingSelectionState | None = None
|
||||
self.initialized = False
|
||||
self.operations: dict[UUID, tuple[str, EmbeddingSelectionOperationResult]] = {}
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def get(self) -> EmbeddingSelectionState:
|
||||
if not self.initialized or self.state is None:
|
||||
raise EmbeddingSelectionNotInitialized("not initialized")
|
||||
return self.state
|
||||
|
||||
async def initialize(self, selection: EmbeddingSelection) -> EmbeddingSelectionState:
|
||||
async with self.lock:
|
||||
if not self.initialized:
|
||||
self.state = EmbeddingSelectionState(0, selection, None)
|
||||
self.initialized = True
|
||||
assert self.state is not None
|
||||
return self.state
|
||||
|
||||
async def find_operation(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
*,
|
||||
request_fingerprint: str,
|
||||
) -> EmbeddingSelectionOperationResult | None:
|
||||
entry = self.operations.get(operation_id)
|
||||
if entry is None:
|
||||
return None
|
||||
old_fingerprint, result = entry
|
||||
if old_fingerprint != request_fingerprint:
|
||||
raise EmbeddingSelectionConflict("operation id conflict")
|
||||
return EmbeddingSelectionOperationResult(
|
||||
result.operation_id,
|
||||
result.generation,
|
||||
result.selection,
|
||||
replayed=True,
|
||||
evidence=result.evidence,
|
||||
)
|
||||
|
||||
async def compare_and_swap(
|
||||
self,
|
||||
*,
|
||||
expected_generation: int,
|
||||
target: EmbeddingSelection,
|
||||
operation_id: UUID,
|
||||
action: str,
|
||||
request_fingerprint: str,
|
||||
operation_evidence: Mapping[str, object] | None = None,
|
||||
) -> EmbeddingSelectionOperationResult:
|
||||
async with self.lock:
|
||||
replay = await self.find_operation(
|
||||
operation_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if replay is not None:
|
||||
return replay
|
||||
if action == "initialize":
|
||||
if self.initialized:
|
||||
raise EmbeddingSelectionConflict("already initialized")
|
||||
if expected_generation != 0:
|
||||
raise EmbeddingSelectionConflict("stale")
|
||||
self.state = EmbeddingSelectionState(expected_generation, target, None)
|
||||
self.initialized = True
|
||||
result = EmbeddingSelectionOperationResult(
|
||||
operation_id,
|
||||
expected_generation,
|
||||
target,
|
||||
evidence=operation_evidence or {},
|
||||
)
|
||||
self.operations[operation_id] = (request_fingerprint, result)
|
||||
return result
|
||||
state = await self.get()
|
||||
if state.generation != expected_generation:
|
||||
raise EmbeddingSelectionConflict("stale")
|
||||
if action == "rollback" and state.previous != target:
|
||||
raise EmbeddingSelectionConflict("not persisted prior")
|
||||
self.state = EmbeddingSelectionState(state.generation + 1, target, state.active)
|
||||
result = EmbeddingSelectionOperationResult(
|
||||
operation_id,
|
||||
self.state.generation,
|
||||
target,
|
||||
evidence=operation_evidence or {},
|
||||
)
|
||||
self.operations[operation_id] = (request_fingerprint, result)
|
||||
return result
|
||||
|
||||
|
||||
class UnavailableSelectionStore(MemorySelectionStore):
|
||||
async def get(self) -> EmbeddingSelectionState:
|
||||
raise EmbeddingSelectionUnavailable("database is unavailable")
|
||||
|
||||
|
||||
class RecordingVerifier:
|
||||
def __init__(
|
||||
self, *, verified_point_count: int = 7, include_gateway_request_id: bool = False
|
||||
) -> None:
|
||||
self.verified: list[EmbeddingSelection] = []
|
||||
self.verified_point_count = verified_point_count
|
||||
self.gateway_request_ids = (uuid4(),) if include_gateway_request_id else ()
|
||||
|
||||
async def verify(
|
||||
self, selection: EmbeddingSelection, profile: EmbeddingProfile
|
||||
) -> EmbeddingSelectionVerificationResult:
|
||||
assert selection.matches_profile(profile)
|
||||
self.verified.append(selection)
|
||||
return EmbeddingSelectionVerificationResult(
|
||||
gateway_request_ids=self.gateway_request_ids,
|
||||
verified_point_count=self.verified_point_count,
|
||||
)
|
||||
|
||||
|
||||
def _evidence(selection: EmbeddingSelection) -> EmbeddingCutoverEvidence:
|
||||
return EmbeddingCutoverEvidence(
|
||||
comparison_id=uuid4(),
|
||||
comparison_reviewed=True,
|
||||
comparison_passed=True,
|
||||
operator_id=uuid4(),
|
||||
approval_id=uuid4(),
|
||||
operator_approved_impact_and_reindex=True,
|
||||
index_operation_id=uuid4(),
|
||||
authoritative_reindex_completed=True,
|
||||
collection_identity_pins_verified=True,
|
||||
consumer_smoke_passed=True,
|
||||
candidate=selection,
|
||||
gateway_route_key=selection.experiment_route_key,
|
||||
gateway_embedding_space_id=selection.embedding_space_id,
|
||||
gateway_dimensions=selection.dimensions,
|
||||
)
|
||||
|
||||
|
||||
async def _service(
|
||||
*profiles: EmbeddingProfile,
|
||||
) -> tuple[EmbeddingSelectionService, MemorySelectionStore, RecordingVerifier]:
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
for profile in profiles:
|
||||
await profile_store.add(profile)
|
||||
await profile_store.activate(profiles[0].id)
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier()
|
||||
return (
|
||||
EmbeddingSelectionService(
|
||||
profiles=profile_store, selections=selection_store, verifier=verifier
|
||||
),
|
||||
selection_store,
|
||||
verifier,
|
||||
)
|
||||
|
||||
|
||||
async def test_bootstrap_pins_initial_route_and_later_config_cannot_remap_it() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
service, _, _ = await _service(current)
|
||||
|
||||
initial = await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
restarted = await service.bootstrap(initial_experiment_route_key="different-lab-route")
|
||||
|
||||
assert initial.generation == restarted.generation == 0
|
||||
assert restarted.active.experiment_route_key == "lab-current-route"
|
||||
assert restarted.active.collection_name == "rag_dense_embed_v1"
|
||||
|
||||
|
||||
async def test_explicit_modelforge_initialization_registers_and_cas_pins_exact_rc_identity() -> (
|
||||
None
|
||||
):
|
||||
profile = _profile(
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
digest="d" * 64,
|
||||
space_id=uuid4(),
|
||||
)
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier(verified_point_count=12, include_gateway_request_id=True)
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
operation_id, operator_id = uuid4(), uuid4()
|
||||
|
||||
result = await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
operator_id=operator_id,
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
|
||||
assert result.generation == 0
|
||||
assert result.selection.profile_id == profile.id
|
||||
assert result.selection.model_digest == profile.model_digest
|
||||
assert result.selection.dimensions == profile.dimensions
|
||||
assert result.selection.embedding_space_id == profile.embedding_space_id
|
||||
assert result.selection.experiment_route_key == "rc-canary-2026-09"
|
||||
assert result.selection.collection_name == "rag_dense_modelforge-rc-con01_rc1"
|
||||
verification = result.evidence["verification"]
|
||||
assert isinstance(verification, Mapping)
|
||||
assert len(verification["gateway_request_ids"]) == 1
|
||||
UUID(verification["gateway_request_ids"][0])
|
||||
assert verification["verified_point_count"] == 12
|
||||
assert await profile_store.get(profile.id) == profile
|
||||
assert await profile_store.get_active() is None
|
||||
assert selection_store.state == EmbeddingSelectionState(0, result.selection, None)
|
||||
|
||||
replay = await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
operator_id=operator_id,
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
assert replay.replayed is True
|
||||
assert replay.selection == result.selection
|
||||
assert len(verifier.verified) == 1
|
||||
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="operation id conflict"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
operator_id=operator_id,
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-next",
|
||||
)
|
||||
|
||||
|
||||
async def test_modelforge_initialization_rejects_existing_ollama() -> None:
|
||||
current = _profile(
|
||||
profile="ollama-current",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
)
|
||||
candidate = _profile(
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
space_id=uuid4(),
|
||||
)
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
selection_store = MemorySelectionStore()
|
||||
await selection_store.initialize(EmbeddingSelection.from_profile(current))
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=RecordingVerifier(),
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="already initialized"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
operator_id=uuid4(),
|
||||
profile=candidate,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
|
||||
assert await profile_store.get(candidate.id) is None
|
||||
assert selection_store.state == EmbeddingSelectionState(
|
||||
0, EmbeddingSelection.from_profile(current), None
|
||||
)
|
||||
|
||||
|
||||
async def test_explicit_modelforge_initialization_requires_verified_route_and_nonempty_index() -> (
|
||||
None
|
||||
):
|
||||
profile = _profile(
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
space_id=uuid4(),
|
||||
)
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier(verified_point_count=0, include_gateway_request_id=True)
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="collection is empty"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
operator_id=uuid4(),
|
||||
profile=profile,
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
assert await profile_store.get(profile.id) is None
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="must use ModelForge"):
|
||||
await service.initialize_modelforge(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
operator_id=uuid4(),
|
||||
profile=_profile(profile="ollama", version="v1", provider=EmbeddingProvider.OLLAMA),
|
||||
experiment_route_key="rc-canary-2026-09",
|
||||
)
|
||||
assert selection_store.state is None
|
||||
|
||||
|
||||
async def test_changed_space_cutover_and_rollback_restore_full_prior_selection() -> None:
|
||||
old_space, new_space = uuid4(), uuid4()
|
||||
current = _profile(profile="embed", version="v1", space_id=old_space)
|
||||
candidate = _profile(
|
||||
profile="embed",
|
||||
version="v2",
|
||||
digest="b" * 64,
|
||||
space_id=new_space,
|
||||
)
|
||||
service, store, verifier = await _service(current, candidate)
|
||||
initial = await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
prior = initial.active
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
operation_id = uuid4()
|
||||
evidence = _evidence(target)
|
||||
|
||||
promoted = await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
target=target,
|
||||
evidence=evidence,
|
||||
)
|
||||
assert promoted.generation == 1
|
||||
assert promoted.selection == target
|
||||
assert promoted.evidence["verification"] == {
|
||||
"gateway_request_ids": [],
|
||||
"verified_point_count": 7,
|
||||
}
|
||||
assert store.state is not None and store.state.previous == prior
|
||||
assert (await service.resolve_active()).selection == target
|
||||
|
||||
replay = await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
target=target,
|
||||
evidence=evidence,
|
||||
)
|
||||
assert replay.replayed is True and replay.generation == 1
|
||||
with pytest.raises(EmbeddingSelectionConflict, match="operation id conflict"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=operation_id,
|
||||
target=target,
|
||||
evidence=replace(evidence, approval_id=uuid4()),
|
||||
)
|
||||
|
||||
rolled_back = await service.rollback(
|
||||
expected_generation=1,
|
||||
operation_id=uuid4(),
|
||||
evidence=EmbeddingRollbackEvidence(
|
||||
operator_id=uuid4(), approval_id=uuid4(), reason="validated candidate regression"
|
||||
),
|
||||
)
|
||||
assert rolled_back.generation == 2
|
||||
assert rolled_back.selection == prior
|
||||
assert prior.embedding_space_id == old_space
|
||||
assert prior.experiment_route_key == "lab-current-route"
|
||||
assert prior.collection_name == "rag_dense_embed_v1"
|
||||
assert (await service.resolve_active()).selection == prior
|
||||
assert verifier.verified == [target, prior]
|
||||
|
||||
|
||||
async def test_concurrent_promotions_use_generation_cas() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate_a = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
candidate_b = _profile(profile="embed", version="v3", digest="c" * 64, space_id=uuid4())
|
||||
service, store, _ = await _service(current, candidate_a, candidate_b)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target_a = EmbeddingSelection.from_profile(candidate_a, experiment_route_key="lab-route-a")
|
||||
target_b = EmbeddingSelection.from_profile(candidate_b, experiment_route_key="lab-route-b")
|
||||
|
||||
outcomes = await asyncio.gather(
|
||||
service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target_a,
|
||||
evidence=_evidence(target_a),
|
||||
),
|
||||
service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target_b,
|
||||
evidence=_evidence(target_b),
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert sum(not isinstance(result, BaseException) for result in outcomes) == 1
|
||||
failures = [result for result in outcomes if isinstance(result, BaseException)]
|
||||
assert len(failures) == 1 and isinstance(failures[0], EmbeddingSelectionConflict)
|
||||
assert store.state is not None and store.state.generation == 1
|
||||
|
||||
|
||||
async def test_stale_route_evidence_and_stale_collection_are_rejected() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, _, verifier = await _service(current, candidate)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
evidence = _evidence(target)
|
||||
stale_route = replace(evidence, gateway_route_key="lab-old-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="gateway route"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=stale_route,
|
||||
)
|
||||
assert verifier.verified == []
|
||||
|
||||
with pytest.raises(ValueError, match="collection does not match"):
|
||||
EmbeddingSelection(
|
||||
profile_id=candidate.id,
|
||||
profile=candidate.profile,
|
||||
version=candidate.version,
|
||||
provider=candidate.provider,
|
||||
model_identifier=candidate.model_identifier,
|
||||
model_digest=candidate.model_digest,
|
||||
dimensions=candidate.dimensions,
|
||||
embedding_space_id=candidate.embedding_space_id,
|
||||
experiment_route_key="lab-candidate-route",
|
||||
collection_name="rag_dense_embed_v1",
|
||||
)
|
||||
|
||||
|
||||
async def test_promotion_requires_a_distinct_candidate_modelforge_route() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, store, verifier = await _service(current, candidate)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-current-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="distinct pinned current and candidate"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=_evidence(target),
|
||||
)
|
||||
|
||||
assert store.state is not None and store.state.generation == 0
|
||||
assert verifier.verified == []
|
||||
|
||||
|
||||
async def test_promotion_refuses_to_drop_an_unresolvable_prior_profile() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
await profile_store.add(candidate)
|
||||
await profile_store.activate(candidate.id)
|
||||
selection_store = MemorySelectionStore()
|
||||
prior = EmbeddingSelection.from_profile(current, experiment_route_key="lab-current-route")
|
||||
await selection_store.initialize(prior)
|
||||
verifier = RecordingVerifier()
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-next-route")
|
||||
|
||||
with pytest.raises(EmbeddingSelectionIntegrityError, match="unregistered"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=_evidence(target),
|
||||
)
|
||||
|
||||
assert selection_store.state == EmbeddingSelectionState(0, prior, None)
|
||||
assert verifier.verified == []
|
||||
|
||||
|
||||
async def test_promotion_rejects_an_empty_candidate_collection() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, store, verifier = await _service(current, candidate)
|
||||
verifier.verified_point_count = 0
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="collection is empty"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=_evidence(target),
|
||||
)
|
||||
|
||||
assert store.state is not None and store.state.generation == 0
|
||||
assert verifier.verified == [target]
|
||||
|
||||
|
||||
async def test_cutover_evidence_requires_real_boolean_attestations() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
candidate = _profile(profile="embed", version="v2", digest="b" * 64, space_id=uuid4())
|
||||
service, _, verifier = await _service(current, candidate)
|
||||
await service.bootstrap(initial_experiment_route_key="lab-current-route")
|
||||
target = EmbeddingSelection.from_profile(candidate, experiment_route_key="lab-candidate-route")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="incomplete or failed"):
|
||||
await service.promote(
|
||||
expected_generation=0,
|
||||
operation_id=uuid4(),
|
||||
target=target,
|
||||
evidence=replace(_evidence(target), comparison_passed=1), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert verifier.verified == []
|
||||
|
||||
|
||||
async def test_failed_durable_read_does_not_fall_back_to_json_active_profile() -> None:
|
||||
current = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
service, _, _ = await _service(current)
|
||||
|
||||
with pytest.raises(EmbeddingSelectionNotInitialized):
|
||||
await service.resolve_active()
|
||||
|
||||
|
||||
async def test_database_unavailability_does_not_fall_back_to_json_active_profile() -> None:
|
||||
profile = _profile(profile="embed", version="v1", space_id=uuid4())
|
||||
profile_store = InMemoryEmbeddingProfileStore()
|
||||
await profile_store.add(profile)
|
||||
await profile_store.activate(profile.id)
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=UnavailableSelectionStore(),
|
||||
verifier=RecordingVerifier(),
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingSelectionUnavailable, match="database is unavailable"):
|
||||
await service.resolve_active()
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.profiles import (
|
||||
ActivationDecision,
|
||||
AlreadyRolledBackError,
|
||||
CandidateAliasTarget,
|
||||
IncompleteEvaluationError,
|
||||
NotActivatedError,
|
||||
ProfileActivationError,
|
||||
require_complete_run,
|
||||
)
|
||||
from ragcore.evaluation import EvaluationCase, EvaluationDataset, EvaluationRun, RetrievalRunProfile
|
||||
from ragcore.evaluation.metrics.models import GateResult, RegressionFinding, RegressionMetric
|
||||
from ragcore.evaluation.models import AnswerabilityStatus, CaseResult
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def _run_profile(index_version: str = "v1") -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="technical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version=index_version,
|
||||
)
|
||||
|
||||
|
||||
def _passing_gate() -> GateResult:
|
||||
return GateResult(thresholds_version="v1", passed=True, regressions=())
|
||||
|
||||
|
||||
def _target() -> CandidateAliasTarget:
|
||||
return CandidateAliasTarget(
|
||||
alias_name="rag_dense_hybrid_active",
|
||||
collection_name="rag_dense_hybrid_v2",
|
||||
version="v2",
|
||||
model_digest="b" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _decision(**overrides: object) -> ActivationDecision:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"created_at": _now(),
|
||||
"dataset_id": "sample",
|
||||
"dataset_version": "v1",
|
||||
"baseline_run_id": uuid4(),
|
||||
"candidate_run_id": uuid4(),
|
||||
"baseline_profile": _run_profile("v1"),
|
||||
"candidate_profile": _run_profile("v2"),
|
||||
"gate": _passing_gate(),
|
||||
"activated": True,
|
||||
"candidate_target": _target(),
|
||||
"index_operation_id": uuid4(),
|
||||
"previous_collection": "rag_dense_hybrid_v1",
|
||||
}
|
||||
values.update(overrides)
|
||||
return ActivationDecision(**values)
|
||||
|
||||
|
||||
def test_candidate_alias_target_rejects_unsafe_identifiers() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
CandidateAliasTarget(
|
||||
alias_name="not a safe id!", collection_name="c", version="v1", model_digest="d"
|
||||
)
|
||||
|
||||
|
||||
def test_activated_decision_requires_candidate_target_and_operation_id() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(candidate_target=None)
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(index_operation_id=None)
|
||||
|
||||
|
||||
def test_rejected_decision_must_not_carry_activation_fields() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(activated=False, gate=_failing_gate())
|
||||
|
||||
|
||||
def _failing_gate() -> GateResult:
|
||||
return GateResult(
|
||||
thresholds_version="v1",
|
||||
passed=False,
|
||||
regressions=(
|
||||
RegressionFinding(
|
||||
metric=RegressionMetric.RECALL_AT_5,
|
||||
tag=None,
|
||||
baseline_value=0.9,
|
||||
candidate_value=0.5,
|
||||
delta=-0.4,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_failed_gate_can_never_be_recorded_as_activated() -> None:
|
||||
with pytest.raises(ProfileActivationError):
|
||||
_decision(gate=_failing_gate(), activated=True)
|
||||
|
||||
|
||||
def test_rollback_requires_an_activated_decision() -> None:
|
||||
rejected = _decision(
|
||||
activated=False, gate=_failing_gate(), candidate_target=None, index_operation_id=None
|
||||
)
|
||||
with pytest.raises(NotActivatedError):
|
||||
rejected.with_rollback(rolled_back_at=_now())
|
||||
|
||||
|
||||
def test_rollback_cannot_happen_twice() -> None:
|
||||
decision = _decision()
|
||||
rolled_back = decision.with_rollback(rolled_back_at=_now())
|
||||
assert rolled_back.rolled_back is True
|
||||
with pytest.raises(AlreadyRolledBackError):
|
||||
rolled_back.with_rollback(rolled_back_at=_now())
|
||||
|
||||
|
||||
def _dataset() -> EvaluationDataset:
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="What restarts the payment service?",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
expected_chunk_ids=(uuid4(),),
|
||||
required_facts=("restart via kubectl rollout restart",),
|
||||
)
|
||||
return EvaluationDataset(id="sample", version="v1", cases=(case,))
|
||||
|
||||
|
||||
def _run(dataset: EvaluationDataset, *, cover_all: bool = True) -> EvaluationRun:
|
||||
case_results = tuple(
|
||||
CaseResult(
|
||||
case_id=case.id,
|
||||
retrieved_chunk_ids=case.expected_chunk_ids,
|
||||
matched_expected_ids=case.expected_chunk_ids,
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
)
|
||||
for case in dataset.cases
|
||||
)
|
||||
if not cover_all:
|
||||
case_results = case_results[:0]
|
||||
return EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset.id,
|
||||
dataset_version=dataset.version,
|
||||
run_profile=_run_profile(),
|
||||
started_at=_now(),
|
||||
case_results=case_results or (
|
||||
CaseResult(
|
||||
case_id="unrelated-case",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_require_complete_run_accepts_full_coverage() -> None:
|
||||
dataset = _dataset()
|
||||
require_complete_run(dataset, _run(dataset))
|
||||
|
||||
|
||||
def test_require_complete_run_rejects_partial_coverage() -> None:
|
||||
dataset = _dataset()
|
||||
with pytest.raises(IncompleteEvaluationError):
|
||||
require_complete_run(dataset, _run(dataset, cover_all=False))
|
||||
|
||||
|
||||
def test_require_complete_run_rejects_wrong_dataset_version() -> None:
|
||||
dataset = _dataset()
|
||||
run = _run(dataset)
|
||||
other_dataset = EvaluationDataset(id="sample", version="v2", cases=dataset.cases)
|
||||
with pytest.raises(IncompleteEvaluationError):
|
||||
require_complete_run(other_dataset, run)
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Fast unit coverage for ProfileActivationService using in-memory index stores.
|
||||
|
||||
Reuses the real ``IndexBuildService`` (never reimplemented), backed by
|
||||
in-memory fakes for ``DerivedIndexStore``/``IndexOperationStore`` instead of
|
||||
real Qdrant/Postgres -- this proves ProfileActivationService's own gating,
|
||||
decision-persistence and rollback-reconstruction logic quickly and
|
||||
deterministically. Slower, real-infrastructure end-to-end coverage lives in
|
||||
``tests/integration/profiles``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Mapping, Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.indexes import (
|
||||
IndexBuildService,
|
||||
IndexOperationStatus,
|
||||
MalformedIndexPoint,
|
||||
StoredIndexPoint,
|
||||
)
|
||||
from ragcore.application.profiles import (
|
||||
ActivationDecisionNotFoundError,
|
||||
ActivationRejectedError,
|
||||
AlreadyRolledBackError,
|
||||
CandidateAliasTarget,
|
||||
IncompleteEvaluationError,
|
||||
ProfileActivationService,
|
||||
)
|
||||
from ragcore.evaluation import EvaluationCase, EvaluationDataset, EvaluationRun, RetrievalRunProfile
|
||||
from ragcore.evaluation.metrics.models import RegressionThresholds
|
||||
from ragcore.evaluation.models import AnswerabilityStatus, CaseResult
|
||||
|
||||
|
||||
class InMemoryDerivedIndexStore:
|
||||
def __init__(self) -> None:
|
||||
self.aliases: dict[str, str] = {}
|
||||
self.shadows: set[str] = set()
|
||||
self.build_calls = 0
|
||||
self.rollback_calls: list[tuple[str, str | None]] = []
|
||||
|
||||
async def create_shadow(self, profile: object) -> None:
|
||||
self.shadows.add(profile.collection_name) # type: ignore[attr-defined]
|
||||
self.build_calls += 1
|
||||
|
||||
async def delete_shadow(self, profile: object) -> None:
|
||||
self.shadows.discard(profile.collection_name) # type: ignore[attr-defined]
|
||||
|
||||
async def upsert_points(self, profile: object, points: Sequence[object]) -> None:
|
||||
return None
|
||||
|
||||
async def delete_points(self, profile: object, point_ids: Sequence[UUID]) -> None:
|
||||
return None
|
||||
|
||||
async def delete_raw_points(self, profile: object, raw_ids: Sequence[str]) -> None:
|
||||
return None
|
||||
|
||||
async def count_points(self, profile: object) -> int:
|
||||
return 0
|
||||
|
||||
async def list_points(
|
||||
self, profile: object
|
||||
) -> tuple[tuple[StoredIndexPoint, ...], tuple[MalformedIndexPoint, ...]]:
|
||||
return (), ()
|
||||
|
||||
async def current_alias_target(self, alias_name: str) -> str | None:
|
||||
return self.aliases.get(alias_name)
|
||||
|
||||
async def activate(self, profile: object) -> None:
|
||||
self.aliases[profile.alias_name] = profile.collection_name # type: ignore[attr-defined]
|
||||
|
||||
async def rollback(self, alias_name: str, previous_collection: str | None) -> None:
|
||||
self.rollback_calls.append((alias_name, previous_collection))
|
||||
if previous_collection is None:
|
||||
self.aliases.pop(alias_name, None)
|
||||
else:
|
||||
self.aliases[alias_name] = previous_collection
|
||||
|
||||
|
||||
class InMemoryIndexOperationStore:
|
||||
def __init__(self) -> None:
|
||||
self._rows: dict[UUID, dict[str, object]] = {}
|
||||
|
||||
async def begin(
|
||||
self,
|
||||
*,
|
||||
alias_name: str,
|
||||
operation: str,
|
||||
collection_name: str,
|
||||
owner_token: str,
|
||||
now: datetime,
|
||||
lease_duration: timedelta,
|
||||
) -> UUID | None:
|
||||
for row in self._rows.values():
|
||||
in_progress = row["status"] == IndexOperationStatus.IN_PROGRESS
|
||||
if row["alias_name"] == alias_name and in_progress:
|
||||
return None
|
||||
operation_id = uuid4()
|
||||
self._rows[operation_id] = {
|
||||
"alias_name": alias_name,
|
||||
"operation": operation,
|
||||
"collection_name": collection_name,
|
||||
"owner_token": owner_token,
|
||||
"status": IndexOperationStatus.IN_PROGRESS,
|
||||
"previous_collection": None,
|
||||
}
|
||||
return operation_id
|
||||
|
||||
async def set_previous_collection(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
owner_token: str,
|
||||
previous_collection: str | None,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
self._rows[operation_id]["previous_collection"] = previous_collection
|
||||
|
||||
async def record_step(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
owner_token: str,
|
||||
step: str,
|
||||
*,
|
||||
now: datetime,
|
||||
details: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
async def finish(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
owner_token: str,
|
||||
status: IndexOperationStatus,
|
||||
*,
|
||||
now: datetime,
|
||||
error_code: str | None = None,
|
||||
error_details: Mapping[str, object] | None = None,
|
||||
) -> bool:
|
||||
self._rows[operation_id]["status"] = status
|
||||
return True
|
||||
|
||||
|
||||
class FixtureSource:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def iter_points(self, profile: object) -> AsyncIterator[object]:
|
||||
return
|
||||
yield # pragma: no cover - makes this an async generator with zero points
|
||||
|
||||
|
||||
class PassingSmoke:
|
||||
async def run(self, profile: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class InMemoryActivationDecisionStore:
|
||||
def __init__(self) -> None:
|
||||
self._decisions: dict[UUID, object] = {}
|
||||
|
||||
async def save(self, decision: object) -> None:
|
||||
self._decisions[decision.id] = decision # type: ignore[attr-defined]
|
||||
|
||||
async def get(self, decision_id: UUID) -> object | None:
|
||||
return self._decisions.get(decision_id)
|
||||
|
||||
async def list_recent(self, limit: int = 20) -> tuple[object, ...]:
|
||||
return tuple(self._decisions.values())[:limit]
|
||||
|
||||
async def mark_rolled_back(self, decision_id: UUID, *, rolled_back_at: datetime) -> None:
|
||||
decision = self._decisions[decision_id]
|
||||
self._decisions[decision_id] = decision.with_rollback(rolled_back_at=rolled_back_at) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def _dataset() -> EvaluationDataset:
|
||||
case = EvaluationCase(
|
||||
id="case-1",
|
||||
question="How do I restart the payment service?",
|
||||
language="en",
|
||||
expected_answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
expected_document_ids=(uuid4(),),
|
||||
expected_chunk_ids=(uuid4(),),
|
||||
required_facts=("restart via kubectl rollout restart",),
|
||||
)
|
||||
return EvaluationDataset(id="sample", version="v1", cases=(case,))
|
||||
|
||||
|
||||
def _run_profile(version: str) -> RetrievalRunProfile:
|
||||
return RetrievalRunProfile(
|
||||
embedding_profile_digest="a" * 64,
|
||||
sparse_profile_version="technical-v1",
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
index_collection_version=version,
|
||||
)
|
||||
|
||||
|
||||
def _complete_run(dataset: EvaluationDataset, *, version: str, hit: bool = True) -> EvaluationRun:
|
||||
case = dataset.cases[0]
|
||||
retrieved = case.expected_chunk_ids if hit else ()
|
||||
return EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset.id,
|
||||
dataset_version=dataset.version,
|
||||
run_profile=_run_profile(version),
|
||||
started_at=datetime.now(UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id=case.id,
|
||||
retrieved_chunk_ids=retrieved,
|
||||
matched_expected_ids=retrieved,
|
||||
missing_expected_ids=() if hit else case.expected_chunk_ids,
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _incomplete_run(dataset: EvaluationDataset) -> EvaluationRun:
|
||||
return EvaluationRun(
|
||||
id=uuid4(),
|
||||
dataset_id=dataset.id,
|
||||
dataset_version=dataset.version,
|
||||
run_profile=_run_profile("v2"),
|
||||
started_at=datetime.now(UTC),
|
||||
case_results=(
|
||||
CaseResult(
|
||||
case_id="a-different-case",
|
||||
retrieved_chunk_ids=(),
|
||||
matched_expected_ids=(),
|
||||
missing_expected_ids=(),
|
||||
unexpected_ids=(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _target() -> CandidateAliasTarget:
|
||||
return CandidateAliasTarget(
|
||||
alias_name="rag_dense_hybrid_active",
|
||||
collection_name="rag_dense_hybrid_v2",
|
||||
version="v2",
|
||||
model_digest="b" * 64,
|
||||
)
|
||||
|
||||
|
||||
_ServiceFixture = tuple[
|
||||
ProfileActivationService, InMemoryDerivedIndexStore, InMemoryActivationDecisionStore
|
||||
]
|
||||
|
||||
|
||||
def _service() -> _ServiceFixture:
|
||||
store = InMemoryDerivedIndexStore()
|
||||
builder = IndexBuildService(store, InMemoryIndexOperationStore())
|
||||
decisions = InMemoryActivationDecisionStore()
|
||||
return ProfileActivationService(index_builder=builder, decisions=decisions), store, decisions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_incomplete_candidate_run_is_rejected_before_any_qdrant_call() -> None:
|
||||
service, store, decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1")
|
||||
with pytest.raises(IncompleteEvaluationError):
|
||||
await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=_incomplete_run(dataset),
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert store.build_calls == 0
|
||||
assert await decisions.list_recent() == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_regressed_candidate_is_rejected_and_recorded_without_activating() -> None:
|
||||
service, store, decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1", hit=True)
|
||||
candidate = _complete_run(dataset, version="v2", hit=False)
|
||||
with pytest.raises(ActivationRejectedError) as excinfo:
|
||||
await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert excinfo.value.gate.passed is False
|
||||
assert store.build_calls == 0
|
||||
recorded = (await decisions.list_recent())[0]
|
||||
assert recorded.activated is False
|
||||
assert recorded.candidate_target is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_passing_candidate_is_activated_and_recorded() -> None:
|
||||
service, store, decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1", hit=True)
|
||||
candidate = _complete_run(dataset, version="v2", hit=True)
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert decision.activated is True
|
||||
assert store.aliases["rag_dense_hybrid_active"] == "rag_dense_hybrid_v2"
|
||||
stored = await decisions.get(decision.id)
|
||||
assert stored is not None
|
||||
assert stored.index_operation_id == decision.index_operation_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_restores_the_previous_alias_and_marks_the_decision() -> None:
|
||||
service, store, _decisions = _service()
|
||||
dataset = _dataset()
|
||||
baseline = _complete_run(dataset, version="v1", hit=True)
|
||||
candidate = _complete_run(dataset, version="v2", hit=True)
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=baseline,
|
||||
candidate_run=candidate,
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
assert decision.previous_collection is None # nothing was active before this test
|
||||
|
||||
rolled_back = await service.rollback(decision.id)
|
||||
assert rolled_back.rolled_back is True
|
||||
assert store.rollback_calls == [("rag_dense_hybrid_active", None)]
|
||||
assert "rag_dense_hybrid_active" not in store.aliases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_twice_is_rejected() -> None:
|
||||
service, _, _ = _service()
|
||||
dataset = _dataset()
|
||||
decision = await service.activate(
|
||||
dataset=dataset,
|
||||
baseline_run=_complete_run(dataset, version="v1", hit=True),
|
||||
candidate_run=_complete_run(dataset, version="v2", hit=True),
|
||||
thresholds=RegressionThresholds(version="v1"),
|
||||
candidate_profile=_target(),
|
||||
source=FixtureSource(),
|
||||
smoke=PassingSmoke(),
|
||||
)
|
||||
await service.rollback(decision.id)
|
||||
with pytest.raises(AlreadyRolledBackError):
|
||||
await service.rollback(decision.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_of_unknown_decision_is_rejected() -> None:
|
||||
service, _, _ = _service()
|
||||
with pytest.raises(ActivationDecisionNotFoundError):
|
||||
await service.rollback(uuid4())
|
||||
@@ -0,0 +1,500 @@
|
||||
"""RestoreGateService against fake ports: manifest verification, reconciliation,
|
||||
golden queries, cross-space denial, and the never-touch-the-source guarantee."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.backup.models import BackupStatus
|
||||
from ragcore.application.restore.models import GoldenQueryProbe, RestoreReport, SecurityDenialProbe
|
||||
from ragcore.application.restore.service import RestoreGateService, read_verified_manifest
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.domain.control import DataPolicy, Grant, KnowledgeSpace, Role, UserGrant
|
||||
|
||||
SOURCE_DB_URL = "postgresql+psycopg://ragcore:ragcore@postgres:5432/source"
|
||||
|
||||
|
||||
def _write_backup(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
collection_name: str = "docs_v1",
|
||||
alias: str = "docs-live",
|
||||
point_ids: Sequence[UUID] = (),
|
||||
status: BackupStatus = BackupStatus.COMPLETE,
|
||||
) -> tuple[Path, dict[Path, dict[UUID, Mapping[str, object]]]]:
|
||||
backup_dir = tmp_path / "backup"
|
||||
backup_dir.mkdir()
|
||||
|
||||
dump_bytes = b"pg-dump-content"
|
||||
dump_path = backup_dir / "postgres.dump"
|
||||
dump_path.write_bytes(dump_bytes)
|
||||
|
||||
snapshot_bytes = b"qdrant-snapshot-content"
|
||||
snapshot_name = f"{collection_name}-1234-2026-01-01.snapshot"
|
||||
snapshot_path = backup_dir / "qdrant" / snapshot_name
|
||||
snapshot_path.parent.mkdir(parents=True)
|
||||
snapshot_path.write_bytes(snapshot_bytes)
|
||||
|
||||
def _checksum(path: Path, content: bytes) -> dict[str, object]:
|
||||
return {
|
||||
"relative_path": str(path.relative_to(backup_dir)),
|
||||
"sha256": hashlib.sha256(content).hexdigest(),
|
||||
"size_bytes": len(content),
|
||||
}
|
||||
|
||||
manifest = {
|
||||
"backup_id": "backup-1",
|
||||
"status": status.value,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
"software_version": "0.0.0",
|
||||
"schema_revision": "abc123",
|
||||
"active_profiles": {},
|
||||
"postgres_dump": _checksum(dump_path, dump_bytes),
|
||||
"qdrant_snapshots": [_checksum(snapshot_path, snapshot_bytes)],
|
||||
"collection_aliases": {alias: collection_name},
|
||||
"blob_artifacts": [],
|
||||
"parsed_artifacts": [],
|
||||
"counts": {},
|
||||
}
|
||||
(backup_dir / "manifest.json").write_text(json.dumps(manifest))
|
||||
|
||||
snapshot_contents = {
|
||||
snapshot_path: {
|
||||
point_id: _point_payload(point_id) for point_id in point_ids
|
||||
}
|
||||
}
|
||||
return backup_dir, snapshot_contents
|
||||
|
||||
|
||||
def _point_payload(point_id: UUID) -> Mapping[str, object]:
|
||||
return {
|
||||
"workspace_id": str(uuid4()),
|
||||
"space_id": str(uuid4()),
|
||||
"document_id": str(uuid4()),
|
||||
"document_version_id": str(uuid4()),
|
||||
"chunk_id": str(point_id),
|
||||
"location": {"page": 1},
|
||||
"text": f"content for {point_id}",
|
||||
}
|
||||
|
||||
|
||||
class _FakePostgresRestorer:
|
||||
def __init__(self, *, revision: str = "abc123") -> None:
|
||||
self._revision = revision
|
||||
self.created_databases: list[str] = []
|
||||
self.restored_dumps: list[Path] = []
|
||||
|
||||
async def create_empty_database(self, *, database_name: str) -> str:
|
||||
self.created_databases.append(database_name)
|
||||
return f"postgresql+psycopg://ragcore:ragcore@postgres:5432/{database_name}"
|
||||
|
||||
async def restore_dump(self, *, dump_path: Path, target_database_url: str) -> None:
|
||||
self.restored_dumps.append(dump_path)
|
||||
|
||||
async def upgrade_to_head(self, *, target_database_url: str) -> str:
|
||||
return self._revision
|
||||
|
||||
async def drop_database(self, *, database_name: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeQdrantRestorer:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
snapshot_contents: Mapping[Path, Mapping[UUID, Mapping[str, object]]],
|
||||
source_collections: Mapping[str, Mapping[UUID, Mapping[str, object]]],
|
||||
nearest_by_vector: Mapping[tuple[float, ...], UUID] | None = None,
|
||||
) -> None:
|
||||
self._snapshot_contents = snapshot_contents
|
||||
self._collections: dict[str, dict[UUID, Mapping[str, object]]] = {
|
||||
name: dict(points) for name, points in source_collections.items()
|
||||
}
|
||||
self._nearest_by_vector = nearest_by_vector or {}
|
||||
self.restore_calls: list[tuple[Path, str]] = []
|
||||
self.alias_calls: list[dict[str, str]] = []
|
||||
|
||||
async def restore_collection(self, *, snapshot_path: Path, collection_name: str) -> None:
|
||||
self.restore_calls.append((snapshot_path, collection_name))
|
||||
self._collections[collection_name] = dict(self._snapshot_contents[snapshot_path])
|
||||
|
||||
async def restore_aliases(self, *, alias_to_collection: Mapping[str, str]) -> None:
|
||||
self.alias_calls.append(dict(alias_to_collection))
|
||||
|
||||
async def scroll_point_ids(self, *, collection_name: str) -> frozenset[UUID]:
|
||||
return frozenset(self._collections.get(collection_name, {}).keys())
|
||||
|
||||
async def fetch_point_payload(
|
||||
self, *, collection_name: str, point_id: UUID
|
||||
) -> Mapping[str, object] | None:
|
||||
return self._collections.get(collection_name, {}).get(point_id)
|
||||
|
||||
async def search_nearest(
|
||||
self, *, collection_name: str, query_vector: Sequence[float], limit: int = 1
|
||||
) -> tuple[UUID, ...]:
|
||||
match = self._nearest_by_vector.get(tuple(query_vector))
|
||||
if match is None or match not in self._collections.get(collection_name, {}):
|
||||
return ()
|
||||
return (match,)
|
||||
|
||||
async def drop_collection(self, *, collection_name: str) -> None:
|
||||
self._collections.pop(collection_name, None)
|
||||
|
||||
|
||||
class _FakeFileCopier:
|
||||
async def copy_tree(self, *, source: Path, destination: Path) -> tuple:
|
||||
return ()
|
||||
|
||||
|
||||
class _FakeControlPlaneReader:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
spaces_by_url: Mapping[str, tuple[KnowledgeSpace, ...]],
|
||||
grants_by_url: Mapping[str, tuple[Grant, ...]] | None = None,
|
||||
user_grants_by_url: Mapping[str, tuple[UserGrant, ...]] | None = None,
|
||||
) -> None:
|
||||
self._spaces_by_url = spaces_by_url
|
||||
self._grants_by_url = grants_by_url or {}
|
||||
self._user_grants_by_url = user_grants_by_url or {}
|
||||
|
||||
async def list_spaces(
|
||||
self, *, database_url: str, workspace_id: UUID
|
||||
) -> tuple[KnowledgeSpace, ...]:
|
||||
return self._spaces_by_url.get(database_url, ())
|
||||
|
||||
async def list_grants(self, *, database_url: str, workspace_id: UUID) -> tuple[Grant, ...]:
|
||||
return self._grants_by_url.get(database_url, ())
|
||||
|
||||
async def list_user_grants(
|
||||
self, *, database_url: str, workspace_id: UUID
|
||||
) -> tuple[UserGrant, ...]:
|
||||
return self._user_grants_by_url.get(database_url, ())
|
||||
|
||||
|
||||
def _space(workspace_id: UUID, space_id: UUID) -> KnowledgeSpace:
|
||||
return KnowledgeSpace(
|
||||
id=space_id,
|
||||
workspace_id=workspace_id,
|
||||
name=f"space-{space_id.hex[:8]}",
|
||||
slug=f"space-{space_id.hex[:8]}",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
|
||||
|
||||
async def test_full_run_passes_when_restore_faithfully_reproduces_the_source(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
point_id = uuid4()
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path, point_ids=(point_id,))
|
||||
query_vector = (0.1, 0.2, 0.3, 0.4)
|
||||
payload = next(iter(next(iter(snapshot_contents.values())).values()))
|
||||
|
||||
workspace_id = uuid4()
|
||||
principal_id = uuid4()
|
||||
granted_space_id = uuid4()
|
||||
denied_space_id = uuid4()
|
||||
disposable_db_url = "postgresql+psycopg://ragcore:ragcore@postgres:5432/restore-1"
|
||||
|
||||
both_spaces = (_space(workspace_id, granted_space_id), _space(workspace_id, denied_space_id))
|
||||
control_plane = _FakeControlPlaneReader(
|
||||
spaces_by_url={
|
||||
SOURCE_DB_URL: both_spaces,
|
||||
disposable_db_url: both_spaces,
|
||||
},
|
||||
user_grants_by_url={
|
||||
disposable_db_url: (
|
||||
UserGrant(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
user_id=principal_id,
|
||||
knowledge_space_id=granted_space_id,
|
||||
role=Role.VIEWER,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
qdrant = _FakeQdrantRestorer(
|
||||
snapshot_contents=snapshot_contents,
|
||||
source_collections={"docs_v1": {point_id: payload}},
|
||||
nearest_by_vector={query_vector: point_id},
|
||||
)
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=qdrant,
|
||||
control_plane=control_plane,
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
expected_citation = Citation.create(
|
||||
workspace_id=UUID(str(payload["workspace_id"])),
|
||||
space_id=UUID(str(payload["space_id"])),
|
||||
document_id=UUID(str(payload["document_id"])),
|
||||
document_version_id=UUID(str(payload["document_version_id"])),
|
||||
chunk_id=UUID(str(payload["chunk_id"])),
|
||||
parent_chunk_id=None,
|
||||
location=dict(payload["location"]), # type: ignore[arg-type]
|
||||
excerpt=str(payload["text"]),
|
||||
source_uri="ragcore://source/restore-gate",
|
||||
acquired_at=datetime.now(UTC),
|
||||
retrieval_run_id=uuid4(),
|
||||
index_alias="docs-live",
|
||||
profile_version="restore-gate",
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-1",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(
|
||||
GoldenQueryProbe(
|
||||
collection_alias="docs-live",
|
||||
point_id=point_id,
|
||||
query_vector=query_vector,
|
||||
expected_excerpt_checksum=expected_citation.excerpt_checksum,
|
||||
),
|
||||
),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=principal_id,
|
||||
workspace_id=workspace_id,
|
||||
granted_space_id=granted_space_id,
|
||||
denied_space_id=denied_space_id,
|
||||
),
|
||||
)
|
||||
|
||||
assert isinstance(report, RestoreReport)
|
||||
assert report.passed
|
||||
assert report.reconciliation[0].matches
|
||||
assert report.golden_queries[0].passed
|
||||
assert report.security_denial is not None
|
||||
assert report.security_denial.passed
|
||||
assert qdrant.restore_calls == [
|
||||
(backup_dir / "qdrant" / "docs_v1-1234-2026-01-01.snapshot", "disposable-docs_v1")
|
||||
]
|
||||
assert qdrant.alias_calls == [{"docs-live": "disposable-docs_v1"}]
|
||||
|
||||
|
||||
async def test_incomplete_manifest_is_rejected_before_touching_any_infra(tmp_path: Path) -> None:
|
||||
backup_dir, _ = _write_backup(tmp_path, status=BackupStatus.INCOMPLETE)
|
||||
postgres = _FakePostgresRestorer()
|
||||
service = RestoreGateService(
|
||||
postgres=postgres,
|
||||
qdrant=_FakeQdrantRestorer(snapshot_contents={}, source_collections={}),
|
||||
control_plane=_FakeControlPlaneReader(spaces_by_url={}),
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="not 'complete'"):
|
||||
await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-2",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert postgres.created_databases == []
|
||||
|
||||
|
||||
async def test_corrupted_artifact_checksum_is_rejected_before_touching_any_infra(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
backup_dir, _ = _write_backup(tmp_path)
|
||||
(backup_dir / "postgres.dump").write_bytes(b"corrupted-bytes")
|
||||
postgres = _FakePostgresRestorer()
|
||||
service = RestoreGateService(
|
||||
postgres=postgres,
|
||||
qdrant=_FakeQdrantRestorer(snapshot_contents={}, source_collections={}),
|
||||
control_plane=_FakeControlPlaneReader(spaces_by_url={}),
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="checksum mismatch"):
|
||||
await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-3",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert postgres.created_databases == []
|
||||
|
||||
|
||||
async def test_reconciliation_reports_a_point_missing_from_the_restored_snapshot(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
present_id = uuid4()
|
||||
missing_id = uuid4()
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path, point_ids=(present_id,))
|
||||
workspace_id = uuid4()
|
||||
|
||||
qdrant = _FakeQdrantRestorer(
|
||||
snapshot_contents=snapshot_contents,
|
||||
# The live source has one extra point the snapshot never captured.
|
||||
source_collections={
|
||||
"docs_v1": {
|
||||
present_id: _point_payload(present_id),
|
||||
missing_id: _point_payload(missing_id),
|
||||
}
|
||||
},
|
||||
)
|
||||
control_plane = _FakeControlPlaneReader(spaces_by_url={})
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=qdrant,
|
||||
control_plane=control_plane,
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-4",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert report.reconciliation[0].missing_point_ids == frozenset({missing_id})
|
||||
assert report.reconciliation[0].orphan_point_ids == frozenset()
|
||||
|
||||
|
||||
async def test_golden_query_fails_when_the_expected_chunk_is_not_returned(tmp_path: Path) -> None:
|
||||
point_id = uuid4()
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path, point_ids=(point_id,))
|
||||
qdrant = _FakeQdrantRestorer(
|
||||
snapshot_contents=snapshot_contents,
|
||||
source_collections={"docs_v1": {point_id: _point_payload(point_id)}},
|
||||
nearest_by_vector={}, # no vector configured -> search_nearest returns nothing
|
||||
)
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=qdrant,
|
||||
control_plane=_FakeControlPlaneReader(spaces_by_url={}),
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-5",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(
|
||||
GoldenQueryProbe(
|
||||
collection_alias="docs-live",
|
||||
point_id=point_id,
|
||||
query_vector=(0.1, 0.2, 0.3, 0.4),
|
||||
expected_excerpt_checksum="0" * 64,
|
||||
),
|
||||
),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=uuid4(),
|
||||
workspace_id=uuid4(),
|
||||
granted_space_id=uuid4(),
|
||||
denied_space_id=uuid4(),
|
||||
),
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert not report.golden_queries[0].passed
|
||||
assert report.golden_queries[0].returned_chunk_id is None
|
||||
|
||||
|
||||
async def test_security_denial_fails_when_the_denied_space_is_still_reachable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
backup_dir, snapshot_contents = _write_backup(tmp_path)
|
||||
workspace_id = uuid4()
|
||||
principal_id = uuid4()
|
||||
granted_space_id = uuid4()
|
||||
denied_space_id = uuid4()
|
||||
disposable_db_url = "postgresql+psycopg://ragcore:ragcore@postgres:5432/restore-6"
|
||||
|
||||
control_plane = _FakeControlPlaneReader(
|
||||
spaces_by_url={
|
||||
disposable_db_url: (
|
||||
_space(workspace_id, granted_space_id),
|
||||
_space(workspace_id, denied_space_id),
|
||||
)
|
||||
},
|
||||
user_grants_by_url={
|
||||
disposable_db_url: (
|
||||
# Misconfigured fixture: a grant to the space that should be denied.
|
||||
UserGrant(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
user_id=principal_id,
|
||||
knowledge_space_id=denied_space_id,
|
||||
role=Role.VIEWER,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
service = RestoreGateService(
|
||||
postgres=_FakePostgresRestorer(),
|
||||
qdrant=_FakeQdrantRestorer(snapshot_contents=snapshot_contents, source_collections={}),
|
||||
control_plane=control_plane,
|
||||
files=_FakeFileCopier(),
|
||||
)
|
||||
|
||||
report = await service.run(
|
||||
backup_dir=backup_dir,
|
||||
source_database_url=SOURCE_DB_URL,
|
||||
disposable_database_name="restore-6",
|
||||
disposable_collection_prefix="disposable",
|
||||
disposable_blob_root=tmp_path / "blobs",
|
||||
disposable_parsed_root=tmp_path / "parsed",
|
||||
golden_queries=(),
|
||||
security_probe=SecurityDenialProbe(
|
||||
principal_id=principal_id,
|
||||
workspace_id=workspace_id,
|
||||
granted_space_id=granted_space_id,
|
||||
denied_space_id=denied_space_id,
|
||||
),
|
||||
)
|
||||
|
||||
assert not report.passed
|
||||
assert report.security_denial is not None
|
||||
assert not report.security_denial.passed
|
||||
assert denied_space_id in report.security_denial.effective_space_ids
|
||||
|
||||
|
||||
def test_read_verified_manifest_rejects_a_missing_manifest_file(tmp_path: Path) -> None:
|
||||
with pytest.raises(Exception, match=r"no manifest\.json"):
|
||||
read_verified_manifest(tmp_path)
|
||||
@@ -0,0 +1,526 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from qdrant_client.http import models
|
||||
|
||||
from ragcore.api.v1.context.service import ContextExecutionRequest, ContextQuery
|
||||
from ragcore.api.v1.search.service import SearchExecutionRequest, SearchFilterData, SearchQuery
|
||||
from ragcore.application.models.selection import ResolvedEmbeddingSelection
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
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, EmbeddingSelection
|
||||
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.infrastructure.retrieval.production_executor import (
|
||||
PostgresDocumentTitleLookup,
|
||||
ProductionContextExecutor,
|
||||
ProductionSearchExecutor,
|
||||
RetrievalPipeline,
|
||||
)
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
DOCUMENT_ID = uuid4()
|
||||
DOCUMENT_VERSION_ID = uuid4()
|
||||
CHUNK_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
|
||||
EMBEDDING_PROFILE = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hybrid",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:v1",
|
||||
model_digest="0" * 64,
|
||||
dimensions=2,
|
||||
context_limit=2048,
|
||||
tokenizer="cl100k",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
|
||||
_PAYLOAD = {
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"space_id": str(SPACE_ID),
|
||||
"document_id": str(DOCUMENT_ID),
|
||||
"document_version_id": str(DOCUMENT_VERSION_ID),
|
||||
"chunk_id": str(CHUNK_ID),
|
||||
"source_id": str(SOURCE_ID),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": EMBEDDING_PROFILE.model_digest,
|
||||
"chunk_profile_version": EMBEDDING_PROFILE.version,
|
||||
"text": "The runbook says to restart the payment service after a failover.",
|
||||
"location": {"page": 1},
|
||||
}
|
||||
|
||||
|
||||
class FakeQdrantClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
alias_target: str = "rag_dense_hybrid_v1",
|
||||
payload: dict[str, object] = _PAYLOAD,
|
||||
) -> None:
|
||||
self.alias_target = alias_target
|
||||
self.payload = payload
|
||||
self.query_collections: list[str] = []
|
||||
|
||||
async def get_aliases(self, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[
|
||||
SimpleNamespace(
|
||||
alias_name="rag_dense_hybrid_active", collection_name=self.alias_target
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_collection(self, collection_name: str, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={"dense": models.VectorParams(size=2, distance=models.Distance.COSINE)},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
self.query_collections.append(str(kwargs.get("collection_name")))
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(id=str(CHUNK_ID), version=0, score=0.9, payload=self.payload)
|
||||
]
|
||||
)
|
||||
|
||||
async def retrieve(self, **kwargs: object) -> list[SimpleNamespace]:
|
||||
ids = kwargs.get("ids", [])
|
||||
return [SimpleNamespace(id=raw_id, payload=self.payload) for raw_id in ids]
|
||||
|
||||
|
||||
class PayloadlessQdrantClient(FakeQdrantClient):
|
||||
"""Ranks a point and then does not return its payload.
|
||||
|
||||
Qdrant answered the search with this point, so it is in the index -- but
|
||||
the follow-up ``retrieve`` gives nothing back for it. The pipeline cannot
|
||||
build a citation without the text, so the hit is dropped; the point of
|
||||
this double is that the caller must be *told* that happened.
|
||||
"""
|
||||
|
||||
async def retrieve(self, **kwargs: object) -> list[SimpleNamespace]:
|
||||
return []
|
||||
|
||||
|
||||
class FakeTitleLookup:
|
||||
def __init__(self, titles: dict | None = None) -> None: # type: ignore[type-arg]
|
||||
self._titles = {DOCUMENT_ID: "Restart Runbook"} if titles is None else titles
|
||||
|
||||
async def titles(self, document_ids: set) -> dict: # type: ignore[type-arg]
|
||||
return {doc_id: self._titles[doc_id] for doc_id in document_ids if doc_id in self._titles}
|
||||
|
||||
|
||||
def _embed_transport(vector: tuple = (0.1, 0.2)) -> httpx.MockTransport: # type: ignore[type-arg]
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"embeddings": [list(vector)]})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _failing_transport(status_code: int = 503) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code, json={"error": "unavailable"})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _empty_store() -> JsonEmbeddingProfileStore:
|
||||
path = Path(tempfile.mkdtemp()) / "profiles.json"
|
||||
return JsonEmbeddingProfileStore(path)
|
||||
|
||||
|
||||
async def _activated_store() -> JsonEmbeddingProfileStore:
|
||||
store = _empty_store()
|
||||
await store.add(EMBEDDING_PROFILE)
|
||||
await store.activate(EMBEDDING_PROFILE.id)
|
||||
return store
|
||||
|
||||
|
||||
async def _activated_store_for(profile: EmbeddingProfile) -> JsonEmbeddingProfileStore:
|
||||
store = _empty_store()
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store
|
||||
|
||||
|
||||
class FixedSelectionResolver:
|
||||
def __init__(self, profile: EmbeddingProfile, *, route: str) -> None:
|
||||
self.selected = ResolvedEmbeddingSelection(
|
||||
generation=8,
|
||||
profile=profile,
|
||||
selection=EmbeddingSelection.from_profile(profile, experiment_route_key=route),
|
||||
)
|
||||
|
||||
async def resolve_active(self) -> ResolvedEmbeddingSelection:
|
||||
return self.selected
|
||||
|
||||
|
||||
def _pipeline(
|
||||
*,
|
||||
embedding_transport: httpx.MockTransport,
|
||||
store,
|
||||
metrics: TelemetryMetrics | None = None,
|
||||
) -> RetrievalPipeline:
|
||||
client = httpx.AsyncClient(transport=embedding_transport, follow_redirects=False)
|
||||
return RetrievalPipeline(
|
||||
qdrant_client=FakeQdrantClient(), # type: ignore[arg-type]
|
||||
embedding_profile_store=store,
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=client,
|
||||
),
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
|
||||
def _scope() -> AuthorizedRetrievalScope:
|
||||
return AuthorizedRetrievalScope(WORKSPACE_ID, frozenset({SPACE_ID}))
|
||||
|
||||
|
||||
def _search_request(query: str = "how do I restart the payment service") -> SearchExecutionRequest:
|
||||
return SearchExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=SearchQuery(query=query, max_results=5, filters=SearchFilterData()),
|
||||
scope=_scope(),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def _context_request(
|
||||
query: str = "how do I restart the payment service",
|
||||
) -> ContextExecutionRequest:
|
||||
return ContextExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=ContextQuery(
|
||||
query=SearchQuery(query=query, max_results=5, filters=SearchFilterData()),
|
||||
max_context_tokens=4000,
|
||||
),
|
||||
scope=_scope(),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_degrades_when_no_active_embedding_profile() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=_empty_store())
|
||||
result = await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
assert result.hits == ()
|
||||
assert "no_active_embedding_profile" in result.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_records_retrieval_duration_and_zero_result() -> None:
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[reader]
|
||||
)
|
||||
metrics = TelemetryMetrics(meter_provider=provider)
|
||||
pipeline = _pipeline(
|
||||
embedding_transport=_embed_transport(), store=_empty_store(), metrics=metrics
|
||||
)
|
||||
|
||||
await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
data = reader.get_metrics_data()
|
||||
assert data is not None
|
||||
names = {
|
||||
metric.name
|
||||
for resource_metric in data.resource_metrics
|
||||
for scope_metric in resource_metric.scope_metrics
|
||||
for metric in scope_metric.metrics
|
||||
}
|
||||
assert "ragcore_retrieval_duration_seconds" in names
|
||||
assert "ragcore_retrieval_zero_result_total" in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_degrades_when_embedding_provider_unavailable() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_failing_transport(), store=await _activated_store())
|
||||
result = await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
assert result.hits == ()
|
||||
assert "embedding_provider_unavailable" in result.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_happy_path_returns_titled_hit_with_fused_candidate() -> None:
|
||||
"""M8-01: no reranking -- the fused RRF ranking is the ranking, not a
|
||||
degraded fallback, so this must not report degraded and rerank_score is
|
||||
always None (kept as a field for response-shape compatibility)."""
|
||||
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert len(result.hits) == 1
|
||||
hit = result.hits[0]
|
||||
assert hit.title == "Restart Runbook"
|
||||
assert "restart the payment service" in hit.text
|
||||
assert hit.citation.document_id == DOCUMENT_ID
|
||||
assert hit.citation.workspace_id == WORKSPACE_ID
|
||||
assert hit.fused.candidate.provenance.chunk_id == CHUNK_ID
|
||||
assert hit.rerank_score is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_falls_back_to_untitled_when_title_missing() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup(titles={})) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request())
|
||||
assert result.hits[0].title == "Untitled document"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_executor_happy_path_returns_context_candidate() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionContextExecutor(pipeline, FakeQdrantClient()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_context_request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert len(result.candidates) == 1
|
||||
assert "restart the payment service" in result.candidates[0].text
|
||||
assert result.candidates[0].citation.document_id == DOCUMENT_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_executor_returns_empty_tuple_when_degraded() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=_empty_store())
|
||||
executor = ProductionContextExecutor(pipeline, FakeQdrantClient()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_context_request())
|
||||
assert result.candidates == ()
|
||||
assert result.degraded is True
|
||||
assert "no_active_embedding_profile" in result.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rest_search_uses_durable_route_and_collection_selection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hybrid",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="c" * 64,
|
||||
dimensions=2,
|
||||
context_limit=8192,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
credential_file = tmp_path / "credential"
|
||||
credential_file.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
credential_file.chmod(0o600)
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
settings = Settings(
|
||||
env="test",
|
||||
ollama_url="http://localhost:11434",
|
||||
modelforge_base_url="http://modelforge:8000",
|
||||
modelforge_credential_file=credential_file,
|
||||
modelforge_embedding_experiment_route="stale-global-route",
|
||||
)
|
||||
payload = {
|
||||
**_PAYLOAD,
|
||||
"embedding_model_digest": profile.model_digest,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
}
|
||||
qdrant = FakeQdrantClient(alias_target="rag_dense_hybrid_stale", payload=payload)
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=qdrant, # type: ignore[arg-type]
|
||||
embedding_profile_store=await _activated_store_for(profile),
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=settings,
|
||||
http_client=httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
),
|
||||
),
|
||||
embedding_selection_resolver=FixedSelectionResolver(profile, route="rc-canary-2026-09"),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
|
||||
result = await executor.execute(_search_request())
|
||||
|
||||
assert result.degraded is False
|
||||
assert len(result.hits) == 1
|
||||
assert qdrant.query_collections
|
||||
assert set(qdrant.query_collections) == {"rag_dense_hybrid_v1"}
|
||||
assert len(seen) == 1
|
||||
assert seen[0].url.path == "/api/v1/capability-experiments/rc-canary-2026-09/invoke"
|
||||
assert json.loads(seen[0].content) == {
|
||||
"input": ["how do I restart the payment service"],
|
||||
"input_type": "query",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_document_title_lookup_returns_empty_dict_for_empty_input() -> None:
|
||||
lookup = PostgresDocumentTitleLookup(session_factory=None) # type: ignore[arg-type]
|
||||
assert await lookup.titles(set()) == {}
|
||||
|
||||
|
||||
class RecordingQdrantClient(FakeQdrantClient):
|
||||
"""Also records every query_points call so filter compilation is inspectable."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.query_calls: list[dict] = [] # type: ignore[type-arg]
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
self.query_calls.append(dict(kwargs))
|
||||
return await super().query_points(**kwargs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_compiles_every_accepted_filter_into_the_qdrant_query() -> None:
|
||||
"""Regression: source_ids/document_ids/trust_levels used to be accepted by
|
||||
the API and silently dropped before ever reaching Qdrant."""
|
||||
|
||||
client = RecordingQdrantClient()
|
||||
embedding_client = httpx.AsyncClient(transport=_embed_transport(), follow_redirects=False)
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=client, # type: ignore[arg-type]
|
||||
embedding_profile_store=await _activated_store(),
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
request = SearchExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=SearchQuery(
|
||||
query="restart",
|
||||
max_results=5,
|
||||
filters=SearchFilterData(
|
||||
source_ids=(SOURCE_ID,),
|
||||
document_ids=(DOCUMENT_ID,),
|
||||
trust_levels=("source",),
|
||||
),
|
||||
),
|
||||
scope=_scope(),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
await executor.execute(request)
|
||||
|
||||
assert client.query_calls
|
||||
for call in client.query_calls:
|
||||
compiled = str(call["query_filter"])
|
||||
assert str(SOURCE_ID) in compiled
|
||||
assert str(DOCUMENT_ID) in compiled
|
||||
assert "trust_level" in compiled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_executor_reports_per_stage_timings() -> None:
|
||||
pipeline = _pipeline(embedding_transport=_embed_transport(), store=await _activated_store())
|
||||
executor = ProductionSearchExecutor(pipeline, FakeTitleLookup()) # type: ignore[arg-type]
|
||||
result = await executor.execute(_search_request())
|
||||
|
||||
timings = dict(result.timings_ms or {})
|
||||
for stage in (
|
||||
"query_embedding",
|
||||
"sparse_encoding",
|
||||
"dense_search",
|
||||
"sparse_search",
|
||||
"fusion",
|
||||
"payload_fetch",
|
||||
):
|
||||
assert stage in timings
|
||||
assert timings[stage] >= 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_reports_when_a_ranked_hit_has_no_payload() -> None:
|
||||
"""A dropped hit is a degradation, not a quietly shorter answer.
|
||||
|
||||
Returning four results where the ranking found five, with nothing in the
|
||||
response to say so, is indistinguishable from "there were only four" --
|
||||
and the caller acts on it.
|
||||
"""
|
||||
|
||||
embedding_client = httpx.AsyncClient(transport=_embed_transport(), follow_redirects=False)
|
||||
pipeline = RetrievalPipeline(
|
||||
qdrant_client=PayloadlessQdrantClient(), # type: ignore[arg-type]
|
||||
embedding_profile_store=await _activated_store(),
|
||||
embedding_adapter_factory=create_embedding_adapter_factory(
|
||||
settings=Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=embedding_client,
|
||||
),
|
||||
)
|
||||
|
||||
result = await pipeline.run(
|
||||
query_text="restart payment",
|
||||
content_types=(),
|
||||
languages=(),
|
||||
scope=_scope(),
|
||||
max_results=5,
|
||||
profile="hybrid-v1",
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
assert result.hits == ()
|
||||
assert "payload_unavailable" in result.degradation_codes
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Sources that carry an interval get synced without anyone asking.
|
||||
|
||||
Until now a sync ran only when an operator pressed the button. For a platform
|
||||
whose promise is that an index keeps step with a folder or a repository, that
|
||||
made staleness the default and freshness a chore someone had to remember.
|
||||
|
||||
The scheduler does not sync anything. It enqueues the same ``sync_source`` job
|
||||
the Control Plane button enqueues, so every ownership, convergence and
|
||||
authorization rule downstream is the one already in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.sources.models import SourceRecord, SourceStatus
|
||||
from ragcore.application.sources.scheduler import SourceSyncScheduler
|
||||
from ragcore.domain.sources import ConnectorKind, SourceSyncState
|
||||
|
||||
NOW = datetime(2026, 8, 23, 12, 0, tzinfo=UTC)
|
||||
WORKSPACE = uuid4()
|
||||
SPACE = uuid4()
|
||||
|
||||
|
||||
def _source(
|
||||
*,
|
||||
interval: int | None = 3600,
|
||||
last_sync_at: datetime | None = None,
|
||||
status: SourceStatus = SourceStatus.ACTIVE,
|
||||
sync_state: SourceSyncState = SourceSyncState.COMPLETED,
|
||||
kind: ConnectorKind = ConnectorKind.FOLDER,
|
||||
) -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE,
|
||||
space_id=SPACE,
|
||||
name="Runbooks",
|
||||
kind=kind,
|
||||
status=status,
|
||||
sync_state=sync_state,
|
||||
last_sync_at=last_sync_at,
|
||||
sync_interval_seconds=interval,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, sources: list[SourceRecord]) -> None:
|
||||
self._sources = sources
|
||||
self.asked_at: list[datetime] = []
|
||||
|
||||
async def list_due_for_sync(self, *, now: datetime, limit: int) -> tuple[SourceRecord, ...]:
|
||||
self.asked_at.append(now)
|
||||
return tuple(self._sources[:limit])
|
||||
|
||||
|
||||
class _Jobs:
|
||||
def __init__(self) -> None:
|
||||
self.enqueued: list[tuple[UUID, str]] = []
|
||||
self.any_enqueued = asyncio.Event()
|
||||
|
||||
async def enqueue(self, **kwargs: object) -> object:
|
||||
self.enqueued.append((kwargs["workspace_id"], str(kwargs["idempotency_key"]))) # type: ignore[arg-type]
|
||||
self.any_enqueued.set()
|
||||
return object()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_due_source_is_enqueued_as_an_ordinary_sync_job() -> None:
|
||||
due = _source(interval=3600, last_sync_at=NOW - timedelta(hours=2))
|
||||
jobs = _Jobs()
|
||||
scheduler = SourceSyncScheduler(_Registry([due]), jobs) # type: ignore[arg-type]
|
||||
|
||||
enqueued = await scheduler.tick(now=NOW)
|
||||
|
||||
assert enqueued == 1
|
||||
assert [workspace for workspace, _ in jobs.enqueued] == [WORKSPACE]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_schedulers_ticking_together_produce_one_job() -> None:
|
||||
"""Several worker replicas each run a scheduler. They must not each start
|
||||
the same sync -- and they do not need a lock to avoid it: the key is
|
||||
derived from when the sync came due, so both compute the same one and the
|
||||
queue's own uniqueness collapses them."""
|
||||
|
||||
due = _source(interval=3600, last_sync_at=NOW - timedelta(hours=2))
|
||||
first, second = _Jobs(), _Jobs()
|
||||
|
||||
await SourceSyncScheduler(_Registry([due]), first).tick(now=NOW) # type: ignore[arg-type]
|
||||
await SourceSyncScheduler(_Registry([due]), second).tick( # type: ignore[arg-type]
|
||||
now=NOW + timedelta(seconds=17)
|
||||
)
|
||||
|
||||
assert first.enqueued and second.enqueued
|
||||
assert first.enqueued[0][1] == second.enqueued[0][1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_that_has_never_synced_is_due_immediately() -> None:
|
||||
jobs = _Jobs()
|
||||
scheduler = SourceSyncScheduler(_Registry([_source(last_sync_at=None)]), jobs) # type: ignore[arg-type]
|
||||
|
||||
assert await scheduler.tick(now=NOW) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"source",
|
||||
[
|
||||
pytest.param(_source(interval=None), id="no interval configured"),
|
||||
pytest.param(
|
||||
_source(last_sync_at=NOW - timedelta(minutes=5)), id="synced within the interval"
|
||||
),
|
||||
pytest.param(_source(status=SourceStatus.PAUSED), id="paused"),
|
||||
pytest.param(_source(status=SourceStatus.DELETED), id="deleted"),
|
||||
pytest.param(
|
||||
_source(sync_state=SourceSyncState.SCANNING), id="a run is already in flight"
|
||||
),
|
||||
pytest.param(
|
||||
_source(sync_state=SourceSyncState.AWAITING_INGEST),
|
||||
id="waiting on its own child ingests",
|
||||
),
|
||||
pytest.param(_source(kind=ConnectorKind.UPLOAD), id="fed by its callers, not synced"),
|
||||
],
|
||||
)
|
||||
async def test_sources_that_must_not_be_scheduled(source: SourceRecord) -> None:
|
||||
"""The registry query is the first filter, but it is not the only one: the
|
||||
scheduler refuses these itself, so a widened query cannot quietly start
|
||||
syncing something that should be left alone."""
|
||||
|
||||
jobs = _Jobs()
|
||||
scheduler = SourceSyncScheduler(_Registry([source]), jobs) # type: ignore[arg-type]
|
||||
|
||||
assert await scheduler.tick(now=NOW) == 0
|
||||
assert jobs.enqueued == []
|
||||
|
||||
|
||||
class _FailingRegistry(_Registry):
|
||||
def __init__(self, sources: list[SourceRecord]) -> None:
|
||||
super().__init__(sources)
|
||||
self.calls = 0
|
||||
|
||||
async def list_due_for_sync(self, *, now: datetime, limit: int) -> tuple[SourceRecord, ...]:
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise RuntimeError("the database blinked")
|
||||
return await super().list_due_for_sync(now=now, limit=limit)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failing_tick_does_not_end_the_loop() -> None:
|
||||
"""The scheduler shares a process with the claim loops. A transient
|
||||
database error while looking for due sources must cost one tick, not the
|
||||
worker -- the next tick finds the same sources still due."""
|
||||
|
||||
registry = _FailingRegistry([_source(last_sync_at=None)])
|
||||
jobs = _Jobs()
|
||||
stop = asyncio.Event()
|
||||
scheduler = SourceSyncScheduler(registry, jobs) # type: ignore[arg-type]
|
||||
|
||||
async def until_enqueued() -> None:
|
||||
await jobs.any_enqueued.wait()
|
||||
stop.set()
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
scheduler.run(stop, interval_seconds=0.01),
|
||||
until_enqueued(),
|
||||
),
|
||||
timeout=5.0,
|
||||
)
|
||||
|
||||
assert registry.calls >= 2
|
||||
assert len(jobs.enqueued) >= 1
|
||||
@@ -0,0 +1,666 @@
|
||||
"""Source synchronisation semantics: discover -> compare -> ingest new/changed
|
||||
-> preserve unchanged -> retire disappeared -> persist the outcome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.documents import DocumentRecord
|
||||
from ragcore.application.jobs import Job, JobService
|
||||
from ragcore.application.sources import (
|
||||
SourceItemRecord,
|
||||
SourceItemState,
|
||||
SourceItemSummary,
|
||||
SourceRecord,
|
||||
SourceStatus,
|
||||
SourceSyncOutcome,
|
||||
SourceSyncRunRecord,
|
||||
SourceSyncService,
|
||||
)
|
||||
from ragcore.application.storage import BlobReference
|
||||
from ragcore.domain.sources import (
|
||||
Acquisition,
|
||||
ConnectorKind,
|
||||
ScanResult,
|
||||
SourceCursor,
|
||||
SourceFingerprint,
|
||||
SourceItem,
|
||||
SourceSyncState,
|
||||
)
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
|
||||
|
||||
class FakeConnector:
|
||||
"""Serves a mutable set of path -> content; pages when asked to."""
|
||||
|
||||
def __init__(self, source_id: UUID, files: dict[str, bytes], *, page_size: int = 100) -> None:
|
||||
self.source_id = source_id
|
||||
self.files = files
|
||||
self.page_size = page_size
|
||||
self.acquired: list[str] = []
|
||||
self.fail_scan = False
|
||||
|
||||
def _items(self) -> list[SourceItem]:
|
||||
return [
|
||||
SourceItem(
|
||||
source_id=self.source_id,
|
||||
external_id=path,
|
||||
locator=path,
|
||||
name=path.rsplit("/", 1)[-1],
|
||||
media_type="text/plain",
|
||||
metadata={"sha256": hashlib.sha256(content).hexdigest(), "commit": "abc1234def"},
|
||||
)
|
||||
for path, content in sorted(self.files.items())
|
||||
]
|
||||
|
||||
async def scan(self, cursor: SourceCursor | None = None) -> ScanResult:
|
||||
if self.fail_scan:
|
||||
raise RuntimeError("mount gone")
|
||||
items = self._items()
|
||||
start = 0
|
||||
if cursor is not None and cursor.value:
|
||||
start = next(i for i, item in enumerate(items) if item.external_id == cursor.value) + 1
|
||||
page = items[start : start + self.page_size]
|
||||
has_more = start + self.page_size < len(items)
|
||||
return ScanResult(
|
||||
tuple(page),
|
||||
SourceCursor(page[-1].external_id) if has_more and page else None,
|
||||
complete=not has_more and cursor is None,
|
||||
)
|
||||
|
||||
async def acquire(self, item: SourceItem) -> Acquisition:
|
||||
self.acquired.append(item.locator)
|
||||
content = self.files[item.locator]
|
||||
|
||||
async def stream() -> AsyncIterator[bytes]:
|
||||
yield content
|
||||
|
||||
return Acquisition(
|
||||
stream=stream(),
|
||||
source_item=item,
|
||||
fingerprint=SourceFingerprint(hashlib.sha256(content).hexdigest()),
|
||||
media_type="text/plain",
|
||||
original_name=item.name,
|
||||
)
|
||||
|
||||
def fingerprint(self, item: SourceItem) -> SourceFingerprint:
|
||||
return SourceFingerprint(str(item.metadata["sha256"]))
|
||||
|
||||
|
||||
_CONVERGEABLE = (SourceSyncState.AWAITING_INGEST, SourceSyncState.COMPLETED_WITH_FAILURES)
|
||||
|
||||
|
||||
class FakeRegistry:
|
||||
"""Mirrors PostgresSourceRegistry's convergence semantics, which is the
|
||||
point of these tests: the counters and the settled state must come out of
|
||||
remembered items, not out of what discovery happened to see."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.items: dict[tuple[UUID, str], SourceItemRecord] = {}
|
||||
self.runs: dict[UUID, SourceSyncRunRecord] = {}
|
||||
self.states: list[tuple[SourceSyncState, bool]] = []
|
||||
self.sync_state = SourceSyncState.IDLE
|
||||
|
||||
async def record_sync_run(self, run: SourceSyncRunRecord) -> SourceSyncRunRecord:
|
||||
self.runs[run.id] = run
|
||||
return run
|
||||
|
||||
async def set_sync_state(
|
||||
self, source_id, state, *, now, error=None, revision=None, successful=False
|
||||
):
|
||||
self.states.append((state, successful))
|
||||
self.sync_state = state
|
||||
return None
|
||||
|
||||
async def list_items(self, source_id: UUID) -> tuple[SourceItemRecord, ...]:
|
||||
return tuple(record for (sid, _), record in self.items.items() if sid == source_id)
|
||||
|
||||
async def upsert_items(self, items: tuple[SourceItemRecord, ...]) -> None:
|
||||
for item in items:
|
||||
self.items[(item.source_id, item.external_id)] = item
|
||||
|
||||
async def mark_items_removed(self, source_id, external_ids, *, now) -> None:
|
||||
for external_id in external_ids:
|
||||
current = self.items[(source_id, external_id)]
|
||||
self.items[(source_id, external_id)] = replace(
|
||||
current,
|
||||
removed=True,
|
||||
last_seen_at=now,
|
||||
# Retirement *is* the applied state: the desired outcome (no
|
||||
# searchable content) has been reached.
|
||||
applied_fingerprint=current.fingerprint,
|
||||
applied_at=now,
|
||||
pending_job_id=None,
|
||||
last_failure_code=None,
|
||||
last_failure_retryable=None,
|
||||
)
|
||||
|
||||
async def mark_item_ingested(
|
||||
self, source_id, external_id, *, fingerprint, document_id, now
|
||||
) -> bool:
|
||||
current = self.items.get((source_id, external_id))
|
||||
if current is None or current.fingerprint != fingerprint:
|
||||
return False
|
||||
self.items[(source_id, external_id)] = replace(
|
||||
current,
|
||||
applied_fingerprint=fingerprint,
|
||||
applied_at=now,
|
||||
pending_job_id=None,
|
||||
attempts=0,
|
||||
last_failure_code=None,
|
||||
last_failure_retryable=None,
|
||||
last_failure_job_id=None,
|
||||
document_id=document_id if document_id is not None else current.document_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def mark_item_failed(
|
||||
self, source_id, external_id, *, fingerprint, failure_code, retryable, now, job_id=None
|
||||
) -> bool:
|
||||
current = self.items.get((source_id, external_id))
|
||||
if current is None or current.fingerprint != fingerprint:
|
||||
return False
|
||||
self.items[(source_id, external_id)] = replace(
|
||||
current,
|
||||
pending_job_id=None,
|
||||
last_seen_at=now,
|
||||
last_failure_code=failure_code[:64],
|
||||
last_failure_retryable=retryable,
|
||||
last_failure_job_id=job_id,
|
||||
)
|
||||
return True
|
||||
|
||||
async def summarize_items(self, source_id: UUID) -> SourceItemSummary:
|
||||
records = [record for (sid, _), record in self.items.items() if sid == source_id]
|
||||
live = [record for record in records if not record.removed]
|
||||
return SourceItemSummary(
|
||||
total=len(records),
|
||||
removed=sum(1 for record in records if record.removed),
|
||||
applied=sum(1 for record in live if record.applied),
|
||||
failed=sum(1 for record in live if record.state is SourceItemState.FAILED),
|
||||
pending=sum(1 for record in live if record.state is SourceItemState.PENDING),
|
||||
)
|
||||
|
||||
async def converge_sync_state(self, source_id, state, *, now, successful=False):
|
||||
if self.sync_state not in _CONVERGEABLE:
|
||||
return None
|
||||
self.states.append((state, successful))
|
||||
self.sync_state = state
|
||||
return object()
|
||||
|
||||
|
||||
class FakeDocuments:
|
||||
def __init__(self) -> None:
|
||||
self.documents: dict[tuple[str, str], DocumentRecord] = {}
|
||||
self.retired: list[UUID] = []
|
||||
|
||||
def seed(self, source_id: UUID, external_id: str) -> DocumentRecord:
|
||||
document = DocumentRecord(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
source_key=str(source_id),
|
||||
external_id=external_id,
|
||||
title=external_id,
|
||||
source_id=source_id,
|
||||
)
|
||||
self.documents[(str(source_id), external_id)] = document
|
||||
return document
|
||||
|
||||
async def get_document_by_source(self, workspace_id, source_key, external_id):
|
||||
return self.documents.get((source_key, external_id))
|
||||
|
||||
async def mark_source_deleted(self, document_id: UUID) -> None:
|
||||
self.retired.append(document_id)
|
||||
|
||||
|
||||
class FakeBlobs:
|
||||
def __init__(self) -> None:
|
||||
self.stored: list[tuple[str, str]] = []
|
||||
|
||||
async def put(
|
||||
self,
|
||||
chunks: AsyncIterable[bytes],
|
||||
*,
|
||||
media_type=None,
|
||||
original_name=None,
|
||||
expected_sha256=None,
|
||||
):
|
||||
body = b"".join([chunk async for chunk in chunks])
|
||||
digest = hashlib.sha256(body).hexdigest()
|
||||
assert expected_sha256 == digest
|
||||
self.stored.append((original_name or "", digest))
|
||||
return BlobReference(
|
||||
digest=digest,
|
||||
size=len(body),
|
||||
media_type=media_type,
|
||||
original_name=original_name,
|
||||
storage_key=f"blobs/{digest}",
|
||||
)
|
||||
|
||||
|
||||
class RecordingJobs:
|
||||
def __init__(self) -> None:
|
||||
self.jobs: list[Job] = []
|
||||
|
||||
async def enqueue(self, job: Job) -> Job:
|
||||
# Mirrors the real repository's idempotency on job_key.
|
||||
existing = next((j for j in self.jobs if j.job_key == job.job_key), None)
|
||||
if existing is not None:
|
||||
return existing
|
||||
self.jobs.append(job)
|
||||
return job
|
||||
|
||||
|
||||
def _source(source_id: UUID, kind: ConnectorKind = ConnectorKind.FOLDER) -> SourceRecord:
|
||||
return SourceRecord(
|
||||
id=source_id,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
name="Runbooks",
|
||||
kind=kind,
|
||||
status=SourceStatus.ACTIVE,
|
||||
config={"root": "/mnt/runbooks", "trust_level": "trusted_internal"},
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
registry: FakeRegistry, documents: FakeDocuments, blobs: FakeBlobs, jobs: RecordingJobs
|
||||
) -> SourceSyncService:
|
||||
return SourceSyncService(
|
||||
registry=registry, # type: ignore[arg-type]
|
||||
documents=documents, # type: ignore[arg-type]
|
||||
blob_storage=blobs, # type: ignore[arg-type]
|
||||
jobs=JobService(jobs), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_sync_ingests_every_item_as_an_ingest_upload_job() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "dir/b.md": b"bravo"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
|
||||
report = await _service(registry, documents, blobs, jobs).run(
|
||||
_source(source_id), connector, job_id=uuid4()
|
||||
)
|
||||
|
||||
# Enqueued is not ingested: the run cannot honestly say "completed" while
|
||||
# the child jobs that make the content searchable are still in flight.
|
||||
assert report.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
assert (report.run.discovered, report.run.new, report.run.changed, report.run.unchanged) == (
|
||||
2,
|
||||
2,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
assert report.run.removed == 0 and report.run.failed == 0
|
||||
assert report.run.pending == 2
|
||||
assert report.run.revision == "abc1234def"
|
||||
assert len(jobs.jobs) == 2
|
||||
for job in jobs.jobs:
|
||||
assert job.operation == "ingest_upload"
|
||||
assert job.payload["source_id"] == str(source_id)
|
||||
assert job.payload["space_id"] == str(SPACE_ID)
|
||||
assert job.payload["workspace_id"] == str(WORKSPACE_ID)
|
||||
assert job.payload["trust_level"] == "trusted_internal"
|
||||
assert job.payload["revision"] == "abc1234def"
|
||||
assert job.payload["external_id"] in {"a.md", "dir/b.md"}
|
||||
assert registry.states[-1] == (SourceSyncState.AWAITING_INGEST, False)
|
||||
assert {record.external_id for record in registry.items.values()} == {"a.md", "dir/b.md"}
|
||||
assert all(record.state is SourceItemState.PENDING for record in registry.items.values())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resync_skips_unchanged_ingests_changed_and_retires_removed() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "b.md": b"bravo", "c.md": b"charlie"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
first_jobs = len(jobs.jobs)
|
||||
retired_doc = documents.seed(source_id, "c.md")
|
||||
|
||||
connector.files["b.md"] = b"bravo v2"
|
||||
del connector.files["c.md"]
|
||||
connector.acquired.clear()
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
assert (
|
||||
report.run.discovered,
|
||||
report.run.new,
|
||||
report.run.changed,
|
||||
report.run.unchanged,
|
||||
report.run.removed,
|
||||
) == (2, 0, 1, 1, 1)
|
||||
assert connector.acquired == ["b.md"], "unchanged content is never re-acquired"
|
||||
assert len(jobs.jobs) == first_jobs + 1
|
||||
assert documents.retired == [retired_doc.id]
|
||||
assert report.retired_document_ids == (retired_doc.id,)
|
||||
assert registry.items[(source_id, "c.md")].removed is True
|
||||
assert registry.items[(source_id, "a.md")].removed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_page_discovery_is_still_complete_and_reconciles_removals() -> None:
|
||||
source_id = uuid4()
|
||||
files = {f"doc{i:02d}.md": f"body {i}".encode() for i in range(7)}
|
||||
connector = FakeConnector(source_id, files, page_size=3)
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
first = await service.run(_source(source_id), connector)
|
||||
assert first.run.discovered == 7
|
||||
|
||||
removed = documents.seed(source_id, "doc03.md")
|
||||
del connector.files["doc03.md"]
|
||||
second = await service.run(_source(source_id), connector)
|
||||
assert second.run.removed == 1
|
||||
assert documents.retired == [removed.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discovery_failure_records_a_failed_run_and_retires_nothing() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
documents.seed(source_id, "a.md")
|
||||
|
||||
connector.fail_scan = True
|
||||
with pytest.raises(Exception, match="discovery failed"):
|
||||
await service.run(_source(source_id), connector)
|
||||
|
||||
failed_runs = [run for run in registry.runs.values() if run.outcome is SourceSyncOutcome.FAILED]
|
||||
assert len(failed_runs) == 1
|
||||
assert failed_runs[0].error_code == "discovery_failed"
|
||||
assert documents.retired == [], "an incomplete discovery never retires documents"
|
||||
assert registry.states[-1] == (SourceSyncState.FAILED, False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_sources_are_not_syncable() -> None:
|
||||
source_id = uuid4()
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
with pytest.raises(Exception, match="only connector-backed"):
|
||||
await service.run(_source(source_id, ConnectorKind.UPLOAD), FakeConnector(source_id, {}))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_re_enqueueing_the_same_content_collapses_onto_one_job() -> None:
|
||||
"""Same source item + same fingerprint -> same idempotency key: a sync
|
||||
that re-sees content whose earlier job is still queued does not duplicate it."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
# Forget the registry memory (as if the first run's bookkeeping were lost)
|
||||
# and sync again: the job key still dedups.
|
||||
registry.items.clear()
|
||||
await service.run(_source(source_id), connector)
|
||||
assert len(jobs.jobs) == 1
|
||||
assert jobs.jobs[0].available_at <= datetime.now(UTC)
|
||||
|
||||
|
||||
async def _apply_all(
|
||||
registry: FakeRegistry, source_id: UUID, *, only: set[str] | None = None
|
||||
) -> None:
|
||||
"""Stand in for the child ingest jobs reporting success."""
|
||||
|
||||
now = datetime.now(UTC)
|
||||
for (sid, external_id), record in list(registry.items.items()):
|
||||
if sid == source_id and not record.removed and (only is None or external_id in only):
|
||||
await registry.mark_item_ingested(
|
||||
source_id,
|
||||
external_id,
|
||||
fingerprint=record.fingerprint,
|
||||
document_id=uuid4(),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_source_only_completes_once_every_child_ingestion_applied() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "b.md": b"bravo"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
|
||||
first = await service.run(_source(source_id), connector)
|
||||
assert first.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
|
||||
await _apply_all(registry, source_id)
|
||||
second = await service.run(_source(source_id), connector)
|
||||
|
||||
assert second.run.outcome is SourceSyncOutcome.COMPLETED
|
||||
assert (second.run.unchanged, second.run.pending, second.run.failed) == (2, 0, 0)
|
||||
assert registry.states[-1] == (SourceSyncState.COMPLETED, True)
|
||||
assert len(jobs.jobs) == 2, "nothing is re-enqueued once the content is applied"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unchanged_item_keeps_the_fingerprint_its_ingestion_applied() -> None:
|
||||
"""The regression that makes convergence meaningless: a re-sync that
|
||||
rebuilds the remembered record loses ``applied_fingerprint`` and every
|
||||
already-searchable item silently reverts to pending."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
applied_at = registry.items[(source_id, "a.md")].applied_at
|
||||
|
||||
await service.run(_source(source_id), connector)
|
||||
|
||||
record = registry.items[(source_id, "a.md")]
|
||||
assert record.applied is True
|
||||
assert record.state is SourceItemState.APPLIED
|
||||
assert record.applied_at == applied_at, "a no-op re-sync must not restamp convergence"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_failed_child_ingestion_settles_the_source_with_failures() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "b.md": b"bravo"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
|
||||
# "a.md" became searchable; "b.md"'s ingestion failed for good.
|
||||
await _apply_all(registry, source_id, only={"a.md"})
|
||||
await registry.mark_item_failed(
|
||||
source_id,
|
||||
"b.md",
|
||||
fingerprint=registry.items[(source_id, "b.md")].fingerprint,
|
||||
failure_code="security_quarantined",
|
||||
retryable=False,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
assert report.run.outcome is SourceSyncOutcome.COMPLETED_WITH_FAILURES
|
||||
assert (report.run.failed, report.run.pending) == (1, 0)
|
||||
assert report.run.error_code == "partial_failures"
|
||||
assert registry.states[-1] == (SourceSyncState.COMPLETED_WITH_FAILURES, False)
|
||||
assert registry.items[(source_id, "b.md")].state is SourceItemState.FAILED
|
||||
assert len(jobs.jobs) == 2, (
|
||||
"a terminally failed fingerprint is not re-enqueued: the child job for "
|
||||
"that exact content already exists and will never report again"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_from_a_terminal_failure_runs_through_the_child_job_not_a_resync() -> None:
|
||||
"""The full recovery loop for content that has not changed.
|
||||
|
||||
The chosen semantics are that a terminally failed item whose *discovered*
|
||||
fingerprint is unchanged is never re-enqueued by a later sync -- the child
|
||||
job for that exact content already exists, so re-enqueueing would either
|
||||
collapse onto it or spin. That is a deliberate choice and it only holds up
|
||||
if the failure is recoverable some other way, so this test walks the whole
|
||||
path: fail -> the failure names its job -> sync again changes nothing ->
|
||||
that job is retried -> the item converges.
|
||||
|
||||
Without the last two steps the "no re-enqueue" rule would be a permanent
|
||||
dead end dressed up as a policy.
|
||||
"""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
|
||||
first = await service.run(_source(source_id), connector)
|
||||
ingest_job_id = first.enqueued_job_ids[0]
|
||||
fingerprint = registry.items[(source_id, "a.md")].fingerprint
|
||||
|
||||
# The child ingestion fails terminally, naming the job that failed.
|
||||
await registry.mark_item_failed(
|
||||
source_id,
|
||||
"a.md",
|
||||
fingerprint=fingerprint,
|
||||
failure_code="embedding_failed",
|
||||
retryable=True,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
failed = registry.items[(source_id, "a.md")]
|
||||
assert failed.state is SourceItemState.FAILED
|
||||
assert failed.applied is False
|
||||
|
||||
# Syncing again is honest about it: nothing new is enqueued, the item is
|
||||
# still failed, and the source does not claim to have completed cleanly.
|
||||
again = await service.run(_source(source_id), connector)
|
||||
assert len(jobs.jobs) == 1, "the same terminal fingerprint is never re-enqueued"
|
||||
assert again.run.outcome is SourceSyncOutcome.COMPLETED_WITH_FAILURES
|
||||
assert again.run.failed == 1
|
||||
assert registry.items[(source_id, "a.md")].state is SourceItemState.FAILED
|
||||
|
||||
# Recovery: an authorized operator retries that child ingest job, and this
|
||||
# time it reports success for the same fingerprint.
|
||||
await registry.mark_item_ingested(
|
||||
source_id,
|
||||
"a.md",
|
||||
fingerprint=fingerprint,
|
||||
document_id=uuid4(),
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
recovered = registry.items[(source_id, "a.md")]
|
||||
assert recovered.applied_fingerprint == fingerprint
|
||||
assert recovered.applied is True
|
||||
assert recovered.state is SourceItemState.APPLIED
|
||||
assert recovered.last_failure_code is None
|
||||
summary = await registry.summarize_items(source_id)
|
||||
assert (summary.failed, summary.pending) == (0, 0)
|
||||
assert summary.converged is True
|
||||
assert ingest_job_id is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_terminal_failure_remembers_the_job_an_operator_must_retry() -> None:
|
||||
""""Retry the failed ingest job" needs an actual job to point at."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
report = await service.run(_source(source_id), connector)
|
||||
job_id = report.enqueued_job_ids[0]
|
||||
|
||||
await registry.mark_item_failed(
|
||||
source_id,
|
||||
"a.md",
|
||||
fingerprint=registry.items[(source_id, "a.md")].fingerprint,
|
||||
failure_code="embedding_failed",
|
||||
retryable=True,
|
||||
now=datetime.now(UTC),
|
||||
job_id=job_id,
|
||||
)
|
||||
|
||||
assert registry.items[(source_id, "a.md")].last_failure_job_id == job_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_changed_content_clears_a_previous_failure_and_keeps_what_was_applied() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
applied = registry.items[(source_id, "a.md")].applied_fingerprint
|
||||
|
||||
connector.files["a.md"] = b"alpha v2"
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
record = registry.items[(source_id, "a.md")]
|
||||
assert report.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
assert record.state is SourceItemState.PENDING
|
||||
assert record.applied is False
|
||||
assert record.applied_fingerprint == applied, (
|
||||
"the old content stays searchable until the new content is really applied"
|
||||
)
|
||||
assert record.pending_job_id == report.enqueued_job_ids[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_retired_item_counts_as_converged_not_as_outstanding_work() -> None:
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "gone.md": b"gone"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
|
||||
del connector.files["gone.md"]
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
assert report.run.outcome is SourceSyncOutcome.COMPLETED
|
||||
assert report.run.removed == 1
|
||||
summary = await registry.summarize_items(source_id)
|
||||
assert (summary.removed, summary.applied, summary.pending, summary.failed) == (1, 1, 0, 0)
|
||||
assert summary.converged is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_retired_item_that_reappears_unchanged_is_pending_again() -> None:
|
||||
"""Retirement records "no searchable content" as the *applied* state. When
|
||||
the file comes back -- even byte-identical -- that applied state is stale:
|
||||
the document is still retired until the re-enqueued ingest reactivates it,
|
||||
so carrying the retirement's fingerprint forward would report the source
|
||||
converged while the reactivation is still in flight."""
|
||||
|
||||
source_id = uuid4()
|
||||
connector = FakeConnector(source_id, {"a.md": b"alpha", "back.md": b"returns"})
|
||||
registry, documents, blobs, jobs = FakeRegistry(), FakeDocuments(), FakeBlobs(), RecordingJobs()
|
||||
service = _service(registry, documents, blobs, jobs)
|
||||
await service.run(_source(source_id), connector)
|
||||
await _apply_all(registry, source_id)
|
||||
|
||||
returning = dict(connector.files)
|
||||
del connector.files["back.md"]
|
||||
await service.run(_source(source_id), connector)
|
||||
assert registry.items[(source_id, "back.md")].state is SourceItemState.REMOVED
|
||||
|
||||
connector.files = returning
|
||||
report = await service.run(_source(source_id), connector)
|
||||
|
||||
record = registry.items[(source_id, "back.md")]
|
||||
assert record.removed is False
|
||||
assert record.state is SourceItemState.PENDING
|
||||
assert record.applied is False
|
||||
assert report.run.outcome is SourceSyncOutcome.AWAITING_INGEST
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Unit coverage for deterministic structure-aware chunking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from ragcore.domain.sources import SourceFingerprint
|
||||
from ragcore.ingestion.chunking import ChunkProfile, StructureAwareChunker
|
||||
from ragcore.ingestion.parsing import (
|
||||
CanonicalArtifact,
|
||||
CanonicalBlock,
|
||||
CanonicalBlockKind,
|
||||
ExtractionReport,
|
||||
)
|
||||
|
||||
|
||||
def artifact() -> CanonicalArtifact:
|
||||
return CanonicalArtifact(
|
||||
document_version_id=uuid4(),
|
||||
source_fingerprint=SourceFingerprint("c" * 64),
|
||||
blocks=(
|
||||
CanonicalBlock(
|
||||
CanonicalBlockKind.HEADING,
|
||||
"/blocks/0000",
|
||||
"Steps",
|
||||
{"line_start": 1, "line_end": 1},
|
||||
),
|
||||
CanonicalBlock(
|
||||
CanonicalBlockKind.PARAGRAPH,
|
||||
"/blocks/0001",
|
||||
"Do the thing.",
|
||||
{"line_start": 2, "line_end": 2},
|
||||
),
|
||||
CanonicalBlock(
|
||||
CanonicalBlockKind.HEADING,
|
||||
"/blocks/0002",
|
||||
"Code",
|
||||
{"line_start": 4, "line_end": 4},
|
||||
),
|
||||
CanonicalBlock(
|
||||
CanonicalBlockKind.CODE,
|
||||
"/blocks/0003",
|
||||
"def first():\n return 1\n\nclass Second:\n pass",
|
||||
{"line_start": 5, "line_end": 9},
|
||||
),
|
||||
CanonicalBlock(
|
||||
CanonicalBlockKind.TABLE,
|
||||
"/blocks/0004",
|
||||
"| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |",
|
||||
{"line_start": 11, "line_end": 14},
|
||||
),
|
||||
),
|
||||
extraction_report=ExtractionReport("text-parser", "1", "text-v1", False),
|
||||
)
|
||||
|
||||
|
||||
def test_same_canonical_input_and_profile_has_identical_ids() -> None:
|
||||
source = artifact()
|
||||
first = StructureAwareChunker().chunk(source, ChunkProfile.PROSE)
|
||||
second = StructureAwareChunker().chunk(source, ChunkProfile.PROSE)
|
||||
|
||||
assert [chunk.id for chunk in first.chunks] == [chunk.id for chunk in second.chunks]
|
||||
assert [chunk.content for chunk in first.chunks] == [
|
||||
chunk.content for chunk in second.chunks
|
||||
]
|
||||
|
||||
|
||||
def test_section_symbol_and_table_boundaries_have_parent_links() -> None:
|
||||
result = StructureAwareChunker().chunk(artifact(), ChunkProfile.CODE)
|
||||
|
||||
children = [chunk for chunk in result.chunks if chunk.parent_id is not None]
|
||||
assert any("/symbol/0000" in chunk.structure_path for chunk in children)
|
||||
assert any("/symbol/0001" in chunk.structure_path for chunk in children)
|
||||
assert all(
|
||||
chunk.parent_id
|
||||
in {parent.id for parent in result.chunks if parent.parent_id is None}
|
||||
for chunk in children
|
||||
)
|
||||
|
||||
table_result = StructureAwareChunker().chunk(artifact(), ChunkProfile.TABLE)
|
||||
table_children = [
|
||||
chunk
|
||||
for chunk in table_result.chunks
|
||||
if chunk.source_structure_path == "/blocks/0004"
|
||||
]
|
||||
assert table_children
|
||||
assert all("| A | B |" in chunk.content for chunk in table_children)
|
||||
|
||||
|
||||
def test_token_maxima_and_profile_change_are_enforced_in_identity() -> None:
|
||||
source = artifact()
|
||||
long_block = CanonicalBlock(
|
||||
CanonicalBlockKind.PARAGRAPH,
|
||||
"/blocks/long",
|
||||
" ".join(f"word{index}" for index in range(2405)),
|
||||
{"line_start": 1, "line_end": 2405},
|
||||
)
|
||||
source = CanonicalArtifact(
|
||||
source.document_version_id,
|
||||
source.source_fingerprint,
|
||||
(long_block,),
|
||||
source.extraction_report,
|
||||
)
|
||||
prose = StructureAwareChunker().chunk(source, ChunkProfile.PROSE)
|
||||
runbook = StructureAwareChunker().chunk(source, ChunkProfile.RUNBOOK)
|
||||
|
||||
assert all(chunk.token_count <= 1200 for chunk in prose.chunks)
|
||||
assert all(chunk.token_count <= 1200 for chunk in runbook.chunks)
|
||||
assert {chunk.id for chunk in prose.chunks}.isdisjoint(
|
||||
{chunk.id for chunk in runbook.chunks}
|
||||
)
|
||||
assert all(
|
||||
chunk.parent_id is not None
|
||||
for chunk in prose.chunks
|
||||
if chunk.metadata["role"] == "child"
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Control-plane domain invariants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.control import (
|
||||
CrossWorkspaceRelationship,
|
||||
InvalidEntity,
|
||||
Role,
|
||||
Scope,
|
||||
Workspace,
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_creates_typed_control_plane_entities() -> None:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
application = workspace.create_application(
|
||||
id=uuid4(), name="Search client", scopes={Scope.SEARCH, Scope.DOCUMENTS_READ}
|
||||
)
|
||||
space = workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Runbooks", slug="runbooks", retention_days=30
|
||||
)
|
||||
service_account = application.create_service_account(
|
||||
id=uuid4(), name="worker", scopes={Scope.SEARCH}
|
||||
)
|
||||
policy = workspace.create_policy(
|
||||
id=uuid4(), operation="search", knowledge_space=space, required_scopes={Scope.SEARCH}
|
||||
)
|
||||
grant = workspace.grant(
|
||||
id=uuid4(), application=application, knowledge_space=space, role=Role.VIEWER
|
||||
)
|
||||
|
||||
assert application.workspace_id == workspace.id
|
||||
assert service_account.application_id == application.id
|
||||
assert service_account.workspace_id == workspace.id
|
||||
assert policy.knowledge_space_id == space.id
|
||||
assert grant.knowledge_space_id == space.id
|
||||
|
||||
|
||||
def test_service_account_cannot_escalate_application_scopes() -> None:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
application = workspace.create_application(id=uuid4(), name="Search", scopes={Scope.SEARCH})
|
||||
|
||||
with pytest.raises(InvalidEntity, match="scopes"):
|
||||
application.create_service_account(
|
||||
id=uuid4(), name="overreach", scopes={Scope.SEARCH, Scope.ANSWER}
|
||||
)
|
||||
|
||||
|
||||
def test_cross_workspace_grant_is_rejected() -> None:
|
||||
first = Workspace(uuid4(), "First", "first")
|
||||
second = Workspace(uuid4(), "Second", "second")
|
||||
application = first.create_application(id=uuid4(), name="Client", scopes={Scope.SEARCH})
|
||||
space = second.create_knowledge_space(id=uuid4(), name="Private", slug="private")
|
||||
|
||||
with pytest.raises(CrossWorkspaceRelationship, match="same workspace"):
|
||||
first.grant(id=uuid4(), application=application, knowledge_space=space, role=Role.VIEWER)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("slug", ["Upper", "with space", "-leading", "trailing-"])
|
||||
def test_workspace_and_space_slugs_are_normalized_by_contract(slug: str) -> None:
|
||||
with pytest.raises(InvalidEntity, match="slug"):
|
||||
Workspace(uuid4(), "Invalid", slug)
|
||||
|
||||
|
||||
def test_retention_policy_must_be_positive() -> None:
|
||||
workspace = Workspace(uuid4(), "Platform", "platform")
|
||||
|
||||
with pytest.raises(InvalidEntity, match="retention_days"):
|
||||
workspace.create_knowledge_space(
|
||||
id=uuid4(), name="Runbooks", slug="runbooks", retention_days=0
|
||||
)
|
||||
@@ -0,0 +1,259 @@
|
||||
"""ClaimCitationValidator: schema, citation and claim-grounding validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.generation.validation import (
|
||||
ClaimCitationValidator,
|
||||
MalformedOutputError,
|
||||
UnknownCitationError,
|
||||
UnsupportedClaimError,
|
||||
ValidationRetriesExhaustedError,
|
||||
)
|
||||
from ragcore.retrieval.context.models import AnswerabilityStatus, ContextBlock, ContextPackage
|
||||
|
||||
WORKSPACE_ID = UUID("00000000-0000-0000-0000-000000000001")
|
||||
SPACE_ID = UUID("00000000-0000-0000-0000-000000000002")
|
||||
RUN_ID = UUID("00000000-0000-0000-0000-000000000003")
|
||||
|
||||
|
||||
def _citation(excerpt: str) -> Citation:
|
||||
return Citation.create(
|
||||
workspace_id=WORKSPACE_ID,
|
||||
space_id=SPACE_ID,
|
||||
document_id=uuid4(),
|
||||
document_version_id=uuid4(),
|
||||
chunk_id=uuid4(),
|
||||
parent_chunk_id=None,
|
||||
location={"page_start": 1},
|
||||
excerpt=excerpt,
|
||||
source_uri="https://example.test/doc.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=RUN_ID,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
|
||||
def _package(*, excerpts: tuple[str, ...]) -> ContextPackage:
|
||||
citations = [_citation(excerpt) for excerpt in excerpts]
|
||||
blocks = tuple(
|
||||
ContextBlock(
|
||||
text=citation.excerpt,
|
||||
citation_ids=(citation.id,),
|
||||
token_count=max(1, len(citation.excerpt.split())),
|
||||
document_id=citation.document_id,
|
||||
chunk_id=citation.chunk_id,
|
||||
)
|
||||
for citation in citations
|
||||
)
|
||||
return ContextPackage(
|
||||
retrieval_run_id=RUN_ID,
|
||||
answerability=AnswerabilityStatus.ANSWERABLE,
|
||||
answerability_reasons=("test_fixture",),
|
||||
blocks=blocks,
|
||||
citations=tuple(citations),
|
||||
token_count=sum(block.token_count for block in blocks),
|
||||
effective_space_ids=(SPACE_ID,),
|
||||
)
|
||||
|
||||
|
||||
EVIDENCE_EXCERPT = (
|
||||
"The quarterly maintenance window for the payment gateway runs every "
|
||||
"second Sunday from midnight to four in the morning Central European time."
|
||||
)
|
||||
|
||||
|
||||
def _raw(*, status: str, answer: str, citations: list[str]) -> str:
|
||||
return json.dumps({"status": status, "answer": answer, "citations": citations})
|
||||
|
||||
|
||||
def test_grounded_answer_with_known_citations_passes() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
citation_id = package.citations[0].id
|
||||
raw_output = _raw(
|
||||
status="answerable",
|
||||
answer=(
|
||||
"The quarterly maintenance window for the payment gateway runs "
|
||||
"every second Sunday from midnight to four in the morning."
|
||||
),
|
||||
citations=[str(citation_id)],
|
||||
)
|
||||
validated = ClaimCitationValidator().parse_and_validate(raw_output, context=package)
|
||||
assert validated.status is AnswerabilityStatus.ANSWERABLE
|
||||
assert validated.citation_ids == (citation_id,)
|
||||
|
||||
|
||||
def test_unknown_citation_is_rejected() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
raw_output = _raw(
|
||||
status="answerable",
|
||||
answer="The maintenance window runs every second Sunday.",
|
||||
citations=[str(uuid4())],
|
||||
)
|
||||
with pytest.raises(UnknownCitationError):
|
||||
ClaimCitationValidator().parse_and_validate(raw_output, context=package)
|
||||
|
||||
|
||||
def test_forbidden_citation_outside_this_answers_evidence_set_is_rejected() -> None:
|
||||
package_a = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
package_b = _package(excerpts=("unrelated other evidence text entirely",))
|
||||
foreign_citation_id = package_b.citations[0].id
|
||||
raw_output = _raw(
|
||||
status="answerable",
|
||||
answer="The maintenance window runs every second Sunday.",
|
||||
citations=[str(foreign_citation_id)],
|
||||
)
|
||||
with pytest.raises(UnknownCitationError):
|
||||
ClaimCitationValidator().parse_and_validate(raw_output, context=package_a)
|
||||
|
||||
|
||||
def test_unsupported_claim_sentence_is_rejected() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
citation_id = package.citations[0].id
|
||||
raw_output = _raw(
|
||||
status="answerable",
|
||||
answer=(
|
||||
"The maintenance window runs every second Sunday. "
|
||||
"The company headquarters relocated to Antarctica last year."
|
||||
),
|
||||
citations=[str(citation_id)],
|
||||
)
|
||||
with pytest.raises(UnsupportedClaimError, match="unsupported_claim"):
|
||||
ClaimCitationValidator().parse_and_validate(raw_output, context=package)
|
||||
|
||||
|
||||
def test_short_filler_sentences_are_never_flagged_as_claims() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
citation_id = package.citations[0].id
|
||||
raw_output = _raw(
|
||||
status="answerable",
|
||||
answer=(
|
||||
"Yes. Indeed. "
|
||||
"The quarterly maintenance window for the payment gateway runs "
|
||||
"every second Sunday from midnight to four in the morning."
|
||||
),
|
||||
citations=[str(citation_id)],
|
||||
)
|
||||
validated = ClaimCitationValidator().parse_and_validate(raw_output, context=package)
|
||||
assert validated.status is AnswerabilityStatus.ANSWERABLE
|
||||
|
||||
|
||||
def test_not_answerable_status_with_empty_answer_passes() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
raw_output = _raw(status="not_answerable", answer="", citations=[])
|
||||
validated = ClaimCitationValidator().parse_and_validate(raw_output, context=package)
|
||||
assert validated.status is AnswerabilityStatus.NOT_ANSWERABLE
|
||||
assert validated.answer == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw_output",
|
||||
[
|
||||
pytest.param("not json at all", id="invalid_json"),
|
||||
pytest.param("[]", id="json_array_not_object"),
|
||||
pytest.param(json.dumps({"answer": "x", "citations": []}), id="missing_status"),
|
||||
pytest.param(
|
||||
json.dumps({"status": "banana", "answer": "x", "citations": []}),
|
||||
id="invalid_status_value",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"status": "answerable", "answer": 5, "citations": []}),
|
||||
id="answer_wrong_type",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"status": "answerable", "answer": "x", "citations": "not-a-list"}),
|
||||
id="citations_wrong_type",
|
||||
),
|
||||
pytest.param(
|
||||
json.dumps({"status": "answerable", "answer": "x", "citations": ["not-a-uuid"]}),
|
||||
id="citation_not_a_uuid",
|
||||
),
|
||||
pytest.param("", id="empty_output"),
|
||||
],
|
||||
)
|
||||
def test_malformed_output_is_rejected(raw_output: str) -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
with pytest.raises(MalformedOutputError):
|
||||
ClaimCitationValidator().parse_and_validate(raw_output, context=package)
|
||||
|
||||
|
||||
async def test_validate_with_retries_succeeds_after_a_malformed_first_attempt() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
citation_id = package.citations[0].id
|
||||
attempts = {"count": 0}
|
||||
|
||||
async def generate() -> str:
|
||||
attempts["count"] += 1
|
||||
if attempts["count"] == 1:
|
||||
return "not json"
|
||||
return _raw(
|
||||
status="answerable",
|
||||
answer="The maintenance window runs every second Sunday.",
|
||||
citations=[str(citation_id)],
|
||||
)
|
||||
|
||||
validated = await ClaimCitationValidator().validate_with_retries(
|
||||
generate=generate, context=package, max_retries=2
|
||||
)
|
||||
assert validated.attempt == 2
|
||||
assert attempts["count"] == 2
|
||||
|
||||
|
||||
async def test_validate_with_retries_exhausts_and_fails_stable() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
attempts = {"count": 0}
|
||||
|
||||
async def generate() -> str:
|
||||
attempts["count"] += 1
|
||||
return "still not json"
|
||||
|
||||
with pytest.raises(ValidationRetriesExhaustedError) as excinfo:
|
||||
await ClaimCitationValidator().validate_with_retries(
|
||||
generate=generate, context=package, max_retries=1
|
||||
)
|
||||
assert excinfo.value.last_code == "malformed_output"
|
||||
# Exactly max_retries + 1 attempts -- bounded, never an unbounded retry loop.
|
||||
assert attempts["count"] == 2
|
||||
|
||||
|
||||
async def test_validate_with_retries_bounds_calls_even_for_content_failures() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
|
||||
async def generate() -> str:
|
||||
return _raw(
|
||||
status="answerable",
|
||||
answer="The company headquarters relocated to Antarctica last year.",
|
||||
citations=[],
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationRetriesExhaustedError) as excinfo:
|
||||
await ClaimCitationValidator().validate_with_retries(
|
||||
generate=generate, context=package, max_retries=0
|
||||
)
|
||||
assert excinfo.value.last_code == "unsupported_claim"
|
||||
|
||||
|
||||
def test_min_overlap_ratio_out_of_range_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="min_overlap_ratio"):
|
||||
ClaimCitationValidator(min_overlap_ratio=0.0)
|
||||
with pytest.raises(ValueError, match="min_overlap_ratio"):
|
||||
ClaimCitationValidator(min_overlap_ratio=1.5)
|
||||
|
||||
|
||||
async def test_negative_max_retries_is_rejected() -> None:
|
||||
package = _package(excerpts=(EVIDENCE_EXCERPT,))
|
||||
|
||||
async def generate() -> str:
|
||||
return "irrelevant"
|
||||
|
||||
with pytest.raises(ValueError, match="max_retries"):
|
||||
await ClaimCitationValidator().validate_with_retries(
|
||||
generate=generate, context=package, max_retries=-1
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from dataclasses import replace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.indexes.models import (
|
||||
IndexBuildError,
|
||||
MalformedIndexPoint,
|
||||
StoredIndexPoint,
|
||||
index_point_fingerprint,
|
||||
)
|
||||
from ragcore.application.indexes.shadow_migration import (
|
||||
ShadowEmbeddingMigrationService,
|
||||
ShadowMigrationCheckpoint,
|
||||
ShadowMigrationStatus,
|
||||
ShadowPreflight,
|
||||
)
|
||||
from ragcore.infrastructure.qdrant.models import (
|
||||
CollectionKind,
|
||||
CollectionProfile,
|
||||
IndexPayload,
|
||||
IndexPoint,
|
||||
)
|
||||
|
||||
|
||||
class MemoryCheckpoints:
|
||||
def __init__(self, *, cancel_after_batch: bool = False) -> None:
|
||||
self.item: ShadowMigrationCheckpoint | None = None
|
||||
self.cancel_after_batch = cancel_after_batch
|
||||
|
||||
async def load(self, _migration_id: UUID) -> ShadowMigrationCheckpoint | None:
|
||||
return self.item
|
||||
|
||||
async def save(self, checkpoint: ShadowMigrationCheckpoint) -> None:
|
||||
if self.cancel_after_batch and checkpoint.request_batches == 1:
|
||||
checkpoint = replace(checkpoint, cancel_requested=True)
|
||||
self.item = checkpoint
|
||||
|
||||
|
||||
class Source:
|
||||
def __init__(self, points: Sequence[IndexPoint]) -> None:
|
||||
self.points = points
|
||||
|
||||
async def iter_points(self, _profile: CollectionProfile) -> AsyncIterator[IndexPoint]:
|
||||
for point in self.points:
|
||||
yield point
|
||||
|
||||
|
||||
class Store:
|
||||
def __init__(self, *, fail_once: bool = False, omit_last: bool = False) -> None:
|
||||
self.points: dict[UUID, StoredIndexPoint] = {}
|
||||
self.fail_once = fail_once
|
||||
self.omit_last = omit_last
|
||||
self.alias = "rag_dense_legacy_v1"
|
||||
self.alias_mutations = 0
|
||||
self.upsert_calls = 0
|
||||
|
||||
async def create_shadow(self, _profile: CollectionProfile) -> None:
|
||||
return None
|
||||
|
||||
async def delete_shadow(self, _profile: CollectionProfile) -> None:
|
||||
raise AssertionError("validated shadow is retained")
|
||||
|
||||
async def upsert_points(
|
||||
self, _profile: CollectionProfile, points: Sequence[IndexPoint]
|
||||
) -> None:
|
||||
self.upsert_calls += 1
|
||||
if self.fail_once:
|
||||
self.fail_once = False
|
||||
raise TimeoutError("transient")
|
||||
for point in points:
|
||||
self.points[point.point_id] = StoredIndexPoint(
|
||||
point.point_id, index_point_fingerprint(point)
|
||||
)
|
||||
|
||||
async def delete_points(self, _profile: CollectionProfile, _point_ids: Sequence[UUID]) -> None:
|
||||
raise AssertionError("no destructive cleanup")
|
||||
|
||||
async def delete_raw_points(self, _profile: CollectionProfile, _raw_ids: Sequence[str]) -> None:
|
||||
raise AssertionError("no destructive cleanup")
|
||||
|
||||
async def count_points(self, _profile: CollectionProfile) -> int:
|
||||
return len(self.points)
|
||||
|
||||
async def list_points(
|
||||
self, _profile: CollectionProfile
|
||||
) -> tuple[tuple[StoredIndexPoint, ...], tuple[MalformedIndexPoint, ...]]:
|
||||
values = tuple(self.points.values())
|
||||
return (values[:-1] if self.omit_last and values else values), ()
|
||||
|
||||
async def current_alias_target(self, _alias_name: str) -> str:
|
||||
return self.alias
|
||||
|
||||
async def activate(self, _profile: CollectionProfile) -> None:
|
||||
self.alias_mutations += 1
|
||||
|
||||
async def rollback(self, _alias_name: str, _previous_collection: str | None) -> None:
|
||||
self.alias_mutations += 1
|
||||
|
||||
|
||||
def profile() -> CollectionProfile:
|
||||
return CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="modelforge-shadow",
|
||||
version="v1",
|
||||
dimensions=3,
|
||||
model_digest="a" * 64,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def points(item: CollectionProfile, count: int = 3) -> tuple[IndexPoint, ...]:
|
||||
workspace, space, document, version, source = (uuid4() for _ in range(5))
|
||||
return tuple(
|
||||
IndexPoint(
|
||||
point_id=(chunk := uuid4()),
|
||||
dense_vector=(1.0, 0.0, 0.0),
|
||||
payload=IndexPayload(
|
||||
workspace_id=workspace,
|
||||
space_id=space,
|
||||
document_id=document,
|
||||
document_version_id=version,
|
||||
chunk_id=chunk,
|
||||
parent_chunk_id=None,
|
||||
source_id=source,
|
||||
status="published",
|
||||
content_type="text/plain",
|
||||
language="en",
|
||||
trust_level="internal",
|
||||
location={"structure_path": f"/{index}"},
|
||||
parser_version="parser-v1",
|
||||
chunk_profile_version=item.version,
|
||||
embedding_model_digest=item.model_digest,
|
||||
text=f"chunk {index}",
|
||||
excerpt=f"chunk {index}",
|
||||
embedding_space_id=item.embedding_space_id,
|
||||
),
|
||||
)
|
||||
for index in range(count)
|
||||
)
|
||||
|
||||
|
||||
def checkpoint(item: CollectionProfile, total: int = 3) -> ShadowMigrationCheckpoint:
|
||||
assert item.embedding_space_id is not None
|
||||
return ShadowMigrationCheckpoint(
|
||||
migration_id=uuid4(),
|
||||
source_index="rag_dense_legacy_v1",
|
||||
target_index=item.collection_name,
|
||||
source_embedding_space="legacy-observed",
|
||||
target_embedding_space_id=item.embedding_space_id,
|
||||
corpus_revision="corpus-sha256",
|
||||
total_chunks=total,
|
||||
)
|
||||
|
||||
|
||||
async def preflight(*, passed: bool = True) -> ShadowPreflight:
|
||||
return ShadowPreflight(
|
||||
production_healthy=passed,
|
||||
source_available=True,
|
||||
modelforge_healthy=True,
|
||||
credential_valid=True,
|
||||
embedding_space_matches=True,
|
||||
dimension_matches=True,
|
||||
distance_metric_matches=True,
|
||||
qdrant_reachable=True,
|
||||
storage_sufficient=True,
|
||||
target_conflict_free=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preflight_failure_writes_no_vectors() -> None:
|
||||
item = profile()
|
||||
store, state = Store(), MemoryCheckpoints()
|
||||
service = ShadowEmbeddingMigrationService(store, state)
|
||||
with pytest.raises(IndexBuildError, match="preflight"):
|
||||
await service.run(
|
||||
checkpoint(item), item, Source(points(item)), lambda: preflight(passed=False)
|
||||
)
|
||||
assert not store.points
|
||||
assert state.item is not None and state.item.status is ShadowMigrationStatus.FAILED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backfill_is_bounded_validated_and_never_mutates_source_alias() -> None:
|
||||
item, store, state = profile(), Store(), MemoryCheckpoints()
|
||||
service = ShadowEmbeddingMigrationService(store, state, batch_size=2)
|
||||
result = await service.run(checkpoint(item), item, Source(points(item)), preflight)
|
||||
assert result.status is ShadowMigrationStatus.READY_FOR_EVALUATION
|
||||
assert result.completed_chunks == 3 and result.failed_chunks == 0
|
||||
assert result.request_batches == 2
|
||||
assert result.validation["passed"] is True
|
||||
assert result.validation["source_alias_unchanged"] is True
|
||||
assert store.alias_mutations == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_skips_integrity_valid_existing_chunk_and_is_idempotent() -> None:
|
||||
item, store, states = profile(), Store(), MemoryCheckpoints()
|
||||
source_points = points(item)
|
||||
existing = source_points[0]
|
||||
store.points[existing.point_id] = StoredIndexPoint(
|
||||
existing.point_id, index_point_fingerprint(existing)
|
||||
)
|
||||
initial = replace(
|
||||
checkpoint(item), status=ShadowMigrationStatus.BACKFILLING, completed_chunks=1
|
||||
)
|
||||
states.item = initial
|
||||
service = ShadowEmbeddingMigrationService(store, states, batch_size=1)
|
||||
result = await service.run(initial, item, Source(source_points), preflight)
|
||||
assert result.completed_chunks == 3
|
||||
assert store.upsert_calls == 2
|
||||
repeated = await service.run(initial, item, Source(source_points), preflight)
|
||||
assert repeated.status is ShadowMigrationStatus.READY_FOR_EVALUATION
|
||||
assert store.upsert_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_finishes_current_batch_and_remains_resumable() -> None:
|
||||
item, store, states = profile(), Store(), MemoryCheckpoints(cancel_after_batch=True)
|
||||
source_points = points(item)
|
||||
service = ShadowEmbeddingMigrationService(store, states, batch_size=1)
|
||||
cancelled = await service.run(checkpoint(item), item, Source(source_points), preflight)
|
||||
assert cancelled.status is ShadowMigrationStatus.CANCELLED
|
||||
assert cancelled.completed_chunks == 1
|
||||
states.cancel_after_batch = False
|
||||
resumed = await service.run(cancelled, item, Source(source_points), preflight)
|
||||
assert resumed.status is ShadowMigrationStatus.READY_FOR_EVALUATION
|
||||
assert resumed.completed_chunks == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_retry_is_bounded_and_counted() -> None:
|
||||
item, store, states = profile(), Store(fail_once=True), MemoryCheckpoints()
|
||||
service = ShadowEmbeddingMigrationService(
|
||||
store,
|
||||
states,
|
||||
batch_size=3,
|
||||
max_retries=1,
|
||||
retryable=lambda error: isinstance(error, TimeoutError),
|
||||
)
|
||||
result = await service.run(checkpoint(item), item, Source(points(item)), preflight)
|
||||
assert result.retried_chunks == 3
|
||||
assert store.upsert_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_point_blocks_evaluation_readiness() -> None:
|
||||
item, store, states = profile(), Store(omit_last=True), MemoryCheckpoints()
|
||||
service = ShadowEmbeddingMigrationService(store, states, batch_size=3)
|
||||
with pytest.raises(IndexBuildError, match="validation"):
|
||||
await service.run(checkpoint(item), item, Source(points(item)), preflight)
|
||||
assert states.item is not None and states.item.status is ShadowMigrationStatus.FAILED
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models.selection import EmbeddingCutoverRejected
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, EmbeddingSelection
|
||||
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
|
||||
from ragcore.infrastructure.models.selection_verifier import ModelForgeRouteSelectionVerifier
|
||||
|
||||
|
||||
def _profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="route-check",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=2,
|
||||
context_limit=4096,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
def _factory(client: httpx.AsyncClient, credential_file: Path):
|
||||
return create_embedding_adapter_factory(
|
||||
settings=Settings(
|
||||
env="test",
|
||||
modelforge_base_url="http://modelforge:8000",
|
||||
modelforge_credential_file=credential_file,
|
||||
modelforge_embedding_experiment_route="stale-global-route",
|
||||
),
|
||||
http_client=client,
|
||||
)
|
||||
|
||||
|
||||
def _write_credential(path: Path) -> None:
|
||||
path.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_probe_rejects_stale_route_embedding_space(tmp_path: Path) -> None:
|
||||
profile = _profile()
|
||||
selection = EmbeddingSelection.from_profile(profile, experiment_route_key="lab-route-current")
|
||||
credential_file = tmp_path / "credential"
|
||||
_write_credential(credential_file)
|
||||
paths: list[str] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
paths.append(request.url.path)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(uuid4()),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
) as client:
|
||||
verifier = ModelForgeRouteSelectionVerifier(_factory(client, credential_file))
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="identity probe"):
|
||||
await verifier.verify(selection, profile)
|
||||
|
||||
assert paths == ["/api/v1/capability-experiments/lab-route-current/invoke"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_probe_records_gateway_request_identity(tmp_path: Path) -> None:
|
||||
profile = _profile()
|
||||
selection = EmbeddingSelection.from_profile(profile, experiment_route_key="lab-route-next")
|
||||
credential_file = tmp_path / "credential"
|
||||
_write_credential(credential_file)
|
||||
request_id = uuid4()
|
||||
|
||||
def respond(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(request_id),
|
||||
},
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(respond), follow_redirects=False
|
||||
) as client:
|
||||
verifier = ModelForgeRouteSelectionVerifier(_factory(client, credential_file))
|
||||
result = await verifier.verify(selection, profile)
|
||||
|
||||
assert result.gateway_request_ids == (request_id,)
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from qdrant_client.http import models
|
||||
|
||||
from ragcore.application.models.selection import EmbeddingCutoverRejected
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import EmbeddingProfile, EmbeddingProvider, EmbeddingSelection
|
||||
from ragcore.infrastructure.qdrant.selection_verifier import QdrantEmbeddingSelectionVerifier
|
||||
|
||||
|
||||
def _profile() -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="selected-index",
|
||||
version="v2",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="b" * 64,
|
||||
dimensions=3,
|
||||
context_limit=4096,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
def __init__(self, *, dimension: int, payloads: list[dict[str, object]]) -> None:
|
||||
self.dimension = dimension
|
||||
self.payloads = payloads
|
||||
self.collections: list[str] = []
|
||||
|
||||
async def get_collection(self, collection_name: str) -> SimpleNamespace:
|
||||
self.collections.append(collection_name)
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={
|
||||
"dense": models.VectorParams(
|
||||
size=self.dimension, distance=models.Distance.COSINE
|
||||
)
|
||||
},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def scroll(self, **_kwargs: object) -> tuple[list[SimpleNamespace], None]:
|
||||
return [SimpleNamespace(payload=value) for value in self.payloads], None
|
||||
|
||||
|
||||
def _payload(profile: EmbeddingProfile) -> dict[str, object]:
|
||||
return {
|
||||
"embedding_model_digest": profile.model_digest,
|
||||
"chunk_profile_version": profile.version,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_verifier_rejects_a_stale_vector_dimension() -> None:
|
||||
profile = _profile()
|
||||
selection = EmbeddingSelection.from_profile(profile, experiment_route_key="lab-route-next")
|
||||
client = FakeQdrant(dimension=2, payloads=[_payload(profile)])
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="vector shape"):
|
||||
await QdrantEmbeddingSelectionVerifier(client).verify(selection, profile) # type: ignore[arg-type]
|
||||
|
||||
assert client.collections == ["rag_dense_selected-index_v2"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_verifier_rejects_stale_profile_identity_pins() -> None:
|
||||
profile = _profile()
|
||||
selection = EmbeddingSelection.from_profile(profile, experiment_route_key="lab-route-next")
|
||||
stale = {**_payload(profile), "embedding_space_id": str(uuid4())}
|
||||
client = FakeQdrant(dimension=3, payloads=[stale])
|
||||
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="embedding-space pin"):
|
||||
await QdrantEmbeddingSelectionVerifier(client).verify(selection, profile) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_verifier_counts_only_the_exact_pinned_collection() -> None:
|
||||
profile = _profile()
|
||||
selection = EmbeddingSelection.from_profile(profile)
|
||||
client = FakeQdrant(dimension=3, payloads=[_payload(profile), _payload(profile)])
|
||||
|
||||
result = await QdrantEmbeddingSelectionVerifier(client).verify(selection, profile) # type: ignore[arg-type]
|
||||
|
||||
assert client.collections == ["rag_dense_selected-index_v2"]
|
||||
assert result.verified_point_count == 2
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client.http import models
|
||||
from qdrant_client.http.exceptions import UnexpectedResponse
|
||||
|
||||
from ragcore.application.indexes.models import IndexBuildError
|
||||
from ragcore.infrastructure.qdrant.adapter import CollectionProfileMismatchError
|
||||
from ragcore.infrastructure.qdrant.models import CollectionKind, CollectionProfile
|
||||
from ragcore.infrastructure.qdrant.rebuild import QdrantRebuildStore
|
||||
|
||||
|
||||
class MissingQdrantCollection:
|
||||
def __init__(self) -> None:
|
||||
self.reads = 0
|
||||
self.creates = 0
|
||||
|
||||
async def get_collection(self, _name: str) -> object:
|
||||
self.reads += 1
|
||||
raise UnexpectedResponse(404, "Not Found", b"missing", httpx.Headers())
|
||||
|
||||
async def create_collection(self, **_kwargs: object) -> None:
|
||||
self.creates += 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_collection_verification_fails_without_recreating_missing_collection() -> (
|
||||
None
|
||||
):
|
||||
client = MissingQdrantCollection()
|
||||
profile = CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="selected-profile",
|
||||
version="v2",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
|
||||
with pytest.raises(CollectionProfileMismatchError, match="refusing to recreate"):
|
||||
await QdrantRebuildStore(client).verify_existing_selection(profile) # type: ignore[arg-type]
|
||||
|
||||
assert client.reads == 1
|
||||
assert client.creates == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_collection_verification_checks_every_existing_point_pin() -> None:
|
||||
space_id = uuid4()
|
||||
|
||||
class StalePinnedCollection:
|
||||
async def get_collection(self, _name: str) -> object:
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={
|
||||
"dense": models.VectorParams(size=4, distance=models.Distance.COSINE)
|
||||
},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def scroll(self, **_kwargs: object) -> tuple[list[SimpleNamespace], None]:
|
||||
return [
|
||||
SimpleNamespace(
|
||||
payload={
|
||||
"embedding_model_digest": "b" * 64,
|
||||
"chunk_profile_version": "v2",
|
||||
"embedding_space_id": str(space_id),
|
||||
}
|
||||
)
|
||||
], None
|
||||
|
||||
profile = CollectionProfile(
|
||||
kind=CollectionKind.DENSE,
|
||||
profile="selected-profile",
|
||||
version="v2",
|
||||
dimensions=4,
|
||||
model_digest="a" * 64,
|
||||
embedding_space_id=space_id,
|
||||
)
|
||||
|
||||
with pytest.raises(IndexBuildError, match="incompatible identity pins"):
|
||||
await QdrantRebuildStore(StalePinnedCollection()).verify_existing_selection( # type: ignore[arg-type]
|
||||
profile
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ragcore.ingestion.language import (
|
||||
detect_language,
|
||||
normalize_language_tag,
|
||||
resolve_document_language,
|
||||
)
|
||||
|
||||
_DUTCH = (
|
||||
"Dit is een handleiding voor het herstarten van de dienst. Controleer eerst "
|
||||
"of de gateway niet bereikbaar is en wacht daarna tien seconden voordat de "
|
||||
"schakelaar opnieuw wordt omgezet. Het systeem moet daarna weer normaal werken."
|
||||
)
|
||||
_ENGLISH = (
|
||||
"This is the runbook for restarting the service. First confirm that the "
|
||||
"gateway is not reachable and then wait ten seconds before you cycle the "
|
||||
"breaker again. The system should be healthy after that and the alert clears."
|
||||
)
|
||||
_FRENCH = (
|
||||
"Ce document décrit la procédure pour redémarrer le service. Vérifiez d'abord "
|
||||
"que la passerelle n'est pas joignable et attendez ensuite dix secondes avant "
|
||||
"de basculer le disjoncteur. Le système doit être de nouveau disponible."
|
||||
)
|
||||
_GERMAN = (
|
||||
"Dieses Dokument beschreibt das Neustarten des Dienstes. Prüfen Sie zuerst, "
|
||||
"ob das Gateway nicht erreichbar ist, und warten Sie danach zehn Sekunden, "
|
||||
"bevor der Schalter wieder umgelegt wird. Das System muss danach laufen."
|
||||
)
|
||||
|
||||
|
||||
def test_detects_each_supported_language() -> None:
|
||||
assert detect_language(_DUTCH) == "nl"
|
||||
assert detect_language(_ENGLISH) == "en"
|
||||
assert detect_language(_FRENCH) == "fr"
|
||||
assert detect_language(_GERMAN) == "de"
|
||||
|
||||
|
||||
def test_short_or_codeish_content_stays_undetected() -> None:
|
||||
assert detect_language("restart") is None
|
||||
assert detect_language("SELECT id FROM documents WHERE space_id = $1;") is None
|
||||
|
||||
|
||||
def test_normalize_language_tag_reduces_locales() -> None:
|
||||
assert normalize_language_tag("nl-BE") == "nl"
|
||||
assert normalize_language_tag("EN_us") == "en"
|
||||
assert normalize_language_tag("not a tag!") is None
|
||||
|
||||
|
||||
def test_resolution_priority_is_explicit_then_detected_then_fallback() -> None:
|
||||
assert resolve_document_language(explicit_locale="fr-FR", text=_DUTCH) == "fr"
|
||||
assert resolve_document_language(explicit_locale=None, text=_DUTCH) == "nl"
|
||||
assert resolve_document_language(explicit_locale="??", text="x") == "en"
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models import GenerationProfileRegistry
|
||||
from ragcore.domain.control.entities import DataPolicy
|
||||
from ragcore.domain.models import GenerationProfile, GenerationProvider
|
||||
from ragcore.infrastructure.models.answer_generator import (
|
||||
AnswerGenerationUnavailableError,
|
||||
OllamaAnswerGenerator,
|
||||
)
|
||||
|
||||
|
||||
class InMemoryGenerationProfileStore:
|
||||
def __init__(self) -> None:
|
||||
self._profiles: dict[UUID, GenerationProfile] = {}
|
||||
self._active_id: UUID | None = None
|
||||
|
||||
async def add(self, profile: GenerationProfile) -> None:
|
||||
self._profiles[profile.id] = profile
|
||||
|
||||
async def get(self, profile_id: UUID) -> GenerationProfile | None:
|
||||
return self._profiles.get(profile_id)
|
||||
|
||||
async def activate(self, profile_id: UUID) -> None:
|
||||
self._active_id = profile_id
|
||||
|
||||
async def get_active(self) -> GenerationProfile | None:
|
||||
return self._profiles.get(self._active_id) if self._active_id else None
|
||||
|
||||
|
||||
def _profile(**overrides: object) -> GenerationProfile:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "answer-hybrid",
|
||||
"version": "v1",
|
||||
"provider": GenerationProvider.OLLAMA,
|
||||
"model_identifier": "llama3.1:8b-instruct-q4_0",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 4096,
|
||||
"max_output_tokens": 512,
|
||||
"temperature": 0.2,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
"timeout_seconds": 5.0,
|
||||
"max_retries": 0,
|
||||
}
|
||||
values.update(overrides)
|
||||
return GenerationProfile(**values)
|
||||
|
||||
|
||||
def _ndjson(*lines: dict[str, object]) -> bytes:
|
||||
return b"\n".join(json.dumps(line).encode() for line in lines)
|
||||
|
||||
|
||||
def _client(handler) -> httpx.AsyncClient: # type: ignore[no-untyped-def]
|
||||
return httpx.AsyncClient(transport=httpx.MockTransport(handler), follow_redirects=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generates_text_from_the_active_profile() -> None:
|
||||
store = InMemoryGenerationProfileStore()
|
||||
registry = GenerationProfileRegistry(store)
|
||||
profile = await registry.register(_profile())
|
||||
await registry.activate(profile.id)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=_ndjson(
|
||||
{"response": "Restart the ", "done": False},
|
||||
{"response": "payment service.", "done": True},
|
||||
),
|
||||
)
|
||||
|
||||
generator = OllamaAnswerGenerator(registry, _client(handler), base_url="http://ollama")
|
||||
text = await generator.generate(
|
||||
prompt="How do I restart it?", generation_profile="anything", spaces=()
|
||||
)
|
||||
assert text == "Restart the payment service."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_no_active_profile() -> None:
|
||||
registry = GenerationProfileRegistry(InMemoryGenerationProfileStore())
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError("must not call Ollama without an active profile")
|
||||
|
||||
generator = OllamaAnswerGenerator(registry, _client(handler), base_url="http://ollama")
|
||||
with pytest.raises(AnswerGenerationUnavailableError):
|
||||
await generator.generate(prompt="anything", generation_profile="anything", spaces=())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_raises_when_the_provider_rejects_the_request() -> None:
|
||||
store = InMemoryGenerationProfileStore()
|
||||
registry = GenerationProfileRegistry(store)
|
||||
profile = await registry.register(_profile())
|
||||
await registry.activate(profile.id)
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(500, json={"error": "model not found"})
|
||||
|
||||
generator = OllamaAnswerGenerator(registry, _client(handler), base_url="http://ollama")
|
||||
with pytest.raises(AnswerGenerationUnavailableError):
|
||||
await generator.generate(prompt="anything", generation_profile="anything", spaces=())
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.control.entities import DataPolicy
|
||||
from ragcore.domain.models import GenerationProfile, GenerationProfileError, GenerationProvider
|
||||
|
||||
|
||||
def _profile(**overrides: object) -> GenerationProfile:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "answer-hybrid",
|
||||
"version": "v1",
|
||||
"provider": GenerationProvider.OLLAMA,
|
||||
"model_identifier": "llama3.1:8b-instruct-q4_0",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 8192,
|
||||
"max_output_tokens": 1024,
|
||||
"temperature": 0.4,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
}
|
||||
values.update(overrides)
|
||||
return GenerationProfile(**values)
|
||||
|
||||
|
||||
def test_valid_profile_is_accepted() -> None:
|
||||
profile = _profile()
|
||||
assert profile.provider is GenerationProvider.OLLAMA
|
||||
assert profile.data_policy is DataPolicy.LOCAL_ONLY
|
||||
|
||||
|
||||
def test_profile_rejects_nil_id() -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(id=UUID(int=0))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_id", ["not a safe id!", "Also-Not-Safe", ""])
|
||||
def test_profile_rejects_unsafe_profile_or_version(bad_id: str) -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(profile=bad_id)
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(version=bad_id)
|
||||
|
||||
|
||||
def test_profile_rejects_floating_model_tags() -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(model_identifier="llama3.1:latest")
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(model_identifier="llama3.1:stable")
|
||||
|
||||
|
||||
def test_profile_requires_a_pinned_sha256_digest() -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(model_digest="not-a-digest")
|
||||
|
||||
|
||||
def test_max_output_tokens_must_not_exceed_context_limit() -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(context_limit=100, max_output_tokens=200)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("temperature", [-0.1, 2.1, float("nan"), float("inf")])
|
||||
def test_temperature_must_be_within_bounds(temperature: float) -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(temperature=temperature)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("policy", [DataPolicy.RETRIEVAL_ONLY, DataPolicy.NO_GENERATION])
|
||||
def test_profile_rejects_space_only_data_policies(policy: DataPolicy) -> None:
|
||||
with pytest.raises(GenerationProfileError):
|
||||
_profile(data_policy=policy)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"policy", [DataPolicy.LOCAL_ONLY, DataPolicy.LOCAL_PREFERRED, DataPolicy.CLOUD_ALLOWED]
|
||||
)
|
||||
def test_profile_accepts_generation_eligible_data_policies(policy: DataPolicy) -> None:
|
||||
profile = _profile(data_policy=policy)
|
||||
assert profile.data_policy is policy
|
||||
|
||||
|
||||
def test_resolve_data_policy_never_broadens_beyond_the_profile() -> None:
|
||||
profile = _profile(data_policy=DataPolicy.LOCAL_ONLY)
|
||||
assert profile.resolve_data_policy(DataPolicy.CLOUD_ALLOWED) is DataPolicy.LOCAL_ONLY
|
||||
assert profile.resolve_data_policy(DataPolicy.LOCAL_PREFERRED) is DataPolicy.LOCAL_ONLY
|
||||
assert profile.resolve_data_policy(DataPolicy.LOCAL_ONLY) is DataPolicy.LOCAL_ONLY
|
||||
assert profile.resolve_data_policy(None) is DataPolicy.LOCAL_ONLY
|
||||
|
||||
|
||||
def test_resolve_data_policy_can_narrow_a_looser_profile() -> None:
|
||||
profile = _profile(data_policy=DataPolicy.CLOUD_ALLOWED)
|
||||
assert profile.resolve_data_policy(DataPolicy.LOCAL_ONLY) is DataPolicy.LOCAL_ONLY
|
||||
assert profile.resolve_data_policy(None) is DataPolicy.CLOUD_ALLOWED
|
||||
|
||||
|
||||
def test_resolve_data_policy_rejects_a_non_generation_request() -> None:
|
||||
profile = _profile()
|
||||
with pytest.raises(GenerationProfileError):
|
||||
profile.resolve_data_policy(DataPolicy.NO_GENERATION)
|
||||
|
||||
|
||||
def test_resolve_temperature_never_exceeds_the_profile_ceiling() -> None:
|
||||
profile = _profile(temperature=0.4)
|
||||
assert profile.resolve_temperature(1.5) == pytest.approx(0.4)
|
||||
assert profile.resolve_temperature(0.1) == pytest.approx(0.1)
|
||||
assert profile.resolve_temperature(None) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_resolve_temperature_rejects_invalid_requests() -> None:
|
||||
profile = _profile()
|
||||
with pytest.raises(GenerationProfileError):
|
||||
profile.resolve_temperature(-1.0)
|
||||
with pytest.raises(GenerationProfileError):
|
||||
profile.resolve_temperature(float("nan"))
|
||||
|
||||
|
||||
def test_resolve_max_output_tokens_never_exceeds_the_profile_cap() -> None:
|
||||
profile = _profile(max_output_tokens=1024)
|
||||
assert profile.resolve_max_output_tokens(4096) == 1024
|
||||
assert profile.resolve_max_output_tokens(256) == 256
|
||||
assert profile.resolve_max_output_tokens(None) == 1024
|
||||
|
||||
|
||||
def test_resolve_max_output_tokens_rejects_invalid_requests() -> None:
|
||||
profile = _profile()
|
||||
with pytest.raises(GenerationProfileError):
|
||||
profile.resolve_max_output_tokens(0)
|
||||
with pytest.raises(GenerationProfileError):
|
||||
profile.resolve_max_output_tokens(-5)
|
||||
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models import (
|
||||
DuplicateGenerationProfileError,
|
||||
GenerationProfileNotFoundError,
|
||||
GenerationProfileRegistry,
|
||||
)
|
||||
from ragcore.domain.control.entities import DataPolicy
|
||||
from ragcore.domain.models import GenerationProfile, GenerationProvider
|
||||
|
||||
|
||||
class InMemoryGenerationProfileStore:
|
||||
def __init__(self) -> None:
|
||||
self._profiles: dict[UUID, GenerationProfile] = {}
|
||||
self._active_id: UUID | None = None
|
||||
|
||||
async def add(self, profile: GenerationProfile) -> None:
|
||||
self._profiles[profile.id] = profile
|
||||
|
||||
async def get(self, profile_id: UUID) -> GenerationProfile | None:
|
||||
return self._profiles.get(profile_id)
|
||||
|
||||
async def activate(self, profile_id: UUID) -> None:
|
||||
self._active_id = profile_id
|
||||
|
||||
async def get_active(self) -> GenerationProfile | None:
|
||||
return self._profiles.get(self._active_id) if self._active_id else None
|
||||
|
||||
|
||||
def _profile(**overrides: object) -> GenerationProfile:
|
||||
values: dict[str, object] = {
|
||||
"id": uuid4(),
|
||||
"profile": "answer-hybrid",
|
||||
"version": "v1",
|
||||
"provider": GenerationProvider.OLLAMA,
|
||||
"model_identifier": "llama3.1:8b-instruct-q4_0",
|
||||
"model_digest": "a" * 64,
|
||||
"context_limit": 8192,
|
||||
"max_output_tokens": 1024,
|
||||
"temperature": 0.4,
|
||||
"data_policy": DataPolicy.LOCAL_ONLY,
|
||||
}
|
||||
values.update(overrides)
|
||||
return GenerationProfile(**values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_persists_a_new_profile() -> None:
|
||||
registry = GenerationProfileRegistry(InMemoryGenerationProfileStore())
|
||||
profile = _profile()
|
||||
registered = await registry.register(profile)
|
||||
assert registered.id == profile.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_rejects_a_duplicate_id() -> None:
|
||||
store = InMemoryGenerationProfileStore()
|
||||
registry = GenerationProfileRegistry(store)
|
||||
profile = _profile()
|
||||
await registry.register(profile)
|
||||
with pytest.raises(DuplicateGenerationProfileError):
|
||||
await registry.register(profile)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_requires_a_registered_profile() -> None:
|
||||
registry = GenerationProfileRegistry(InMemoryGenerationProfileStore())
|
||||
with pytest.raises(GenerationProfileNotFoundError):
|
||||
await registry.activate(uuid4())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_requires_something_activated_first() -> None:
|
||||
store = InMemoryGenerationProfileStore()
|
||||
registry = GenerationProfileRegistry(store)
|
||||
profile = await registry.register(_profile())
|
||||
with pytest.raises(GenerationProfileNotFoundError):
|
||||
await registry.active()
|
||||
|
||||
activated = await registry.activate(profile.id)
|
||||
assert activated.id == profile.id
|
||||
assert (await registry.active()).id == profile.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activating_a_second_profile_replaces_the_active_one() -> None:
|
||||
store = InMemoryGenerationProfileStore()
|
||||
registry = GenerationProfileRegistry(store)
|
||||
first = await registry.register(_profile())
|
||||
second = await registry.register(_profile(id=uuid4(), version="v2"))
|
||||
await registry.activate(first.id)
|
||||
await registry.activate(second.id)
|
||||
assert (await registry.active()).id == second.id
|
||||
@@ -0,0 +1,335 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.models.selection import (
|
||||
EmbeddingCutoverRejected,
|
||||
EmbeddingSelectionConflict,
|
||||
EmbeddingSelectionNotInitialized,
|
||||
EmbeddingSelectionOperationResult,
|
||||
EmbeddingSelectionState,
|
||||
EmbeddingSelectionVerificationResult,
|
||||
)
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.control import DataPolicy
|
||||
from ragcore.domain.models import (
|
||||
EmbeddingProfile,
|
||||
EmbeddingProvider,
|
||||
EmbeddingSelection,
|
||||
NormalizationMode,
|
||||
)
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.ops import modelforge_embedding_bootstrap as bootstrap
|
||||
|
||||
_OPERATOR_TOKEN = "operator-token-with-at-least-32-random-characters"
|
||||
|
||||
|
||||
class MemorySelectionStore:
|
||||
def __init__(self) -> None:
|
||||
self.state: EmbeddingSelectionState | None = None
|
||||
self.operations: dict[UUID, tuple[str, str, EmbeddingSelectionOperationResult]] = {}
|
||||
|
||||
async def get(self) -> EmbeddingSelectionState:
|
||||
if self.state is None:
|
||||
raise EmbeddingSelectionNotInitialized("not initialized")
|
||||
return self.state
|
||||
|
||||
async def find_operation(
|
||||
self,
|
||||
operation_id: UUID,
|
||||
*,
|
||||
request_fingerprint: str,
|
||||
) -> EmbeddingSelectionOperationResult | None:
|
||||
entry = self.operations.get(operation_id)
|
||||
if entry is None:
|
||||
return None
|
||||
_action, old_fingerprint, result = entry
|
||||
if old_fingerprint != request_fingerprint:
|
||||
raise EmbeddingSelectionConflict("operation id conflict")
|
||||
return EmbeddingSelectionOperationResult(
|
||||
operation_id=result.operation_id,
|
||||
generation=result.generation,
|
||||
selection=result.selection,
|
||||
replayed=True,
|
||||
evidence=result.evidence,
|
||||
)
|
||||
|
||||
async def compare_and_swap(
|
||||
self,
|
||||
*,
|
||||
expected_generation: int,
|
||||
target: EmbeddingSelection,
|
||||
operation_id: UUID,
|
||||
action: str,
|
||||
request_fingerprint: str,
|
||||
operation_evidence: Mapping[str, object] | None = None,
|
||||
) -> EmbeddingSelectionOperationResult:
|
||||
if operation_id in self.operations:
|
||||
replay = await self.find_operation(
|
||||
operation_id,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
assert replay is not None
|
||||
return replay
|
||||
if action != "initialize" or expected_generation != 0 or self.state is not None:
|
||||
raise EmbeddingSelectionConflict("already initialized or stale")
|
||||
self.state = EmbeddingSelectionState(0, target, None)
|
||||
result = EmbeddingSelectionOperationResult(
|
||||
operation_id=operation_id,
|
||||
generation=0,
|
||||
selection=target,
|
||||
evidence=operation_evidence or {},
|
||||
)
|
||||
self.operations[operation_id] = (action, request_fingerprint, result)
|
||||
return result
|
||||
|
||||
|
||||
class RecordingVerifier:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[EmbeddingSelection, EmbeddingProfile]] = []
|
||||
self.gateway_request_id = uuid4()
|
||||
|
||||
async def verify(
|
||||
self, selection: EmbeddingSelection, profile: EmbeddingProfile
|
||||
) -> EmbeddingSelectionVerificationResult:
|
||||
self.calls.append((selection, profile))
|
||||
return EmbeddingSelectionVerificationResult(
|
||||
gateway_request_ids=(self.gateway_request_id,), verified_point_count=12
|
||||
)
|
||||
|
||||
|
||||
class ClosingResource:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
async def dispose(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _rc_settings(tmp_path: Path) -> Settings:
|
||||
return Settings(
|
||||
env="rc",
|
||||
secret_key="s" * 48,
|
||||
api_token_pepper="p" * 48,
|
||||
qdrant_api_key="q" * 48,
|
||||
database_url="postgresql+psycopg://rc:rc-very-long-random-password@localhost:5432/ragcore_rc",
|
||||
public_base_url="https://ragcore-rc.example.test",
|
||||
trusted_proxy_ips=("127.0.0.1",),
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
model_state_root=tmp_path / "models",
|
||||
embedding_cutover_operator_id=uuid4(),
|
||||
embedding_cutover_operator_token_sha256=hashlib.sha256(
|
||||
_OPERATOR_TOKEN.encode("utf-8")
|
||||
).hexdigest(),
|
||||
)
|
||||
|
||||
|
||||
def _manifest(
|
||||
path: Path, *, profile: str = "modelforge-rc-con01", version: str = "rc1"
|
||||
) -> dict[str, object]:
|
||||
value: dict[str, object] = {
|
||||
"profile": profile,
|
||||
"version": version,
|
||||
"embedding_space_id": str(uuid4()),
|
||||
"embedding_space_digest": "a" * 64,
|
||||
"dimensions": 1024,
|
||||
"experiment_route_key": "rc-canary-2026-09",
|
||||
}
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
return value
|
||||
|
||||
|
||||
def _arguments(path: Path, *, operation_id: UUID | None = None) -> argparse.Namespace:
|
||||
return bootstrap.parser().parse_args(
|
||||
[
|
||||
"initialize",
|
||||
"--expected-generation",
|
||||
"0",
|
||||
"--operation-id",
|
||||
str(operation_id or uuid4()),
|
||||
"--manifest-file",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_parser_requires_generation_zero_operation_id_and_manifest() -> None:
|
||||
manifest = Path("manifest.json")
|
||||
args = bootstrap.parser().parse_args(
|
||||
[
|
||||
"initialize",
|
||||
"--expected-generation",
|
||||
"0",
|
||||
"--operation-id",
|
||||
str(uuid4()),
|
||||
"--manifest-file",
|
||||
str(manifest),
|
||||
]
|
||||
)
|
||||
|
||||
assert args.operation == "initialize"
|
||||
assert args.expected_generation == 0
|
||||
assert args.manifest_file == manifest
|
||||
with pytest.raises(SystemExit):
|
||||
bootstrap.parser().parse_args(
|
||||
["initialize", "--expected-generation", "-1", "--operation-id", str(uuid4())]
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_rejects_duplicate_or_unknown_fields(tmp_path: Path) -> None:
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text('{"profile":"a","profile":"b"}', encoding="utf-8")
|
||||
|
||||
with pytest.raises(bootstrap.EmbeddingBootstrapOperatorError, match="valid JSON"):
|
||||
bootstrap._read_manifest(path)
|
||||
|
||||
path.write_text(json.dumps({"unexpected": True}), encoding="utf-8")
|
||||
with pytest.raises(bootstrap.EmbeddingBootstrapOperatorError, match="field set"):
|
||||
bootstrap._read_manifest(path)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bootstrap_rejects_collection_already_referenced_by_qdrant_alias() -> None:
|
||||
space_id = uuid4()
|
||||
profile = EmbeddingProfile(
|
||||
id=space_id,
|
||||
profile="modelforge-rc-con01",
|
||||
version="rc1",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=4,
|
||||
context_limit=4096,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
normalization=NormalizationMode.L2,
|
||||
embedding_space_id=space_id,
|
||||
)
|
||||
selection = EmbeddingSelection.from_profile(profile, experiment_route_key="rc-canary-route")
|
||||
|
||||
class AliasedClient:
|
||||
async def get_aliases(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[SimpleNamespace(collection_name=selection.collection_name)]
|
||||
)
|
||||
|
||||
verifier = bootstrap._UnaliasedRCCollectionVerifier(AliasedClient()) # type: ignore[arg-type]
|
||||
with pytest.raises(EmbeddingCutoverRejected, match="already a Qdrant alias target"):
|
||||
await verifier.verify(selection, profile)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_registers_and_initializes_exact_verified_rc_selection_idempotently(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
settings = _rc_settings(tmp_path)
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
manifest = _manifest(manifest_path)
|
||||
operation_id = uuid4()
|
||||
profile_store = JsonEmbeddingProfileStore(settings.embedding_profiles_path)
|
||||
selection_store = MemorySelectionStore()
|
||||
verifier = RecordingVerifier()
|
||||
from ragcore.application.models.selection import EmbeddingSelectionService
|
||||
|
||||
service = EmbeddingSelectionService(
|
||||
profiles=profile_store,
|
||||
selections=selection_store,
|
||||
verifier=verifier,
|
||||
)
|
||||
|
||||
async def build_service(
|
||||
_settings: Settings,
|
||||
) -> tuple[object, ClosingResource, ClosingResource, ClosingResource]:
|
||||
return _service_tuple(service)
|
||||
|
||||
monkeypatch.setattr(bootstrap, "_build_service", build_service)
|
||||
arguments = _arguments(manifest_path, operation_id=operation_id)
|
||||
|
||||
first = await bootstrap.execute(
|
||||
arguments,
|
||||
operator_token=_OPERATOR_TOKEN,
|
||||
settings=settings,
|
||||
)
|
||||
replay = await bootstrap.execute(
|
||||
arguments,
|
||||
operator_token=_OPERATOR_TOKEN,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
space_id = UUID(str(manifest["embedding_space_id"]))
|
||||
profile = await profile_store.get(space_id)
|
||||
assert profile is not None
|
||||
assert profile.model_digest == manifest["embedding_space_digest"]
|
||||
assert profile.dimensions == manifest["dimensions"]
|
||||
assert profile.embedding_space_id == space_id
|
||||
assert await profile_store.get_active() is None
|
||||
assert selection_store.state is not None
|
||||
selection = selection_store.state.active
|
||||
assert selection.profile_id == space_id
|
||||
assert selection.experiment_route_key == manifest["experiment_route_key"]
|
||||
assert selection.collection_name == "rag_dense_modelforge-rc-con01_rc1"
|
||||
assert verifier.calls == [(selection, profile)]
|
||||
assert first["status"] == "applied"
|
||||
assert replay["status"] == "replayed"
|
||||
assert first["generation"] == replay["generation"] == 0
|
||||
assert first["verification"]["verified_point_count"] == 12 # type: ignore[index]
|
||||
assert _OPERATOR_TOKEN not in json.dumps(first)
|
||||
|
||||
|
||||
def _service_tuple(
|
||||
service: object,
|
||||
) -> tuple[object, ClosingResource, ClosingResource, ClosingResource]:
|
||||
resource = ClosingResource()
|
||||
return service, resource, resource, resource
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_non_rc_collection_namespace_before_connecting(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
settings = _rc_settings(tmp_path)
|
||||
manifest_path = tmp_path / "bootstrap.json"
|
||||
_manifest(manifest_path, profile="modelforge-rag", version="m6")
|
||||
|
||||
async def no_connect(_settings: Settings) -> object:
|
||||
raise AssertionError("service connections must follow manifest validation")
|
||||
|
||||
monkeypatch.setattr(bootstrap, "_build_service", no_connect)
|
||||
with pytest.raises(bootstrap.EmbeddingBootstrapOperatorError, match="modelforge-rc-"):
|
||||
await bootstrap.execute(
|
||||
_arguments(manifest_path),
|
||||
operator_token=_OPERATOR_TOKEN,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_non_rc_or_wrong_operator_token_before_manifest_read(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
missing_manifest = tmp_path / "does-not-exist.json"
|
||||
arguments = _arguments(missing_manifest)
|
||||
|
||||
with pytest.raises(bootstrap.EmbeddingBootstrapOperatorError, match="restricted"):
|
||||
await bootstrap.execute(arguments, operator_token=_OPERATOR_TOKEN, settings=Settings())
|
||||
|
||||
settings = _rc_settings(tmp_path)
|
||||
with pytest.raises(bootstrap.EmbeddingBootstrapOperatorError, match="authentication failed"):
|
||||
await bootstrap.execute(
|
||||
arguments,
|
||||
operator_token="wrong-token-value-that-is-long-enough",
|
||||
settings=settings,
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ragcore.application.models.selection import EmbeddingSelectionOperationResult
|
||||
from ragcore.config import Settings
|
||||
from ragcore.domain.models import EmbeddingProvider, EmbeddingSelection
|
||||
from ragcore.ops.modelforge_embedding_cutover import (
|
||||
_PROMOTION_EVIDENCE_FIELDS,
|
||||
EmbeddingCutoverOperatorError,
|
||||
_authorize_operator,
|
||||
_read_evidence,
|
||||
_redacted_receipt,
|
||||
execute,
|
||||
parser,
|
||||
)
|
||||
|
||||
_OPERATOR_TOKEN = "operator-token-with-at-least-32-random-characters"
|
||||
|
||||
|
||||
def _rc_settings(tmp_path: Path, *, token_hash: str | None = None) -> Settings:
|
||||
return Settings(
|
||||
env="rc",
|
||||
secret_key="s" * 48,
|
||||
api_token_pepper="p" * 48,
|
||||
qdrant_api_key="q" * 48,
|
||||
database_url="postgresql+psycopg://rc:rc-very-long-random-password@localhost:5432/ragcore_rc",
|
||||
public_base_url="https://ragcore-rc.example.test",
|
||||
trusted_proxy_ips=("127.0.0.1",),
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
model_state_root=tmp_path / "models",
|
||||
embedding_cutover_operator_id=uuid4() if token_hash is not None else None,
|
||||
embedding_cutover_operator_token_sha256=token_hash,
|
||||
)
|
||||
|
||||
|
||||
def test_parser_requires_bounded_operation_identity_and_evidence_file(tmp_path: Path) -> None:
|
||||
evidence = tmp_path / "evidence.json"
|
||||
args = parser().parse_args(
|
||||
[
|
||||
"promote",
|
||||
"--expected-generation",
|
||||
"7",
|
||||
"--operation-id",
|
||||
str(uuid4()),
|
||||
"--evidence-file",
|
||||
str(evidence),
|
||||
]
|
||||
)
|
||||
|
||||
assert args.operation == "promote"
|
||||
assert args.expected_generation == 7
|
||||
assert args.evidence_file == evidence
|
||||
|
||||
with pytest.raises(SystemExit):
|
||||
parser().parse_args(
|
||||
["rollback", "--expected-generation", "-1", "--operation-id", str(uuid4())]
|
||||
)
|
||||
|
||||
|
||||
def test_parser_rejects_nil_operation_ids() -> None:
|
||||
with pytest.raises(SystemExit):
|
||||
parser().parse_args(
|
||||
[
|
||||
"rollback",
|
||||
"--expected-generation",
|
||||
"0",
|
||||
"--operation-id",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"--evidence-file",
|
||||
"evidence.json",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_operator_authentication_requires_rc_configuration_and_exact_secret(tmp_path: Path) -> None:
|
||||
expected = hashlib.sha256(_OPERATOR_TOKEN.encode()).hexdigest()
|
||||
settings = _rc_settings(tmp_path, token_hash=expected)
|
||||
|
||||
_authorize_operator(settings, _OPERATOR_TOKEN)
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="authentication failed"):
|
||||
_authorize_operator(settings, "wrong-token-value-that-is-long-enough")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="identity is not configured"):
|
||||
_authorize_operator(_rc_settings(tmp_path), _OPERATOR_TOKEN)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="restricted"):
|
||||
_authorize_operator(Settings(env="development"), _OPERATOR_TOKEN)
|
||||
|
||||
|
||||
def test_rc_settings_reject_malformed_operator_token_hash() -> None:
|
||||
with pytest.raises(ValidationError, match="lowercase SHA-256"):
|
||||
Settings(embedding_cutover_operator_token_sha256="not-a-hash")
|
||||
|
||||
|
||||
def test_rc_requires_production_strength_settings() -> None:
|
||||
with pytest.raises(ValidationError, match="secret_key"):
|
||||
Settings(env="rc", secret_key="development-only-change-me")
|
||||
|
||||
|
||||
def test_rc_operator_identity_and_token_hash_must_be_paired() -> None:
|
||||
with pytest.raises(ValidationError, match="must be set together"):
|
||||
Settings(embedding_cutover_operator_id=uuid4())
|
||||
with pytest.raises(ValidationError, match="must not be the nil UUID"):
|
||||
Settings(
|
||||
embedding_cutover_operator_id="00000000-0000-0000-0000-000000000000",
|
||||
embedding_cutover_operator_token_sha256="a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def test_evidence_parser_rejects_unexpected_fields(tmp_path: Path) -> None:
|
||||
path = tmp_path / "evidence.json"
|
||||
path.write_text(
|
||||
json.dumps({"comparison_id": str(uuid4()), "secret": "extra"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="unexpected field set"):
|
||||
_read_evidence(path, expected_fields=_PROMOTION_EVIDENCE_FIELDS)
|
||||
|
||||
|
||||
def test_evidence_parser_rejects_duplicate_keys(tmp_path: Path) -> None:
|
||||
path = tmp_path / "evidence.json"
|
||||
path.write_text('{"operator_id":"a","operator_id":"b"}', encoding="utf-8")
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="valid JSON"):
|
||||
_read_evidence(path, expected_fields=frozenset({"operator_id"}))
|
||||
|
||||
|
||||
def test_receipt_omits_submitted_evidence_and_retains_only_bounded_verification() -> None:
|
||||
operation_id = uuid4()
|
||||
selection = EmbeddingSelection(
|
||||
profile_id=uuid4(),
|
||||
profile="rc-candidate",
|
||||
version="v2",
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="a" * 64,
|
||||
dimensions=4,
|
||||
embedding_space_id=uuid4(),
|
||||
experiment_route_key="lab-candidate-route",
|
||||
collection_name="rag_dense_rc-candidate_v2",
|
||||
)
|
||||
result = EmbeddingSelectionOperationResult(
|
||||
operation_id=operation_id,
|
||||
generation=9,
|
||||
selection=selection,
|
||||
evidence={
|
||||
"operator_id": str(uuid4()),
|
||||
"approval_id": str(uuid4()),
|
||||
"reason": "private change note",
|
||||
"verification": {
|
||||
"gateway_request_ids": [str(uuid4())],
|
||||
"verified_point_count": 25,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
receipt = _redacted_receipt("promote", result)
|
||||
|
||||
assert receipt["operation_id"] == str(operation_id)
|
||||
assert "operator_id" not in receipt and "approval_id" not in receipt
|
||||
assert "private change note" not in json.dumps(receipt)
|
||||
assert receipt["verification"]["verified_point_count"] == 25 # type: ignore[index]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_non_rc_before_reading_evidence_or_connecting(tmp_path: Path) -> None:
|
||||
evidence = tmp_path / "missing-evidence.json"
|
||||
arguments = parser().parse_args(
|
||||
[
|
||||
"promote",
|
||||
"--expected-generation",
|
||||
"0",
|
||||
"--operation-id",
|
||||
str(uuid4()),
|
||||
"--evidence-file",
|
||||
str(evidence),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="restricted"):
|
||||
await execute(arguments, settings=Settings(env="development"), operator_token="x" * 40)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_evidence_claimed_by_another_operator(tmp_path: Path) -> None:
|
||||
token_hash = hashlib.sha256(_OPERATOR_TOKEN.encode()).hexdigest()
|
||||
settings = _rc_settings(tmp_path, token_hash=token_hash)
|
||||
evidence_path = tmp_path / "promotion.json"
|
||||
evidence_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"comparison_id": str(uuid4()),
|
||||
"comparison_reviewed": True,
|
||||
"comparison_passed": True,
|
||||
"operator_id": str(uuid4()),
|
||||
"approval_id": str(uuid4()),
|
||||
"operator_approved_impact_and_reindex": True,
|
||||
"index_operation_id": str(uuid4()),
|
||||
"authoritative_reindex_completed": True,
|
||||
"collection_identity_pins_verified": True,
|
||||
"consumer_smoke_passed": True,
|
||||
"candidate_profile_id": str(uuid4()),
|
||||
"candidate_experiment_route_key": "lab-candidate-route",
|
||||
"gateway_route_key": "lab-candidate-route",
|
||||
"gateway_embedding_space_id": str(uuid4()),
|
||||
"gateway_dimensions": 4,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
arguments = parser().parse_args(
|
||||
[
|
||||
"promote",
|
||||
"--expected-generation",
|
||||
"0",
|
||||
"--operation-id",
|
||||
str(uuid4()),
|
||||
"--evidence-file",
|
||||
str(evidence_path),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="authenticated identity"):
|
||||
await execute(arguments, settings=settings, operator_token=_OPERATOR_TOKEN)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_rejects_invalid_evidence_before_connecting(tmp_path: Path) -> None:
|
||||
token_hash = hashlib.sha256(_OPERATOR_TOKEN.encode()).hexdigest()
|
||||
settings = _rc_settings(tmp_path, token_hash=token_hash)
|
||||
evidence_path = tmp_path / "malformed.json"
|
||||
evidence_path.write_text("not-json", encoding="utf-8")
|
||||
arguments = parser().parse_args(
|
||||
[
|
||||
"rollback",
|
||||
"--expected-generation",
|
||||
"3",
|
||||
"--operation-id",
|
||||
str(uuid4()),
|
||||
"--evidence-file",
|
||||
str(evidence_path),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingCutoverOperatorError, match="not valid JSON"):
|
||||
await execute(arguments, settings=settings, operator_token=_OPERATOR_TOKEN)
|
||||
@@ -0,0 +1,720 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client.http import models
|
||||
|
||||
from ragcore.ops.modelforge_selected_comparison import (
|
||||
ComparisonCase,
|
||||
ComparisonClaim,
|
||||
ComparisonManifest,
|
||||
ComparisonPins,
|
||||
ComparisonStatus,
|
||||
ComparisonTargetPin,
|
||||
ModelForgeComparisonClient,
|
||||
PinnedIndex,
|
||||
SelectedComparisonError,
|
||||
SelectedComparisonRunner,
|
||||
_parse_pins,
|
||||
)
|
||||
from ragcore.retrieval.ranking import FusionConfig
|
||||
from ragcore.retrieval.sparse import SparseEncoderProfile
|
||||
|
||||
COMPARISON_ID = uuid4()
|
||||
PROJECT_ID = uuid4()
|
||||
SUITE_REVISION_ID = uuid4()
|
||||
WORKSPACE_ID = uuid4()
|
||||
CASE_ID = uuid4()
|
||||
CHUNK_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
DOCUMENT_ID = uuid4()
|
||||
CURRENT_DEPLOYMENT_ID = uuid4()
|
||||
CANDIDATE_DEPLOYMENT_ID = uuid4()
|
||||
CURRENT_SPACE_ID = uuid4()
|
||||
CANDIDATE_SPACE_ID = uuid4()
|
||||
|
||||
|
||||
def _index(ref: str, profile: str, digest: str, space_id: UUID) -> PinnedIndex:
|
||||
return PinnedIndex(
|
||||
ref=ref,
|
||||
profile=profile,
|
||||
version="v1",
|
||||
dimensions=2,
|
||||
model_digest=digest,
|
||||
embedding_space_id=space_id,
|
||||
embedding_space_digest=("e" if profile == "current" else "f") * 64,
|
||||
)
|
||||
|
||||
|
||||
def _manifest(tmp_path: Path) -> ComparisonManifest:
|
||||
retrieval = {
|
||||
"candidate_k": 40,
|
||||
"dense_limit": 40,
|
||||
"sparse_limit": 40,
|
||||
"output_k": 10,
|
||||
"timeout_seconds": 2.0,
|
||||
"sparse_profile": {
|
||||
"name": "technical",
|
||||
"version": "v1",
|
||||
"hash_namespace": "ragcore-sparse-v1",
|
||||
"max_tokens": 4096,
|
||||
"term_weight": 1.0,
|
||||
"identifier_weight": 3.0,
|
||||
"identifier_component_weight": 0.5,
|
||||
"normalization": "none",
|
||||
},
|
||||
"fusion": {"rrf_k": 60, "max_fused_candidates": 40, "max_per_document": 3},
|
||||
}
|
||||
return ComparisonManifest(
|
||||
comparison_id=COMPARISON_ID,
|
||||
project_id=PROJECT_ID,
|
||||
suite_revision_id=SUITE_REVISION_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
corpus_sha256="a" * 64,
|
||||
consumer_revision_sha256="b" * 64,
|
||||
modelforge_base_url="http://modelforge:8000",
|
||||
credential_file=tmp_path / "credential",
|
||||
qdrant_url="http://qdrant:6333",
|
||||
current_index=_index("current-index", "current", "c" * 64, CURRENT_SPACE_ID),
|
||||
candidate_index=_index("candidate-index", "candidate", "d" * 64, CANDIDATE_SPACE_ID),
|
||||
retrieval_config=retrieval,
|
||||
sparse_profile=SparseEncoderProfile(),
|
||||
fusion_config=FusionConfig(),
|
||||
output_k=10,
|
||||
timeout_seconds=2.0,
|
||||
)
|
||||
|
||||
|
||||
def _write_credential(path: Path) -> None:
|
||||
path.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _manifest_payload(manifest: ComparisonManifest) -> dict[str, object]:
|
||||
def index_payload(index: PinnedIndex) -> dict[str, object]:
|
||||
return {
|
||||
"ref": index.ref,
|
||||
"profile": index.profile,
|
||||
"version": index.version,
|
||||
"dimensions": index.dimensions,
|
||||
"model_digest": index.model_digest,
|
||||
"embedding_space_id": str(index.embedding_space_id),
|
||||
"embedding_space_digest": index.embedding_space_digest,
|
||||
}
|
||||
|
||||
return {
|
||||
"comparison_id": str(manifest.comparison_id),
|
||||
"project_id": str(manifest.project_id),
|
||||
"suite_revision_id": str(manifest.suite_revision_id),
|
||||
"workspace_id": str(manifest.workspace_id),
|
||||
"corpus_sha256": manifest.corpus_sha256,
|
||||
"consumer_revision_sha256": manifest.consumer_revision_sha256,
|
||||
"modelforge_base_url": manifest.modelforge_base_url,
|
||||
"credential_file": str(manifest.credential_file),
|
||||
"qdrant_url": manifest.qdrant_url,
|
||||
"current_index": index_payload(manifest.current_index),
|
||||
"candidate_index": index_payload(manifest.candidate_index),
|
||||
"retrieval_config": dict(manifest.retrieval_config),
|
||||
}
|
||||
|
||||
|
||||
def _claim(
|
||||
manifest: ComparisonManifest,
|
||||
*,
|
||||
current_space: UUID = CURRENT_SPACE_ID,
|
||||
current_route_key: str | None = None,
|
||||
) -> ComparisonClaim:
|
||||
now = datetime.now(UTC)
|
||||
current = ComparisonTargetPin(
|
||||
deployment_id=CURRENT_DEPLOYMENT_ID,
|
||||
embedding_space_id=current_space,
|
||||
embedding_space_digest="e" * 64,
|
||||
dimension=2,
|
||||
artifact_sha256="1" * 64,
|
||||
runtime_fingerprint="runtime-current",
|
||||
)
|
||||
candidate = ComparisonTargetPin(
|
||||
deployment_id=CANDIDATE_DEPLOYMENT_ID,
|
||||
embedding_space_id=CANDIDATE_SPACE_ID,
|
||||
embedding_space_digest="f" * 64,
|
||||
dimension=2,
|
||||
artifact_sha256="2" * 64,
|
||||
runtime_fingerprint="runtime-candidate",
|
||||
)
|
||||
raw_pins = {
|
||||
"project_id": str(PROJECT_ID),
|
||||
"current_index_ref": manifest.current_index.ref,
|
||||
"candidate_index_ref": manifest.candidate_index.ref,
|
||||
"corpus_sha256": manifest.corpus_sha256,
|
||||
"retrieval_config_sha256": manifest.retrieval_config_sha256,
|
||||
"candidate_route_key": "candidate-route",
|
||||
"current": {
|
||||
"deployment_id": str(CURRENT_DEPLOYMENT_ID),
|
||||
"embedding_space_id": str(current_space),
|
||||
"embedding_space_digest": "e" * 64,
|
||||
"dimension": 2,
|
||||
"artifact_sha256": "1" * 64,
|
||||
"runtime_fingerprint": "runtime-current",
|
||||
},
|
||||
"candidate": {
|
||||
"deployment_id": str(CANDIDATE_DEPLOYMENT_ID),
|
||||
"embedding_space_id": str(CANDIDATE_SPACE_ID),
|
||||
"embedding_space_digest": "f" * 64,
|
||||
"dimension": 2,
|
||||
"artifact_sha256": "2" * 64,
|
||||
"runtime_fingerprint": "runtime-candidate",
|
||||
},
|
||||
}
|
||||
if current_route_key is not None:
|
||||
raw_pins["current_route_key"] = current_route_key
|
||||
return ComparisonClaim(
|
||||
comparison_id=COMPARISON_ID,
|
||||
suite_revision_id=SUITE_REVISION_ID,
|
||||
current_deployment_id=CURRENT_DEPLOYMENT_ID,
|
||||
candidate_deployment_id=CANDIDATE_DEPLOYMENT_ID,
|
||||
expected_cases=1,
|
||||
claim_token="claim-token-" + "x" * 32,
|
||||
claim_expires_at=now + timedelta(seconds=120),
|
||||
deadline_at=now + timedelta(minutes=10),
|
||||
pins_digest=hashlib.sha256(
|
||||
json.dumps(raw_pins, sort_keys=True, separators=(",", ":"), default=str).encode()
|
||||
).hexdigest(),
|
||||
pins=ComparisonPins(
|
||||
raw=raw_pins,
|
||||
project_id=PROJECT_ID,
|
||||
corpus_sha256=manifest.corpus_sha256,
|
||||
retrieval_config_sha256=manifest.retrieval_config_sha256,
|
||||
current_index_ref=manifest.current_index.ref,
|
||||
candidate_index_ref=manifest.candidate_index.ref,
|
||||
current_route_key=current_route_key,
|
||||
candidate_route_key="candidate-route",
|
||||
current=current,
|
||||
candidate=candidate,
|
||||
),
|
||||
cases=(
|
||||
ComparisonCase(
|
||||
id=CASE_ID,
|
||||
suite_revision_id=SUITE_REVISION_ID,
|
||||
case_key="one",
|
||||
query="where is the answer",
|
||||
relevant_chunk_ids=(CHUNK_ID,),
|
||||
critical=True,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class FakeControl:
|
||||
def __init__(self, claim: ComparisonClaim) -> None:
|
||||
self.claim_value = claim
|
||||
self.progress_calls: list[tuple[int, int]] = []
|
||||
self.finish_calls: list[tuple[str, str]] = []
|
||||
self.submitted: tuple[object, ...] | None = None
|
||||
self.cancel = False
|
||||
|
||||
async def claim(self, comparison_id: UUID) -> ComparisonClaim:
|
||||
assert comparison_id == self.claim_value.comparison_id
|
||||
return self.claim_value
|
||||
|
||||
async def progress(
|
||||
self,
|
||||
comparison_id: UUID,
|
||||
claim_token: str,
|
||||
current_completed: int,
|
||||
candidate_completed: int,
|
||||
) -> ComparisonStatus:
|
||||
assert comparison_id == self.claim_value.comparison_id
|
||||
assert claim_token == self.claim_value.claim_token
|
||||
self.progress_calls.append((current_completed, candidate_completed))
|
||||
return ComparisonStatus(
|
||||
status="cancel_requested" if self.cancel else "running",
|
||||
current_completed_cases=current_completed,
|
||||
candidate_completed_cases=candidate_completed,
|
||||
)
|
||||
|
||||
async def submit_result(
|
||||
self,
|
||||
comparison_id: UUID,
|
||||
claim_token: str,
|
||||
pins_digest: str,
|
||||
consumer_revision_sha256: str,
|
||||
current_results: object,
|
||||
candidate_results: object,
|
||||
current_gateway_requests: object,
|
||||
candidate_gateway_requests: object,
|
||||
) -> ComparisonStatus:
|
||||
self.submitted = (
|
||||
pins_digest,
|
||||
consumer_revision_sha256,
|
||||
current_results,
|
||||
candidate_results,
|
||||
current_gateway_requests,
|
||||
candidate_gateway_requests,
|
||||
)
|
||||
return ComparisonStatus(
|
||||
status="completed",
|
||||
current_completed_cases=1,
|
||||
candidate_completed_cases=1,
|
||||
baseline_run_id=uuid4(),
|
||||
candidate_run_id=uuid4(),
|
||||
evaluation_comparison_id=uuid4(),
|
||||
)
|
||||
|
||||
async def finish(
|
||||
self, comparison_id: UUID, claim_token: str, outcome: str, error_code: str
|
||||
) -> ComparisonStatus:
|
||||
self.finish_calls.append((outcome, error_code))
|
||||
return ComparisonStatus(
|
||||
status=outcome,
|
||||
current_completed_cases=0,
|
||||
candidate_completed_cases=0,
|
||||
error_code=error_code,
|
||||
)
|
||||
|
||||
|
||||
class FakeQdrant:
|
||||
def __init__(self, manifest: ComparisonManifest) -> None:
|
||||
self.manifest = manifest
|
||||
self.request_ids: list[str] = []
|
||||
|
||||
async def retrieve(self, *, collection_name: str, ids: list[str], **_: object) -> list[object]:
|
||||
assert collection_name == self.manifest.current_index.ref
|
||||
assert ids == [str(CHUNK_ID)]
|
||||
return [
|
||||
SimpleNamespace(
|
||||
id=str(CHUNK_ID),
|
||||
payload={"workspace_id": str(WORKSPACE_ID), "space_id": str(SPACE_ID)},
|
||||
)
|
||||
]
|
||||
|
||||
async def get_aliases(self) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[
|
||||
SimpleNamespace(
|
||||
alias_name="rag_dense_current_active",
|
||||
collection_name=self.manifest.current_index.ref,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_collection(self, collection_name: str) -> SimpleNamespace:
|
||||
assert collection_name in {
|
||||
self.manifest.current_index.ref,
|
||||
self.manifest.candidate_index.ref,
|
||||
}
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={"dense": models.VectorParams(size=2, distance=models.Distance.COSINE)},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def query_points(self, *, collection_name: str, **_: object) -> models.QueryResponse:
|
||||
index = (
|
||||
self.manifest.current_index
|
||||
if collection_name == self.manifest.current_index.ref
|
||||
else self.manifest.candidate_index
|
||||
)
|
||||
point_id = CHUNK_ID
|
||||
self.request_ids.append(collection_name)
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(
|
||||
id=str(point_id),
|
||||
version=0,
|
||||
score=0.9,
|
||||
payload={
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"space_id": str(SPACE_ID),
|
||||
"document_id": str(DOCUMENT_ID),
|
||||
"document_version_id": str(UUID("10000000-0000-0000-0000-000000000001")),
|
||||
"chunk_id": str(CHUNK_ID),
|
||||
"source_id": str(UUID("10000000-0000-0000-0000-000000000002")),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": index.model_digest,
|
||||
"chunk_profile_version": index.version,
|
||||
"embedding_space_id": str(index.embedding_space_id),
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _gateway_transport(
|
||||
manifest: ComparisonManifest,
|
||||
*,
|
||||
duplicate: bool = False,
|
||||
current_route_key: str | None = None,
|
||||
fail_route_key: str | None = None,
|
||||
seen: list[str] | None = None,
|
||||
) -> httpx.MockTransport:
|
||||
request_id = uuid4()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path.endswith("/invoke"):
|
||||
assert json.loads(request.content)["input"] == ["where is the answer"]
|
||||
if seen is not None:
|
||||
seen.append(request.url.path)
|
||||
if fail_route_key is not None and request.url.path.endswith(
|
||||
f"/{fail_route_key}/invoke"
|
||||
):
|
||||
return httpx.Response(503)
|
||||
if current_route_key is not None and request.url.path.endswith(
|
||||
f"/{current_route_key}/invoke"
|
||||
):
|
||||
space_id = manifest.current_index.embedding_space_id
|
||||
response_id = uuid4()
|
||||
elif request.url.path.endswith("/candidate-route/invoke"):
|
||||
space_id = manifest.candidate_index.embedding_space_id
|
||||
response_id = request_id if duplicate else uuid4()
|
||||
else:
|
||||
space_id = manifest.current_index.embedding_space_id
|
||||
response_id = request_id
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": 2,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(space_id),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(response_id),
|
||||
},
|
||||
)
|
||||
raise AssertionError(request.url.path)
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_runner_executes_both_pinned_legs_and_submits_gateway_evidence(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
control = FakeControl(_claim(manifest))
|
||||
qdrant = FakeQdrant(manifest)
|
||||
seen: list[str] = []
|
||||
async with httpx.AsyncClient(
|
||||
transport=_gateway_transport(manifest, seen=seen), follow_redirects=False
|
||||
) as client:
|
||||
result = await SelectedComparisonRunner(manifest, control, qdrant, client).run()
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert control.progress_calls == [(1, 0), (1, 1)]
|
||||
assert control.finish_calls == []
|
||||
assert control.submitted is not None
|
||||
assert qdrant.request_ids == [
|
||||
"current-index",
|
||||
"current-index",
|
||||
"candidate-index",
|
||||
"candidate-index",
|
||||
]
|
||||
assert result["current_gateway_requests"] != result["candidate_gateway_requests"]
|
||||
assert seen == [
|
||||
"/api/v1/capabilities/rag.embedding@1/invoke",
|
||||
"/api/v1/capability-experiments/candidate-route/invoke",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_runner_uses_optional_current_lab_route_pin(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
current_route_key = "current-lab-route"
|
||||
control = FakeControl(_claim(manifest, current_route_key=current_route_key))
|
||||
seen: list[str] = []
|
||||
async with httpx.AsyncClient(
|
||||
transport=_gateway_transport(manifest, current_route_key=current_route_key, seen=seen),
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
result = await SelectedComparisonRunner(
|
||||
manifest, control, FakeQdrant(manifest), client
|
||||
).run()
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert seen == [
|
||||
"/api/v1/capability-experiments/current-lab-route/invoke",
|
||||
"/api/v1/capability-experiments/candidate-route/invoke",
|
||||
]
|
||||
|
||||
|
||||
def test_claim_pins_reject_malformed_optional_current_route_key(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
malformed = {**_claim(manifest).pins.raw, "current_route_key": "../stable"}
|
||||
|
||||
with pytest.raises(SelectedComparisonError, match="CURRENT_ROUTE_KEY_INVALID"):
|
||||
_parse_pins(malformed)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_current_lab_route_failure_does_not_fall_back_to_stable_or_ollama(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
current_route_key = "current-lab-route"
|
||||
control = FakeControl(_claim(manifest, current_route_key=current_route_key))
|
||||
seen: list[str] = []
|
||||
async with httpx.AsyncClient(
|
||||
transport=_gateway_transport(
|
||||
manifest,
|
||||
current_route_key=current_route_key,
|
||||
fail_route_key=current_route_key,
|
||||
seen=seen,
|
||||
),
|
||||
follow_redirects=False,
|
||||
) as client:
|
||||
result = await SelectedComparisonRunner(
|
||||
manifest, control, FakeQdrant(manifest), client
|
||||
).run()
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert seen == ["/api/v1/capability-experiments/current-lab-route/invoke"]
|
||||
assert control.submitted is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_runner_fails_closed_on_duplicate_gateway_identity(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
control = FakeControl(_claim(manifest))
|
||||
async with httpx.AsyncClient(
|
||||
transport=_gateway_transport(manifest, duplicate=True), follow_redirects=False
|
||||
) as client:
|
||||
result = await SelectedComparisonRunner(
|
||||
manifest, control, FakeQdrant(manifest), client
|
||||
).run()
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error_code"] == "DUPLICATE_GATEWAY_REQUEST_ID"
|
||||
assert control.submitted is None
|
||||
assert control.finish_calls == [("failed", "DUPLICATE_GATEWAY_REQUEST_ID")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_runner_honours_cancellation_before_candidate_leg(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
control = FakeControl(_claim(manifest))
|
||||
control.cancel = True
|
||||
qdrant = FakeQdrant(manifest)
|
||||
async with httpx.AsyncClient(
|
||||
transport=_gateway_transport(manifest), follow_redirects=False
|
||||
) as client:
|
||||
result = await SelectedComparisonRunner(manifest, control, qdrant, client).run()
|
||||
|
||||
assert result["status"] == "cancelled"
|
||||
assert result["error_code"] == "COMPARISON_CANCEL_REQUESTED"
|
||||
assert result["completed_cases"] == 0
|
||||
assert control.submitted is None
|
||||
assert control.finish_calls == [("cancelled", "COMPARISON_CANCEL_REQUESTED")]
|
||||
assert qdrant.request_ids == ["current-index", "current-index"]
|
||||
|
||||
|
||||
class FailingProgressControl(FakeControl):
|
||||
async def progress(
|
||||
self,
|
||||
comparison_id: UUID,
|
||||
claim_token: str,
|
||||
current_completed: int,
|
||||
candidate_completed: int,
|
||||
) -> ComparisonStatus:
|
||||
del comparison_id, claim_token, current_completed, candidate_completed
|
||||
raise SelectedComparisonError("LEASE_RENEWAL_FAILED")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_runner_reports_lease_failure_without_submitting_partial_results(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
control = FailingProgressControl(_claim(manifest))
|
||||
async with httpx.AsyncClient(
|
||||
transport=_gateway_transport(manifest), follow_redirects=False
|
||||
) as client:
|
||||
result = await SelectedComparisonRunner(
|
||||
manifest, control, FakeQdrant(manifest), client
|
||||
).run()
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error_code"] == "LEASE_RENEWAL_FAILED"
|
||||
assert result["completed_cases"] == 0
|
||||
assert control.submitted is None
|
||||
assert control.finish_calls == [("failed", "LEASE_RENEWAL_FAILED")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_selected_runner_rejects_expired_claim_before_inference(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
claim = replace(
|
||||
_claim(manifest),
|
||||
claim_expires_at=datetime.now(UTC) - timedelta(seconds=1),
|
||||
deadline_at=datetime.now(UTC) + timedelta(minutes=5),
|
||||
)
|
||||
control = FakeControl(claim)
|
||||
async with httpx.AsyncClient(transport=_gateway_transport(manifest)) as client:
|
||||
result = await SelectedComparisonRunner(
|
||||
manifest, control, FakeQdrant(manifest), client
|
||||
).run()
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert result["error_code"] == "COMPARISON_CLAIM_EXPIRED"
|
||||
assert control.finish_calls == [("failed", "COMPARISON_CLAIM_EXPIRED")]
|
||||
|
||||
|
||||
def test_manifest_requires_exact_runtime_contract(tmp_path: Path) -> None:
|
||||
manifest = {
|
||||
"comparison_id": str(COMPARISON_ID),
|
||||
"project_id": str(PROJECT_ID),
|
||||
"suite_revision_id": str(SUITE_REVISION_ID),
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"corpus_sha256": "a" * 64,
|
||||
"consumer_revision_sha256": "b" * 64,
|
||||
"modelforge_base_url": "https://api.example.com",
|
||||
"credential_file": "credential",
|
||||
"qdrant_url": "http://qdrant:6333",
|
||||
"current_index": {},
|
||||
"candidate_index": {},
|
||||
"retrieval_config": {},
|
||||
}
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
|
||||
with pytest.raises(SelectedComparisonError, match="MODELFORGE_BASE_URL_MUST_BE_LOCAL_ORIGIN"):
|
||||
ComparisonManifest.from_path(path)
|
||||
|
||||
|
||||
def test_manifest_loads_the_pinned_index_and_retrieval_contract(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
path = tmp_path / "manifest.json"
|
||||
path.write_text(json.dumps(_manifest_payload(manifest)), encoding="utf-8")
|
||||
|
||||
loaded = ComparisonManifest.from_path(path)
|
||||
|
||||
assert loaded.current_index == manifest.current_index
|
||||
assert loaded.candidate_index == manifest.candidate_index
|
||||
assert loaded.retrieval_config_sha256 == manifest.retrieval_config_sha256
|
||||
assert loaded.sparse_profile.digest == manifest.sparse_profile.digest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_client_parses_and_verifies_claim_pins(tmp_path: Path) -> None:
|
||||
manifest = _manifest(tmp_path)
|
||||
_write_credential(manifest.credential_file)
|
||||
claim = _claim(manifest, current_route_key="current-lab-route")
|
||||
response_body = {
|
||||
"comparison": {
|
||||
"id": str(claim.comparison_id),
|
||||
"suite_revision_id": str(claim.suite_revision_id),
|
||||
"current_deployment_id": str(claim.current_deployment_id),
|
||||
"candidate_deployment_id": str(claim.candidate_deployment_id),
|
||||
"expected_cases": claim.expected_cases,
|
||||
"deadline_at": claim.deadline_at.isoformat(),
|
||||
"pins": claim.pins.raw,
|
||||
"pins_digest": claim.pins_digest,
|
||||
},
|
||||
"claim_token": claim.claim_token,
|
||||
"claim_expires_at": claim.claim_expires_at.isoformat(),
|
||||
"cases": [
|
||||
{
|
||||
"id": str(CASE_ID),
|
||||
"suite_revision_id": str(SUITE_REVISION_ID),
|
||||
"case_key": "one",
|
||||
"query": "where is the answer",
|
||||
"relevant_chunk_ids": [str(CHUNK_ID)],
|
||||
"relevant_document_ids": [],
|
||||
"relevance_grades": {},
|
||||
"label_provenance": {},
|
||||
"critical": True,
|
||||
"review_status": "reviewed",
|
||||
"created_at": claim.claim_expires_at.isoformat(),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
def handler(_: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=response_body)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler), follow_redirects=False
|
||||
) as client:
|
||||
parsed = await ModelForgeComparisonClient(
|
||||
client,
|
||||
base_url=manifest.modelforge_base_url,
|
||||
credential_file=manifest.credential_file,
|
||||
).claim(COMPARISON_ID)
|
||||
|
||||
assert parsed.pins.current.dimension == 2
|
||||
assert parsed.pins.current_route_key == "current-lab-route"
|
||||
assert parsed.cases[0].relevant_chunk_ids == (CHUNK_ID,)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_control_client_serializes_uuid_results_without_secret_echo(tmp_path: Path) -> None:
|
||||
credential = tmp_path / "credential"
|
||||
_write_credential(credential)
|
||||
seen: list[httpx.Request] = []
|
||||
response_body = {
|
||||
"status": "completed",
|
||||
"current_completed_cases": 1,
|
||||
"candidate_completed_cases": 1,
|
||||
"claim_expires_at": None,
|
||||
"deadline_at": None,
|
||||
"error_code": None,
|
||||
"baseline_run_id": None,
|
||||
"candidate_run_id": None,
|
||||
"evaluation_comparison_id": None,
|
||||
}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
assert "mf_" not in request.url.path
|
||||
return httpx.Response(200, json=response_body)
|
||||
|
||||
case_id, result_id = uuid4(), uuid4()
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.MockTransport(handler), follow_redirects=False
|
||||
) as client:
|
||||
api = ModelForgeComparisonClient(
|
||||
client,
|
||||
base_url="http://modelforge:8000",
|
||||
credential_file=credential,
|
||||
)
|
||||
await api.submit_result(
|
||||
COMPARISON_ID,
|
||||
"claim-token-" + "x" * 32,
|
||||
"a" * 64,
|
||||
"b" * 64,
|
||||
[
|
||||
{
|
||||
"case_id": case_id,
|
||||
"ranked_results": [{"chunk_id": result_id, "score": 0.5}],
|
||||
"latency_ms": 1.0,
|
||||
"error_code": None,
|
||||
}
|
||||
],
|
||||
[{"case_id": case_id, "ranked_results": [], "latency_ms": 1.0, "error_code": None}],
|
||||
{case_id: uuid4()},
|
||||
{case_id: uuid4()},
|
||||
)
|
||||
|
||||
body = seen[0].content.decode("utf-8")
|
||||
assert str(result_id) in body
|
||||
assert "claim-token" in body
|
||||
assert "mf_" not in body
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Unit coverage for canonical text/code parsing and pinned Docling selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.domain.sources import SourceFingerprint
|
||||
from ragcore.infrastructure.parsers import (
|
||||
DoclingConversion,
|
||||
DoclingParser,
|
||||
PinnedDoclingError,
|
||||
TextParser,
|
||||
)
|
||||
from ragcore.ingestion.parsing import (
|
||||
CanonicalBlockKind,
|
||||
ParseRequest,
|
||||
ParserRouter,
|
||||
UnsupportedParserError,
|
||||
)
|
||||
|
||||
|
||||
def parse_request(content: bytes, name: str, media_type: str) -> ParseRequest:
|
||||
return ParseRequest(uuid4(), SourceFingerprint("a" * 64), content, media_type, name)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_markdown_preserves_headings_tables_code_and_locations() -> None:
|
||||
parser = TextParser()
|
||||
request = parse_request(
|
||||
b"# Title\n\nIntro\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\n```python\nprint(1)\n```\n",
|
||||
"runbook.md",
|
||||
"text/markdown",
|
||||
)
|
||||
|
||||
artifact = await parser.parse(request)
|
||||
|
||||
assert [block.kind for block in artifact.blocks] == [
|
||||
CanonicalBlockKind.HEADING,
|
||||
CanonicalBlockKind.PARAGRAPH,
|
||||
CanonicalBlockKind.TABLE,
|
||||
CanonicalBlockKind.CODE,
|
||||
]
|
||||
assert artifact.blocks[0].source_location == {"line_start": 1, "line_end": 1}
|
||||
assert artifact.blocks[2].content.startswith("| A | B |")
|
||||
assert artifact.blocks[3].metadata["language"] == "python"
|
||||
assert artifact.blocks[3].source_location == {"line_start": 9, "line_end": 11}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_uses_code_parser_before_text_fallback() -> None:
|
||||
from ragcore.infrastructure.parsers import CodeParser
|
||||
|
||||
router = ParserRouter((CodeParser(), TextParser()))
|
||||
artifact = await router.parse(
|
||||
parse_request(b"def f():\n return 1\n", "main.py", "text/plain")
|
||||
)
|
||||
|
||||
assert artifact.extraction_report.normalizer_profile == "code-v1"
|
||||
assert artifact.blocks[0].kind is CanonicalBlockKind.CODE
|
||||
with pytest.raises(UnsupportedParserError):
|
||||
await ParserRouter((TextParser(),)).parse(
|
||||
parse_request(b"%PDF-1.7", "file.pdf", "application/pdf")
|
||||
)
|
||||
|
||||
|
||||
class Backend:
|
||||
version = "2.117.0"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ocr_values: list[bool] = []
|
||||
|
||||
async def convert(self, content: bytes, *, enable_ocr: bool) -> DoclingConversion:
|
||||
self.ocr_values.append(enable_ocr)
|
||||
return DoclingConversion(())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_docling_ocr_is_selected_only_without_a_text_layer() -> None:
|
||||
backend = Backend()
|
||||
parser = DoclingParser(backend)
|
||||
await parser.parse(parse_request(b"pdf", "scan.pdf", "application/pdf"))
|
||||
request = parse_request(b"pdf", "scan.pdf", "application/pdf")
|
||||
request = ParseRequest(
|
||||
request.document_version_id,
|
||||
request.source_fingerprint,
|
||||
request.content,
|
||||
request.media_type,
|
||||
request.original_name,
|
||||
ocr_required=True,
|
||||
text_layer_present=False,
|
||||
)
|
||||
artifact = await parser.parse(request)
|
||||
|
||||
assert backend.ocr_values == [False, True]
|
||||
assert artifact.extraction_report.ocr_used is True
|
||||
|
||||
|
||||
def test_docling_minor_version_is_pinned() -> None:
|
||||
class OldBackend(Backend):
|
||||
version = "2.47.9"
|
||||
|
||||
with pytest.raises(PinnedDoclingError):
|
||||
DoclingParser(OldBackend())
|
||||
@@ -0,0 +1,40 @@
|
||||
"""safe_attributes: sensitive query/document text is absent by default."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ragcore.platform.telemetry import safe_attributes
|
||||
|
||||
|
||||
def test_sensitive_keys_are_dropped_entirely() -> None:
|
||||
result = safe_attributes(
|
||||
{
|
||||
"query": "what is the secret plan",
|
||||
"document": "confidential contents",
|
||||
"prompt": "system prompt text",
|
||||
"answer": "the generated answer",
|
||||
"excerpt": "a citation excerpt",
|
||||
"duration_ms": 12.5,
|
||||
}
|
||||
)
|
||||
assert result == {"duration_ms": 12.5}
|
||||
|
||||
|
||||
def test_matching_is_case_insensitive_and_substring_based() -> None:
|
||||
result = safe_attributes(
|
||||
{"UserQuery": "text", "Document_Id_Content": "text", "status_code": 200}
|
||||
)
|
||||
assert result == {"status_code": 200}
|
||||
|
||||
|
||||
def test_unsupported_value_types_are_dropped() -> None:
|
||||
result = safe_attributes({"safe_number": 1, "unsafe_dict": {"a": 1}, "unsafe_list": [1, 2]})
|
||||
assert result == {"safe_number": 1}
|
||||
|
||||
|
||||
def test_boolean_and_string_values_pass_through() -> None:
|
||||
result = safe_attributes({"degraded": True, "operation": "search"})
|
||||
assert result == {"degraded": True, "operation": "search"}
|
||||
|
||||
|
||||
def test_empty_attributes_produce_an_empty_result() -> None:
|
||||
assert safe_attributes({}) == {}
|
||||
@@ -0,0 +1,65 @@
|
||||
"""ops/dashboards and ops/alerts stay in sync with the real metric/attribute names."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
_REPO_ROOT = Path(__file__).parents[4]
|
||||
|
||||
|
||||
def _known_metric_names() -> set[str]:
|
||||
"""The stable metric names TelemetryMetrics actually creates."""
|
||||
|
||||
return {
|
||||
"ragcore_request_duration_seconds",
|
||||
"ragcore_request_errors_total",
|
||||
"ragcore_job_queue_depth",
|
||||
"ragcore_job_age_seconds",
|
||||
"ragcore_qdrant_errors_total",
|
||||
"ragcore_provider_errors_total",
|
||||
}
|
||||
|
||||
|
||||
def test_operations_dashboard_only_references_real_metric_names() -> None:
|
||||
document = yaml.safe_load(
|
||||
(_REPO_ROOT / "ops" / "dashboards" / "operations_overview.yaml").read_text()
|
||||
)
|
||||
known = _known_metric_names()
|
||||
for panel in document["panels"]:
|
||||
assert panel["metric"] in known, f"{panel['metric']} is not a real TelemetryMetrics metric"
|
||||
|
||||
|
||||
def test_operations_dashboard_correlation_attribute_matches_the_real_span_attribute() -> None:
|
||||
document = yaml.safe_load(
|
||||
(_REPO_ROOT / "ops" / "dashboards" / "operations_overview.yaml").read_text()
|
||||
)
|
||||
assert document["correlation"]["attribute"] == "correlation_id"
|
||||
|
||||
|
||||
def test_queue_age_alert_references_a_real_metric_name() -> None:
|
||||
document = yaml.safe_load((_REPO_ROOT / "ops" / "alerts" / "queue_age_slo.yaml").read_text())
|
||||
assert document["alert"] == "JobQueueAgeSloBreached"
|
||||
assert document["signal"]["metric"] in _known_metric_names()
|
||||
assert document["signal"]["metric"] == "ragcore_job_age_seconds"
|
||||
|
||||
|
||||
def test_declared_metric_names_are_exhaustive_against_the_real_instruments() -> None:
|
||||
"""Guards the other direction: if a new metric is added to
|
||||
TelemetryMetrics without updating the dashboard, this fails instead of
|
||||
the dashboard silently going stale."""
|
||||
|
||||
telemetry = TelemetryMetrics(meter_name="dashboard-completeness-check")
|
||||
instrument_attrs = (
|
||||
"request_duration_seconds",
|
||||
"request_errors_total",
|
||||
"job_queue_depth",
|
||||
"job_age_seconds",
|
||||
"qdrant_errors_total",
|
||||
"provider_errors_total",
|
||||
)
|
||||
for attr in instrument_attrs:
|
||||
assert hasattr(telemetry, attr)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""TelemetryMetrics: the representative required-metrics subset, recorded correctly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
from ragcore.platform.telemetry import TelemetryMetrics
|
||||
|
||||
|
||||
def _telemetry() -> tuple[TelemetryMetrics, InMemoryMetricReader]:
|
||||
"""A fresh, isolated MeterProvider per call.
|
||||
|
||||
OpenTelemetry's global provider may only be set once per process, so
|
||||
each test builds its own explicit provider and passes it directly to
|
||||
``TelemetryMetrics`` rather than fighting over global state.
|
||||
"""
|
||||
|
||||
reader = InMemoryMetricReader()
|
||||
provider = MeterProvider(
|
||||
resource=Resource.create({"service.name": "test"}), metric_readers=[reader]
|
||||
)
|
||||
return TelemetryMetrics(meter_provider=provider), reader
|
||||
|
||||
|
||||
def _metric_named(reader: InMemoryMetricReader, name: str) -> Any | None:
|
||||
data = reader.get_metrics_data()
|
||||
if data is None:
|
||||
return None
|
||||
for resource_metric in data.resource_metrics:
|
||||
for scope_metric in resource_metric.scope_metrics:
|
||||
for metric in scope_metric.metrics:
|
||||
if metric.name == name:
|
||||
return metric
|
||||
return None
|
||||
|
||||
|
||||
def _data_points(metric: Any) -> list[Any]:
|
||||
return list(metric.data.data_points)
|
||||
|
||||
|
||||
def test_successful_requests_record_duration_but_no_error() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.request_duration_seconds.record(0.042, attributes={"status_code": 200})
|
||||
|
||||
duration_metric = _metric_named(reader, "ragcore_request_duration_seconds")
|
||||
assert duration_metric is not None
|
||||
points = _data_points(duration_metric)
|
||||
assert len(points) == 1
|
||||
assert points[0].sum == pytest.approx(0.042)
|
||||
|
||||
|
||||
def test_server_errors_and_rate_limits_increment_the_error_counter() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_request(duration_seconds=0.1, status_code=200)
|
||||
telemetry.record_request(duration_seconds=0.2, status_code=503)
|
||||
telemetry.record_request(duration_seconds=0.3, status_code=429)
|
||||
|
||||
error_metric = _metric_named(reader, "ragcore_request_errors_total")
|
||||
assert error_metric is not None
|
||||
total = sum(point.value for point in _data_points(error_metric))
|
||||
assert total == 2
|
||||
|
||||
|
||||
def test_job_queue_depth_rises_on_enqueue_and_falls_on_claim() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_job_enqueued()
|
||||
telemetry.record_job_enqueued()
|
||||
telemetry.record_job_claimed(age_seconds=5.0)
|
||||
|
||||
depth_metric = _metric_named(reader, "ragcore_job_queue_depth")
|
||||
assert depth_metric is not None
|
||||
net_depth = sum(point.value for point in _data_points(depth_metric))
|
||||
assert net_depth == 1
|
||||
|
||||
age_metric = _metric_named(reader, "ragcore_job_age_seconds")
|
||||
assert age_metric is not None
|
||||
assert _data_points(age_metric)[0].sum == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_qdrant_and_provider_errors_are_recorded_by_operation_and_provider() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_qdrant_error(operation="upsert")
|
||||
telemetry.record_provider_error(provider="ollama-generation")
|
||||
|
||||
qdrant_metric = _metric_named(reader, "ragcore_qdrant_errors_total")
|
||||
provider_metric = _metric_named(reader, "ragcore_provider_errors_total")
|
||||
assert qdrant_metric is not None
|
||||
assert provider_metric is not None
|
||||
(qdrant_point,) = _data_points(qdrant_metric)
|
||||
(provider_point,) = _data_points(provider_metric)
|
||||
assert dict(qdrant_point.attributes)["operation"] == "upsert"
|
||||
assert dict(provider_point.attributes)["provider"] == "ollama-generation"
|
||||
|
||||
|
||||
def test_database_errors_are_recorded_by_operation() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_database_error(operation="publish_validated")
|
||||
|
||||
metric = _metric_named(reader, "ragcore_database_errors_total")
|
||||
assert metric is not None
|
||||
(point,) = _data_points(metric)
|
||||
assert dict(point.attributes)["operation"] == "publish_validated"
|
||||
|
||||
|
||||
def test_provider_duration_is_recorded_by_provider() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_provider_duration(provider="ollama-embedding", duration_seconds=0.25)
|
||||
|
||||
metric = _metric_named(reader, "ragcore_provider_duration_seconds")
|
||||
assert metric is not None
|
||||
(point,) = _data_points(metric)
|
||||
assert dict(point.attributes)["provider"] == "ollama-embedding"
|
||||
assert point.sum == pytest.approx(0.25)
|
||||
|
||||
|
||||
def test_retrieval_duration_and_zero_result_are_recorded() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_retrieval(duration_seconds=0.05, zero_result=False)
|
||||
telemetry.record_retrieval(duration_seconds=0.03, zero_result=True)
|
||||
|
||||
duration_metric = _metric_named(reader, "ragcore_retrieval_duration_seconds")
|
||||
zero_result_metric = _metric_named(reader, "ragcore_retrieval_zero_result_total")
|
||||
assert duration_metric is not None
|
||||
assert zero_result_metric is not None
|
||||
points = _data_points(duration_metric)
|
||||
assert len(points) == 1
|
||||
assert points[0].count == 2
|
||||
assert sum(point.value for point in _data_points(zero_result_metric)) == 1
|
||||
|
||||
|
||||
def test_answerability_result_is_recorded() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_answerability(result="answerable")
|
||||
telemetry.record_answerability(result="unanswerable")
|
||||
|
||||
metric = _metric_named(reader, "ragcore_answerability_result_total")
|
||||
assert metric is not None
|
||||
results = {dict(point.attributes)["result"] for point in _data_points(metric)}
|
||||
assert results == {"answerable", "unanswerable"}
|
||||
|
||||
|
||||
def test_ingestion_job_result_is_recorded_by_operation_and_result() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_ingestion_job_result(operation="ingest_upload", result="succeeded")
|
||||
|
||||
metric = _metric_named(reader, "ragcore_ingestion_job_result_total")
|
||||
assert metric is not None
|
||||
(point,) = _data_points(metric)
|
||||
assert dict(point.attributes) == {"operation": "ingest_upload", "result": "succeeded"}
|
||||
|
||||
|
||||
def test_rate_limit_refusal_is_recorded_by_operation() -> None:
|
||||
telemetry, reader = _telemetry()
|
||||
telemetry.record_rate_limit_refusal(operation="search")
|
||||
|
||||
metric = _metric_named(reader, "ragcore_rate_limit_refusals_total")
|
||||
assert metric is not None
|
||||
(point,) = _data_points(metric)
|
||||
assert dict(point.attributes)["operation"] == "search"
|
||||
@@ -0,0 +1,129 @@
|
||||
"""correlated_span: one real OpenTelemetry trace across separate components."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
from ragcore.platform.telemetry import correlated_span, trace_id_from_correlation_id
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracing() -> Iterator[tuple[TracerProvider, InMemorySpanExporter]]:
|
||||
"""A fresh, isolated TracerProvider per test.
|
||||
|
||||
OpenTelemetry's global provider may only be set once per process, so
|
||||
each test gets its own explicit provider passed to ``correlated_span``
|
||||
rather than fighting over global state.
|
||||
"""
|
||||
|
||||
span_exporter = InMemorySpanExporter()
|
||||
provider = TracerProvider()
|
||||
provider.add_span_processor(SimpleSpanProcessor(span_exporter))
|
||||
yield provider, span_exporter
|
||||
|
||||
|
||||
def test_trace_id_is_deterministic_for_the_same_correlation_id() -> None:
|
||||
assert trace_id_from_correlation_id("req-123") == trace_id_from_correlation_id("req-123")
|
||||
|
||||
|
||||
def test_trace_id_differs_for_different_correlation_ids() -> None:
|
||||
assert trace_id_from_correlation_id("req-123") != trace_id_from_correlation_id("req-456")
|
||||
|
||||
|
||||
def test_trace_id_is_never_zero() -> None:
|
||||
assert trace_id_from_correlation_id("x") != 0
|
||||
|
||||
|
||||
def test_empty_correlation_id_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="correlation_id"):
|
||||
trace_id_from_correlation_id("")
|
||||
|
||||
|
||||
def test_two_spans_from_different_components_share_one_trace(
|
||||
tracing: tuple[TracerProvider, InMemorySpanExporter],
|
||||
) -> None:
|
||||
"""The core acceptance proof: an "API request" span and a later "job
|
||||
processing" span, opened independently with the same correlation id,
|
||||
land in the literal same OpenTelemetry trace -- not just a matching
|
||||
attribute a human has to notice.
|
||||
"""
|
||||
|
||||
provider, exporter = tracing
|
||||
correlation_id = "req-shared-across-request-and-job"
|
||||
|
||||
with correlated_span(
|
||||
"api.handle_request", correlation_id=correlation_id, tracer_provider=provider
|
||||
):
|
||||
pass
|
||||
|
||||
with correlated_span("job.process", correlation_id=correlation_id, tracer_provider=provider):
|
||||
pass
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 2
|
||||
trace_ids = {span.context.trace_id for span in spans}
|
||||
assert len(trace_ids) == 1
|
||||
assert trace_ids.pop() == trace_id_from_correlation_id(correlation_id)
|
||||
|
||||
|
||||
def test_spans_from_different_correlation_ids_are_in_different_traces(
|
||||
tracing: tuple[TracerProvider, InMemorySpanExporter],
|
||||
) -> None:
|
||||
provider, exporter = tracing
|
||||
with correlated_span("api.handle_request", correlation_id="req-1", tracer_provider=provider):
|
||||
pass
|
||||
with correlated_span("api.handle_request", correlation_id="req-2", tracer_provider=provider):
|
||||
pass
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
trace_ids = {span.context.trace_id for span in spans}
|
||||
assert len(trace_ids) == 2
|
||||
|
||||
|
||||
def test_correlation_id_is_always_set_as_a_safe_attribute(
|
||||
tracing: tuple[TracerProvider, InMemorySpanExporter],
|
||||
) -> None:
|
||||
provider, exporter = tracing
|
||||
with correlated_span("api.handle_request", correlation_id="req-999", tracer_provider=provider):
|
||||
pass
|
||||
|
||||
(span,) = exporter.get_finished_spans()
|
||||
assert span.attributes["correlation_id"] == "req-999"
|
||||
|
||||
|
||||
def test_sensitive_attributes_are_dropped_before_reaching_the_span(
|
||||
tracing: tuple[TracerProvider, InMemorySpanExporter],
|
||||
) -> None:
|
||||
provider, exporter = tracing
|
||||
with correlated_span(
|
||||
"api.handle_request",
|
||||
correlation_id="req-1",
|
||||
tracer_provider=provider,
|
||||
attributes={"query": "sensitive text", "duration_ms": 42},
|
||||
):
|
||||
pass
|
||||
|
||||
(span,) = exporter.get_finished_spans()
|
||||
assert "query" not in span.attributes
|
||||
assert span.attributes["duration_ms"] == 42
|
||||
|
||||
|
||||
def test_an_exception_inside_the_span_is_recorded_and_reraised(
|
||||
tracing: tuple[TracerProvider, InMemorySpanExporter],
|
||||
) -> None:
|
||||
provider, exporter = tracing
|
||||
with (
|
||||
pytest.raises(RuntimeError, match="boom"),
|
||||
correlated_span("api.handle_request", correlation_id="req-1", tracer_provider=provider),
|
||||
):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
(span,) = exporter.get_finished_spans()
|
||||
assert span.status.status_code == trace.StatusCode.ERROR
|
||||
assert any(event.name == "exception" for event in span.events)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""AnswerabilityClassifier: deterministic, content-free, non-probabilistic decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.retrieval.answerability import (
|
||||
MAX_EVIDENCE_ITEMS,
|
||||
AnswerabilityClassifier,
|
||||
AnswerabilityDecision,
|
||||
AnswerabilityReason,
|
||||
AnswerabilityStatus,
|
||||
AnswerabilityThresholds,
|
||||
EvidenceSignal,
|
||||
)
|
||||
|
||||
|
||||
def make_signal(**changes: object) -> EvidenceSignal:
|
||||
value: dict[str, object] = {
|
||||
"document_id": uuid4(),
|
||||
"source_id": uuid4(),
|
||||
"trust_level": "source",
|
||||
"fused_score": 0.05,
|
||||
"rerank_score": 0.8,
|
||||
"covers_distinct_facet": True,
|
||||
"is_fresh": True,
|
||||
"exact_identifier_match": False,
|
||||
}
|
||||
value.update(changes)
|
||||
return EvidenceSignal(**value)
|
||||
|
||||
|
||||
def default_thresholds(**changes: object) -> AnswerabilityThresholds:
|
||||
value: dict[str, object] = {"version": "answerability-v1"}
|
||||
value.update(changes)
|
||||
return AnswerabilityThresholds(**value)
|
||||
|
||||
|
||||
def test_sufficient_diverse_evidence_is_answerable() -> None:
|
||||
evidence = (make_signal(), make_signal())
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.ANSWERABLE
|
||||
assert decision.reasons == (AnswerabilityReason.SUFFICIENT_EVIDENCE,)
|
||||
assert decision.evidence_count == 2
|
||||
assert decision.document_diversity == 2
|
||||
assert decision.source_independence == 2
|
||||
assert decision.facet_coverage_ratio == 1.0
|
||||
assert decision.thresholds_version == "answerability-v1"
|
||||
|
||||
|
||||
def test_empty_evidence_is_not_answerable() -> None:
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(())
|
||||
|
||||
assert decision.status is AnswerabilityStatus.NOT_ANSWERABLE
|
||||
assert AnswerabilityReason.INSUFFICIENT_EVIDENCE in decision.reasons
|
||||
assert decision.evidence_count == 0
|
||||
assert decision.document_diversity == 0
|
||||
|
||||
|
||||
def test_insufficient_evidence_count_is_not_answerable() -> None:
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify((make_signal(),))
|
||||
|
||||
assert decision.status is AnswerabilityStatus.NOT_ANSWERABLE
|
||||
assert AnswerabilityReason.INSUFFICIENT_EVIDENCE in decision.reasons
|
||||
|
||||
|
||||
def test_low_document_diversity_is_not_answerable() -> None:
|
||||
document_id = uuid4()
|
||||
evidence = (
|
||||
make_signal(document_id=document_id, source_id=uuid4()),
|
||||
make_signal(document_id=document_id, source_id=uuid4()),
|
||||
)
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.NOT_ANSWERABLE
|
||||
assert AnswerabilityReason.LOW_DOCUMENT_DIVERSITY in decision.reasons
|
||||
assert decision.document_diversity == 1
|
||||
|
||||
|
||||
def test_low_source_independence_is_not_answerable() -> None:
|
||||
source_id = uuid4()
|
||||
evidence = (
|
||||
make_signal(document_id=uuid4(), source_id=source_id),
|
||||
make_signal(document_id=uuid4(), source_id=source_id),
|
||||
)
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.NOT_ANSWERABLE
|
||||
assert AnswerabilityReason.LOW_SOURCE_INDEPENDENCE in decision.reasons
|
||||
assert decision.source_independence == 1
|
||||
|
||||
|
||||
def test_below_rerank_threshold_is_not_answerable_when_gating_enabled() -> None:
|
||||
thresholds = default_thresholds(min_rerank_score=0.5)
|
||||
evidence = (
|
||||
make_signal(rerank_score=0.1),
|
||||
make_signal(rerank_score=0.2),
|
||||
)
|
||||
decision = AnswerabilityClassifier(thresholds).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.NOT_ANSWERABLE
|
||||
assert AnswerabilityReason.BELOW_RERANK_THRESHOLD in decision.reasons
|
||||
|
||||
|
||||
def test_rerank_gating_disabled_by_default_ignores_low_or_missing_scores() -> None:
|
||||
evidence = (
|
||||
make_signal(rerank_score=None),
|
||||
make_signal(rerank_score=None),
|
||||
)
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.ANSWERABLE
|
||||
|
||||
|
||||
def test_partial_facet_coverage_is_partially_answerable() -> None:
|
||||
evidence = (
|
||||
make_signal(covers_distinct_facet=True),
|
||||
make_signal(covers_distinct_facet=False),
|
||||
)
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(
|
||||
evidence, total_query_facets=2
|
||||
)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.PARTIALLY_ANSWERABLE
|
||||
assert AnswerabilityReason.PARTIAL_FACET_COVERAGE in decision.reasons
|
||||
assert decision.facet_coverage_ratio == 0.5
|
||||
|
||||
|
||||
def test_conflicting_evidence_fixture_is_classified_despite_otherwise_meeting_thresholds() -> None:
|
||||
first_document, second_document = uuid4(), uuid4()
|
||||
evidence = (
|
||||
make_signal(
|
||||
document_id=first_document,
|
||||
source_id=uuid4(),
|
||||
conflicts_with_documents=frozenset({second_document}),
|
||||
),
|
||||
make_signal(document_id=second_document, source_id=uuid4()),
|
||||
)
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.CONFLICTING_EVIDENCE
|
||||
assert decision.reasons == (AnswerabilityReason.CONFLICTING_SOURCES,)
|
||||
|
||||
|
||||
def test_conflict_pointing_outside_the_current_evidence_set_does_not_trigger() -> None:
|
||||
evidence = (make_signal(conflicts_with_documents=frozenset({uuid4()})), make_signal())
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(evidence)
|
||||
|
||||
assert decision.status is AnswerabilityStatus.ANSWERABLE
|
||||
|
||||
|
||||
def test_decision_never_carries_a_probability_or_confidence_field() -> None:
|
||||
decision = AnswerabilityClassifier(default_thresholds()).classify(
|
||||
(make_signal(), make_signal())
|
||||
)
|
||||
field_names = {field for field in decision.__dataclass_fields__}
|
||||
|
||||
assert "confidence" not in field_names
|
||||
assert "probability" not in field_names
|
||||
assert all(isinstance(reason, AnswerabilityReason) for reason in decision.reasons)
|
||||
|
||||
|
||||
def test_thresholds_version_is_validated_and_propagated() -> None:
|
||||
with pytest.raises(ValueError, match="version"):
|
||||
AnswerabilityThresholds(version="Not A Safe Version!")
|
||||
|
||||
decision = AnswerabilityClassifier(default_thresholds(version="answerability-v2")).classify(
|
||||
(make_signal(), make_signal())
|
||||
)
|
||||
assert decision.thresholds_version == "answerability-v2"
|
||||
|
||||
|
||||
def test_thresholds_are_bounded() -> None:
|
||||
with pytest.raises(ValueError, match="min_evidence_count"):
|
||||
default_thresholds(min_evidence_count=0)
|
||||
with pytest.raises(ValueError, match="min_facet_coverage_ratio"):
|
||||
default_thresholds(min_facet_coverage_ratio=0.0)
|
||||
with pytest.raises(ValueError, match="min_facet_coverage_ratio"):
|
||||
default_thresholds(min_facet_coverage_ratio=1.5)
|
||||
|
||||
|
||||
def test_evidence_input_is_bounded() -> None:
|
||||
classifier = AnswerabilityClassifier(default_thresholds())
|
||||
with pytest.raises(ValueError, match="bounded limit"):
|
||||
classifier.classify(tuple(make_signal() for _ in range(MAX_EVIDENCE_ITEMS + 1)))
|
||||
with pytest.raises(ValueError, match="total_query_facets"):
|
||||
classifier.classify((make_signal(),), total_query_facets=0)
|
||||
|
||||
|
||||
def test_evidence_signal_rejects_self_conflict_and_nil_ids() -> None:
|
||||
document_id = uuid4()
|
||||
with pytest.raises(ValueError, match="own document"):
|
||||
make_signal(document_id=document_id, conflicts_with_documents=frozenset({document_id}))
|
||||
with pytest.raises(ValueError, match="document_id"):
|
||||
make_signal(document_id=UUID(int=0))
|
||||
|
||||
|
||||
def test_evidence_signal_rejects_nonfinite_scores() -> None:
|
||||
with pytest.raises(ValueError, match="fused_score"):
|
||||
make_signal(fused_score=float("nan"))
|
||||
with pytest.raises(ValueError, match="rerank_score"):
|
||||
make_signal(rerank_score=float("inf"))
|
||||
|
||||
|
||||
def test_classification_is_deterministic_given_the_same_input() -> None:
|
||||
evidence = (make_signal(), make_signal(), make_signal(covers_distinct_facet=False))
|
||||
classifier = AnswerabilityClassifier(default_thresholds())
|
||||
|
||||
first = classifier.classify(evidence, total_query_facets=2)
|
||||
second = classifier.classify(evidence, total_query_facets=2)
|
||||
|
||||
assert first == second
|
||||
assert isinstance(first, AnswerabilityDecision)
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.retrieval.context import (
|
||||
ContextAssembler,
|
||||
ContextCandidate,
|
||||
ContextContractError,
|
||||
)
|
||||
from ragcore.retrieval.context.models import WordTokenCounter
|
||||
|
||||
|
||||
def make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
document_id,
|
||||
version_id,
|
||||
chunk_id,
|
||||
run_id,
|
||||
excerpt,
|
||||
*,
|
||||
parent_chunk_id=None,
|
||||
):
|
||||
return Citation.create(
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=parent_chunk_id,
|
||||
location={"page_start": 1},
|
||||
excerpt=excerpt,
|
||||
source_uri="https://example.test/context.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
|
||||
def candidate(citation, text, rank, score):
|
||||
return ContextCandidate(citation=citation, text=text, rank=rank, score=score)
|
||||
|
||||
|
||||
def test_token_budget_never_exceeded() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
scope = AuthorizedRetrievalScope(workspace_id, frozenset({space_id}))
|
||||
candidates = tuple(
|
||||
candidate(
|
||||
make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
run_id,
|
||||
"one two",
|
||||
),
|
||||
"one two",
|
||||
rank,
|
||||
1.0 / rank,
|
||||
)
|
||||
for rank in (1, 2, 3)
|
||||
)
|
||||
|
||||
package = ContextAssembler(token_counter=WordTokenCounter()).assemble(
|
||||
candidates,
|
||||
scope=scope,
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=8,
|
||||
)
|
||||
|
||||
assert package.token_count <= 8
|
||||
assert package.token_count == sum(block.token_count for block in package.blocks)
|
||||
|
||||
|
||||
def test_context_preserves_delimiters_and_parent_provenance() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
parent = make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
document_id,
|
||||
version_id,
|
||||
parent_id,
|
||||
run_id,
|
||||
"parent evidence",
|
||||
)
|
||||
child = make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
document_id,
|
||||
version_id,
|
||||
child_id,
|
||||
run_id,
|
||||
"child evidence",
|
||||
parent_chunk_id=parent_id,
|
||||
)
|
||||
package = ContextAssembler().assemble(
|
||||
(ContextCandidate(child, "child evidence", 1, 0.9, "parent evidence", parent),),
|
||||
scope=AuthorizedRetrievalScope(workspace_id, frozenset({space_id})),
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
|
||||
block = package.blocks[0]
|
||||
assert str(parent.id) in block.text
|
||||
assert str(child.id) in block.text
|
||||
assert block.text.index(str(parent.id)) < block.text.index(str(child.id))
|
||||
assert block.citation_ids == (parent.id, child.id)
|
||||
|
||||
|
||||
def test_unauthorized_parent_expansion_is_rejected() -> None:
|
||||
workspace_id, space_id, forbidden_space, run_id = uuid4(), uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
parent = make_citation(
|
||||
workspace_id,
|
||||
forbidden_space,
|
||||
document_id,
|
||||
version_id,
|
||||
parent_id,
|
||||
run_id,
|
||||
"forbidden parent",
|
||||
)
|
||||
child = make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
document_id,
|
||||
version_id,
|
||||
child_id,
|
||||
run_id,
|
||||
"allowed child",
|
||||
parent_chunk_id=parent_id,
|
||||
)
|
||||
|
||||
with pytest.raises(ContextContractError):
|
||||
ContextAssembler().assemble(
|
||||
(ContextCandidate(child, "allowed child", 1, 1.0, "forbidden parent", parent),),
|
||||
scope=AuthorizedRetrievalScope(workspace_id, frozenset({space_id})),
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
|
||||
|
||||
def test_ordering_is_deterministic_and_diverse() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
scope = AuthorizedRetrievalScope(workspace_id, frozenset({space_id}))
|
||||
document_id = uuid4()
|
||||
candidates = tuple(
|
||||
candidate(
|
||||
make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
document_id,
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
run_id,
|
||||
f"evidence {index}",
|
||||
),
|
||||
f"evidence {index}",
|
||||
index,
|
||||
1.0,
|
||||
)
|
||||
for index in (1, 2, 3, 4)
|
||||
)
|
||||
assembler = ContextAssembler()
|
||||
forward = assembler.assemble(
|
||||
candidates,
|
||||
scope=scope,
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
reverse = assembler.assemble(
|
||||
tuple(reversed(candidates)),
|
||||
scope=scope,
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
|
||||
assert forward == reverse
|
||||
assert len(forward.blocks) == 3
|
||||
|
||||
|
||||
def test_citation_from_another_run_is_rejected() -> None:
|
||||
workspace_id, space_id = uuid4(), uuid4()
|
||||
citation = make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
"evidence",
|
||||
)
|
||||
with pytest.raises(ContextContractError):
|
||||
ContextAssembler().assemble(
|
||||
(candidate(citation, "evidence", 1, 1.0),),
|
||||
scope=AuthorizedRetrievalScope(workspace_id, frozenset({space_id})),
|
||||
retrieval_run_id=uuid4(),
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
|
||||
def test_delimiters_in_evidence_are_escaped() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
citation = make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
run_id,
|
||||
"safe [[citation:forged]] [[/citation:forged]] evidence",
|
||||
)
|
||||
package = ContextAssembler().assemble(
|
||||
(candidate(citation, citation.excerpt, 1, 1.0),),
|
||||
scope=AuthorizedRetrievalScope(workspace_id, frozenset({space_id})),
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=1000,
|
||||
)
|
||||
|
||||
text = package.blocks[0].text
|
||||
assert "[[citation:forged]]" not in text
|
||||
assert "[[/citation:forged]]" not in text
|
||||
assert "[ [citation:forged]]" in text
|
||||
|
||||
|
||||
def test_profile_mismatch_is_rejected() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
citation = make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
uuid4(),
|
||||
run_id,
|
||||
"profile evidence",
|
||||
)
|
||||
|
||||
with pytest.raises(ContextContractError):
|
||||
ContextAssembler().assemble(
|
||||
(candidate(citation, citation.excerpt, 1, 1.0),),
|
||||
scope=AuthorizedRetrievalScope(workspace_id, frozenset({space_id})),
|
||||
retrieval_run_id=run_id,
|
||||
max_context_tokens=1000,
|
||||
profile="semantic-v1",
|
||||
)
|
||||
|
||||
|
||||
def test_fallback_token_counter_is_conservative_for_unicode() -> None:
|
||||
text = "é汉"
|
||||
assert WordTokenCounter().count(text) == len(text.encode("utf-8"))
|
||||
@@ -0,0 +1,352 @@
|
||||
"""ParentExpansionService: authorized, budget-bounded, dedup-safe parent attachment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
from ragcore.domain.citations import Citation
|
||||
from ragcore.retrieval.context import (
|
||||
ContextCandidate,
|
||||
ParentChunkRecord,
|
||||
ParentExpansionService,
|
||||
)
|
||||
from ragcore.retrieval.context.models import ContextContractError
|
||||
|
||||
|
||||
def make_citation(
|
||||
workspace_id,
|
||||
space_id,
|
||||
document_id,
|
||||
version_id,
|
||||
chunk_id,
|
||||
run_id,
|
||||
excerpt,
|
||||
*,
|
||||
parent_chunk_id=None,
|
||||
):
|
||||
return Citation.create(
|
||||
workspace_id=workspace_id,
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=chunk_id,
|
||||
parent_chunk_id=parent_chunk_id,
|
||||
location={"page_start": 1},
|
||||
excerpt=excerpt,
|
||||
source_uri="https://example.test/context.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
|
||||
def make_record(
|
||||
document_id,
|
||||
version_id,
|
||||
space_id,
|
||||
chunk_id,
|
||||
text,
|
||||
) -> ParentChunkRecord:
|
||||
return ParentChunkRecord(
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
space_id=space_id,
|
||||
chunk_id=chunk_id,
|
||||
location={"page_start": 1},
|
||||
text=text,
|
||||
source_uri="https://example.test/parent.md",
|
||||
acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
class FakeLookup:
|
||||
"""Deterministic authorized-lookup fixture: records keyed by chunk id."""
|
||||
|
||||
def __init__(self, records: dict[UUID, ParentChunkRecord | None]) -> None:
|
||||
self._records = records
|
||||
self.calls: list[UUID] = []
|
||||
|
||||
async def get(self, parent_chunk_id, *, scope):
|
||||
self.calls.append(parent_chunk_id)
|
||||
return self._records.get(parent_chunk_id)
|
||||
|
||||
|
||||
def _scope(workspace_id, space_id) -> AuthorizedRetrievalScope:
|
||||
return AuthorizedRetrievalScope(workspace_id, frozenset({space_id}))
|
||||
|
||||
|
||||
async def test_authorized_parent_is_attached_and_improves_context() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, child_id, run_id,
|
||||
"child text", parent_chunk_id=parent_id,
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "child text", 1, 1.0)
|
||||
lookup = FakeLookup(
|
||||
{parent_id: make_record(document_id, version_id, space_id, parent_id, "parent text")}
|
||||
)
|
||||
service = ParentExpansionService(lookup)
|
||||
|
||||
(result,) = await service.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert result.parent_text == "parent text"
|
||||
assert result.parent_citation is not None
|
||||
assert result.parent_citation.chunk_id == parent_id
|
||||
assert result.parent_citation.excerpt == "parent text"
|
||||
|
||||
|
||||
async def test_missing_or_forbidden_parent_is_never_included() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, child_id, run_id,
|
||||
"child text", parent_chunk_id=parent_id,
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "child text", 1, 1.0)
|
||||
# The lookup port itself denies the parent (inactive/forbidden/not found).
|
||||
lookup = FakeLookup({parent_id: None})
|
||||
service = ParentExpansionService(lookup)
|
||||
|
||||
(result,) = await service.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert result.parent_text is None
|
||||
assert result.parent_citation is None
|
||||
|
||||
|
||||
async def test_parent_outside_effective_scope_is_rejected_defensively() -> None:
|
||||
workspace_id, space_id, forbidden_space, run_id = uuid4(), uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, child_id, run_id,
|
||||
"child text", parent_chunk_id=parent_id,
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "child text", 1, 1.0)
|
||||
# A misbehaving/compromised lookup port claims a parent from a space the
|
||||
# caller never granted; the service must reject it regardless.
|
||||
lookup = FakeLookup(
|
||||
{parent_id: make_record(document_id, version_id, forbidden_space, parent_id, "leaked text")}
|
||||
)
|
||||
service = ParentExpansionService(lookup)
|
||||
|
||||
(result,) = await service.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert result.parent_citation is None
|
||||
assert result.parent_text is None
|
||||
|
||||
|
||||
async def test_parent_identical_to_child_text_is_not_attached() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, child_id, run_id,
|
||||
"same text", parent_chunk_id=parent_id,
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "same text", 1, 1.0)
|
||||
lookup = FakeLookup(
|
||||
{parent_id: make_record(document_id, version_id, space_id, parent_id, "same text")}
|
||||
)
|
||||
service = ParentExpansionService(lookup)
|
||||
|
||||
(result,) = await service.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert result.parent_citation is None
|
||||
|
||||
|
||||
async def test_candidate_without_a_parent_chunk_never_triggers_a_lookup() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, chunk_id, run_id, "top-level text"
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "top-level text", 1, 1.0)
|
||||
lookup = FakeLookup({})
|
||||
service = ParentExpansionService(lookup)
|
||||
|
||||
(result,) = await service.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert result.parent_citation is None
|
||||
assert lookup.calls == []
|
||||
|
||||
|
||||
async def test_shared_parent_is_expanded_only_once_avoiding_duplicate_text() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id = uuid4()
|
||||
first_child, second_child = uuid4(), uuid4()
|
||||
candidates = (
|
||||
ContextCandidate(
|
||||
make_citation(
|
||||
workspace_id, space_id, document_id, version_id, first_child, run_id,
|
||||
"first child", parent_chunk_id=parent_id,
|
||||
),
|
||||
"first child",
|
||||
1,
|
||||
1.0,
|
||||
),
|
||||
ContextCandidate(
|
||||
make_citation(
|
||||
workspace_id, space_id, document_id, version_id, second_child, run_id,
|
||||
"second child", parent_chunk_id=parent_id,
|
||||
),
|
||||
"second child",
|
||||
2,
|
||||
0.5,
|
||||
),
|
||||
)
|
||||
lookup = FakeLookup(
|
||||
{parent_id: make_record(document_id, version_id, space_id, parent_id, "shared parent")}
|
||||
)
|
||||
service = ParentExpansionService(lookup)
|
||||
|
||||
first, second = await service.expand(
|
||||
candidates,
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert first.parent_text == "shared parent"
|
||||
assert second.parent_text is None
|
||||
assert lookup.calls == [parent_id] # looked up once, not twice
|
||||
|
||||
|
||||
async def test_expansion_budget_is_bounded_and_enforced_in_order() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_ids = [uuid4(), uuid4(), uuid4()]
|
||||
candidates = tuple(
|
||||
ContextCandidate(
|
||||
make_citation(
|
||||
workspace_id, space_id, document_id, version_id, uuid4(), run_id,
|
||||
f"child {index}", parent_chunk_id=parent_ids[index],
|
||||
),
|
||||
f"child {index}",
|
||||
index + 1,
|
||||
1.0,
|
||||
)
|
||||
for index in range(3)
|
||||
)
|
||||
records = {
|
||||
parent_id: make_record(document_id, version_id, space_id, parent_id, "x" * 10)
|
||||
for parent_id in parent_ids
|
||||
}
|
||||
lookup = FakeLookup(records)
|
||||
# Each parent's text is 10 word-like bytes; a budget of 15 allows only one.
|
||||
service = ParentExpansionService(lookup, max_expansion_tokens=15)
|
||||
|
||||
results = await service.expand(
|
||||
candidates,
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
expanded_count = sum(1 for item in results if item.parent_citation is not None)
|
||||
assert expanded_count == 1
|
||||
assert results[0].parent_citation is not None # rank-1 candidate expanded first
|
||||
|
||||
|
||||
async def test_expansion_is_deterministic_given_the_same_input() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, child_id, run_id,
|
||||
"child text", parent_chunk_id=parent_id,
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "child text", 1, 1.0)
|
||||
record = make_record(document_id, version_id, space_id, parent_id, "parent text")
|
||||
service_a = ParentExpansionService(FakeLookup({parent_id: record}))
|
||||
service_b = ParentExpansionService(FakeLookup({parent_id: record}))
|
||||
|
||||
(result_a,) = await service_a.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
(result_b,) = await service_b.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
|
||||
assert result_a == result_b
|
||||
|
||||
|
||||
async def test_max_expansion_tokens_is_bounded() -> None:
|
||||
with pytest.raises(ValueError, match="max_expansion_tokens"):
|
||||
ParentExpansionService(FakeLookup({}), max_expansion_tokens=0)
|
||||
|
||||
|
||||
async def test_invalid_token_counter_result_raises() -> None:
|
||||
workspace_id, space_id, run_id = uuid4(), uuid4(), uuid4()
|
||||
document_id, version_id = uuid4(), uuid4()
|
||||
parent_id, child_id = uuid4(), uuid4()
|
||||
child_citation = make_citation(
|
||||
workspace_id, space_id, document_id, version_id, child_id, run_id,
|
||||
"child text", parent_chunk_id=parent_id,
|
||||
)
|
||||
candidate = ContextCandidate(child_citation, "child text", 1, 1.0)
|
||||
lookup = FakeLookup(
|
||||
{parent_id: make_record(document_id, version_id, space_id, parent_id, "parent text")}
|
||||
)
|
||||
|
||||
class BrokenCounter:
|
||||
def count(self, text: str) -> int:
|
||||
return -1
|
||||
|
||||
service = ParentExpansionService(lookup, token_counter=BrokenCounter())
|
||||
with pytest.raises(ContextContractError, match="token counter"):
|
||||
await service.expand(
|
||||
(candidate,),
|
||||
scope=_scope(workspace_id, space_id),
|
||||
retrieval_run_id=run_id,
|
||||
index_alias="rag_dense_hybrid_active",
|
||||
profile_version="hybrid-v1",
|
||||
)
|
||||
@@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.retrieval.ranking import FusionConfig, fuse_candidates
|
||||
from ragcore.retrieval.search.models import (
|
||||
Candidate,
|
||||
CandidateBackendKind,
|
||||
CandidateProvenance,
|
||||
)
|
||||
|
||||
|
||||
def candidate(
|
||||
backend: CandidateBackendKind,
|
||||
label: str,
|
||||
rank: int,
|
||||
*,
|
||||
document: str = "document-a",
|
||||
parent: str | None = None,
|
||||
) -> Candidate:
|
||||
document_id = uuid5(NAMESPACE_URL, document)
|
||||
version_id = uuid5(NAMESPACE_URL, f"{document}:version")
|
||||
return Candidate(
|
||||
point_id=uuid5(NAMESPACE_URL, f"point:{label}:{backend}"),
|
||||
score=1.0,
|
||||
rank=rank,
|
||||
backend=backend,
|
||||
provenance=CandidateProvenance(
|
||||
workspace_id=uuid5(NAMESPACE_URL, "workspace"),
|
||||
space_id=uuid5(NAMESPACE_URL, "space"),
|
||||
document_id=document_id,
|
||||
document_version_id=version_id,
|
||||
chunk_id=uuid5(NAMESPACE_URL, f"chunk:{label}"),
|
||||
source_id=uuid5(NAMESPACE_URL, f"source:{document}"),
|
||||
parent_chunk_id=uuid5(NAMESPACE_URL, parent) if parent else None,
|
||||
status="published",
|
||||
content_type="text/plain",
|
||||
language="en",
|
||||
trust_level="source",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_rrf_matches_golden_example_and_keeps_explicit_score_semantics() -> None:
|
||||
result = fuse_candidates(
|
||||
[
|
||||
candidate(CandidateBackendKind.DENSE, "a", 1),
|
||||
candidate(CandidateBackendKind.DENSE, "b", 2),
|
||||
],
|
||||
[
|
||||
candidate(CandidateBackendKind.SPARSE, "b", 1),
|
||||
candidate(CandidateBackendKind.SPARSE, "c", 2),
|
||||
],
|
||||
)
|
||||
|
||||
assert [item.candidate.provenance.chunk_id for item in result] == [
|
||||
uuid5(NAMESPACE_URL, "chunk:b"),
|
||||
uuid5(NAMESPACE_URL, "chunk:a"),
|
||||
uuid5(NAMESPACE_URL, "chunk:c"),
|
||||
]
|
||||
assert result[0].rrf_score == pytest.approx(1 / 61 + 1 / 62)
|
||||
assert result[0].dense_rank == 2
|
||||
assert result[0].sparse_rank == 1
|
||||
assert not hasattr(result[0], "probability")
|
||||
|
||||
|
||||
def test_ties_have_stable_identity_order_independent_of_input_order() -> None:
|
||||
dense = [
|
||||
candidate(CandidateBackendKind.DENSE, "z", 1),
|
||||
candidate(CandidateBackendKind.DENSE, "a", 1),
|
||||
]
|
||||
first = fuse_candidates(dense, [])
|
||||
second = fuse_candidates(list(reversed(dense)), [])
|
||||
|
||||
assert first == second
|
||||
assert [item.candidate.provenance.chunk_id for item in first] == sorted(
|
||||
(uuid5(NAMESPACE_URL, "chunk:z"), uuid5(NAMESPACE_URL, "chunk:a")),
|
||||
key=str,
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_and_near_duplicate_chunks_are_suppressed() -> None:
|
||||
duplicate_dense = candidate(CandidateBackendKind.DENSE, "duplicate", 1)
|
||||
duplicate_sparse = candidate(CandidateBackendKind.SPARSE, "duplicate", 4)
|
||||
near_first = candidate(CandidateBackendKind.DENSE, "near-first", 2, parent="parent-a")
|
||||
near_second = candidate(CandidateBackendKind.DENSE, "near-second", 3, parent="parent-a")
|
||||
|
||||
result = fuse_candidates(
|
||||
[duplicate_dense, near_first, near_second],
|
||||
[duplicate_sparse],
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].dense_rank == 1
|
||||
assert result[0].sparse_rank == 4
|
||||
assert result[1].candidate.provenance.chunk_id == near_first.provenance.chunk_id
|
||||
|
||||
|
||||
def test_per_document_limit_is_enforced_before_fused_limit() -> None:
|
||||
result = fuse_candidates(
|
||||
[
|
||||
candidate(CandidateBackendKind.DENSE, "a1", 1, document="doc-a"),
|
||||
candidate(CandidateBackendKind.DENSE, "a2", 2, document="doc-a"),
|
||||
candidate(CandidateBackendKind.DENSE, "a3", 3, document="doc-a"),
|
||||
candidate(CandidateBackendKind.DENSE, "b1", 4, document="doc-b"),
|
||||
],
|
||||
[],
|
||||
config=FusionConfig(max_fused_candidates=4, max_per_document=2),
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
assert (
|
||||
sum(
|
||||
item.candidate.provenance.document_id == uuid5(NAMESPACE_URL, "doc-a")
|
||||
for item in result
|
||||
)
|
||||
== 2
|
||||
)
|
||||
assert any(
|
||||
item.candidate.provenance.document_id == uuid5(NAMESPACE_URL, "doc-b") for item in result
|
||||
)
|
||||
|
||||
|
||||
def test_config_bounds_reject_unbounded_or_invalid_values() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
FusionConfig(rrf_k=0)
|
||||
with pytest.raises(ValueError):
|
||||
FusionConfig(max_per_document=5, max_fused_candidates=4)
|
||||
with pytest.raises(ValueError):
|
||||
FusionConfig(rrf_k=1.5) # type: ignore[arg-type]
|
||||
with pytest.raises(ValueError):
|
||||
FusionConfig(max_per_document=True) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_conflicting_provenance_and_backend_list_mismatch_fail_closed() -> None:
|
||||
with pytest.raises(ValueError, match="provenance"):
|
||||
fuse_candidates(
|
||||
[candidate(CandidateBackendKind.DENSE, "same", 1)],
|
||||
[candidate(CandidateBackendKind.SPARSE, "same", 1, document="doc-b")],
|
||||
)
|
||||
with pytest.raises(ValueError, match="backend"):
|
||||
fuse_candidates([candidate(CandidateBackendKind.SPARSE, "wrong", 1)], [])
|
||||
|
||||
|
||||
def test_input_candidate_bound_is_enforced() -> None:
|
||||
candidates = [
|
||||
candidate(CandidateBackendKind.DENSE, f"chunk-{index}", index + 1) for index in range(51)
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="bounded"):
|
||||
fuse_candidates(candidates, [])
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.retrieval.sparse import SparseEncoder, SparseEncoderProfile, SparseVector
|
||||
from ragcore.retrieval.sparse.models import SparseNormalization
|
||||
|
||||
|
||||
def _value(vector: SparseVector, token_id: int) -> float:
|
||||
return vector.values[vector.indices.index(token_id)]
|
||||
|
||||
|
||||
def test_exact_technical_identifiers_have_stable_boosted_tokens() -> None:
|
||||
encoder = SparseEncoder()
|
||||
|
||||
document = encoder.encode_document("Fout CVE-2025-1234 in API/v1; retry CVE-2025-1234")
|
||||
query = encoder.encode_query("cve-2025-1234")
|
||||
|
||||
identifier_id = encoder.token_id("CVE-2025-1234")
|
||||
assert identifier_id in document.indices
|
||||
assert identifier_id in query.indices
|
||||
assert _value(query, identifier_id) == pytest.approx(3.0)
|
||||
assert _value(document, identifier_id) == pytest.approx(3.0 * (1 + math.log(2)))
|
||||
assert encoder.token_id("CVE-2025-1234") == encoder.token_id("cve-2025-1234")
|
||||
|
||||
|
||||
def test_dutch_and_english_text_share_casefolded_lexical_tokens() -> None:
|
||||
encoder = SparseEncoder()
|
||||
|
||||
vector = encoder.encode(
|
||||
"De API-configuratie gebruikt RAGCore in productie; the API configuration "
|
||||
"uses RAGCore in production."
|
||||
)
|
||||
|
||||
assert encoder.token_id("api") in vector.indices
|
||||
assert encoder.token_id("ragcore") in vector.indices
|
||||
assert encoder.token_id("configuratie") in vector.indices
|
||||
assert encoder.token_id("configuration") in vector.indices
|
||||
assert encoder.token_id("productie") in vector.indices
|
||||
assert encoder.token_id("production") in vector.indices
|
||||
|
||||
|
||||
def test_query_and_document_preprocessing_are_identical_and_sorted() -> None:
|
||||
encoder = SparseEncoder()
|
||||
text = "src/ragcore/retrieval/sparse\nHTTP_500"
|
||||
|
||||
assert encoder.encode_query(text) == encoder.encode_document(text)
|
||||
vector = encoder.encode(text)
|
||||
assert vector.indices == tuple(sorted(vector.indices))
|
||||
assert len(vector.indices) == len(set(vector.indices))
|
||||
assert all(math.isfinite(value) and value > 0 for value in vector.values)
|
||||
|
||||
|
||||
def test_profile_is_immutable_and_digest_covers_weighting_contract() -> None:
|
||||
profile = SparseEncoderProfile()
|
||||
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
profile.identifier_weight = 4.0 # type: ignore[misc]
|
||||
assert profile.profile_id == "technical-v1"
|
||||
assert profile.digest != SparseEncoderProfile(identifier_weight=4.0).digest
|
||||
|
||||
|
||||
def test_empty_and_unicode_input_are_safe_and_l2_is_bounded() -> None:
|
||||
encoder = SparseEncoder(
|
||||
SparseEncoderProfile(normalization=SparseNormalization.L2)
|
||||
)
|
||||
|
||||
assert encoder.encode(" … !!! ") == SparseVector((), ())
|
||||
vector = encoder.encode("Café naïve koffie")
|
||||
assert math.sqrt(sum(value * value for value in vector.values)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
["x\x00y", "a " * 20],
|
||||
)
|
||||
def test_input_limits_and_control_characters_fail_closed(text: str) -> None:
|
||||
profile = SparseEncoderProfile(max_tokens=5)
|
||||
encoder = SparseEncoder(profile)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
encoder.encode(text)
|
||||
|
||||
def test_normalized_and_direct_token_limits_fail_closed() -> None:
|
||||
encoder = SparseEncoder()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
encoder.encode("\ufb03" * 400_000)
|
||||
with pytest.raises(ValueError):
|
||||
encoder.token_id("x" * 4_097)
|
||||
with pytest.raises(ValueError):
|
||||
SparseEncoderProfile(term_weight=1_000_001.0)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Regression tests for production backup wiring."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def backup_script() -> object:
|
||||
path = Path(__file__).parents[3] / "scripts" / "backup.py"
|
||||
spec = importlib.util.spec_from_file_location("backup_script", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
async def test_active_profile_provenance_contains_only_active_non_secret_revisions(
|
||||
backup_script: object, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class EmbeddingStore:
|
||||
def __init__(self, path: Path) -> None:
|
||||
pass
|
||||
|
||||
async def get_active(self) -> object:
|
||||
return SimpleNamespace(profile="hybrid-v1", version="v3")
|
||||
|
||||
class GenerationStore:
|
||||
def __init__(self, session_factory: object) -> None:
|
||||
pass
|
||||
|
||||
async def get_active(self) -> object:
|
||||
return SimpleNamespace(profile="answer-local", version="v1")
|
||||
|
||||
monkeypatch.setattr(backup_script, "JsonEmbeddingProfileStore", EmbeddingStore)
|
||||
monkeypatch.setattr(backup_script, "PostgresGenerationProfileStore", GenerationStore)
|
||||
result = await backup_script.active_profile_provenance(
|
||||
settings=SimpleNamespace(embedding_profiles_path=Path("profiles.json")),
|
||||
session_factory=object(),
|
||||
)
|
||||
assert result == {"embedding": "hybrid-v1:v3", "generation": "answer-local:v1"}
|
||||
|
||||
|
||||
async def test_active_profile_provenance_allows_missing_optional_profiles(
|
||||
backup_script: object, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
class EmptyStore:
|
||||
def __init__(self, _: object) -> None:
|
||||
pass
|
||||
|
||||
async def get_active(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(backup_script, "JsonEmbeddingProfileStore", EmptyStore)
|
||||
monkeypatch.setattr(backup_script, "PostgresGenerationProfileStore", EmptyStore)
|
||||
result = await backup_script.active_profile_provenance(
|
||||
settings=SimpleNamespace(embedding_profiles_path=Path("profiles.json")),
|
||||
session_factory=object(),
|
||||
)
|
||||
assert result == {}
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.application.sources import SourceConnector
|
||||
from ragcore.domain.sources import (
|
||||
Acquisition,
|
||||
FolderSourceConfig,
|
||||
ScanResult,
|
||||
SourceCursor,
|
||||
SourceFingerprint,
|
||||
SourceItem,
|
||||
SourceValidationError,
|
||||
WebsiteSourceConfig,
|
||||
)
|
||||
|
||||
|
||||
def _item(external_id: str = "item-1") -> SourceItem:
|
||||
return SourceItem(
|
||||
source_id=uuid4(), external_id=external_id, locator="file:///docs/item.txt", name="item.txt"
|
||||
)
|
||||
|
||||
|
||||
def test_connector_contract_has_no_parse_or_publication_responsibility() -> None:
|
||||
assert not hasattr(SourceConnector, "parse")
|
||||
assert not hasattr(SourceConnector, "publish")
|
||||
assert {"scan", "acquire", "fingerprint"} <= set(dir(SourceConnector))
|
||||
|
||||
|
||||
def test_partial_scan_cannot_imply_deletion() -> None:
|
||||
result = ScanResult(
|
||||
items=(_item("still-present"),), next_cursor=SourceCursor("page-2"), complete=False
|
||||
)
|
||||
assert result.deleted_item_ids({"still-present", "temporarily-missing"}) == frozenset()
|
||||
|
||||
complete = ScanResult(items=(_item("still-present"),), next_cursor=None, complete=True)
|
||||
assert complete.deleted_item_ids({"still-present", "source-deleted"}) == {"source-deleted"}
|
||||
|
||||
|
||||
def test_configs_are_typed_and_secret_safe(tmp_path: Path) -> None:
|
||||
config = FolderSourceConfig(tmp_path.resolve())
|
||||
assert config.root == tmp_path.resolve()
|
||||
with pytest.raises(SourceValidationError):
|
||||
FolderSourceConfig(Path("relative/root"))
|
||||
with pytest.raises(SourceValidationError):
|
||||
WebsiteSourceConfig(("example.test",), "https://other.test/sitemap.xml")
|
||||
|
||||
with pytest.raises(SourceValidationError):
|
||||
SourceItem(
|
||||
source_id=uuid4(),
|
||||
external_id="secret-item",
|
||||
locator="https://example.test/item",
|
||||
name="item",
|
||||
metadata={"api_token": "must-not-enter-source-metadata"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acquisition_is_a_stream_and_does_not_parse_or_publish() -> None:
|
||||
async def stream():
|
||||
yield b"bytes"
|
||||
|
||||
item = _item()
|
||||
acquisition = Acquisition(stream(), item, SourceFingerprint("a" * 64))
|
||||
assert [part async for part in acquisition.stream] == [b"bytes"]
|
||||
assert not hasattr(acquisition, "parsed_document")
|
||||
assert not hasattr(acquisition, "publication")
|
||||
@@ -0,0 +1,318 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from ragcore.config import Settings
|
||||
|
||||
|
||||
def test_log_level_is_normalized(tmp_path: Path) -> None:
|
||||
settings = Settings(
|
||||
log_level="warning",
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
)
|
||||
assert settings.log_level == "WARNING"
|
||||
|
||||
|
||||
def test_invalid_log_level_is_rejected() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(log_level="verbose")
|
||||
|
||||
|
||||
def _production_settings(tmp_path: Path, **overrides: object) -> Settings:
|
||||
values: dict[str, object] = {
|
||||
"env": "production",
|
||||
"secret_key": "s" * 40,
|
||||
"api_token_pepper": "p" * 40,
|
||||
"qdrant_api_key": "q" * 40,
|
||||
"database_url": "postgresql+psycopg://app:s3cure-db-passw0rd-xyz@postgres:5432/ragcore",
|
||||
"blob_root": tmp_path / "blobs",
|
||||
"parsed_root": tmp_path / "parsed",
|
||||
"temp_root": tmp_path / "tmp",
|
||||
"model_state_root": tmp_path / "models",
|
||||
"public_base_url": "https://ragcore.example.test",
|
||||
"trusted_proxy_ips": "172.30.50.0/29",
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(**values)
|
||||
|
||||
|
||||
def test_production_rejects_default_secret(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="secret_key"):
|
||||
Settings(
|
||||
env="production",
|
||||
secret_key="development-only-change-me",
|
||||
api_token_pepper="p" * 40,
|
||||
qdrant_api_key="q" * 40,
|
||||
database_url="postgresql+psycopg://app:password@postgres:5432/ragcore",
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
)
|
||||
|
||||
|
||||
def test_production_rejects_short_secret(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="secret_key"):
|
||||
_production_settings(tmp_path, secret_key="too-short")
|
||||
|
||||
|
||||
def test_production_rejects_placeholder_qdrant_key(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="qdrant_api_key"):
|
||||
_production_settings(tmp_path, qdrant_api_key="replace-with-at-least-32-random-characters")
|
||||
|
||||
|
||||
def test_production_accepts_strong_secrets_and_absolute_storage(tmp_path: Path) -> None:
|
||||
settings = _production_settings(tmp_path)
|
||||
|
||||
assert settings.env == "production"
|
||||
assert settings.blob_root.is_absolute()
|
||||
|
||||
|
||||
def test_trusted_proxy_ips_are_normalized_and_deduplicated() -> None:
|
||||
settings = Settings(trusted_proxy_ips="172.30.50.2, 172.30.50.0/29,172.30.50.2")
|
||||
assert settings.trusted_proxy_ips == ("172.30.50.2", "172.30.50.0/29")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["*", "0.0.0.0/0", "::/0", "proxy.local"])
|
||||
def test_wildcard_or_non_ip_proxy_trust_is_rejected(value: str) -> None:
|
||||
with pytest.raises(ValidationError, match="trusted_proxy_ips"):
|
||||
Settings(trusted_proxy_ips=value)
|
||||
|
||||
|
||||
def test_production_requires_an_explicit_trusted_proxy(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="trusted_proxy_ips"):
|
||||
_production_settings(tmp_path, trusted_proxy_ips=())
|
||||
|
||||
|
||||
def test_production_requires_an_https_public_url(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="public_base_url"):
|
||||
_production_settings(tmp_path, public_base_url="http://127.0.0.1:1237")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "value"),
|
||||
[
|
||||
("database_url", "sqlite:///ragcore.db"),
|
||||
("qdrant_url", "postgresql://qdrant:6333"),
|
||||
("ollama_url", "not-a-url"),
|
||||
("public_base_url", "http://"),
|
||||
],
|
||||
)
|
||||
def test_invalid_urls_are_rejected(field_name: str, value: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(**{field_name: value})
|
||||
|
||||
|
||||
def test_storage_path_traversal_is_rejected() -> None:
|
||||
with pytest.raises(ValidationError, match="storage paths"):
|
||||
Settings(blob_root=Path("../outside"))
|
||||
|
||||
|
||||
def test_production_rejects_user_share_storage(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="direct pool path"):
|
||||
_production_settings(tmp_path, blob_root=Path("/mnt/pool/appdata/ragcore/blobs"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("password", ["ragcore", "postgres", "password", "changeme", "RAGCORE"])
|
||||
def test_production_rejects_default_or_weak_database_passwords(
|
||||
tmp_path: Path, password: str
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError, match="weak password"):
|
||||
_production_settings(
|
||||
tmp_path,
|
||||
database_url=f"postgresql+psycopg://app:{password}@postgres:5432/ragcore",
|
||||
)
|
||||
|
||||
|
||||
def test_production_rejects_short_database_password(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="weak password"):
|
||||
_production_settings(
|
||||
tmp_path, database_url="postgresql+psycopg://app:short1@postgres:5432/ragcore"
|
||||
)
|
||||
|
||||
|
||||
def test_production_accepts_a_strong_database_password(tmp_path: Path) -> None:
|
||||
settings = _production_settings(
|
||||
tmp_path,
|
||||
database_url="postgresql+psycopg://app:a-genuinely-long-random-password@postgres:5432/ragcore",
|
||||
)
|
||||
assert settings.env == "production"
|
||||
|
||||
|
||||
def test_verify_runtime_directories_writable_passes_for_writable_dirs(tmp_path: Path) -> None:
|
||||
settings = Settings(
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
model_state_root=tmp_path / "models",
|
||||
)
|
||||
settings.ensure_runtime_directories()
|
||||
settings.verify_runtime_directories_writable()
|
||||
|
||||
|
||||
def test_verify_runtime_directories_writable_raises_for_a_missing_directory(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
settings = Settings(
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
model_state_root=tmp_path / "models",
|
||||
)
|
||||
with pytest.raises(OSError):
|
||||
settings.verify_runtime_directories_writable()
|
||||
|
||||
|
||||
def test_production_ignores_placeholder_oidc_secret_when_oidc_is_not_configured(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""oidc_client_secret defaults to a placeholder; that's fine as long as
|
||||
oidc_workspace_id is unset, since the /auth/* routes never activate."""
|
||||
|
||||
settings = _production_settings(tmp_path)
|
||||
assert settings.oidc_workspace_id is None
|
||||
assert settings.oidc_client_secret.get_secret_value() == "replace-me"
|
||||
|
||||
|
||||
def test_production_rejects_placeholder_oidc_secret_once_oidc_is_configured(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError, match="oidc_client_secret"):
|
||||
_production_settings(tmp_path, oidc_workspace_id=uuid4())
|
||||
|
||||
|
||||
def test_production_accepts_a_strong_oidc_secret_once_configured(tmp_path: Path) -> None:
|
||||
settings = _production_settings(
|
||||
tmp_path, oidc_workspace_id=uuid4(), oidc_client_secret="o" * 40
|
||||
)
|
||||
assert settings.oidc_workspace_id is not None
|
||||
|
||||
|
||||
def test_embedding_profiles_path_derives_from_model_state_root(tmp_path: Path) -> None:
|
||||
settings = Settings(model_state_root=tmp_path / "models")
|
||||
assert settings.embedding_profiles_path == tmp_path / "models" / "embedding_profiles.json"
|
||||
|
||||
|
||||
def test_rc_embedding_bootstrap_requires_the_operator_command(tmp_path: Path) -> None:
|
||||
rc_settings = _production_settings(tmp_path, env="rc")
|
||||
|
||||
assert rc_settings.automatic_embedding_bootstrap_enabled is False
|
||||
assert Settings(env="development").automatic_embedding_bootstrap_enabled is True
|
||||
assert _production_settings(tmp_path).automatic_embedding_bootstrap_enabled is True
|
||||
|
||||
|
||||
def test_ensure_runtime_directories_creates_model_state_root(tmp_path: Path) -> None:
|
||||
settings = Settings(
|
||||
blob_root=tmp_path / "blobs",
|
||||
parsed_root=tmp_path / "parsed",
|
||||
temp_root=tmp_path / "tmp",
|
||||
model_state_root=tmp_path / "models",
|
||||
)
|
||||
settings.ensure_runtime_directories()
|
||||
assert settings.model_state_root.is_dir()
|
||||
|
||||
|
||||
def test_model_state_root_traversal_is_rejected() -> None:
|
||||
with pytest.raises(ValidationError, match="storage paths"):
|
||||
Settings(model_state_root=Path("../outside"))
|
||||
|
||||
|
||||
def test_production_rejects_user_share_model_state_root(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="direct pool path"):
|
||||
_production_settings(tmp_path, model_state_root=Path("/mnt/pool/appdata/ragcore/models"))
|
||||
|
||||
|
||||
def test_production_requires_absolute_model_state_root(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValidationError, match="must be absolute"):
|
||||
_production_settings(tmp_path, model_state_root=Path("relative/models"))
|
||||
|
||||
|
||||
def test_secret_repr_and_json_are_redacted() -> None:
|
||||
secret = "actual-secret-value-that-must-not-leak"
|
||||
settings = Settings(secret_key=secret, api_token_pepper="another-secret-value")
|
||||
|
||||
assert secret not in repr(settings)
|
||||
assert secret not in settings.model_dump_json()
|
||||
assert "**********" in settings.model_dump_json()
|
||||
|
||||
|
||||
# -- the filesystem source allowlist ----------------------------------------
|
||||
|
||||
|
||||
def test_source_roots_default_to_empty_so_no_folder_source_can_be_read() -> None:
|
||||
"""Fail closed. There is no implicit default root: a deployment that never
|
||||
named one reads no server-side paths at all."""
|
||||
|
||||
assert Settings().source_roots == ()
|
||||
|
||||
|
||||
def test_source_roots_accept_the_comma_separated_environment_form() -> None:
|
||||
settings = Settings(source_roots="/mnt/rag-sources, /srv/corpora")
|
||||
|
||||
assert settings.source_roots == (Path("/mnt/rag-sources"), Path("/srv/corpora"))
|
||||
|
||||
|
||||
def test_source_roots_accept_a_list() -> None:
|
||||
settings = Settings(source_roots=["/mnt/rag-sources"])
|
||||
|
||||
assert settings.source_roots == (Path("/mnt/rag-sources"),)
|
||||
|
||||
|
||||
def test_empty_entries_are_ignored_rather_than_becoming_roots() -> None:
|
||||
"""A trailing comma must not silently produce a root that means "here"."""
|
||||
|
||||
assert Settings(source_roots="/mnt/rag-sources,,").source_roots == (Path("/mnt/rag-sources"),)
|
||||
assert Settings(source_roots="").source_roots == ()
|
||||
|
||||
|
||||
def test_a_filesystem_root_is_rejected_at_startup() -> None:
|
||||
"""Configuring "/" would make the allowlist meaningless, so it is refused
|
||||
when the process boots rather than when a source is first read."""
|
||||
|
||||
with pytest.raises(ValidationError, match="must not be a filesystem root"):
|
||||
Settings(source_roots="/")
|
||||
|
||||
|
||||
def test_a_relative_source_root_is_rejected_at_startup() -> None:
|
||||
with pytest.raises(ValidationError, match="absolute"):
|
||||
Settings(source_roots="rag-sources")
|
||||
|
||||
|
||||
def test_a_traversing_source_root_is_rejected_at_startup() -> None:
|
||||
with pytest.raises(ValidationError, match="traverse"):
|
||||
Settings(source_roots="/mnt/rag-sources/../..")
|
||||
|
||||
|
||||
def test_duplicate_source_roots_collapse() -> None:
|
||||
settings = Settings(source_roots="/mnt/rag-sources,/mnt/rag-sources")
|
||||
|
||||
assert settings.source_roots == (Path("/mnt/rag-sources"),)
|
||||
|
||||
|
||||
def test_production_rejects_a_user_share_source_root(tmp_path: Path) -> None:
|
||||
"""Same rule the storage paths follow: /mnt/user is Unraid's fuse share,
|
||||
not a direct pool path."""
|
||||
|
||||
with pytest.raises(ValidationError, match="direct pool path"):
|
||||
_production_settings(tmp_path, source_roots="/mnt/pool/rag-sources")
|
||||
|
||||
|
||||
def test_production_accepts_a_direct_pool_source_root(tmp_path: Path) -> None:
|
||||
settings = _production_settings(tmp_path, source_roots="/mnt/cache/rag-sources")
|
||||
|
||||
assert settings.source_roots == (Path("/mnt/cache/rag-sources"),)
|
||||
|
||||
|
||||
def test_worker_concurrency_defaults_to_one_and_is_bounded() -> None:
|
||||
"""One by default: a deployment that upgrades gets exactly the throughput
|
||||
it had, until an operator asks for more."""
|
||||
|
||||
assert Settings(env="test").worker_concurrency == 1
|
||||
assert Settings(env="test", worker_concurrency=8).worker_concurrency == 8
|
||||
|
||||
for rejected in (0, -1, 65):
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(env="test", worker_concurrency=rejected)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Production server wiring for the reverse-proxy trust boundary."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import ragcore.main as main
|
||||
|
||||
|
||||
def test_run_passes_only_configured_trusted_proxies_to_uvicorn(monkeypatch: Any) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_run(application: str, **kwargs: Any) -> None:
|
||||
captured["application"] = application
|
||||
captured.update(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"get_settings",
|
||||
lambda: SimpleNamespace(trusted_proxy_ips=("172.30.50.2",)),
|
||||
)
|
||||
monkeypatch.setattr(main.uvicorn, "run", fake_run)
|
||||
|
||||
main.run()
|
||||
|
||||
assert captured["application"] == "ragcore.main:app"
|
||||
assert captured["proxy_headers"] is True
|
||||
assert captured["forwarded_allow_ips"] == ["172.30.50.2"]
|
||||
assert "*" not in captured["forwarded_allow_ips"]
|
||||
@@ -0,0 +1,12 @@
|
||||
from ragcore.config import Settings
|
||||
from ragcore.worker import _configured_secrets
|
||||
|
||||
|
||||
def test_worker_logging_uses_all_configured_secret_values() -> None:
|
||||
settings = Settings(
|
||||
secret_key="s" * 40,
|
||||
api_token_pepper="p" * 40,
|
||||
qdrant_api_key="q" * 40,
|
||||
)
|
||||
|
||||
assert _configured_secrets(settings) == ("s" * 40, "p" * 40, "q" * 40)
|
||||
@@ -0,0 +1,353 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from qdrant_client.http import models
|
||||
|
||||
from ragcore.application.models.selection import ResolvedEmbeddingSelection
|
||||
from ragcore.application.retrieval.authorization import AuthorizedRetrievalScope
|
||||
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, EmbeddingSelection
|
||||
from ragcore.infrastructure.models.embedding_factory import create_embedding_adapter_factory
|
||||
from ragcore.infrastructure.models.persistence import JsonEmbeddingProfileStore
|
||||
from ragcore.web.query_lab.executor import PipelineQueryLabExecutor, QueryLabExecutionRequest
|
||||
from ragcore.web.query_lab.models import QueryLabQuery
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
DOCUMENT_ID = uuid4()
|
||||
DOCUMENT_VERSION_ID = uuid4()
|
||||
CHUNK_ID = uuid4()
|
||||
SOURCE_ID = uuid4()
|
||||
|
||||
EMBEDDING_PROFILE = EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hybrid",
|
||||
version="v1",
|
||||
provider=EmbeddingProvider.OLLAMA,
|
||||
model_identifier="nomic-embed-text:v1",
|
||||
model_digest="0" * 64,
|
||||
dimensions=2,
|
||||
context_limit=2048,
|
||||
tokenizer="cl100k",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
)
|
||||
|
||||
_PAYLOAD = {
|
||||
"workspace_id": str(WORKSPACE_ID),
|
||||
"space_id": str(SPACE_ID),
|
||||
"document_id": str(DOCUMENT_ID),
|
||||
"document_version_id": str(DOCUMENT_VERSION_ID),
|
||||
"chunk_id": str(CHUNK_ID),
|
||||
"source_id": str(SOURCE_ID),
|
||||
"parent_chunk_id": None,
|
||||
"status": "published",
|
||||
"content_type": "text/plain",
|
||||
"language": "en",
|
||||
"trust_level": "source",
|
||||
"embedding_model_digest": EMBEDDING_PROFILE.model_digest,
|
||||
"chunk_profile_version": EMBEDDING_PROFILE.version,
|
||||
"text": "The runbook says to restart the payment service after a failover.",
|
||||
"location": {"page": 1},
|
||||
}
|
||||
|
||||
|
||||
class FakeQdrantClient:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
alias_target: str = "rag_dense_hybrid_v1",
|
||||
payload: dict[str, object] = _PAYLOAD,
|
||||
) -> None:
|
||||
self.alias_target = alias_target
|
||||
self.payload = payload
|
||||
self.retrieve_calls: list[dict[str, object]] = []
|
||||
self.query_collections: list[str] = []
|
||||
|
||||
async def get_aliases(self, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
aliases=[
|
||||
SimpleNamespace(
|
||||
alias_name="rag_dense_hybrid_active", collection_name=self.alias_target
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
async def get_collection(self, collection_name: str, **kwargs: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
params=SimpleNamespace(
|
||||
vectors={"dense": models.VectorParams(size=2, distance=models.Distance.COSINE)},
|
||||
sparse_vectors={"sparse": models.SparseVectorParams()},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def query_points(self, **kwargs: object) -> models.QueryResponse:
|
||||
self.query_collections.append(str(kwargs.get("collection_name")))
|
||||
return models.QueryResponse(
|
||||
points=[
|
||||
models.ScoredPoint(id=str(CHUNK_ID), version=0, score=0.9, payload=self.payload)
|
||||
]
|
||||
)
|
||||
|
||||
async def retrieve(self, **kwargs: object) -> list[SimpleNamespace]:
|
||||
self.retrieve_calls.append(kwargs)
|
||||
ids = kwargs.get("ids", [])
|
||||
return [SimpleNamespace(id=raw_id, payload=self.payload) for raw_id in ids]
|
||||
|
||||
|
||||
def _embed_transport(vector: tuple[float, ...] = (0.1, 0.2)) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"embeddings": [list(vector)]})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _failing_transport(status_code: int = 503) -> httpx.MockTransport:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(status_code, json={"error": "unavailable"})
|
||||
|
||||
return httpx.MockTransport(handler)
|
||||
|
||||
|
||||
def _embedding_adapter_factory(transport: httpx.MockTransport, *, settings: Settings | None = None):
|
||||
client = httpx.AsyncClient(transport=transport, follow_redirects=False)
|
||||
return create_embedding_adapter_factory(
|
||||
settings=settings or Settings(env="test", ollama_url="http://localhost:11434"),
|
||||
http_client=client,
|
||||
)
|
||||
|
||||
|
||||
def _empty_store() -> JsonEmbeddingProfileStore:
|
||||
path = Path(tempfile.mkdtemp()) / "profiles.json"
|
||||
return JsonEmbeddingProfileStore(path)
|
||||
|
||||
|
||||
async def _activated_store() -> JsonEmbeddingProfileStore:
|
||||
store = _empty_store()
|
||||
await store.add(EMBEDDING_PROFILE)
|
||||
await store.activate(EMBEDDING_PROFILE.id)
|
||||
return store
|
||||
|
||||
|
||||
async def _activated_store_for(profile: EmbeddingProfile) -> JsonEmbeddingProfileStore:
|
||||
store = _empty_store()
|
||||
await store.add(profile)
|
||||
await store.activate(profile.id)
|
||||
return store
|
||||
|
||||
|
||||
def _modelforge_profile(*, version: str = "v1") -> EmbeddingProfile:
|
||||
return EmbeddingProfile(
|
||||
id=uuid4(),
|
||||
profile="hybrid",
|
||||
version=version,
|
||||
provider=EmbeddingProvider.MODELFORGE,
|
||||
model_identifier="rag.embedding@1",
|
||||
model_digest="c" * 64,
|
||||
dimensions=2,
|
||||
context_limit=8192,
|
||||
tokenizer="capability-managed",
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
embedding_space_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
class FixedSelectionResolver:
|
||||
def __init__(self, profile: EmbeddingProfile, *, route: str) -> None:
|
||||
self.selected = ResolvedEmbeddingSelection(
|
||||
generation=4,
|
||||
profile=profile,
|
||||
selection=EmbeddingSelection.from_profile(profile, experiment_route_key=route),
|
||||
)
|
||||
|
||||
async def resolve_active(self) -> ResolvedEmbeddingSelection:
|
||||
return self.selected
|
||||
|
||||
|
||||
def _request(query: str = "how do I restart the payment service") -> QueryLabExecutionRequest:
|
||||
return QueryLabExecutionRequest(
|
||||
principal=Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset()),
|
||||
query=QueryLabQuery(query=query, max_results=5),
|
||||
scope=AuthorizedRetrievalScope(WORKSPACE_ID, frozenset({SPACE_ID})),
|
||||
retrieval_run_id=uuid4(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degrades_when_no_active_embedding_profile() -> None:
|
||||
executor = PipelineQueryLabExecutor(
|
||||
qdrant_client=FakeQdrantClient(),
|
||||
embedding_profile_store=_empty_store(),
|
||||
embedding_adapter_factory=_embedding_adapter_factory(_embed_transport()),
|
||||
)
|
||||
trace = await executor.execute(_request())
|
||||
assert trace.degraded is True
|
||||
assert "no_active_embedding_profile" in trace.degradation_codes
|
||||
assert trace.dense_candidates == ()
|
||||
assert trace.revisions.embedding_profile is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_degrades_when_embedding_provider_unavailable() -> None:
|
||||
executor = PipelineQueryLabExecutor(
|
||||
qdrant_client=FakeQdrantClient(),
|
||||
embedding_profile_store=await _activated_store(),
|
||||
embedding_adapter_factory=_embedding_adapter_factory(_failing_transport()),
|
||||
)
|
||||
trace = await executor.execute(_request())
|
||||
assert trace.degraded is True
|
||||
assert "embedding_provider_unavailable" in trace.degradation_codes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_pipeline_happy_path_produces_a_bounded_trace() -> None:
|
||||
"""M8-01: no reranking -- the rerank trace section always reports
|
||||
'skipped', never 'reranked' or 'degraded', since the step no longer runs
|
||||
(the deployed Ollama never had a working /api/rerank endpoint)."""
|
||||
|
||||
executor = PipelineQueryLabExecutor(
|
||||
qdrant_client=FakeQdrantClient(),
|
||||
embedding_profile_store=await _activated_store(),
|
||||
embedding_adapter_factory=_embedding_adapter_factory(_embed_transport()),
|
||||
)
|
||||
trace = await executor.execute(_request())
|
||||
|
||||
assert trace.degraded is False
|
||||
assert len(trace.dense_candidates) == 1
|
||||
assert len(trace.sparse_candidates) == 1
|
||||
assert len(trace.fusion.candidates) == 1
|
||||
assert trace.rerank.state == "skipped"
|
||||
assert trace.rerank.degradation_code is None
|
||||
assert len(trace.citations) == 1
|
||||
assert trace.citations[0].document_id == DOCUMENT_ID
|
||||
assert "restart the payment service" in trace.citations[0].excerpt_preview
|
||||
assert trace.answerability.status in {"partially_answerable", "not_answerable", "answerable"}
|
||||
assert trace.revisions.embedding_profile == "hybrid/v1"
|
||||
assert trace.revisions.reranker_profile is None
|
||||
assert trace.revisions.collection_alias == "rag_dense_hybrid_active"
|
||||
assert set(trace.timings_ms) >= {"embed_query", "dense_search", "sparse_search", "rerank"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_lab_uses_durable_route_and_collection_selection(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = _modelforge_profile()
|
||||
credential_file = tmp_path / "credential"
|
||||
credential_file.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
credential_file.chmod(0o600)
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
settings = Settings(
|
||||
env="test",
|
||||
ollama_url="http://localhost:11434",
|
||||
modelforge_base_url="http://modelforge:8000",
|
||||
modelforge_credential_file=credential_file,
|
||||
modelforge_embedding_experiment_route="stale-global-route",
|
||||
)
|
||||
payload = {
|
||||
**_PAYLOAD,
|
||||
"embedding_model_digest": profile.model_digest,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
}
|
||||
qdrant = FakeQdrantClient(alias_target="rag_dense_hybrid_old", payload=payload)
|
||||
executor = PipelineQueryLabExecutor(
|
||||
qdrant_client=qdrant,
|
||||
embedding_profile_store=await _activated_store_for(profile),
|
||||
embedding_adapter_factory=_embedding_adapter_factory(
|
||||
httpx.MockTransport(respond), settings=settings
|
||||
),
|
||||
embedding_selection_resolver=FixedSelectionResolver(profile, route="rc-canary-2026-09"),
|
||||
)
|
||||
|
||||
trace = await executor.execute(_request())
|
||||
|
||||
assert trace.degraded is False
|
||||
assert trace.dense_candidates
|
||||
assert trace.revisions.embedding_profile == "hybrid/v1"
|
||||
assert trace.revisions.collection_alias == "rag_dense_hybrid_active"
|
||||
assert qdrant.query_collections == ["rag_dense_hybrid_v1", "rag_dense_hybrid_v1"]
|
||||
assert len(seen) == 1
|
||||
assert seen[0].url.path == "/api/v1/capability-experiments/rc-canary-2026-09/invoke"
|
||||
assert json.loads(seen[0].content) == {
|
||||
"input": ["how do I restart the payment service"],
|
||||
"input_type": "query",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_lab_uses_selected_collection_after_alias_changes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
profile = _modelforge_profile(version="v2")
|
||||
credential_file = tmp_path / "credential"
|
||||
credential_file.write_text("mf_" + "x" * 48, encoding="utf-8")
|
||||
credential_file.chmod(0o600)
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"capability": "rag.embedding@1",
|
||||
"dimension": profile.dimensions,
|
||||
"normalized": True,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"data": [[1.0, 0.0]],
|
||||
"request_id": str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
settings = Settings(
|
||||
env="test",
|
||||
ollama_url="http://localhost:11434",
|
||||
modelforge_base_url="http://modelforge:8000",
|
||||
modelforge_credential_file=credential_file,
|
||||
modelforge_embedding_experiment_route="rc-canary-2026-09",
|
||||
)
|
||||
payload = {
|
||||
**_PAYLOAD,
|
||||
"embedding_model_digest": profile.model_digest,
|
||||
"embedding_space_id": str(profile.embedding_space_id),
|
||||
"chunk_profile_version": profile.version,
|
||||
}
|
||||
qdrant = FakeQdrantClient(alias_target="rag_dense_hybrid_v1", payload=payload)
|
||||
executor = PipelineQueryLabExecutor(
|
||||
qdrant_client=qdrant,
|
||||
embedding_profile_store=await _activated_store_for(profile),
|
||||
embedding_adapter_factory=_embedding_adapter_factory(
|
||||
httpx.MockTransport(respond), settings=settings
|
||||
),
|
||||
embedding_selection_resolver=FixedSelectionResolver(profile, route="rc-canary-2026-09"),
|
||||
)
|
||||
|
||||
trace = await executor.execute(_request())
|
||||
|
||||
assert trace.degraded is False
|
||||
assert qdrant.query_collections == ["rag_dense_hybrid_v2", "rag_dense_hybrid_v2"]
|
||||
assert len(seen) == 1
|
||||
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.web.query_lab.models import (
|
||||
AnswerabilityTrace,
|
||||
CandidateBackendLabel,
|
||||
CandidateTrace,
|
||||
ContextTrace,
|
||||
FusionTrace,
|
||||
InclusionState,
|
||||
ParentExpansionTrace,
|
||||
QueryLabFilterRequest,
|
||||
QueryLabQuery,
|
||||
QueryLabTrace,
|
||||
RerankTrace,
|
||||
RevisionTrace,
|
||||
preview,
|
||||
)
|
||||
|
||||
|
||||
def _candidate(**overrides: object) -> CandidateTrace:
|
||||
values = {
|
||||
"rank": 1,
|
||||
"chunk_id": uuid4(),
|
||||
"document_id": uuid4(),
|
||||
"space_id": uuid4(),
|
||||
"backend": CandidateBackendLabel.DENSE,
|
||||
"score": 0.5,
|
||||
"score_label": "similarity_score",
|
||||
"content_type": "text/plain",
|
||||
"trust_level": "source",
|
||||
"language": "en",
|
||||
"text_preview": "hello",
|
||||
"text_truncated": False,
|
||||
"inclusion": InclusionState.INCLUDED,
|
||||
"reason": "selected_for_fusion",
|
||||
}
|
||||
values.update(overrides)
|
||||
return CandidateTrace(**values)
|
||||
|
||||
|
||||
def test_query_rejects_empty_and_oversized_text() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
QueryLabQuery(query=" ")
|
||||
with pytest.raises(ValueError):
|
||||
QueryLabQuery(query="x" * 8001)
|
||||
|
||||
|
||||
def test_query_rejects_duplicate_requested_spaces() -> None:
|
||||
space_id = uuid4()
|
||||
with pytest.raises(ValueError):
|
||||
QueryLabQuery(query="test", requested_space_ids=(space_id, space_id))
|
||||
|
||||
|
||||
def test_query_rejects_unsafe_profile() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
QueryLabQuery(query="test", profile="not a safe id!")
|
||||
|
||||
|
||||
def test_filter_request_bounds_cardinality() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
QueryLabFilterRequest(content_types=tuple(f"type-{i}" for i in range(51)))
|
||||
|
||||
|
||||
def test_preview_truncates_and_flags() -> None:
|
||||
text, truncated = preview("a" * 500, 400)
|
||||
assert len(text) == 400
|
||||
assert truncated is True
|
||||
text, truncated = preview("short", 400)
|
||||
assert text == "short"
|
||||
assert truncated is False
|
||||
|
||||
|
||||
def test_candidate_trace_rejects_unsafe_reason_code() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_candidate(reason="not a safe code!")
|
||||
|
||||
|
||||
def test_candidate_trace_rejects_oversized_preview() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_candidate(text_preview="a" * 401)
|
||||
|
||||
|
||||
def test_fusion_trace_bounds_display_candidates() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
FusionTrace(candidates=tuple(_candidate(rank=i) for i in range(1, 22)), suppressed_count=0)
|
||||
|
||||
|
||||
def test_fusion_trace_rejects_negative_suppressed_count() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
FusionTrace(candidates=(), suppressed_count=-1)
|
||||
|
||||
|
||||
def test_rerank_trace_rejects_unknown_state() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
RerankTrace(state="bogus", degradation_code=None, candidates=(), elapsed_ms=1.0)
|
||||
|
||||
|
||||
def test_answerability_trace_requires_at_least_one_reason() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
AnswerabilityTrace(
|
||||
status="not_answerable",
|
||||
reasons=(),
|
||||
evidence_count=0,
|
||||
document_diversity=0,
|
||||
source_independence=0,
|
||||
facet_coverage_ratio=0.0,
|
||||
thresholds_version="v1",
|
||||
)
|
||||
|
||||
|
||||
def test_context_trace_requires_positive_max_tokens() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ContextTrace(token_count=0, max_context_tokens=0, block_count=0, citation_ids=())
|
||||
|
||||
|
||||
def test_parent_expansion_trace_rejects_negative_counts() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
ParentExpansionTrace(expanded_count=-1, skipped_count=0, total_expansion_tokens=0)
|
||||
|
||||
|
||||
def _full_trace(**overrides: object) -> QueryLabTrace:
|
||||
from ragcore.web.query_lab.models import EffectiveFilterTrace
|
||||
|
||||
values: dict[str, object] = {
|
||||
"retrieval_run_id": uuid4(),
|
||||
"query": "example query",
|
||||
"profile": "hybrid-v1",
|
||||
"effective_filters": EffectiveFilterTrace(
|
||||
requested_space_ids=(),
|
||||
effective_space_ids=(),
|
||||
denied_space_ids=(),
|
||||
trust_required_space_ids=(),
|
||||
requested_content_types=(),
|
||||
applied_content_types=(),
|
||||
requested_languages=(),
|
||||
applied_languages=(),
|
||||
unsupported_filter_fields=(),
|
||||
),
|
||||
"dense_candidates": (),
|
||||
"sparse_candidates": (),
|
||||
"fusion": FusionTrace(candidates=(), suppressed_count=0),
|
||||
"rerank": RerankTrace(
|
||||
state="skipped", degradation_code=None, candidates=(), elapsed_ms=0.0
|
||||
),
|
||||
"parent_expansion": ParentExpansionTrace(0, 0, 0),
|
||||
"context": ContextTrace(
|
||||
token_count=0, max_context_tokens=100, block_count=0, citation_ids=()
|
||||
),
|
||||
"answerability": AnswerabilityTrace(
|
||||
status="not_answerable",
|
||||
reasons=("insufficient_evidence",),
|
||||
evidence_count=0,
|
||||
document_diversity=0,
|
||||
source_independence=0,
|
||||
facet_coverage_ratio=0.0,
|
||||
thresholds_version="v1",
|
||||
),
|
||||
"citations": (),
|
||||
"timings_ms": {},
|
||||
"revisions": RevisionTrace(None, None, "technical/v1", None, "v1"),
|
||||
"degraded": False,
|
||||
"degradation_codes": (),
|
||||
}
|
||||
values.update(overrides)
|
||||
return QueryLabTrace(**values)
|
||||
|
||||
|
||||
def test_trace_rejects_unsafe_timing_name() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_full_trace(timings_ms={"not a safe name": 1.0})
|
||||
|
||||
|
||||
def test_trace_rejects_negative_timing() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_full_trace(timings_ms={"embed_query": -1.0})
|
||||
|
||||
|
||||
def test_trace_rejects_duplicate_degradation_codes() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_full_trace(degradation_codes=("backend_timeout", "backend_timeout"))
|
||||
|
||||
|
||||
def test_trace_rejects_too_many_citations() -> None:
|
||||
from ragcore.web.query_lab.models import CitationTrace
|
||||
|
||||
citations = tuple(
|
||||
CitationTrace(
|
||||
id=uuid4(),
|
||||
document_id=uuid4(),
|
||||
chunk_id=uuid4(),
|
||||
excerpt_preview="text",
|
||||
excerpt_truncated=False,
|
||||
location={},
|
||||
source_uri="ragcore://source/x",
|
||||
)
|
||||
for _ in range(21)
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
_full_trace(citations=citations)
|
||||
|
||||
|
||||
def test_trace_accepts_a_well_formed_minimal_result() -> None:
|
||||
trace = _full_trace()
|
||||
assert trace.degraded is False
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from ragcore.api.v1.search.service import AuthorizationInputs
|
||||
from ragcore.application.authorization import AuthorizationService
|
||||
from ragcore.application.retrieval.authorization import RetrievalAuthorizationService
|
||||
from ragcore.domain.authorization import PermissionDenied, Principal, PrincipalType
|
||||
from ragcore.domain.control import DataPolicy, KnowledgeSpace, Role, UserGrant
|
||||
from ragcore.web.query_lab.executor import QueryLabExecutionRequest
|
||||
from ragcore.web.query_lab.models import (
|
||||
AnswerabilityTrace,
|
||||
ContextTrace,
|
||||
EffectiveFilterTrace,
|
||||
FusionTrace,
|
||||
ParentExpansionTrace,
|
||||
QueryLabQuery,
|
||||
QueryLabTrace,
|
||||
RerankTrace,
|
||||
RevisionTrace,
|
||||
)
|
||||
from ragcore.web.query_lab.service import QueryLabService
|
||||
|
||||
WORKSPACE_ID = uuid4()
|
||||
SPACE_ID = uuid4()
|
||||
|
||||
|
||||
def _space() -> KnowledgeSpace:
|
||||
return KnowledgeSpace(
|
||||
id=SPACE_ID,
|
||||
workspace_id=WORKSPACE_ID,
|
||||
name="Docs",
|
||||
slug="docs",
|
||||
active=True,
|
||||
trust_required=False,
|
||||
data_policy=DataPolicy.LOCAL_ONLY,
|
||||
retention_days=None,
|
||||
)
|
||||
|
||||
|
||||
def _user_grant(user_id, role: Role = Role.VIEWER) -> UserGrant:
|
||||
return UserGrant(
|
||||
id=uuid4(),
|
||||
workspace_id=WORKSPACE_ID,
|
||||
user_id=user_id,
|
||||
knowledge_space_id=SPACE_ID,
|
||||
role=role,
|
||||
active=True,
|
||||
)
|
||||
|
||||
|
||||
class StaticInputsProvider:
|
||||
def __init__(self, spaces=(), user_grants=()) -> None:
|
||||
self._spaces = spaces
|
||||
self._user_grants = user_grants
|
||||
|
||||
async def load(self, principal: Principal) -> AuthorizationInputs:
|
||||
return AuthorizationInputs(
|
||||
spaces=self._spaces, grants=(), user_grants=self._user_grants, application=None
|
||||
)
|
||||
|
||||
|
||||
class RecordingExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[QueryLabExecutionRequest] = []
|
||||
|
||||
async def execute(self, request: QueryLabExecutionRequest) -> QueryLabTrace:
|
||||
self.requests.append(request)
|
||||
return _trace(request)
|
||||
|
||||
|
||||
def _trace(request: QueryLabExecutionRequest) -> QueryLabTrace:
|
||||
return QueryLabTrace(
|
||||
retrieval_run_id=request.retrieval_run_id,
|
||||
query=request.query.query,
|
||||
profile=request.query.profile,
|
||||
effective_filters=EffectiveFilterTrace((), (), (), (), (), (), (), (), ()),
|
||||
dense_candidates=(),
|
||||
sparse_candidates=(),
|
||||
fusion=FusionTrace(candidates=(), suppressed_count=0),
|
||||
rerank=RerankTrace(state="skipped", degradation_code=None, candidates=(), elapsed_ms=0.0),
|
||||
parent_expansion=ParentExpansionTrace(0, 0, 0),
|
||||
context=ContextTrace(token_count=0, max_context_tokens=100, block_count=0, citation_ids=()),
|
||||
answerability=AnswerabilityTrace(
|
||||
status="not_answerable",
|
||||
reasons=("insufficient_evidence",),
|
||||
evidence_count=0,
|
||||
document_diversity=0,
|
||||
source_independence=0,
|
||||
facet_coverage_ratio=0.0,
|
||||
thresholds_version="v1",
|
||||
),
|
||||
citations=(),
|
||||
timings_ms={},
|
||||
revisions=RevisionTrace(None, None, "technical/v1", None, "v1"),
|
||||
degraded=False,
|
||||
degradation_codes=(),
|
||||
)
|
||||
|
||||
|
||||
def _service(executor: RecordingExecutor, spaces=(), user_grants=()) -> QueryLabService:
|
||||
return QueryLabService(
|
||||
authorization=AuthorizationService(),
|
||||
retrieval_authorization=RetrievalAuthorizationService(),
|
||||
inputs=StaticInputsProvider(spaces, user_grants),
|
||||
executor=executor,
|
||||
)
|
||||
|
||||
|
||||
def _principal(role: Role) -> Principal:
|
||||
return Principal(uuid4(), WORKSPACE_ID, PrincipalType.HUMAN, frozenset({role}))
|
||||
|
||||
|
||||
def test_can_use_reflects_the_privilege_gate_without_side_effects() -> None:
|
||||
service = _service(RecordingExecutor())
|
||||
assert service.can_use(_principal(Role.SPACE_MANAGER)) is True
|
||||
assert service.can_use(_principal(Role.PLATFORM_ADMIN)) is True
|
||||
assert service.can_use(_principal(Role.VIEWER)) is False
|
||||
assert service.can_use(_principal(Role.EDITOR)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_viewer_is_denied_query_lab_even_with_a_valid_grant() -> None:
|
||||
principal = _principal(Role.VIEWER)
|
||||
executor = RecordingExecutor()
|
||||
service = _service(executor, spaces=(_space(),), user_grants=(_user_grant(principal.id),))
|
||||
with pytest.raises(PermissionDenied):
|
||||
await service.run(principal, QueryLabQuery(query="test"))
|
||||
assert executor.requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editor_is_denied_query_lab_even_with_a_valid_grant() -> None:
|
||||
principal = _principal(Role.EDITOR)
|
||||
executor = RecordingExecutor()
|
||||
service = _service(executor, spaces=(_space(),), user_grants=(_user_grant(principal.id),))
|
||||
with pytest.raises(PermissionDenied):
|
||||
await service.run(principal, QueryLabQuery(query="test"))
|
||||
assert executor.requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_space_manager_with_a_grant_can_run_and_scope_is_resolved_server_side() -> None:
|
||||
principal = _principal(Role.SPACE_MANAGER)
|
||||
executor = RecordingExecutor()
|
||||
service = _service(
|
||||
executor,
|
||||
spaces=(_space(),),
|
||||
user_grants=(_user_grant(principal.id, role=Role.SPACE_MANAGER),),
|
||||
)
|
||||
trace = await service.run(
|
||||
principal, QueryLabQuery(query="test", requested_space_ids=(SPACE_ID,))
|
||||
)
|
||||
assert trace.query == "test"
|
||||
assert executor.requests[0].scope.effective_space_ids == frozenset({SPACE_ID})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_space_manager_without_any_grant_resolves_to_no_effective_spaces() -> None:
|
||||
principal = _principal(Role.SPACE_MANAGER)
|
||||
executor = RecordingExecutor()
|
||||
service = _service(executor, spaces=(_space(),), user_grants=())
|
||||
trace = await service.run(
|
||||
principal, QueryLabQuery(query="test", requested_space_ids=(SPACE_ID,))
|
||||
)
|
||||
assert executor.requests == []
|
||||
assert trace.effective_filters.effective_space_ids == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_manual_unauthorized_requested_space_is_never_included() -> None:
|
||||
principal = _principal(Role.SPACE_MANAGER)
|
||||
executor = RecordingExecutor()
|
||||
service = _service(
|
||||
executor,
|
||||
spaces=(_space(),),
|
||||
user_grants=(_user_grant(principal.id, role=Role.SPACE_MANAGER),),
|
||||
)
|
||||
unauthorized_space = uuid4()
|
||||
trace = await service.run(
|
||||
principal, QueryLabQuery(query="test", requested_space_ids=(unauthorized_space,))
|
||||
)
|
||||
# The requested space was never granted, so the resolved scope is empty
|
||||
# and the executor is never invoked -- a client cannot widen access by
|
||||
# simply asking for a different space id.
|
||||
assert executor.requests == []
|
||||
assert trace.effective_filters.effective_space_ids == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_admin_with_a_grant_can_run_query_lab() -> None:
|
||||
principal = _principal(Role.PLATFORM_ADMIN)
|
||||
executor = RecordingExecutor()
|
||||
service = _service(
|
||||
executor, spaces=(_space(),), user_grants=(_user_grant(principal.id, role=Role.VIEWER),)
|
||||
)
|
||||
trace = await service.run(
|
||||
principal, QueryLabQuery(query="test", requested_space_ids=(SPACE_ID,))
|
||||
)
|
||||
assert executor.requests[0].scope.effective_space_ids == frozenset({SPACE_ID})
|
||||
assert trace.retrieval_run_id == executor.requests[0].retrieval_run_id
|
||||
Reference in new issue
Block a user