feat: editor improvements and architectural grid prototype
Multiple feature additions and improvements across the application: - NextGen Editor: drag handles, smart paste, block actions - Structured views: Kanban and table layouts for notes - Architectural Grid: new brainstorming/agent interface prototype - Flashcards: SM-2 revision algorithm with AI generation - MCP server: robustness improvements - Graph/PDF chat: fix click propagation and copy behavior - Various UI/UX enhancements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
242
architectural-grid1/src/services/clusteringService.ts
Normal file
242
architectural-grid1/src/services/clusteringService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
|
||||
import { Note, NoteCluster, BridgeNote } from '../types';
|
||||
import { cosineSimilarity } from './geminiService';
|
||||
|
||||
export function dbscan(notes: Note[], eps: number, minPts: number): number[] {
|
||||
const n = notes.length;
|
||||
const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id
|
||||
let clusterId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = getNeighbors(i, notes, eps);
|
||||
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1; // remains noise for now
|
||||
continue;
|
||||
}
|
||||
|
||||
labels[i] = clusterId;
|
||||
const queue = neighbors.filter(idx => idx !== i);
|
||||
|
||||
for (let j = 0; j < queue.length; j++) {
|
||||
const pIdx = queue[j];
|
||||
|
||||
if (labels[pIdx] === -1) {
|
||||
labels[pIdx] = clusterId; // noisy point becomes border point
|
||||
}
|
||||
|
||||
if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) {
|
||||
// This should not happen in standard DBSCAN unless we re-visit
|
||||
}
|
||||
|
||||
if (labels[pIdx] === clusterId && labels[pIdx] !== -1) {
|
||||
// Skip if already processed in this cluster
|
||||
}
|
||||
|
||||
// If it was already labeled, skip re-neighboring
|
||||
const pWasNoise = labels[pIdx] === -1;
|
||||
if (labels[pIdx] === -1) labels[pIdx] = clusterId;
|
||||
|
||||
// If point was not processed
|
||||
if (pWasNoise || labels[pIdx] === clusterId ) {
|
||||
// This is a simplified queue processing
|
||||
}
|
||||
}
|
||||
|
||||
// Standard DBSCAN expansion
|
||||
expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts);
|
||||
clusterId++;
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) {
|
||||
let i = 0;
|
||||
while (i < neighbors.length) {
|
||||
const qIdx = neighbors[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = clusterId;
|
||||
} else if (labels[qIdx] === undefined || labels[qIdx] === -1) {
|
||||
// unreachable
|
||||
}
|
||||
|
||||
if (labels[qIdx] === clusterId || labels[qIdx] === -1) {
|
||||
const qNeighbors = getNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = clusterId;
|
||||
neighbors.push(qn);
|
||||
} else if (!labels.hasOwnProperty(qn)) {
|
||||
// logic error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean DBSCAN implementation
|
||||
export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } {
|
||||
const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0);
|
||||
if (validNotes.length === 0) return { labels: [], clusters: [] };
|
||||
|
||||
const n = validNotes.length;
|
||||
const labels = new Array(n).fill(-1);
|
||||
let cId = 0;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (labels[i] !== -1) continue;
|
||||
|
||||
const neighbors = findNeighbors(i, validNotes, eps);
|
||||
if (neighbors.length < minPts) {
|
||||
labels[i] = -1;
|
||||
} else {
|
||||
labels[i] = cId;
|
||||
expand(i, neighbors, labels, cId, validNotes, eps, minPts);
|
||||
cId++;
|
||||
}
|
||||
}
|
||||
|
||||
const clusters: NoteCluster[] = [];
|
||||
const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'];
|
||||
|
||||
for (let i = 0; i < cId; i++) {
|
||||
const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id);
|
||||
clusters.push({
|
||||
id: `cluster-${i}`,
|
||||
name: `Cluster ${i + 1}`,
|
||||
noteIds,
|
||||
color: colorPalette[i % colorPalette.length]
|
||||
});
|
||||
}
|
||||
|
||||
return { labels, clusters };
|
||||
}
|
||||
|
||||
function findNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const targetEmbedding = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!);
|
||||
const dist = 1 - sim;
|
||||
if (dist <= eps) {
|
||||
neighbors.push(i);
|
||||
}
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) {
|
||||
const queue = [...neighbors];
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const qIdx = queue[i];
|
||||
if (labels[qIdx] === -1) {
|
||||
labels[qIdx] = cId;
|
||||
}
|
||||
if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue;
|
||||
if (labels[qIdx] === cId) {
|
||||
// already visited but let's check neighbors if we just added it
|
||||
}
|
||||
|
||||
// If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point
|
||||
// This is the standard DBSCAN: noise points can become border points
|
||||
}
|
||||
|
||||
// Re-implementing correctly
|
||||
let head = 0;
|
||||
while(head < queue.length) {
|
||||
const qIdx = queue[head];
|
||||
if (labels[qIdx] === -1) labels[qIdx] = cId;
|
||||
if (labels[qIdx] === cId) {
|
||||
const qNeighbors = findNeighbors(qIdx, notes, eps);
|
||||
if (qNeighbors.length >= minPts) {
|
||||
for(const qn of qNeighbors) {
|
||||
if (labels[qn] === -1) {
|
||||
labels[qn] = cId;
|
||||
queue.push(qn);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
head++;
|
||||
}
|
||||
}
|
||||
|
||||
function getNeighbors(idx: number, notes: Note[], eps: number): number[] {
|
||||
const neighbors: number[] = [];
|
||||
const target = notes[idx].embedding!;
|
||||
for (let i = 0; i < notes.length; i++) {
|
||||
if (!notes[i].embedding) continue;
|
||||
const dist = 1 - cosineSimilarity(target, notes[i].embedding!);
|
||||
if (dist <= eps) neighbors.push(i);
|
||||
}
|
||||
return neighbors;
|
||||
}
|
||||
|
||||
export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] {
|
||||
const bridges: BridgeNote[] = [];
|
||||
const validNotes = notes.filter(n => n.embedding);
|
||||
|
||||
for (const note of validNotes) {
|
||||
const connectedClusters = new Set<string>();
|
||||
|
||||
for (const cluster of clusters) {
|
||||
// Check if note has strong links to ANY note in this cluster
|
||||
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding);
|
||||
const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold);
|
||||
|
||||
if (hasStrongLink) {
|
||||
connectedClusters.add(cluster.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedClusters.size >= 2) {
|
||||
bridges.push({
|
||||
noteId: note.id,
|
||||
connectedClusterIds: Array.from(connectedClusters),
|
||||
bridgeScore: connectedClusters.size / Math.max(clusters.length, 1)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore);
|
||||
}
|
||||
|
||||
export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return undefined;
|
||||
|
||||
const embeddingDim = clusterNotes[0].embedding!.length;
|
||||
const centroid = new Array(embeddingDim).fill(0);
|
||||
|
||||
for (const note of clusterNotes) {
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] += note.embedding![i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < embeddingDim; i++) {
|
||||
centroid[i] /= clusterNotes.length;
|
||||
}
|
||||
|
||||
return centroid;
|
||||
}
|
||||
|
||||
export function getMostCentralNoteTitles(noteIds: string[], centroid: number[] | undefined, allNotes: Note[], count: number = 5): string[] {
|
||||
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
|
||||
if (clusterNotes.length === 0) return [];
|
||||
if (!centroid) return clusterNotes.slice(0, count).map(n => n.title);
|
||||
|
||||
const scored = clusterNotes.map(n => ({
|
||||
title: n.title,
|
||||
similarity: cosineSimilarity(n.embedding!, centroid)
|
||||
}));
|
||||
|
||||
scored.sort((a, b) => b.similarity - a.similarity);
|
||||
return scored.slice(0, count).map(item => item.title);
|
||||
}
|
||||
Reference in New Issue
Block a user