- purge route: 400 (not 500) on a valid-JSON non-object body
- scanner: backstop-purge admin-disabled note/news_item/deck_card points
(their deletion-tracking lives inside the skipped scan_* fns), mirroring the
files path; gated on a concrete allow-set so fail-open never deletes
- processor: record_ingest_dropped("admin_disabled") so consent-skipped index
tasks are observable/alertable
- app.py: list /api/v1/vector-sync/purge in the endpoints log line
- capabilities: drop empty-string doc types; return frozenset throughout
- purge: document the count-before-delete approximation
- tests: non-object body -> 400, ProvisioningRequiredError -> 428, cache TTL
expiry refetch, and the scanner consent backstop
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
2.9 KiB
Python
78 lines
2.9 KiB
Python
"""Global purge of indexed vectors by doc type (admin consent enforcement).
|
|
|
|
When an admin disables a content source for semantic search in Astrolabe,
|
|
consent is binding on data-at-rest: the already-indexed content for that
|
|
source's doc type(s) must be deleted, not merely hidden. Astrolabe calls the
|
|
``/api/v1/vector-sync/purge`` route on disable, which delegates here.
|
|
|
|
The purge is global (every owner) because the admin disable is a global
|
|
decision. It is safe to call for a doc type with no indexed points — Qdrant
|
|
deletes zero points and reports a count of 0.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
|
|
from nextcloud_mcp_server.config import get_settings
|
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _doc_type_filter(doc_type: str) -> Filter:
|
|
return Filter(
|
|
must=[FieldCondition(key="doc_type", match=MatchValue(value=doc_type))]
|
|
)
|
|
|
|
|
|
async def purge_doc_types(doc_types: list[str]) -> dict[str, int]:
|
|
"""Delete every indexed point whose ``doc_type`` is in ``doc_types``.
|
|
|
|
Returns a mapping of doc_type -> number of points deleted (counted before
|
|
deletion). Each doc type is purged independently so a failure on one does
|
|
not abort the rest; failures re-raise after the loop only if every doc type
|
|
failed, otherwise partial progress is returned.
|
|
|
|
The count is taken just before the delete (two separate Qdrant calls), so
|
|
it is approximate — a point indexed in the gap is deleted but not counted.
|
|
This is acceptable: indexing of a disabled source is already gated upstream,
|
|
so the window is effectively empty in practice.
|
|
"""
|
|
qdrant_client = await get_qdrant_client()
|
|
collection = get_settings().get_collection_name()
|
|
|
|
purged: dict[str, int] = {}
|
|
last_error: Exception | None = None
|
|
for doc_type in dict.fromkeys(doc_types): # de-dupe, preserve order
|
|
flt = _doc_type_filter(doc_type)
|
|
try:
|
|
count_result = await qdrant_client.count(
|
|
collection_name=collection,
|
|
count_filter=flt,
|
|
exact=True,
|
|
)
|
|
await qdrant_client.delete(
|
|
collection_name=collection,
|
|
points_selector=flt,
|
|
)
|
|
purged[doc_type] = int(count_result.count)
|
|
logger.info(
|
|
"Purged %d indexed point(s) for disabled doc_type=%s",
|
|
purged[doc_type],
|
|
doc_type,
|
|
)
|
|
except Exception as exc: # noqa: BLE001 — record and continue
|
|
last_error = exc
|
|
logger.exception(
|
|
"Failed to purge indexed points for doc_type=%s",
|
|
doc_type,
|
|
)
|
|
|
|
if not purged and last_error is not None:
|
|
# Nothing succeeded — surface the failure to the caller (HTTP 500).
|
|
raise last_error
|
|
return purged
|