feat: add opt-in MCP decomposition hook points (design §10)
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
c7da612f20
commit
d883052fb8
@@ -0,0 +1,91 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user