Adds the seven §10.2 hook-point modules + five env vars so Astrolabe Cloud can offload document processing to the external document-processor / embedding gateway. Purely additive: with every setting unset the server behaves exactly as today, so self-hosters are unaffected (Deck #92). Hook points (all default to current monolith behavior): - config: EMBEDDING_PROVIDER, INGEST_MODE, STATUS_BACKEND, COLLECTION_METADATA_SOURCE, FACT_EVENT_EMITTER (+ supporting settings), validated in Settings.__post_init__ (fail-fast STATUS_BACKEND=local with INGEST_MODE=external); shared canonical.py. - vector/payload_keys.py + acl_hash.py: cross-impl NAMESPACE/point_id (§2.2) and BLAKE2b-128 ACL hash (§11), pinned by fixtures shared with the document-processor repo. - embedding/gateway_client.py: OpenAI-compatible GatewayProvider authenticating via M2M OIDC client-credentials (separate realm); manual-only registry entry. - vector/collection_metadata.py: sentinel-point / API metadata source with env fallback. - vector/queue/: hexagonal ingest producer ports + memory/NATS adapters (Postgres seam); INGEST_MODE=external publishes mcp.ingest.requested.{tenant} instead of the in-memory stream and skips the in-process processor pool. The lifespan becomes a composition root across both deployment branches. - vector/queue/status.py: STATUS_BACKEND=bus subscriber feeding a StatusStore the vector-sync status endpoint reads. - admin/payload_backfill.py: POST /api/v1/admin/payload-backfill (admin scope); processor writes the new payload keys; query-side ACL pre-filter gated behind ACL_PREFILTER_ENABLED (default off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
92 lines
3.1 KiB
Python
92 lines
3.1 KiB
Python
"""Collection metadata source + sentinel (design §10.1)."""
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
|
|
from nextcloud_mcp_server.config import Settings
|
|
from nextcloud_mcp_server.vector import collection_metadata as cm
|
|
from nextcloud_mcp_server.vector.payload_keys import EMBEDDING_IDENTITY
|
|
|
|
|
|
async def test_qdrant_read_hit(mocker):
|
|
client = mocker.AsyncMock()
|
|
client.retrieve.return_value = [
|
|
SimpleNamespace(
|
|
payload={
|
|
EMBEDDING_IDENTITY: "mistral-embed",
|
|
cm.CHUNKING_CONFIG: {"chunk_size": 1024, "chunk_overlap": 100},
|
|
cm.IS_SENTINEL: True,
|
|
}
|
|
)
|
|
]
|
|
meta = await cm.read_collection_metadata(client, "col", Settings())
|
|
assert meta["embedding_identity"] == "mistral-embed"
|
|
assert meta["chunking_config"]["chunk_size"] == 1024
|
|
|
|
|
|
async def test_qdrant_miss_falls_back_to_env(mocker):
|
|
client = mocker.AsyncMock()
|
|
client.retrieve.return_value = [] # no sentinel
|
|
settings = Settings(document_chunk_size=2048, document_chunk_overlap=200)
|
|
meta = await cm.read_collection_metadata(client, "col", settings)
|
|
assert meta == cm.env_default_metadata(settings)
|
|
assert meta["chunking_config"]["chunk_size"] == 2048
|
|
|
|
|
|
async def test_qdrant_error_falls_back_to_env(mocker):
|
|
client = mocker.AsyncMock()
|
|
client.retrieve.side_effect = RuntimeError("qdrant down")
|
|
settings = Settings()
|
|
meta = await cm.read_collection_metadata(client, "col", settings)
|
|
assert meta == cm.env_default_metadata(settings)
|
|
|
|
|
|
async def test_api_source(mocker):
|
|
settings = Settings(
|
|
collection_metadata_source="api",
|
|
collection_metadata_api_url="http://cp",
|
|
)
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
assert request.url.path == "/v1/qdrant-collections/col/metadata"
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"embedding_identity": "amazon.titan-embed-text-v2:0",
|
|
"chunking_config": {"chunk_size": 512, "chunk_overlap": 50},
|
|
},
|
|
)
|
|
|
|
transport = httpx.MockTransport(handler)
|
|
orig = httpx.AsyncClient
|
|
mocker.patch.object(
|
|
httpx,
|
|
"AsyncClient",
|
|
lambda *a, **k: orig(*a, **{**k, "transport": transport}),
|
|
)
|
|
|
|
meta = await cm.read_collection_metadata(mocker.AsyncMock(), "col", settings)
|
|
assert meta["embedding_identity"] == "amazon.titan-embed-text-v2:0"
|
|
|
|
|
|
async def test_upsert_sentinel_builds_point(mocker):
|
|
client = mocker.AsyncMock()
|
|
await cm.upsert_sentinel(
|
|
client,
|
|
"col",
|
|
embedding_identity="mistral-embed",
|
|
chunking_config={"chunk_size": 2048, "chunk_overlap": 200},
|
|
dimension=4,
|
|
)
|
|
client.upsert.assert_awaited_once()
|
|
kwargs = client.upsert.await_args.kwargs
|
|
assert kwargs["collection_name"] == "col"
|
|
point = kwargs["points"][0]
|
|
assert str(point.id) == cm.SENTINEL_POINT_ID
|
|
# Non-zero dense (cosine-safe), empty sparse.
|
|
assert point.vector["dense"][0] != 0.0
|
|
assert len(point.vector["dense"]) == 4
|
|
assert point.payload[EMBEDDING_IDENTITY] == "mistral-embed"
|
|
assert point.payload[cm.IS_SENTINEL] is True
|