diff --git a/.dockerignore b/.dockerignore index 8e0ab28..bd5793c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ !nextcloud_mcp_server/**/*.py !nextcloud_mcp_server/**/*.html +!nextcloud_mcp_server/auth/static/* diff --git a/README.md b/README.md index 577c0b2..af876cd 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ +

+ Nextcloud MCP Server +

+ # Nextcloud MCP Server [![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server) @@ -29,6 +33,12 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \ # 3. Test the connection curl http://127.0.0.1:8000/health/ready + +# 4. Connect to the endpoint +http://127.0.0.1:8000/sse + +# 4. Or with --transport streamable-http +http://127.0.0.1:8000/mcp ``` **Next Steps:** @@ -123,6 +133,7 @@ This enables natural language queries and helps discover related content across - **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables - **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup - **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in) +- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing ### Advanced Topics - **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works (experimental) diff --git a/docs/images/user-info-tab.png b/docs/images/user-info-tab.png new file mode 100644 index 0000000..fba06fe Binary files /dev/null and b/docs/images/user-info-tab.png differ diff --git a/docs/images/vector-sync-status.png b/docs/images/vector-sync-status.png new file mode 100644 index 0000000..db1d967 Binary files /dev/null and b/docs/images/vector-sync-status.png differ diff --git a/docs/images/vector-viz-chunk-context.png b/docs/images/vector-viz-chunk-context.png new file mode 100644 index 0000000..3db9909 Binary files /dev/null and b/docs/images/vector-viz-chunk-context.png differ diff --git a/docs/images/vector-viz-document-types-2col.png b/docs/images/vector-viz-document-types-2col.png new file mode 100644 index 0000000..0c6e80a Binary files /dev/null and b/docs/images/vector-viz-document-types-2col.png differ diff --git a/docs/images/vector-viz-results.png b/docs/images/vector-viz-results.png new file mode 100644 index 0000000..72d3d66 Binary files /dev/null and b/docs/images/vector-viz-results.png differ diff --git a/docs/images/welcome-tab-updated-diagram.png b/docs/images/welcome-tab-updated-diagram.png new file mode 100644 index 0000000..4eada45 Binary files /dev/null and b/docs/images/welcome-tab-updated-diagram.png differ diff --git a/docs/user-guide/vector-sync-ui.md b/docs/user-guide/vector-sync-ui.md new file mode 100644 index 0000000..0f5eac0 --- /dev/null +++ b/docs/user-guide/vector-sync-ui.md @@ -0,0 +1,93 @@ +# Vector Sync UI Guide + +This guide covers the browser-based interface for the Nextcloud MCP Server's semantic search and vector synchronization features. + +## Overview + +The Vector Sync UI (`/app`) provides an interactive interface to test semantic search queries and visualize results from your Nextcloud documents. It exposes the same retrieval capabilities that LLMs use in Retrieval-Augmented Generation (RAG) workflows, powered by Alpine.js for reactive state, htmx for dynamic updates, and Plotly.js for 3D visualization. + +**Supported Apps**: Notes, Files (text/PDF), Calendar (events/tasks), Contacts (CardDAV), and Deck are indexed and searchable. + +## Accessing the UI + +Navigate to `/app` after authentication: +- **BasicAuth mode**: `http://localhost:8000/app` (uses credentials from environment) +- **OAuth mode**: `http://localhost:8000/app` (redirects to login if not authenticated) + +## Tabs + +### Welcome Page + +Landing page that introduces semantic search and RAG workflows. Shows authentication status, explains how vector embeddings work, and provides feature navigation. Adapts content based on whether `VECTOR_SYNC_ENABLED=true`. + +### User Info + +Displays authentication details and session information: +- **BasicAuth**: Username, mode badge, Nextcloud host +- **OAuth**: Username, session ID (truncated), background access status, IdP profile, revocation option + +### Vector Sync Status + +Real-time monitoring of document indexing: +- **Indexed Documents**: Total chunks stored in Qdrant vector database (immediately searchable) +- **Pending Documents**: Queue awaiting embedding processing +- **Status**: "✓ Idle" (green) when up-to-date, "⟳ Syncing" (orange) during processing + +Auto-refreshes every 10 seconds via htmx. Check this tab after adding content to verify indexing completion. + +### Vector Visualization + +Interactive search interface with 3D PCA plot of semantic space. + +**Search Controls**: +- **Query**: Natural language search (e.g., "health benefits of coffee") +- **Algorithm**: Semantic (Dense) for pure vector search, or BM25 Hybrid (default) combining vectors + keywords +- **Fusion** (Hybrid only): RRF (Reciprocal Rank Fusion) or DBSF (Distribution-Based Score Fusion) +- **Advanced**: Filter by document type, adjust score threshold (0.0-1.0), set result limit (max 100) + +**3D Visualization**: + +The plot uses Principal Component Analysis (PCA) to reduce 768-dimensional embeddings to 3D. Documents are positioned by semantic similarity with the query point shown in red. Point size and opacity indicate relevance, and the Viridis color scale shows relative scores (yellow = highest match). + +**Critical Fix**: Vectors are L2-normalized before PCA to match Qdrant's cosine distance, ensuring query points position accurately near similar documents. Without normalization, magnitude differences cause misleading spatial separation. + +**Results List**: + +Each result shows document title (clickable link to Nextcloud), excerpt, raw score, relative percentage, and document type. Click "Show Chunk" to view the matched text segment with surrounding context (up to 500 characters before/after). + +## Configuration + +**Required**: +```bash +VECTOR_SYNC_ENABLED=true +``` + +**Optional** (for browser-accessible links): +```bash +NEXTCLOUD_PUBLIC_ISSUER_URL=https://your-public-nextcloud-url.com +``` + +**Admin Access**: Webhooks tab only visible to Nextcloud admins (verified via Provisioning API). + +## Use Cases + +**Testing Search Queries**: Preview results before they reach LLMs in RAG workflows. Compare semantic vs. hybrid algorithms, verify relevance scores, and validate that correct documents are retrieved. Use chunk context to see exactly which text segments match and why unexpected documents appear. + +**Monitoring Indexing**: Track real-time progress after creating or modifying documents. Check if the queue is backing up (high pending count) or confirm the system is idle after bulk imports. Verify documents become searchable immediately after indexing completes. + +**Algorithm Comparison**: Pure semantic search excels at conceptual queries and synonyms. BM25 hybrid combines semantic understanding with precise keyword matching for better accuracy on specific terms. Experiment with RRF vs. DBSF fusion for different score distributions. + +## Troubleshooting + +**Vector Sync Tab Not Visible**: Set `VECTOR_SYNC_ENABLED=true` and restart the server. + +**No Search Results**: Check Vector Sync Status to confirm documents are indexed (not just pending). Try broader queries or lower the score threshold in Advanced options. Initial indexing may take time depending on document volume. + +**Links to Nextcloud Apps Not Working**: Set `NEXTCLOUD_PUBLIC_ISSUER_URL` to your browser-accessible Nextcloud URL for correct link generation. + +## Related Documentation + +- [Configuration Guide](../configuration.md) - Environment variables and settings +- [Authentication Modes](../authentication.md) - BasicAuth vs OAuth setup +- [Installation Guide](../installation.md) - Getting started +- [ADR-008: MCP Sampling for RAG](../ADR-008-mcp-sampling-for-rag.md) - Technical details on RAG workflows diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index acdb23d..56fd4f5 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -24,6 +24,7 @@ from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.cors import CORSMiddleware from starlette.responses import JSONResponse, RedirectResponse from starlette.routing import Mount, Route +from starlette.staticfiles import StaticFiles from nextcloud_mcp_server.auth import ( InsufficientScopeError, @@ -1491,7 +1492,7 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): # Create a separate Starlette app for browser routes that need session auth # This prevents SessionAuthBackend from interfering with FastMCP's OAuth browser_routes = [ - Route("/", user_info_html, methods=["GET"]), # /app → webapp (HTML UI) + Route("/", user_info_html, methods=["GET"]), # /app → user info with all tabs Route( "/revoke", revoke_session, methods=["POST"], name="revoke_session_endpoint" ), # /app/revoke → revoke_session @@ -1527,6 +1528,14 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): ), ] + # Add static files mount if directory exists + static_dir = os.path.join(os.path.dirname(__file__), "auth", "static") + if os.path.isdir(static_dir): + browser_routes.append( + Mount("/static", StaticFiles(directory=static_dir), name="static") + ) + logger.info(f"Mounted static files from {static_dir}") + browser_app = Starlette(routes=browser_routes) browser_app.add_middleware( AuthenticationMiddleware, # type: ignore[invalid-argument-type] diff --git a/nextcloud_mcp_server/auth/static/nextcloud-logo.png b/nextcloud_mcp_server/auth/static/nextcloud-logo.png new file mode 100644 index 0000000..104315c Binary files /dev/null and b/nextcloud_mcp_server/auth/static/nextcloud-logo.png differ diff --git a/nextcloud_mcp_server/auth/static/vector-viz.css b/nextcloud_mcp_server/auth/static/vector-viz.css new file mode 100644 index 0000000..3e9398b --- /dev/null +++ b/nextcloud_mcp_server/auth/static/vector-viz.css @@ -0,0 +1,192 @@ +.viz-layout { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; + min-height: 0; + overflow-y: auto; +} +.viz-card { + background: var(--color-main-background); + border-radius: 0; + padding: 16px; + box-shadow: none; +} +.viz-controls-card { + flex: 0 0 auto; + border-bottom: 1px solid var(--color-border); + padding-bottom: 16px; +} +.viz-controls-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + align-items: end; +} +@media (min-width: 768px) { + .viz-controls-grid { + grid-template-columns: 2fr 1.5fr 1.5fr auto auto; + } +} +.viz-control-group { + display: flex; + flex-direction: column; + gap: 4px; +} +.viz-control-group label { + font-weight: 500; + color: var(--color-main-text); + font-size: 13px; +} +.viz-control-group input[type="text"], +.viz-control-group input[type="number"], +.viz-control-group select { + width: 100%; + padding: 7px 10px; + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + font-size: 14px; + background: var(--color-main-background); + color: var(--color-main-text); +} +.viz-control-group input:focus, +.viz-control-group select:focus { + outline: none; + border-color: var(--color-primary-element); +} +.viz-control-group input[type="range"] { + width: 100%; +} +.viz-control-group select[multiple] { + min-height: 100px; +} +.viz-weight-display { + display: inline-block; + min-width: 40px; + text-align: right; + color: #666; +} +.viz-btn { + background: var(--color-primary-element); + color: white; + border: none; + padding: 7px 16px; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 14px; + font-weight: 500; + white-space: nowrap; +} +.viz-btn:hover { + background: #0052a3; +} +.viz-btn-secondary { + background: #6c757d; + color: white; + border: none; + padding: 7px 16px; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 14px; + white-space: nowrap; +} +.viz-btn-secondary:hover { + background: #5a6268; +} +.viz-card-plot { + flex: 0 0 auto; + display: flex; + flex-direction: column; + min-height: 500px; + height: 600px; + /* Remove horizontal padding to extend to full viewport width */ + padding-left: 0; + padding-right: 0; + margin-left: -16px; + margin-right: -16px; +} +#viz-plot-container { + width: 100%; + height: 100%; + position: relative; + overflow: visible; +} +#viz-plot { + width: 100%; + height: 100%; +} +.viz-loading { + text-align: center; + padding: 40px; + color: #666; +} +.viz-loading-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: white; + color: #666; +} +.viz-no-results { + text-align: center; + padding: 40px; + color: #666; + font-style: italic; +} +.viz-advanced-section { + margin-top: 12px; + padding: 12px; + background: var(--color-background-hover); + border-radius: var(--border-radius); + border: 1px solid var(--color-border); +} +.viz-info-box { + background: var(--color-primary-element-light); + border-left: 3px solid var(--color-primary-element); + padding: 10px 12px; + margin-bottom: 16px; + font-size: 13px; + color: var(--color-main-text); +} +.chunk-toggle-btn { + background: #6c757d; + color: white; + border: none; + padding: 4px 10px; + border-radius: 3px; + cursor: pointer; + font-size: 12px; + margin-top: 6px; +} +.chunk-toggle-btn:hover { + background: #5a6268; +} +.chunk-context { + background: var(--color-background-hover); + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + padding: 12px; + margin-top: 8px; + font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; +} +.chunk-text { + color: var(--color-text-maxcontrast); +} +.chunk-matched { + background: #fff3cd; + border: 1px solid #ffc107; + padding: 2px 4px; + border-radius: var(--border-radius); + font-weight: 500; + color: var(--color-main-text); +} +.chunk-ellipsis { + color: var(--color-text-maxcontrast); + font-style: italic; +} diff --git a/nextcloud_mcp_server/auth/static/vector-viz.js b/nextcloud_mcp_server/auth/static/vector-viz.js new file mode 100644 index 0000000..c01c1e7 --- /dev/null +++ b/nextcloud_mcp_server/auth/static/vector-viz.js @@ -0,0 +1,253 @@ +// Initialize vizApp for vector visualization +function vizApp() { + return { + query: '', + algorithm: 'bm25_hybrid', + fusion: 'rrf', + showAdvanced: false, + showQueryPoint: true, + docTypes: [''], + limit: 50, + scoreThreshold: 0.0, + loading: false, + results: [], + coordinates: null, + queryCoords: null, + expandedChunks: {}, + chunkLoading: {}, + + init() { + // Set up window resize listener to resize plot + window.addEventListener('resize', () => { + if (this.coordinates && this.results.length > 0) { + Plotly.Plots.resize('viz-plot'); + } + }); + }, + + async executeSearch() { + this.loading = true; + this.results = []; + + try { + const params = new URLSearchParams({ + query: this.query, + algorithm: this.algorithm, + limit: this.limit, + score_threshold: this.scoreThreshold, + }); + + if (this.algorithm === 'bm25_hybrid') { + params.append('fusion', this.fusion); + } + + 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(); + + if (data.success) { + this.results = data.results; + this.coordinates = data.coordinates_3d; + this.queryCoords = data.query_coords; + this.renderPlot(this.coordinates, this.queryCoords, this.results); + } else { + alert('Search failed: ' + data.error); + } + } catch (error) { + alert('Error: ' + error.message); + } finally { + this.loading = false; + } + }, + + updatePlot() { + // Toggle query point visibility without recreating the plot + // This preserves camera position naturally since layout is untouched + if (this.coordinates && this.queryCoords && this.results.length > 0) { + const plotDiv = document.getElementById('viz-plot'); + + // If plot exists, just toggle the query trace visibility + if (plotDiv && plotDiv.data && plotDiv.data.length >= 2) { + // Trace index 1 is the query point + Plotly.restyle('viz-plot', { visible: this.showQueryPoint }, [1]); + } else { + // Plot doesn't exist yet, render it + this.renderPlot(this.coordinates, this.queryCoords, this.results); + } + } + }, + + renderPlot(coordinates, queryCoords, results) { + // Get container dimensions before creating layout + const container = document.getElementById('viz-plot-container'); + const width = container.clientWidth; + const height = container.clientHeight; + + const scores = results.map(r => r.score); + + // Trace 1: Document results (always visible) + const documentTrace = { + x: coordinates.map(c => c[0]), + y: coordinates.map(c => c[1]), + z: coordinates.map(c => c[2]), + mode: 'markers', + type: 'scatter3d', + name: 'Documents', + visible: true, + customdata: results.map((r, i) => ({ + title: r.title, + raw_score: r.original_score, + relative_score: r.score, + x: coordinates[i][0], + y: coordinates[i][1], + z: coordinates[i][2] + })), + hovertemplate: + '%{customdata.title}
' + + 'Raw Score: %{customdata.raw_score:.3f} (%{customdata.relative_score:.0%} relative)
' + + '(x=%{customdata.x}, y=%{customdata.y}, z=%{customdata.z})' + + '', + marker: { + size: results.map(r => 4 + (Math.pow(r.score, 2) * 10)), + opacity: results.map(r => 0.3 + (r.score * 0.7)), + color: scores, + colorscale: 'Viridis', + showscale: true, + colorbar: { + title: 'Relative Score', + x: 1.02, + xanchor: 'left', + thickness: 20, + len: 0.8 + }, + cmin: 0, + cmax: 1 + } + }; + + // Trace 2: Query point (visibility controlled by toggle) + const queryTrace = { + x: [queryCoords[0]], + y: [queryCoords[1]], + z: [queryCoords[2]], + mode: 'markers', + type: 'scatter3d', + name: 'Query', + visible: this.showQueryPoint, // Initial visibility from state + hovertemplate: + 'Search Query
' + + `(x=${queryCoords[0]}, y=${queryCoords[1]}, z=${queryCoords[2]})` + + '', + marker: { + size: 10, + color: '#ef5350', // Subdued red (Material Design Red 400) + line: { + color: '#c62828', // Darker red border (Material Design Red 800) + width: 1 + } + } + }; + + const layout = { + title: `Vector Space (PCA 3D) - ${results.length} results`, + width: width, // Explicit width from container + height: height, // Explicit height from container + scene: { + xaxis: { title: 'PC1' }, + yaxis: { title: 'PC2' }, + zaxis: { title: 'PC3' }, + camera: { + eye: { x: 1.5, y: 1.5, z: 1.5 } + }, + // Full width for 3D scene + domain: { + x: [0, 1], + y: [0, 1] + } + }, + hovermode: 'closest', + autosize: true, // Enable auto-sizing for window resizes + showlegend: false, // Hide legend + margin: { l: 0, r: 100, t: 40, b: 0 } // Right margin for colorbar + }; + + // Always render both traces - visibility is controlled by the visible property + const traces = [documentTrace, queryTrace]; + + // Enable responsive resizing + const config = { + responsive: true, + displayModeBar: true + }; + + // Use newPlot() with explicit dimensions - renders at correct size immediately + // Camera position will be preserved by subsequent Plotly.restyle() calls in updatePlot() + Plotly.newPlot('viz-plot', traces, layout, config); + }, + + getNextcloudUrl(result) { + // Use global NEXTCLOUD_BASE_URL if set, otherwise construct from window location + const baseUrl = window.NEXTCLOUD_BASE_URL || ''; + switch (result.doc_type) { + case 'note': + return `${baseUrl}/apps/notes/note/${result.id}`; + case 'file': + return `${baseUrl}/apps/files/?fileId=${result.id}`; + case 'calendar': + return `${baseUrl}/apps/calendar`; + case 'contact': + return `${baseUrl}/apps/contacts`; + case 'deck': + return `${baseUrl}/apps/deck`; + default: + return `${baseUrl}`; + } + }, + + hasChunkPosition(result) { + return result.chunk_start_offset != null && result.chunk_end_offset != null; + }, + + isChunkExpanded(resultKey) { + return this.expandedChunks[resultKey] !== undefined; + }, + + async toggleChunk(result) { + const resultKey = `${result.doc_type}_${result.id}`; + + if (this.isChunkExpanded(resultKey)) { + delete this.expandedChunks[resultKey]; + return; + } + + this.chunkLoading[resultKey] = true; + + try { + const params = new URLSearchParams({ + doc_type: result.doc_type, + doc_id: result.id, + start: result.chunk_start_offset, + end: result.chunk_end_offset, + context: 500 + }); + + const response = await fetch(`/app/chunk-context?${params}`); + const data = await response.json(); + + if (data.success) { + this.expandedChunks[resultKey] = data; + } else { + alert('Failed to load chunk: ' + data.error); + } + } catch (error) { + alert('Error loading chunk: ' + error.message); + } finally { + delete this.chunkLoading[resultKey]; + } + } + }; +} diff --git a/nextcloud_mcp_server/auth/templates/base.html b/nextcloud_mcp_server/auth/templates/base.html new file mode 100644 index 0000000..163f4c4 --- /dev/null +++ b/nextcloud_mcp_server/auth/templates/base.html @@ -0,0 +1,524 @@ + + + + + + + + + {% block title %}Nextcloud MCP Server{% endblock %} + + + + + + + + {% block extra_head %}{% endblock %} + + + + + +
+ + + Nextcloud MCP Server + +
+ + + {% block content %}{% endblock %} + + {% block scripts %}{% endblock %} + + diff --git a/nextcloud_mcp_server/auth/templates/error.html b/nextcloud_mcp_server/auth/templates/error.html new file mode 100644 index 0000000..3d7405f --- /dev/null +++ b/nextcloud_mcp_server/auth/templates/error.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} + +{% block title %}{{ error_title|default('Error') }} - Nextcloud MCP Server{% endblock %} + +{% block content %} +

