"""Vector visualization routes for testing search algorithms. Provides a web UI for users to test different search algorithms on their own indexed documents and visualize results in 2D space using PCA. All processing happens server-side following ADR-012: - Search execution via shared search/algorithms.py - PCA dimensionality reduction (768-dim → 2D) - Only 2D coordinates + metadata sent to client - Bandwidth-efficient (2 floats per doc vs 768) """ import logging import time import numpy as np from starlette.authentication import requires from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse from nextcloud_mcp_server.config import get_settings from nextcloud_mcp_server.search import ( FuzzySearchAlgorithm, HybridSearchAlgorithm, KeywordSearchAlgorithm, SemanticSearchAlgorithm, ) from nextcloud_mcp_server.vector.pca import PCA from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client logger = logging.getLogger(__name__) @requires("authenticated", redirect="oauth_login") async def vector_visualization_html(request: Request) -> HTMLResponse: """Vector visualization page with search controls and interactive plot. Provides UI for testing search algorithms with real-time visualization. Requires vector sync to be enabled. Args: request: Starlette request object Returns: HTML page with search interface """ settings = get_settings() if not settings.vector_sync_enabled: return HTMLResponse( """

Vector Visualization

Vector sync is not enabled. Set VECTOR_SYNC_ENABLED=true to use this feature.
""" ) # Get user info from auth context username = ( request.user.display_name if hasattr(request.user, "display_name") else "unknown" ) html_content = f"""

Vector Visualization

Testing search algorithms on your indexed documents. User: {username}

Advanced Options

Hold Ctrl/Cmd to select multiple
Executing search and computing PCA projection...

Search Results ()

