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>
243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
|
|
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);
|
|
}
|