fix(vector): address PR review round 2 — status branching, doc_id guard, doc restore

- _ensure_keyword_payload_indexes: distinguish 400 (schema conflict, warning)
  from other status codes (5xx/network, error) so a transient outage doesn't
  silently leave the collection unindexed.
- build_search_result_from_point: use .get("doc_id") + return None on missing
  instead of KeyError-crashing the search; reverse metadata merge order so
  payload-derived chunk_index/total_chunks win over caller-supplied extras.
- docs/configuration.md: restore the OpenAI/Mistral/Bedrock/Simple provider
  sections + reference-table rows that were dropped in the rebase. Reword
  the "Startup migrations" bullet to describe what the code actually does
  (no sampling — full scroll, zero writes when clean). Add operator note
  about the SemanticSearchResult.id TypeError path.
- tests: pytest.approx for float equality (Sonar python:S1244); coverage
  for non-400 → ERROR, payload={doc_id: None}, and missing doc_id key.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-08 21:37:10 +02:00
co-authored by Claude Opus 4.7
parent 6aba589a6e
commit b5b4025bb4
5 changed files with 188 additions and 26 deletions
+13 -8
View File
@@ -11,6 +11,8 @@ from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
@runtime_checkable
class NextcloudClientProtocol(Protocol):
@@ -91,7 +93,6 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
... # Search notes
"""
logger = logging.getLogger(__name__)
settings = get_settings()
qdrant_client = await get_qdrant_client()
@@ -205,15 +206,19 @@ def build_search_result_from_point(
if point.payload is None:
return None
doc_id = str(point.payload["doc_id"])
raw_doc_id = point.payload.get("doc_id")
if raw_doc_id is None:
logger.warning("Skipping point %s: missing doc_id in payload", point.id)
return None
doc_id = str(raw_doc_id)
doc_type = point.payload.get("doc_type", "note")
metadata: dict[str, Any] = {
"chunk_index": point.payload.get("chunk_index"),
"total_chunks": point.payload.get("total_chunks"),
}
if metadata_extras:
metadata.update(metadata_extras)
# Caller-supplied metadata is merged first; payload-derived common fields
# (chunk_index, total_chunks) win in case of key collisions so they always
# reflect the actual point.
metadata: dict[str, Any] = dict(metadata_extras) if metadata_extras else {}
metadata["chunk_index"] = point.payload.get("chunk_index")
metadata["total_chunks"] = point.payload.get("total_chunks")
# File-specific metadata for PDF viewer
if doc_type == "file" and (path := point.payload.get("file_path")):
+15 -3
View File
@@ -46,9 +46,21 @@ async def _ensure_keyword_payload_indexes(
except UnexpectedResponse as e:
body = getattr(e, "content", b"") or b""
body_text = body.decode("utf-8", errors="replace")
logger.warning(
"Failed to create payload index on '%s': %s", field, body_text
)
# 400 is the expected schema-conflict path (index already exists
# with a different type). 5xx / network-shaped errors should not
# be silently downgraded — keep the loop going so the remaining
# fields still get attempted, but log at error so operators see it.
if e.status_code == 400:
logger.warning(
"Schema conflict on payload index '%s': %s", field, body_text
)
else:
logger.error(
"Unexpected error creating payload index on '%s' (status %s): %s",
field,
e.status_code,
body_text,
)
async def _backfill_doc_id_to_string(