Loading results...
No results found. Try a different query or adjust your search parameters.
""" return HTMLResponse(content=html_content) @requires("authenticated", redirect="oauth_login") async def vector_visualization_search(request: Request) -> JSONResponse: """Execute server-side search and return 2D coordinates + results. All processing happens server-side: 1. Execute search via shared algorithm module 2. Fetch matching vectors from Qdrant 3. Apply PCA reduction (768-dim → 2D) 4. Return coordinates + metadata only Args: request: Starlette request with query parameters Returns: JSON response with coordinates_2d and results """ settings = get_settings() if not settings.vector_sync_enabled: return JSONResponse( {"success": False, "error": "Vector sync not enabled"}, status_code=400, ) # Get user info from auth context username = ( request.user.display_name if hasattr(request.user, "display_name") else None ) if not username: return JSONResponse( {"success": False, "error": "User not authenticated"}, status_code=401, ) # Parse query parameters query = request.query_params.get("query", "") algorithm = request.query_params.get("algorithm", "hybrid") limit = int(request.query_params.get("limit", "50")) score_threshold = float(request.query_params.get("score_threshold", "0.7")) semantic_weight = float(request.query_params.get("semantic_weight", "0.5")) keyword_weight = float(request.query_params.get("keyword_weight", "0.3")) fuzzy_weight = float(request.query_params.get("fuzzy_weight", "0.2")) # Parse doc_types (comma-separated list, None = all types) doc_types_param = request.query_params.get("doc_types", "") doc_types = doc_types_param.split(",") if doc_types_param else None logger.info( f"Viz search: user={username}, query='{query}', " f"algorithm={algorithm}, limit={limit}, doc_types={doc_types}" ) try: # Start total request timer request_start = time.perf_counter() # Get authenticated HTTP client from session # In BasicAuth mode: uses username/password from session # In OAuth mode: uses access token from session from nextcloud_mcp_server.auth.userinfo_routes import ( _get_authenticated_client_for_userinfo, ) async with await _get_authenticated_client_for_userinfo(request) as http_client: # noqa: F841 # Create search algorithm (no client needed - verification removed) if algorithm == "semantic": search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold) elif algorithm == "keyword": search_algo = KeywordSearchAlgorithm() elif algorithm == "fuzzy": search_algo = FuzzySearchAlgorithm() elif algorithm == "hybrid": search_algo = HybridSearchAlgorithm( semantic_weight=semantic_weight, keyword_weight=keyword_weight, fuzzy_weight=fuzzy_weight, ) else: return JSONResponse( {"success": False, "error": f"Unknown algorithm: {algorithm}"}, status_code=400, ) # Execute search (supports cross-app when doc_types=None) # Get unverified results with buffer for filtering search_start = time.perf_counter() all_results = [] if doc_types is None or len(doc_types) == 0: # Cross-app search - search all indexed types unverified_results = await search_algo.search( query=query, user_id=username, limit=limit * 2, # Buffer for verification filtering doc_type=None, # Search all types score_threshold=score_threshold, ) all_results.extend(unverified_results) else: # Search each document type and combine for doc_type in doc_types: unverified_results = await search_algo.search( query=query, user_id=username, limit=limit * 2, # Buffer for verification filtering doc_type=doc_type, score_threshold=score_threshold, ) all_results.extend(unverified_results) # Sort by score before verification all_results.sort(key=lambda r: r.score, reverse=True) # No verification needed for visualization - we only need Qdrant metadata # (title, excerpt, doc_type) which is already in search results. # Verification is only needed for sampling (LLM needs full content). search_results = all_results[:limit] search_duration = time.perf_counter() - search_start # Normalize scores relative to this result set for better visualization # (best result = 1.0, worst result = 0.0 within THIS result set) # This makes visual encoding meaningful regardless of RRF normalization if search_results: scores = [r.score for r in search_results] min_score, max_score = min(scores), max(scores) score_range = max_score - min_score if max_score > min_score else 1.0 logger.info( f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] " f"→ [0.0, 1.0]" ) # Rescale each result's score to 0-1 within this result set for r in search_results: r.score = (r.score - min_score) / score_range if not search_results: return JSONResponse( { "success": True, "results": [], "coordinates_2d": [], "message": "No results found", } ) # Fetch vectors for matching results from Qdrant vector_fetch_start = time.perf_counter() qdrant_client = await get_qdrant_client() doc_ids = [r.id for r in search_results] # Retrieve vectors for the matching documents from qdrant_client.models import FieldCondition, Filter, MatchAny points_response = await qdrant_client.scroll( collection_name=settings.get_collection_name(), scroll_filter=Filter( must=[ FieldCondition( key="doc_id", match=MatchAny(any=[str(doc_id) for doc_id in doc_ids]), ), FieldCondition( key="user_id", match={"value": username}, ), ] ), limit=len(doc_ids) * 2, # Account for multiple chunks per doc with_vectors=True, with_payload=["doc_id"], # Need doc_id to map vectors to results ) points = points_response[0] if not points: return JSONResponse( { "success": True, "results": [], "coordinates_2d": [], "message": "No vectors found for results", } ) # Extract vectors vectors = np.array([p.vector for p in points if p.vector is not None]) vector_fetch_duration = time.perf_counter() - vector_fetch_start if len(vectors) < 2: # Not enough points for PCA return JSONResponse( { "success": True, "results": [ { "id": r.id, "doc_type": r.doc_type, "title": r.title, "excerpt": r.excerpt, "score": r.score, } for r in search_results ], "coordinates_2d": [[0, 0]] * len(search_results), "message": "Not enough vectors for PCA", } ) # Apply PCA dimensionality reduction (768-dim → 2D) pca_start = time.perf_counter() pca = PCA(n_components=2) coords_2d = pca.fit_transform(vectors) pca_duration = time.perf_counter() - pca_start # After fit, these attributes are guaranteed to be set assert pca.explained_variance_ratio_ is not None logger.info( f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, " f"PC2={pca.explained_variance_ratio_[1]:.3f}" ) # Map results to coordinates (use first chunk per document) result_coords = [] seen_doc_ids = set() for point, coord in zip(points, coords_2d): if point.payload: doc_id = int(point.payload.get("doc_id", 0)) if doc_id not in seen_doc_ids and doc_id in doc_ids: seen_doc_ids.add(doc_id) result_coords.append(coord.tolist()) # Build response response_results = [ { "id": r.id, "doc_type": r.doc_type, "title": r.title, "excerpt": r.excerpt, "score": r.score, } for r in search_results ] # Calculate total request duration total_duration = time.perf_counter() - request_start # Log comprehensive timing metrics logger.info( f"Viz search timing: total={total_duration * 1000:.1f}ms, " f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), " f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), " f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), " f"results={len(search_results)}, vectors={len(vectors)}" ) return JSONResponse( { "success": True, "results": response_results, "coordinates_2d": result_coords[: len(search_results)], "pca_variance": { "pc1": float(pca.explained_variance_ratio_[0]), "pc2": float(pca.explained_variance_ratio_[1]), }, "timing": { "total_ms": round(total_duration * 1000, 2), "search_ms": round(search_duration * 1000, 2), "vector_fetch_ms": round(vector_fetch_duration * 1000, 2), "pca_ms": round(pca_duration * 1000, 2), "num_results": len(search_results), "num_vectors": len(vectors), }, } ) except Exception as e: logger.error(f"Viz search error: {e}", exc_info=True) return JSONResponse( {"success": False, "error": str(e)}, status_code=500, )