feat(astrolabe): add 3D PCA visualization for semantic search

- Add Plotly.js 3D scatter plot showing search results in PCA space
- Create shared visualization.py module to avoid code duplication
- Pass include_pca parameter through API chain to enable coordinates
- Fix OAuth redirects to use /settings/user/astroglobe

The visualization shows document embeddings projected to 3D via PCA,
with the query point highlighted in red. Uses Viridis colorscale
for score visualization, matching the existing vector-viz page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-12-18 00:01:09 +01:00
co-authored by Claude Opus 4.5
parent a4106ee20d
commit 97b48ca3dd
8 changed files with 970 additions and 12 deletions
+23 -7
View File
@@ -61,7 +61,7 @@ class ApiController extends Controller {
// Should not happen (NoAdminRequired ensures user is logged in)
$this->logger->error('Revoke access called without authenticated user');
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'mcp'])
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astroglobe'])
);
}
@@ -72,7 +72,7 @@ class ApiController extends Controller {
if (!$token) {
$this->logger->error("Cannot revoke access: No token found for user $userId");
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'mcp'])
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astroglobe'])
);
}
@@ -93,7 +93,7 @@ class ApiController extends Controller {
// Redirect back to personal settings
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'mcp'])
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astroglobe'])
);
}
@@ -107,6 +107,7 @@ class ApiController extends Controller {
* @param string $algorithm Search algorithm (semantic, bm25, hybrid)
* @param int $limit Number of results (max 50)
* @param string $doc_types Comma-separated document types (e.g., "note,file")
* @param string $include_pca Whether to include PCA coordinates for visualization
* @return JSONResponse
*/
#[NoAdminRequired]
@@ -114,7 +115,8 @@ class ApiController extends Controller {
string $query = '',
string $algorithm = 'hybrid',
int $limit = 10,
string $doc_types = ''
string $doc_types = '',
string $include_pca = 'true'
): JSONResponse {
if (empty($query)) {
return new JSONResponse([
@@ -166,8 +168,11 @@ class ApiController extends Controller {
}
}
// Parse include_pca (string "true"/"false" from query params)
$includePcaBool = in_array(strtolower($include_pca), ['true', '1', 'yes'], true);
// Execute search via MCP server with OAuth token
$result = $this->client->search($query, $algorithm, $limit, false, $docTypesArray, $accessToken);
$result = $this->client->search($query, $algorithm, $limit, $includePcaBool, $docTypesArray, $accessToken);
if (isset($result['error'])) {
return new JSONResponse([
@@ -176,12 +181,23 @@ class ApiController extends Controller {
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new JSONResponse([
$response = [
'success' => true,
'results' => $result['results'] ?? [],
'algorithm_used' => $result['algorithm_used'] ?? $algorithm,
'total_documents' => $result['total_documents'] ?? 0,
]);
];
// Include PCA visualization coordinates if requested and available
if ($includePcaBool) {
$response['coordinates_3d'] = $result['coordinates_3d'] ?? [];
$response['query_coords'] = $result['query_coords'] ?? [];
if (isset($result['pca_variance'])) {
$response['pca_variance'] = $result['pca_variance'];
}
}
return new JSONResponse($response);
}
/**
+4 -4
View File
@@ -200,7 +200,7 @@ class OAuthController extends Controller {
// Redirect back to personal settings
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'mcp'])
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astroglobe'])
);
} catch (\Exception $e) {
$this->logger->error('OAuth callback failed', [
@@ -215,7 +215,7 @@ class OAuthController extends Controller {
// Redirect to settings with error
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', [
'section' => 'mcp',
'section' => 'astroglobe',
'error' => urlencode($e->getMessage())
])
);
@@ -234,7 +234,7 @@ class OAuthController extends Controller {
$user = $this->userSession->getUser();
if (!$user) {
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'mcp'])
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astroglobe'])
);
}
@@ -250,7 +250,7 @@ class OAuthController extends Controller {
}
return new RedirectResponse(
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'mcp'])
$this->urlGenerator->linkToRoute('settings.PersonalSettings.index', ['section' => 'astroglobe'])
);
}
+7
View File
@@ -13,6 +13,7 @@
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.29.2",
"plotly.js-dist-min": "^2.35.3",
"vue": "^2.7.16",
"vue-material-design-icons": "^5.3.1"
},
@@ -9969,6 +9970,12 @@
"pathe": "^2.0.3"
}
},
"node_modules/plotly.js-dist-min": {
"version": "2.35.3",
"resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.35.3.tgz",
"integrity": "sha512-sz2HLP8gkysLx/BanM2PtJTtZ1PLPwdHwMWNri2YxLBy3IOeuDsVQtlmWa4hoK3j/fi4naaD3uZJqH5ozM3zGg==",
"license": "MIT"
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
+1
View File
@@ -22,6 +22,7 @@
"@nextcloud/l10n": "^3.1.0",
"@nextcloud/router": "^3.0.1",
"@nextcloud/vue": "^8.29.2",
"plotly.js-dist-min": "^2.35.3",
"vue": "^2.7.16",
"vue-material-design-icons": "^5.3.1"
},
+190 -1
View File
@@ -131,6 +131,22 @@
<span class="mcp-algorithm-badge">{{ algorithmUsed }}</span>
</div>
<!-- 3D Visualization -->
<div v-if="coordinates.length > 0" class="mcp-viz-container">
<div class="mcp-viz-header">
<h3>{{ t('astroglobe', 'Vector Space Visualization') }}</h3>
<NcCheckboxRadioSwitch
:checked.sync="showQueryPoint"
type="switch"
@update:checked="updatePlot">
{{ t('astroglobe', 'Show query point') }}
</NcCheckboxRadioSwitch>
</div>
<div id="viz-plot-container" class="mcp-viz-plot-container">
<div id="viz-plot" ref="vizPlot" />
</div>
</div>
<div class="mcp-results-list">
<div
v-for="result in results"
@@ -248,6 +264,7 @@ import Refresh from 'vue-material-design-icons/Refresh.vue'
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import Plotly from 'plotly.js-dist-min'
// App name for translations
const APP_NAME = 'astroglobe'
@@ -287,6 +304,10 @@ export default {
results: [],
algorithmUsed: '',
searched: false,
// Visualization state
coordinates: [],
queryCoords: [],
showQueryPoint: true,
// Vector status state
vectorStatus: null,
statusLoading: false,
@@ -325,6 +346,8 @@ export default {
this.loading = true
this.error = null
this.searched = true
this.coordinates = []
this.queryCoords = []
try {
const url = generateUrl('/apps/astroglobe/api/search')
@@ -332,6 +355,7 @@ export default {
query: queryText,
algorithm: this.algorithm,
limit: parseInt(this.limit) || 20,
include_pca: true,
}
if (this.selectedDocTypes.length > 0) {
@@ -343,6 +367,15 @@ export default {
if (response.data.success) {
this.results = response.data.results || []
this.algorithmUsed = response.data.algorithm_used || this.algorithm
this.coordinates = response.data.coordinates_3d || []
this.queryCoords = response.data.query_coords || []
// Render visualization after DOM updates
if (this.coordinates.length > 0) {
this.$nextTick(() => {
this.renderPlot()
})
}
} else {
this.error = response.data.error || this.t('astroglobe', 'Search failed')
this.results = []
@@ -382,7 +415,130 @@ export default {
},
goToSettings() {
window.location.href = generateUrl('/settings/user/mcp')
window.location.href = generateUrl('/settings/user/astroglobe')
},
renderPlot() {
const container = document.getElementById('viz-plot-container')
if (!container) return
const width = container.clientWidth
const height = container.clientHeight || 400
const coordinates = this.coordinates
const queryCoords = this.queryCoords
const results = this.results
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 || r.score,
relative_score: r.score,
x: coordinates[i][0],
y: coordinates[i][1],
z: coordinates[i][2],
})),
hovertemplate:
'<b>%{customdata.title}</b><br>'
+ 'Raw Score: %{customdata.raw_score:.3f} (%{customdata.relative_score:.0%} relative)<br>'
+ '(x=%{customdata.x}, y=%{customdata.y}, z=%{customdata.z})'
+ '<extra></extra>',
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,
hovertemplate:
'<b>Search Query</b><br>'
+ `(x=${queryCoords[0]}, y=${queryCoords[1]}, z=${queryCoords[2]})`
+ '<extra></extra>',
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,
height,
scene: {
xaxis: { title: 'PC1' },
yaxis: { title: 'PC2' },
zaxis: { title: 'PC3' },
camera: {
eye: { x: 1.5, y: 1.5, z: 1.5 },
},
domain: {
x: [0, 1],
y: [0, 1],
},
},
hovermode: 'closest',
autosize: true,
showlegend: false,
margin: { l: 0, r: 100, t: 40, b: 0 },
}
const traces = [documentTrace, queryTrace]
const config = {
responsive: true,
displayModeBar: true,
}
Plotly.newPlot('viz-plot', traces, layout, config)
},
updatePlot() {
// Toggle query point visibility without recreating the plot
if (this.coordinates.length > 0 && this.queryCoords.length > 0 && this.results.length > 0) {
const plotDiv = document.getElementById('viz-plot')
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()
}
}
},
},
}
@@ -478,6 +634,39 @@ export default {
margin: 16px 0;
}
// Visualization
.mcp-viz-container {
background: var(--color-background-hover);
border-radius: var(--border-radius-large);
padding: 16px;
margin-bottom: 24px;
}
.mcp-viz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
.mcp-viz-plot-container {
width: 100%;
height: 400px;
background: var(--color-main-background);
border-radius: var(--border-radius);
}
#viz-plot {
width: 100%;
height: 100%;
}
// Results
.mcp-results {
margin-top: 24px;