{{ error_title|default('Error') }}

+ +
+ Error: {{ error_message }} +
+ +{% if login_url %} +

Login again

+{% endif %} + +{% if back_url %} +

Go Back

+{% endif %} +{% endblock %} diff --git a/nextcloud_mcp_server/auth/templates/success.html b/nextcloud_mcp_server/auth/templates/success.html new file mode 100644 index 0000000..24eea31 --- /dev/null +++ b/nextcloud_mcp_server/auth/templates/success.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}{{ success_title|default('Success') }} - Nextcloud MCP Server{% endblock %} + +{% block extra_head %} +{% if redirect_url and redirect_delay %} + +{% endif %} +{% endblock %} + +{% block content %} +
+

{{ success_title|default('✓ Success') }}

+ {% for message in success_messages %} +

{{ message }}

+ {% endfor %} + {% if redirect_url %} +

Redirecting...

+ {% endif %} +
+{% endblock %} diff --git a/nextcloud_mcp_server/auth/templates/user_info.html b/nextcloud_mcp_server/auth/templates/user_info.html new file mode 100644 index 0000000..25f527b --- /dev/null +++ b/nextcloud_mcp_server/auth/templates/user_info.html @@ -0,0 +1,650 @@ +{% extends "base.html" %} + +{% block title %}Nextcloud MCP Server{% endblock %} + +{% block extra_head %} + + + + + + + + + + + +{% endblock %} + +{% block extra_styles %} + /* Smooth htmx transitions */ + .htmx-swapping { + opacity: 0; + transition: opacity 200ms ease-out; + } + + .htmx-settling { + opacity: 1; + transition: opacity 200ms ease-in; + } + + /* Logout button styling */ + .logout-section { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--color-border); + } + + /* Welcome tab specific styles */ + .hero-section { + background: linear-gradient(135deg, var(--color-primary-element) 0%, #0082c9 100%); + color: white; + padding: 60px 24px; + margin: -24px -24px 40px -24px; + border-radius: 0 0 var(--border-radius-large) var(--border-radius-large); + text-align: center; + } + + .hero-section h1 { + color: white; + font-size: 36px; + margin: 0 0 16px 0; + font-weight: 600; + } + + .hero-section p { + font-size: 18px; + opacity: 0.95; + max-width: 700px; + margin: 0 auto; + line-height: 1.6; + } + + .feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + margin: 32px 0; + } + + .feature-card { + background: var(--color-main-background); + border: 2px solid var(--color-border); + border-radius: var(--border-radius-large); + padding: 24px; + transition: all 0.2s; + cursor: pointer; + text-decoration: none; + color: inherit; + display: block; + } + + .feature-card:hover { + border-color: var(--color-primary-element); + box-shadow: 0 4px 12px rgba(0, 103, 158, 0.15); + transform: translateY(-2px); + } + + .feature-card h3 { + color: var(--color-primary-element); + font-size: 20px; + margin: 12px 0 8px 0; + font-weight: 600; + display: flex; + align-items: center; + gap: 12px; + } + + .feature-card p { + color: var(--color-text-maxcontrast); + font-size: 14px; + line-height: 1.6; + margin: 8px 0 0 0; + } + + .feature-icon { + width: 48px; + height: 48px; + background: var(--color-primary-element-light); + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + } + + .feature-icon svg { + width: 28px; + height: 28px; + fill: var(--color-primary-element); + } + + .info-section { + background: var(--color-background-hover); + border-radius: var(--border-radius-large); + padding: 32px; + margin: 32px 0; + } + + .info-section h2 { + color: var(--color-main-text); + font-size: 24px; + margin: 0 0 16px 0; + border: none; + padding: 0; + } + + .info-section p { + color: var(--color-text-maxcontrast); + line-height: 1.7; + margin: 12px 0; + } + + .info-section ul { + margin: 12px 0; + padding-left: 24px; + } + + .info-section li { + color: var(--color-text-maxcontrast); + line-height: 1.7; + margin: 8px 0; + } + + .info-section code { + background: var(--color-main-background); + padding: 2px 8px; + border-radius: var(--border-radius); + font-size: 13px; + } + + .auth-status { + background: var(--color-primary-element-light); + border-left: 4px solid var(--color-primary-element); + padding: 16px 20px; + margin: 24px 0; + border-radius: var(--border-radius); + display: flex; + align-items: center; + gap: 12px; + } + + .auth-status svg { + width: 24px; + height: 24px; + fill: var(--color-primary-element); + flex-shrink: 0; + } + + .auth-status-text { + flex: 1; + } + + .auth-status-text strong { + display: block; + color: var(--color-main-text); + font-size: 14px; + margin-bottom: 4px; + } + + .auth-status-text span { + color: var(--color-text-maxcontrast); + font-size: 13px; + } +{% endblock %} + +{% block content %} +
+ + + + +
+
+ +
+ +
+

Welcome to Nextcloud MCP Server

+

+ Interactive user interface for semantic search and document retrieval. + Test queries, visualize results, and explore your Nextcloud content using RAG workflows. +

+
+ + +
+ + + +
+ Authenticated as: {{ username }} + Authentication mode: {{ auth_mode }} +
+
+ + {% if vector_sync_enabled %} + +
+

About Semantic Search

+

+ This interface provides access to semantic search capabilities powered by vector embeddings. + Unlike traditional keyword search, semantic search understands the meaning of your queries and finds + conceptually similar content across your Nextcloud apps. +

+

+ How it works: +

+
    +
  • Documents from Notes, Calendar, Files, Contacts, and Deck are indexed into a vector database
  • +
  • Each document chunk is converted to a 768-dimensional vector embedding that captures semantic meaning
  • +
  • Queries are also converted to embeddings and matched against document vectors using similarity search
  • +
  • Results can be retrieved using pure semantic search or hybrid BM25 search combining keywords and semantics
  • +
+
+ +
+

RAG Workflow Integration

+

+ This UI allows you to test the same queries that Large Language Models (LLMs) would use in a + Retrieval-Augmented Generation (RAG) workflow. When an AI assistant needs to answer questions about your data: +

+
    +
  • Step 1: The assistant converts your question into a search query
  • +
  • Step 2: The MCP server retrieves relevant document chunks using semantic search
  • +
  • Step 3: Retrieved context is passed to the LLM to generate an informed answer
  • +
+ + +
+
+ MCP Sampling RAG Workflow +
+ + +
+
+ +
+
👤
+
User
+
+ "What are health
benefits of coffee?" +
+
+ + +
+
+
+ + +
+
MCP Client + LLM
+ +
+
(Claude Code)
+
+ +
+
🧠
+
Client's LLM
+
(Claude)
+
+ +
+ Enables RAG:
+ Receives context,
+ generates answer +
+
+ + +
+
+
+ Query +
+ Sampling +
+
+ + +
+
MCP Server
+ +
+
1. Semantic Search
+
+ Vector embeddings
+ BM25 Hybrid + RRF +
+
+ +
+
2. Retrieve Context
+
+ Top relevant docs
+ with scores +
+
+ +
+
3. Format Response
+
+ Document chunks
+ with citations +
+
+ +
+
4. Send to LLM
+
+ Via MCP sampling
+ for answer generation +
+
+
+ + +
+
+
+ Retrieve +
+
+ + +
+ Nextcloud +
Nextcloud
+
+ Notes, Calendar,
+ Files, Contacts,
+ Deck +
+
+
+ + +
+
+ How RAG works via MCP Sampling: +
+
    +
  1. User asks question through MCP Client
  2. +
  3. Client sends query to MCP Server
  4. +
  5. Server retrieves relevant document context from Nextcloud
  6. +
  7. Server sends context back to Client's LLM (MCP Sampling)
  8. +
  9. Client's LLM generates answer with citations using retrieved context
  10. +
  11. Answer returned to user
  12. +
+
+ The server has no LLM - it only retrieves context. The client's existing LLM is reused for answer generation. +
+
+
+
+ +

+ Key Point: The MCP server retrieves context but doesn't generate answers itself. + Through MCP sampling, it requests the client's LLM to generate responses, giving users + full control over which model is used and ensuring all processing happens client-side. +

+ +

+ By using this interface, you can preview search results, understand relevance scores, and verify + that the system retrieves the right information before it reaches the LLM. +

+
+ + +

Available Features

+ + + {% else %} + +
+

Vector Sync is Disabled

+

+ Semantic search and vector visualization features are currently disabled. + To enable these features, set VECTOR_SYNC_ENABLED=true in your environment configuration. +

+

+ Learn more: + + Configuration Guide + +

+
+ + +

Available Features

+ + {% endif %} + + +
+

Documentation

+

+ For detailed information about configuration, authentication modes, and advanced features, + please refer to the project documentation: +

+ +
+
+ + +
+
+

User Information

+ {{ user_info_tab_html|safe }} +
+
+ + {% if show_vector_sync_tab %} + +
+
+

Vector Sync Status

+ {{ vector_sync_tab_html|safe }} +
+
+ + +
+
+

Vector Visualization

+
+

Loading vector visualization...

+
+
+
+ {% endif %} + + {% if show_webhooks_tab %} + +
+
+

Webhook Management

+ {{ webhooks_tab_html|safe }} +
+
+ {% endif %} +
+
+
+ + + +{% endblock %} diff --git a/nextcloud_mcp_server/auth/templates/vector_viz.html b/nextcloud_mcp_server/auth/templates/vector_viz.html index 561afb2..a052d9e 100644 --- a/nextcloud_mcp_server/auth/templates/vector_viz.html +++ b/nextcloud_mcp_server/auth/templates/vector_viz.html @@ -1,286 +1,111 @@ - -
-
-

Vector Visualization

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

Advanced Options

- -
+
+
- -
+ +
-
-
- - -
+
+ + +
-
- - -
+
+ + +
+ +
+ +
- - -
-

- BM25 Hybrid Search: Combines dense semantic vectors with sparse BM25 keyword vectors. -

-

- RRF: Reciprocal Rank Fusion - Rank-based fusion producing scores in [0.0, 1.0] -

-

- DBSF: Distribution-Based Score Fusion - Sums normalized scores (can exceed 1.0) -

-
-
- -
- -
-
-
- Executing search and computing PCA projection... -
-
+
-
-
-

Search Results ()

+ +
+
+
+ Executing search and computing PCA projection... +
+
+
+
+ + +
+

Search Results ()

Loading results... @@ -335,5 +160,6 @@
-
-
+
+
+
diff --git a/nextcloud_mcp_server/auth/templates/welcome.html b/nextcloud_mcp_server/auth/templates/welcome.html new file mode 100644 index 0000000..85a3f52 --- /dev/null +++ b/nextcloud_mcp_server/auth/templates/welcome.html @@ -0,0 +1,392 @@ +{% extends "base.html" %} + +{% block title %}Welcome - Nextcloud MCP Server{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block extra_styles %} + /* Welcome page specific styles */ + .hero-section { + background: linear-gradient(135deg, var(--color-primary-element) 0%, #0082c9 100%); + color: white; + padding: 60px 24px; + margin: -24px -24px 40px -24px; + border-radius: 0 0 var(--border-radius-large) var(--border-radius-large); + text-align: center; + } + + .hero-section h1 { + color: white; + font-size: 36px; + margin: 0 0 16px 0; + font-weight: 600; + } + + .hero-section p { + font-size: 18px; + opacity: 0.95; + max-width: 700px; + margin: 0 auto; + line-height: 1.6; + } + + .feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 24px; + margin: 32px 0; + } + + .feature-card { + background: var(--color-main-background); + border: 2px solid var(--color-border); + border-radius: var(--border-radius-large); + padding: 24px; + transition: all 0.2s; + cursor: pointer; + text-decoration: none; + color: inherit; + display: block; + } + + .feature-card:hover { + border-color: var(--color-primary-element); + box-shadow: 0 4px 12px rgba(0, 103, 158, 0.15); + transform: translateY(-2px); + } + + .feature-card h3 { + color: var(--color-primary-element); + font-size: 20px; + margin: 12px 0 8px 0; + font-weight: 600; + display: flex; + align-items: center; + gap: 12px; + } + + .feature-card p { + color: var(--color-text-maxcontrast); + font-size: 14px; + line-height: 1.6; + margin: 8px 0 0 0; + } + + .feature-icon { + width: 48px; + height: 48px; + background: var(--color-primary-element-light); + border-radius: var(--border-radius); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + } + + .feature-icon svg { + width: 28px; + height: 28px; + fill: var(--color-primary-element); + } + + .info-section { + background: var(--color-background-hover); + border-radius: var(--border-radius-large); + padding: 32px; + margin: 32px 0; + } + + .info-section h2 { + color: var(--color-main-text); + font-size: 24px; + margin: 0 0 16px 0; + border: none; + padding: 0; + } + + .info-section p { + color: var(--color-text-maxcontrast); + line-height: 1.7; + margin: 12px 0; + } + + .info-section ul { + margin: 12px 0; + padding-left: 24px; + } + + .info-section li { + color: var(--color-text-maxcontrast); + line-height: 1.7; + margin: 8px 0; + } + + .info-section code { + background: var(--color-main-background); + padding: 2px 8px; + border-radius: var(--border-radius); + font-size: 13px; + } + + .auth-status { + background: var(--color-primary-element-light); + border-left: 4px solid var(--color-primary-element); + padding: 16px 20px; + margin: 24px 0; + border-radius: var(--border-radius); + display: flex; + align-items: center; + gap: 12px; + } + + .auth-status svg { + width: 24px; + height: 24px; + fill: var(--color-primary-element); + flex-shrink: 0; + } + + .auth-status-text { + flex: 1; + } + + .auth-status-text strong { + display: block; + color: var(--color-main-text); + font-size: 14px; + margin-bottom: 4px; + } + + .auth-status-text span { + color: var(--color-text-maxcontrast); + font-size: 13px; + } +{% endblock %} + +{% block content %} +
+ +
+
+ +
+

Welcome to Nextcloud MCP Server

+

+ Interactive user interface for semantic search and document retrieval. + Test queries, visualize results, and explore your Nextcloud content using RAG workflows. +

+
+ + +
+ + + +
+ Authenticated as: {{ username }} + Authentication mode: {{ auth_mode }} +
+
+ + {% if vector_sync_enabled %} + +
+

About Semantic Search

+

+ This interface provides access to semantic search capabilities powered by vector embeddings. + Unlike traditional keyword search, semantic search understands the meaning of your queries and finds + conceptually similar content across your Nextcloud apps. +

+

+ How it works: +

+
    +
  • Documents from Notes, Calendar, Files, Contacts, and Deck are indexed into a vector database
  • +
  • Each document chunk is converted to a 768-dimensional vector embedding that captures semantic meaning
  • +
  • Queries are also converted to embeddings and matched against document vectors using similarity search
  • +
  • Results can be retrieved using pure semantic search or hybrid BM25 search combining keywords and semantics
  • +
+
+ +
+

RAG Workflow Integration

+

+ This UI allows you to test the same queries that Large Language Models (LLMs) would use in a + Retrieval-Augmented Generation (RAG) workflow. When an AI assistant needs to answer questions about your data: +

+
    +
  • Step 1: The assistant converts your question into a search query
  • +
  • Step 2: The MCP server retrieves relevant document chunks using semantic search
  • +
  • Step 3: Retrieved context is passed to the LLM to generate an informed answer
  • +
+ + +
+
+ MCP Sampling RAG Workflow +
+
+┌─────────────────┐
+│   MCP Client   │  User asks: "What are health benefits of coffee?"
+│  (Claude Code)  │
+└────────┬────────┘
+         │ (1) User question
+         ↓
+┌────────────────────────────────────────────────────────────────────────┐
+│                      Nextcloud MCP Server                          │
+│  ┌──────────────────────────────────────────────────────────────────┐  │
+│  │ nc_semantic_search_answer Tool (MCP Sampling-enabled)      │  │
+│  │                                                                  │  │
+│  │  (2) Semantic Search                                             │  │
+│  │  ┌────────────────────────────────────────────────────────┐     │  │
+│  │  │ Query: "health benefits of coffee"                     │     │  │
+│  │  │ → Convert to 768D vector embedding                     │     │  │
+│  │  │ → Search Qdrant (BM25 Hybrid + RRF fusion)             │     │  │
+│  │  │ → Retrieve top 5 relevant document chunks              │     │  │
+│  │  └────────────────────────────────────────────────────────┘     │  │
+│  │                                                                  │  │
+│  │  (3) Construct Prompt with Context                               │  │
+│  │  ┌────────────────────────────────────────────────────────┐     │  │
+│  │  │ "What are health benefits of coffee?                   │     │  │
+│  │  │                                                         │     │  │
+│  │  │  Documents:                                             │     │  │
+│  │  │  - [MED-2155] Effects of habitual coffee consumption...│     │  │
+│  │  │  - [MED-1646] Beverage consumption guidance...         │     │  │
+│  │  │  - [MED-1627] Coffee and depression risk...            │     │  │
+│  │  │  ...                                                    │     │  │
+│  │  │                                                         │     │  │
+│  │  │  Provide answer with citations."                        │     │  │
+│  │  └────────────────────────────────────────────────────────┘     │  │
+│  │                                                                  │  │
+│  │  (4) MCP Sampling Request                                        │  │
+│  │  ─────────────────────────────────────────────────────────────> │  │
+│  └──────────────────────────────────────────────────────────────────┘  │
+└────────────────────────────────────────────────────────────────────────┘
+         │
+         │ Sampling request with prompt + context
+         ↓
+┌─────────────────┐
+│   MCP Client   │  (5) Client's LLM generates answer using retrieved context
+│    (Claude)     │      → "Coffee consumption (2-3 cups/day) is associated with
+└────────┬────────┘         reduced risk of type 2 diabetes, cardiovascular disease,
+         │                  and improved liver health (Document 1, 2)..."
+         │
+         │ (6) Answer with citations
+         ↓
+┌─────────────────┐
+│      User       │  Receives comprehensive answer with source citations
+└─────────────────┘
+
+ +

+ Key Point: The MCP server retrieves context but doesn't generate answers itself. + Through MCP sampling, it requests the client's LLM to generate responses, giving users + full control over which model is used and ensuring all processing happens client-side. +

+ +

+ By using this interface, you can preview search results, understand relevance scores, and verify + that the system retrieves the right information before it reaches the LLM. +

+
+ + +

Available Features

+ + + {% else %} + +
+

Vector Sync is Disabled

+

+ Semantic search and vector visualization features are currently disabled. + To enable these features, set VECTOR_SYNC_ENABLED=true in your environment configuration. +

+

+ Learn more: + + Configuration Guide + +

+
+ + +

Available Features

+ + {% endif %} + + +
+

Documentation

+

+ For detailed information about configuration, authentication modes, and advanced features, + please refer to the project documentation: +

+ +
+
+
+
+{% endblock %} diff --git a/nextcloud_mcp_server/auth/userinfo_routes.py b/nextcloud_mcp_server/auth/userinfo_routes.py index d57806c..4a015da 100644 --- a/nextcloud_mcp_server/auth/userinfo_routes.py +++ b/nextcloud_mcp_server/auth/userinfo_routes.py @@ -9,15 +9,21 @@ For OAuth mode: Requires browser-based OAuth login to establish session. import logging import os +from pathlib import Path from typing import Any import httpx +from jinja2 import Environment, FileSystemLoader from starlette.authentication import requires from starlette.requests import Request from starlette.responses import HTMLResponse, JSONResponse logger = logging.getLogger(__name__) +# Setup Jinja2 environment for templates +_template_dir = Path(__file__).parent / "templates" +_jinja_env = Environment(loader=FileSystemLoader(_template_dir)) + async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient: """Get an authenticated HTTP client for user info page operations. @@ -431,51 +437,14 @@ async def user_info_html(request: Request) -> HTMLResponse: oauth_ctx = getattr(request.app.state, "oauth_context", None) login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login" - error_html = f""" - - - - - - Error - Nextcloud MCP Server - - - -
-

Error Retrieving User Info

-
- Error: {user_context["error"]} -
-

Login again

-
- - - """ - return HTMLResponse(content=error_html) + template = _jinja_env.get_template("error.html") + return HTMLResponse( + content=template.render( + error_title="Error Retrieving User Info", + error_message=user_context["error"], + login_url=login_url, + ) + ) # Build HTML response auth_mode = user_context.get("auth_mode", "unknown") @@ -654,457 +623,26 @@ async def user_info_html(request: Request) -> HTMLResponse:
""" - html_content = f""" - - - - - - Nextcloud MCP Server + # Check if vector sync is enabled (needed for Welcome tab) + vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true" - - - - - - - - - - - - - - - -
-

Nextcloud MCP Server

- - -
- - { - "" - if not show_vector_sync_tab - else ''' - - ''' - } - { - "" - if not show_vector_sync_tab - else ''' - - ''' - } - { - "" - if not show_webhooks_tab - else ''' - - ''' - } -
- - -
- -
- {user_info_tab_html} -
- - { - "" - if not show_vector_sync_tab - else f''' - -
- {vector_sync_tab_html} -
- ''' - } - - { - "" - if not show_vector_sync_tab - else ''' - -
-
-

Loading vector visualization...

-
-
- ''' - } - - { - "" - if not show_webhooks_tab - else f''' - -
- {webhooks_tab_html} -
- ''' - } -
- - { - f'' - if auth_mode == "oauth" - else "" - } -
- - - """ - - return HTMLResponse(content=html_content) + # Render template + template = _jinja_env.get_template("user_info.html") + return HTMLResponse( + content=template.render( + user_info_tab_html=user_info_tab_html, + vector_sync_tab_html=vector_sync_tab_html, + webhooks_tab_html=webhooks_tab_html, + show_vector_sync_tab=show_vector_sync_tab, + show_webhooks_tab=show_webhooks_tab, + logout_url=logout_url if auth_mode == "oauth" else None, + nextcloud_host_for_links=nextcloud_host_for_links, + # Additional context for Welcome tab + vector_sync_enabled=vector_sync_enabled, + username=username, + auth_mode=auth_mode, + ) + ) @requires("authenticated", redirect="oauth_login") @@ -1124,17 +662,12 @@ async def revoke_session(request: Request) -> HTMLResponse: oauth_ctx = getattr(request.app.state, "oauth_context", None) if not oauth_ctx: + template = _jinja_env.get_template("error.html") return HTMLResponse( - """ - - - Error - -

Error

-

OAuth mode not enabled

- - - """, + content=template.render( + error_title="Error", + error_message="OAuth mode not enabled", + ), status_code=400, ) @@ -1142,17 +675,12 @@ async def revoke_session(request: Request) -> HTMLResponse: session_id = request.cookies.get("mcp_session") if not storage or not session_id: + template = _jinja_env.get_template("error.html") return HTMLResponse( - """ - - - Error - -

Error

-

Session not found

- - - """, + content=template.render( + error_title="Error", + error_message="Session not found", + ), status_code=400, ) @@ -1165,57 +693,26 @@ async def revoke_session(request: Request) -> HTMLResponse: # Redirect back to user page user_page_url = str(request.url_for("user_info_html")) + template = _jinja_env.get_template("success.html") return HTMLResponse( - f""" - - - - - - Background Access Revoked - - - -
-

✓ Background Access Revoked

-

Your refresh token has been deleted successfully.

-

Browser session remains active.

-

Redirecting back to user page...

-
- - - """ + content=template.render( + success_title="✓ Background Access Revoked", + success_messages=[ + "Your refresh token has been deleted successfully.", + "Browser session remains active.", + ], + redirect_url=user_page_url, + redirect_delay=2, + ) ) except Exception as e: logger.error(f"Failed to revoke background access: {e}") + template = _jinja_env.get_template("error.html") return HTMLResponse( - f""" - - - Error - -

Error

-

Failed to revoke background access: {e}

- - - """, + content=template.render( + error_title="Error", + error_message=f"Failed to revoke background access: {e}", + ), status_code=500, ) diff --git a/nextcloud_mcp_server/auth/viz_routes.py b/nextcloud_mcp_server/auth/viz_routes.py index d6776f4..3497084 100644 --- a/nextcloud_mcp_server/auth/viz_routes.py +++ b/nextcloud_mcp_server/auth/viz_routes.py @@ -1,13 +1,14 @@ """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. +indexed documents and visualize results in 3D 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) +- Query embedding generation +- PCA dimensionality reduction (768-dim → 3D) +- Only 3D coordinates + metadata sent to client +- Bandwidth-efficient (3 floats per doc vs 768) """ import logging @@ -77,19 +78,20 @@ async def vector_visualization_html(request: Request) -> HTMLResponse: @requires("authenticated", redirect="oauth_login") async def vector_visualization_search(request: Request) -> JSONResponse: - """Execute server-side search and return 2D coordinates + results. + """Execute server-side search and return 3D 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 + 2. Generate query embedding + 3. Fetch matching vectors from Qdrant + 4. Apply PCA reduction (768-dim → 3D) to query + documents + 5. Return coordinates + metadata only Args: request: Starlette request with query parameters Returns: - JSON response with coordinates_2d and results + JSON response with coordinates_3d and results (including query point) """ settings = get_settings() @@ -209,7 +211,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse: { "success": True, "results": [], - "coordinates_2d": [], + "coordinates_3d": [], + "query_coords": None, "message": "No results found", } ) @@ -253,7 +256,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse: } ) - # Extract dense vectors (handle both named and unnamed vectors) + # Extract dense vectors and group by document def extract_dense_vector(point): if point.vector is None: return None @@ -263,13 +266,21 @@ async def vector_visualization_search(request: Request) -> JSONResponse: # If unnamed vector (array), use directly return point.vector - vectors = np.array( - [v for v in (extract_dense_vector(p) for p in points) if v is not None] - ) + # Group chunk vectors by doc_id + from collections import defaultdict + + doc_chunks = defaultdict(list) + for point in points: + if point.payload: + doc_id = int(point.payload.get("doc_id", 0)) + vector = extract_dense_vector(point) + if vector is not None: + doc_chunks[doc_id].append(vector) + vector_fetch_duration = time.perf_counter() - vector_fetch_start - if len(vectors) < 2: - # Not enough points for PCA + if len(doc_chunks) < 2: + # Not enough documents for PCA return JSONResponse( { "success": True, @@ -283,35 +294,131 @@ async def vector_visualization_search(request: Request) -> JSONResponse: } for r in search_results ], - "coordinates_2d": [[0, 0]] * len(search_results), - "message": "Not enough vectors for PCA", + "coordinates_3d": [[0, 0, 0]] * len(search_results), + "query_coords": [0, 0, 0], + "message": "Not enough documents for PCA", } ) - # Apply PCA dimensionality reduction (768-dim → 2D) + # Detect embedding dimension from first available vector + embedding_dim = None + for chunks in doc_chunks.values(): + if chunks: + embedding_dim = len(chunks[0]) + break + + if embedding_dim is None: + return JSONResponse( + { + "success": False, + "error": "Could not determine embedding dimension", + }, + status_code=500, + ) + + logger.info(f"Detected embedding dimension: {embedding_dim}") + + # Average chunk vectors per document to create document-level embeddings + # Maintain order of search_results for coordinate mapping + doc_vectors = [] + for result in search_results: + if result.id in doc_chunks: + # Average all chunk embeddings for this document + chunk_vectors = np.array(doc_chunks[result.id]) + avg_vector = np.mean(chunk_vectors, axis=0) + doc_vectors.append(avg_vector) + logger.debug(f"Doc {result.id}: averaged {len(chunk_vectors)} chunks") + else: + # Document not found in vectors (shouldn't happen) + logger.warning(f"Doc {result.id} not found in fetched vectors") + # Use zero vector as fallback with detected dimension + doc_vectors.append(np.zeros(embedding_dim)) + + doc_vectors = np.array(doc_vectors) + + # Generate query embedding for visualization + query_embed_start = time.perf_counter() + from nextcloud_mcp_server.embedding.service import get_embedding_service + + embedding_service = get_embedding_service() + query_embedding = await embedding_service.embed(query) + query_embed_duration = time.perf_counter() - query_embed_start + + logger.info(f"Generated query embedding (dimension={len(query_embedding)})") + + # Combine query vector with document vectors for PCA + # Query will be the last point in the array + all_vectors = np.vstack([doc_vectors, np.array([query_embedding])]) + + # Normalize vectors to unit length (L2 normalization) + # This is critical because Qdrant uses COSINE distance, which only measures + # vector direction (angle), not magnitude. PCA uses Euclidean distance which + # considers both direction and magnitude. By normalizing to unit length, + # Euclidean distances in PCA space will match cosine distances. + norms = np.linalg.norm(all_vectors, axis=1, keepdims=True) + + # Check for zero-norm vectors (can happen with empty/corrupted embeddings) + zero_norm_mask = norms[:, 0] < 1e-10 + if zero_norm_mask.any(): + zero_indices = np.where(zero_norm_mask)[0] + logger.warning( + f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. " + "Replacing with small epsilon to avoid division by zero." + ) + # Replace zero norms with small epsilon to avoid NaN + norms[zero_norm_mask] = 1e-10 + + all_vectors_normalized = all_vectors / norms + logger.info( + f"Normalized vectors: query_norm={norms[-1][0]:.3f}, " + f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]" + ) + + # Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors pca_start = time.perf_counter() - pca = PCA(n_components=2) - coords_2d = pca.fit_transform(vectors) + pca = PCA(n_components=3) + coords_3d = pca.fit_transform(all_vectors_normalized) 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}" + # Check for NaN values in PCA output (numerical instability) + nan_mask = np.isnan(coords_3d) + if nan_mask.any(): + nan_rows = np.where(nan_mask.any(axis=1))[0] + logger.error( + f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. " + "Replacing NaN with 0.0 to prevent JSON serialization error." + ) + # Replace NaN with 0 to allow JSON serialization + coords_3d = np.nan_to_num(coords_3d, nan=0.0) + + # Split query coords from document coords + # Round to 2 decimal places for cleaner display + query_coords_3d = [ + round(float(x), 2) for x in coords_3d[-1] + ] # Last point is query + doc_coords_3d = coords_3d[:-1] # All but last are documents + + total_chunks = sum(len(chunks) for chunks in doc_chunks.values()) + avg_chunks_per_doc = ( + total_chunks / len(doc_vectors) if doc_vectors.size > 0 else 0 ) - # Map results to coordinates (use first chunk per document) - result_coords = [] - seen_doc_ids = set() + logger.info( + f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, " + f"PC2={pca.explained_variance_ratio_[1]:.3f}, " + f"PC3={pca.explained_variance_ratio_[2]:.3f}" + ) + logger.info( + f"Embedding stats: documents={len(doc_vectors)}, " + f"total_chunks={total_chunks}, avg_chunks_per_doc={avg_chunks_per_doc:.1f}, " + f"query_dim={len(query_embedding)}, doc_vector_dim={doc_vectors.shape[1] if doc_vectors.size > 0 else 0}" + ) - 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()) + # Coordinates already match search_results order (1:1 mapping) + result_coords = [[round(float(x), 2) for x in coord] for coord in doc_coords_3d] # Build response response_results = [ @@ -338,26 +445,30 @@ async def vector_visualization_search(request: Request) -> JSONResponse: 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"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_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)}" + f"results={len(search_results)}, doc_vectors={len(doc_vectors)}" ) return JSONResponse( { "success": True, "results": response_results, - "coordinates_2d": result_coords[: len(search_results)], + "coordinates_3d": result_coords[: len(search_results)], + "query_coords": query_coords_3d, "pca_variance": { "pc1": float(pca.explained_variance_ratio_[0]), "pc2": float(pca.explained_variance_ratio_[1]), + "pc3": float(pca.explained_variance_ratio_[2]), }, "timing": { "total_ms": round(total_duration * 1000, 2), "search_ms": round(search_duration * 1000, 2), "vector_fetch_ms": round(vector_fetch_duration * 1000, 2), + "query_embed_ms": round(query_embed_duration * 1000, 2), "pca_ms": round(pca_duration * 1000, 2), "num_results": len(search_results), - "num_vectors": len(vectors), + "num_doc_vectors": len(doc_vectors), }, } ) diff --git a/nextcloud_mcp_server/vector/document_chunker.py b/nextcloud_mcp_server/vector/document_chunker.py index 0104c8f..b2c1c3d 100644 --- a/nextcloud_mcp_server/vector/document_chunker.py +++ b/nextcloud_mcp_server/vector/document_chunker.py @@ -3,7 +3,7 @@ import logging from dataclasses import dataclass -from langchain_text_splitters import MarkdownTextSplitter +from langchain_text_splitters import RecursiveCharacterTextSplitter logger = logging.getLogger(__name__) @@ -20,9 +20,9 @@ class ChunkWithPosition: class DocumentChunker: """Chunk large documents for optimal embedding using LangChain text splitters. - Uses MarkdownTextSplitter which is optimized for Markdown content like - Nextcloud Notes. Respects markdown structure (headers, code blocks, lists) - while maintaining semantic boundaries. + Uses RecursiveCharacterTextSplitter which preserves semantic boundaries + by splitting on sentence and paragraph boundaries before resorting to + character-level splitting. """ def __init__(self, chunk_size: int = 2048, overlap: int = 200): @@ -36,15 +36,14 @@ class DocumentChunker: self.chunk_size = chunk_size self.overlap = overlap - # Initialize LangChain MarkdownTextSplitter - # Optimized for Markdown content with special handling for: - # - Headers (# ## ###) - # - Code blocks (``` ```) - # - Lists (- * 1.) - # - Horizontal rules (---) - # - Paragraphs and sentences - # This preserves both markdown structure and semantic boundaries - self.splitter = MarkdownTextSplitter( + # Initialize LangChain RecursiveCharacterTextSplitter + # Uses hierarchical splitting to preserve semantic boundaries: + # - Paragraphs (\n\n) + # - Sentences (. ! ?) + # - Words (spaces) + # - Characters (last resort) + # This prevents mid-sentence splitting while maintaining semantic coherence + self.splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=overlap, add_start_index=True, # Enable position tracking @@ -55,14 +54,14 @@ class DocumentChunker: """ Split text into overlapping chunks with position tracking. - Uses LangChain's MarkdownTextSplitter to create chunks that respect - both markdown structure and semantic boundaries. Optimized for Nextcloud - Notes content with special handling for headers, code blocks, lists, etc. - Preserves character positions for each chunk to enable precise document - retrieval. + Uses LangChain's RecursiveCharacterTextSplitter to create chunks that + preserve semantic boundaries by splitting at paragraphs and sentences + before resorting to word or character-level splitting. This ensures + sentences are kept intact. Preserves character positions for each chunk + to enable precise document retrieval. Args: - content: Markdown text content to chunk + content: Text content to chunk Returns: List of chunks with their character positions in the original content diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 2caaa05..be396c3 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -159,8 +159,8 @@ class TestChunkConfigValidation: def test_default_chunk_settings(self): """Test default chunk size and overlap values.""" settings = Settings() - assert settings.document_chunk_size == 512 - assert settings.document_chunk_overlap == 50 + assert settings.document_chunk_size == 2048 + assert settings.document_chunk_overlap == 200 def test_valid_chunk_settings(self): """Test valid chunk size and overlap configuration.""" @@ -205,7 +205,7 @@ class TestChunkConfigValidation: ) def test_small_chunk_size_warning(self, caplog): - """Test that chunk size < 100 triggers warning.""" + """Test that chunk size < 512 triggers warning.""" import logging caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") @@ -214,19 +214,19 @@ class TestChunkConfigValidation: document_chunk_overlap=10, ) assert ( - "DOCUMENT_CHUNK_SIZE is set to 64 words, which is quite small" + "DOCUMENT_CHUNK_SIZE is set to 64 characters, which is quite small" in caplog.text ) - assert "Consider using at least 256 words" in caplog.text + assert "Consider using at least 1024 characters" in caplog.text def test_reasonable_chunk_size_no_warning(self, caplog): - """Test that chunk size >= 100 doesn't trigger warning.""" + """Test that chunk size >= 512 doesn't trigger warning.""" import logging caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config") Settings( - document_chunk_size=256, - document_chunk_overlap=25, + document_chunk_size=1024, + document_chunk_overlap=100, ) assert "DOCUMENT_CHUNK_SIZE" not in caplog.text