Files
mcp-nextcloud/nextcloud_mcp_server/admin/payload_backfill.py
T
Chris CoutinhoandClaude Opus 4.8 b5ed1e3b4d fix: address PR #814 review + SonarCloud gate
SonarCloud:
- Resolve 6 S5332 hotspots (http→https in test fixture URLs).
- S6418: hoist the unauthenticated AsyncOpenAI placeholder to a named constant
  + NOSONAR (genuine non-secret; gateway ignores it when unauthenticated).
- Fix two reliability bugs: None-index guard in the gateway token-cache test
  (S2259) and float `> 0.0` instead of `!= 0.0` in the sentinel test (S1244).
- status.py idle path sleeps 0.1s instead of sleep(0) (S7491); NOSONAR on the
  protocol-required async no-await aclose() stubs (S7503).

Claude review:
- Remove three leftover debug print() calls in app.py (logger.info already
  covers them).
- payload_backfill: drop parsed_at from the backfilled-keys docstring (it is
  per-document state, not a deployment scalar); add a clean 404 precondition
  for BasicAuth deployments without an OAuth token verifier.
- status.py: task_status typed TaskStatus | None (drop type: ignore).
- nats.py: TODO to thread etags for file/deck/news; note etag default → None.
- factory: warn on unknown INGEST_BUS_URL scheme; raise ValueError instead of
  assert for the external-mode preconditions.
- docs/configuration.md: document the decomposition hook-point env vars + that
  nats-py ships core (lazy-imported) and external+bus uses two NATS connections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 18:36:42 +02:00

115 lines
4.3 KiB
Python

"""One-shot payload backfill admin endpoint (design §10.2).
``POST /api/v1/admin/payload-backfill`` walks the collection and adds default
values for any *missing* decomposition payload keys (so existing corpora gain
them without a re-index), then upserts the collection-metadata sentinel.
Scope: this backfills the cheap, deployment-level scalar keys
(``processor_version``, ``pipeline_tier``, ``embedding_identity``) only.
``parsed_at`` is per-document state (the local processor sets it at index time),
not a deployment-level scalar, so it is intentionally not backfilled. It also
deliberately does NOT synthesize ``acl_hash`` — a correct value needs
per-document share enumeration (a separate job), and writing a placeholder
``acl_hash`` would be unsafe to pre-filter on. The query-side ACL pre-filter
therefore stays disabled until a real ACL backfill runs (see
``ACL_PREFILTER_ENABLED``).
"""
from __future__ import annotations
import logging
from qdrant_client import models
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
AdminScopeRequired,
require_admin_scope,
)
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_embedding_service
from nextcloud_mcp_server.vector import payload_keys
from nextcloud_mcp_server.vector.collection_metadata import (
env_default_metadata,
upsert_sentinel,
)
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
async def handle_payload_backfill(request: Request) -> JSONResponse:
# require_admin_scope authenticates via the OAuth token verifier; in
# BasicAuth-only deployments there is no oauth_context, so surface a clean
# 404 rather than a confusing 401 from the broad except below.
if getattr(request.app.state, "oauth_context", None) is None:
return JSONResponse(
{"error": "admin API requires an OAuth-capable deployment"},
status_code=404,
)
try:
await require_admin_scope(request)
except AdminScopeRequired:
return JSONResponse({"error": "admin scope required"}, status_code=403)
except Exception:
return JSONResponse({"error": "unauthorized"}, status_code=401)
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse({"error": "vector sync disabled"}, status_code=404)
client = await get_qdrant_client()
collection = settings.get_collection_name()
meta = env_default_metadata(settings)
# Deployment-level scalar defaults (safe to set only where missing).
defaults = {
payload_keys.PROCESSOR_VERSION: "backfill",
payload_keys.PIPELINE_TIER: "fast",
payload_keys.EMBEDDING_IDENTITY: meta["embedding_identity"],
}
applied: dict[str, str] = {}
for key, value in defaults.items():
try:
await client.set_payload(
collection_name=collection,
payload={key: value},
# Only points missing this key (don't clobber existing values).
points=models.Filter(
must=[
models.IsEmptyCondition(is_empty=models.PayloadField(key=key))
]
),
wait=True,
)
applied[key] = "set-where-missing"
except Exception as e:
logger.warning("payload backfill failed for key %s: %s", key, e)
applied[key] = f"error: {e}"
# Upsert the collection-metadata sentinel so query-path metadata reads work
# for this collection even without a control plane.
sentinel_ok = True
try:
dimension = get_embedding_service().get_dimension()
await upsert_sentinel(
client,
collection,
embedding_identity=meta["embedding_identity"],
chunking_config=meta["chunking_config"],
dimension=dimension,
)
except Exception as e:
sentinel_ok = False
logger.warning("sentinel upsert failed during backfill: %s", e)
return JSONResponse(
{
"status": "ok",
"collection": collection,
"keys_applied": applied,
"sentinel_upserted": sentinel_ok,
}
)