Merge remote-tracking branch 'origin/master' into fix/qdrant-doc-id-keyword-index

This commit is contained in:
Chris Coutinho
2026-05-09 13:48:21 +02:00
12 changed files with 1296 additions and 284 deletions
+47 -51
View File
@@ -18,11 +18,11 @@ from pathlib import Path
import anyio
import numpy as np
from jinja2 import Environment, FileSystemLoader
from qdrant_client.models import FieldCondition, Filter, MatchValue
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.api.management import _parse_int_param
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
@@ -33,13 +33,15 @@ from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.search.context import (
get_chunk_bbox_and_page_from_qdrant,
get_chunk_with_context,
)
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client_basic_auth,
)
from nextcloud_mcp_server.vector.pca import PCA
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__)
@@ -539,7 +541,8 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
doc_id = request.query_params.get("doc_id")
start_str = request.query_params.get("start")
end_str = request.query_params.get("end")
context_chars = int(request.query_params.get("context", "500"))
chunk_index_str = request.query_params.get("chunk_index")
total_chunks_str = request.query_params.get("total_chunks")
# Validate required parameters
if not all([doc_type, doc_id, start_str, end_str]):
@@ -557,9 +560,25 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
assert start_str is not None
assert end_str is not None
start = int(start_str)
end = int(end_str)
# doc_id is keyword-indexed in Qdrant as str — pass through verbatim.
context_chars = _parse_int_param(
request.query_params.get("context"),
500,
0,
10000,
"context_chars",
)
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
if end <= start:
raise ValueError("end must be greater than start")
chunk_index: int | None = None
if chunk_index_str is not None:
chunk_index = _parse_int_param(
chunk_index_str, 0, 0, 1000000, "chunk_index"
)
total_chunks = _parse_int_param(total_chunks_str, 1, 1, 1000000, "total_chunks")
# doc_id is keyword-indexed in Qdrant as str — pass through verbatim
# (no int coercion; producers always stringify on write).
user_id = request.user.display_name
settings = get_settings()
@@ -587,6 +606,8 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
doc_type=doc_type,
chunk_start=start,
chunk_end=end,
chunk_index=chunk_index,
total_chunks=total_chunks,
context_chars=context_chars,
)
@@ -609,50 +630,22 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
# For PDF files, also fetch the chunk bbox from Qdrant so the client
# can overlay a highlight on top of a render-on-demand page image
# (Deck #76).
# (Deck #76). Qdrant's page_number is trusted over the
# context-expansion fallback when present.
chunk_bbox = None
page_number = None
page_number = chunk_context.page_number
if doc_type == "file":
try:
settings = get_settings()
qdrant_client = await get_qdrant_client()
username = request.user.display_name
points_response = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
get_placeholder_filter(),
FieldCondition(
key="doc_id", match=MatchValue(value=doc_id)
),
FieldCondition(
key="user_id", match=MatchValue(value=username)
),
FieldCondition(
key="chunk_start_offset", match=MatchValue(value=start)
),
FieldCondition(
key="chunk_end_offset", match=MatchValue(value=end)
),
]
),
limit=1,
with_vectors=False,
with_payload=["chunk_bbox", "page_number"],
)
points = points_response[0]
if points and points[0].payload:
chunk_bbox = points[0].payload.get("chunk_bbox")
page_number = points[0].payload.get("page_number")
if chunk_bbox:
logger.info(
f"Found chunk bbox: page={page_number}, "
f"rects={len(chunk_bbox)}"
)
except Exception as e:
logger.warning(f"Failed to fetch chunk bbox: {e}")
qdrant_bbox, qdrant_page = await get_chunk_bbox_and_page_from_qdrant(
user_id=user_id,
doc_id=doc_id,
chunk_index=chunk_index,
chunk_start=start,
chunk_end=end,
)
if qdrant_bbox is not None:
chunk_bbox = qdrant_bbox
if qdrant_page is not None:
page_number = qdrant_page
# Return response compatible with frontend expectations
response_data: dict = {
@@ -662,16 +655,19 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
"after_context": chunk_context.after_context,
"has_more_before": chunk_context.has_before_truncation,
"has_more_after": chunk_context.has_after_truncation,
"page_number": page_number,
"chunk_index": chunk_context.chunk_index,
"total_chunks": chunk_context.total_chunks,
}
if chunk_bbox:
response_data["chunk_bbox"] = chunk_bbox
response_data["page_number"] = page_number
return JSONResponse(response_data)
except ValueError as e:
logger.error(f"Invalid parameter format: {e}")
# User-supplied bad input → 400, not a server error.
logger.warning("Invalid parameter format: %s", e)
return JSONResponse(
{"success": False, "error": f"Invalid parameter format: {e}"},
status_code=400,