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:
co-authored by
Claude Opus 4.5
parent
a4106ee20d
commit
97b48ca3dd
+23
-7
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+1
@@ -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"
|
||||
},
|
||||
|
||||
Vendored
+190
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user