feat: Add vector visualization pane with multi-select document types

- Add /app/vector-viz endpoint for interactive search testing
- Implement server-side PCA dimensionality reduction (768-dim → 2D)
- Support multi-select document type filter for cross-app search
- Support all search algorithms: semantic, keyword, fuzzy, hybrid
- Display 2D scatter plot of vector embeddings using Plotly
- Show search results with scores and document types
- Register viz routes in app.py
This commit is contained in:
Chris Coutinho
2025-11-15 02:32:10 +01:00
parent 9a62c8478f
commit 916af1c8f3
2 changed files with 78 additions and 10 deletions
+13
View File
@@ -1477,6 +1477,10 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
user_info_html,
vector_sync_status_fragment,
)
from nextcloud_mcp_server.auth.viz_routes import (
vector_visualization_html,
vector_visualization_search,
)
from nextcloud_mcp_server.auth.webhook_routes import (
disable_webhook_preset,
enable_webhook_preset,
@@ -1496,6 +1500,15 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
vector_sync_status_fragment,
methods=["GET"],
), # /app/vector-sync/status
# Vector visualization routes
Route(
"/vector-viz", vector_visualization_html, methods=["GET"]
), # /app/vector-viz
Route(
"/vector-viz/search",
vector_visualization_search,
methods=["GET"],
), # /app/vector-viz/search
# Webhook management routes (admin-only)
Route("/webhooks", webhook_management_pane, methods=["GET"]), # /app/webhooks
Route(
+65 -10
View File
@@ -183,6 +183,21 @@ async def vector_visualization_html(request: Request) -> HTMLResponse:
</select>
</div>
<div class="control-group">
<label>Document Types (multi-select)</label>
<select x-model="docTypes" multiple size="4" style="height: auto;">
<option value="">All Types (cross-app search)</option>
<option value="note">Notes</option>
<option value="file">Files</option>
<option value="calendar">Calendar Events</option>
<option value="contact">Contacts</option>
<option value="deck">Deck Cards</option>
</select>
<small style="color: #666; display: block; margin-top: 4px;">
Hold Ctrl/Cmd to select multiple. Select "All Types" for cross-app search.
</small>
</div>
<div class="control-group weight-controls" :class="{{ active: algorithm === 'hybrid' }}">
<label>Hybrid Weights</label>
<div style="margin-bottom: 8px;">
@@ -249,6 +264,7 @@ async def vector_visualization_html(request: Request) -> HTMLResponse:
return {{
query: '',
algorithm: 'hybrid',
docTypes: [''], // Default to "All Types"
limit: 50,
scoreThreshold: 0.7,
semanticWeight: 0.5,
@@ -276,6 +292,12 @@ async def vector_visualization_html(request: Request) -> HTMLResponse:
fuzzy_weight: this.fuzzyWeight,
}});
// Add doc_types parameter (filter out empty string for "All Types")
const selectedTypes = this.docTypes.filter(t => t !== '');
if (selectedTypes.length > 0) {{
params.append('doc_types', selectedTypes.join(','));
}}
const response = await fetch(`/app/vector-viz/search?${{params}}`);
const data = await response.json();
@@ -371,9 +393,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
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}"
f"algorithm={algorithm}, limit={limit}, doc_types={doc_types}"
)
try:
@@ -445,15 +471,44 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
status_code=400,
)
# Execute search
search_results = await search_algo.search(
query=query,
user_id=username,
limit=limit,
doc_type="note",
nextcloud_client=nextcloud_client,
score_threshold=score_threshold,
)
# Execute search (supports cross-app when doc_types=None)
if doc_types is None or len(doc_types) == 0:
# Cross-app search - search all indexed types
search_results = await search_algo.search(
query=query,
user_id=username,
limit=limit,
doc_type=None, # Search all types
nextcloud_client=nextcloud_client,
score_threshold=score_threshold,
)
elif len(doc_types) == 1:
# Single document type
search_results = await search_algo.search(
query=query,
user_id=username,
limit=limit,
doc_type=doc_types[0],
nextcloud_client=nextcloud_client,
score_threshold=score_threshold,
)
else:
# Multiple document types - search each and combine
all_results = []
for doc_type in doc_types:
results = await search_algo.search(
query=query,
user_id=username,
limit=limit * 2, # Get extra per type
doc_type=doc_type,
nextcloud_client=nextcloud_client,
score_threshold=score_threshold,
)
all_results.extend(results)
# Sort by score and limit
all_results.sort(key=lambda r: r.score, reverse=True)
search_results = all_results[:limit]
if not search_results:
return JSONResponse(