feat(cluster): implement cluster detection and bridge notes discovery
Add automatic note clustering using density-based algorithm (DBSCAN variant) and bridge notes detection for connecting different thematic clusters. Features: - NoteCluster, ClusterMember, BridgeNote, BridgeSuggestion models - Clustering service with pgvector cosine similarity - Bridge notes detection (notes connecting >=2 clusters) - AI-powered suggestions for missing cluster connections - /insights page with React Flow visualization - Cron endpoint for automatic recalculation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
270
memento-note/components/bridge-notes-dashboard.tsx
Normal file
270
memento-note/components/bridge-notes-dashboard.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected: number[]
|
||||
clusterNames?: string[]
|
||||
note?: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BridgeSuggestion {
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}
|
||||
|
||||
interface BridgeNotesDashboardProps {
|
||||
onNoteClick?: (noteId: string) => void
|
||||
}
|
||||
|
||||
export function BridgeNotesDashboard({ onNoteClick }: BridgeNotesDashboardProps) {
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
||||
const [suggestions, setSuggestions] = useState<BridgeSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'bridges' | 'suggestions'>('bridges')
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Load bridge notes
|
||||
const bridgesRes = await fetch('/api/bridge-notes?details=true')
|
||||
if (bridgesRes.ok) {
|
||||
const bridgesData = await bridgesRes.json()
|
||||
setBridgeNotes(bridgesData.bridgeNotes || [])
|
||||
}
|
||||
|
||||
// Load suggestions
|
||||
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
|
||||
if (suggestionsRes.ok) {
|
||||
const suggestionsData = await suggestionsRes.json()
|
||||
setSuggestions(suggestionsData.suggestions || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading bridge data:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generateNewSuggestions = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bridge-notes/suggestions', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating suggestions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const dismissSuggestion = async (clusterAId: number, clusterBId: number) => {
|
||||
try {
|
||||
await fetch('/api/bridge-notes', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clusterAId, clusterBId })
|
||||
})
|
||||
|
||||
// Remove from local state
|
||||
setSuggestions(prev => prev.filter(
|
||||
s => !(s.clusterAId === clusterAId && s.clusterBId === clusterBId)
|
||||
))
|
||||
} catch (error) {
|
||||
console.error('Error dismissing suggestion:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-20 bg-gray-200 rounded"></div>
|
||||
<div className="h-20 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
{/* Tabs */}
|
||||
<div className="border-b">
|
||||
<nav className="flex -mb-px">
|
||||
<button
|
||||
onClick={() => setActiveTab('bridges')}
|
||||
className={`px-6 py-3 font-medium text-sm ${
|
||||
activeTab === 'bridges'
|
||||
? 'border-b-2 border-blue-500 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Bridge Notes ({bridgeNotes.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suggestions')}
|
||||
className={`px-6 py-3 font-medium text-sm ${
|
||||
activeTab === 'suggestions'
|
||||
? 'border-b-2 border-blue-500 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Connection Opportunities ({suggestions.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'bridges' && (
|
||||
<div>
|
||||
{bridgeNotes.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<p>No bridge notes found yet</p>
|
||||
<p className="text-sm mt-1">Bridge notes connect different clusters of ideas</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{bridgeNotes.map((bridge) => (
|
||||
<div
|
||||
key={bridge.noteId}
|
||||
onClick={() => onNoteClick?.(bridge.noteId)}
|
||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
Bridge Score: {bridge.bridgeScore.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Connects {bridge.clustersConnected.length} {bridge.clustersConnected.length === 1 ? 'cluster' : 'clusters'}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-medium text-gray-900">
|
||||
{bridge.note?.title || 'Untitled'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mt-1 line-clamp-2">
|
||||
{bridge.note?.content?.replace(/<[^>]+>/g, '').slice(0, 150) || 'No content'}
|
||||
</p>
|
||||
{bridge.clusterNames && bridge.clusterNames.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{bridge.clusterNames.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-700"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'suggestions' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
AI-suggested ideas to connect your isolated clusters
|
||||
</p>
|
||||
<button
|
||||
onClick={generateNewSuggestions}
|
||||
className="px-3 py-1.5 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
Generate New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<p>No connection suggestions yet</p>
|
||||
<p className="text-sm mt-1">All your clusters may already be connected!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={`${suggestion.clusterAId}-${suggestion.clusterBId}`}
|
||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-lg">💡</span>
|
||||
<span className="font-semibold text-gray-900">
|
||||
{suggestion.suggestedTitle}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">#{index + 1}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600 mb-2">
|
||||
<span className="px-2 py-0.5 rounded bg-blue-100 text-blue-700">
|
||||
{suggestion.clusterAName}
|
||||
</span>
|
||||
<span>↔</span>
|
||||
<span className="px-2 py-0.5 rounded bg-purple-100 text-purple-700">
|
||||
{suggestion.clusterBName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
{suggestion.suggestedContent}
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 italic">
|
||||
"{suggestion.justification}"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => dismissSuggestion(suggestion.clusterAId, suggestion.clusterBId)}
|
||||
className="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Dismiss suggestion"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
225
memento-note/components/cluster-visualization.tsx
Normal file
225
memento-note/components/cluster-visualization.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
ConnectionMode,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
interface Cluster {
|
||||
clusterId: number
|
||||
name?: string
|
||||
noteIds: string[]
|
||||
}
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected: number[]
|
||||
note?: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ClusterVisualizationProps {
|
||||
clusters: Cluster[]
|
||||
bridgeNotes: BridgeNote[]
|
||||
onNodeClick?: (nodeId: string, type: 'note' | 'cluster') => void
|
||||
}
|
||||
|
||||
// Generate HSL colors for clusters
|
||||
function generateClusterColor(clusterId: number, totalClusters: number): string {
|
||||
const hue = (clusterId * 360 / totalClusters) % 360
|
||||
return `hsl(${hue}, 70%, 60%)`
|
||||
}
|
||||
|
||||
export function ClusterVisualization({
|
||||
clusters,
|
||||
bridgeNotes,
|
||||
onNodeClick
|
||||
}: ClusterVisualizationProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
const [selectedCluster, setSelectedCluster] = useState<number | null>(null)
|
||||
|
||||
// Build graph from clusters and bridge notes
|
||||
useEffect(() => {
|
||||
if (clusters.length === 0) return
|
||||
|
||||
const newNodes: Node[] = []
|
||||
const newEdges: Edge[] = []
|
||||
const noteToCluster = new Map<string, number>()
|
||||
|
||||
const clusterColor = generateClusterColor
|
||||
|
||||
// Create cluster nodes and position notes around them
|
||||
const clusterCenterX = 200
|
||||
const clusterCenterY = 200
|
||||
const clusterSpacing = 400
|
||||
|
||||
clusters.forEach((cluster, clusterIndex) => {
|
||||
const cx = clusterCenterX + (clusterIndex % 4) * clusterSpacing
|
||||
const cy = clusterCenterY + Math.floor(clusterIndex / 4) * clusterSpacing
|
||||
const color = clusterColor(cluster.clusterId, clusters.length)
|
||||
|
||||
// Add cluster label node
|
||||
newNodes.push({
|
||||
id: `cluster-${cluster.clusterId}`,
|
||||
type: 'default',
|
||||
position: { x: cx, y: cy - 150 },
|
||||
data: {
|
||||
label: (
|
||||
<div className="px-3 py-1 rounded-full text-sm font-medium" style={{ backgroundColor: color }}>
|
||||
{cluster.name || `Cluster ${cluster.clusterId}`}
|
||||
<span className="ml-2 text-xs opacity-75">({cluster.noteIds.length} notes)</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
style: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
},
|
||||
className: 'cursor-pointer'
|
||||
})
|
||||
|
||||
// Add note nodes for this cluster
|
||||
const noteRadius = 120
|
||||
cluster.noteIds.forEach((noteId, noteIndex) => {
|
||||
const angle = (noteIndex / cluster.noteIds.length) * 2 * Math.PI
|
||||
const nx = cx + Math.cos(angle) * noteRadius
|
||||
const ny = cy + Math.sin(angle) * noteRadius
|
||||
|
||||
noteToCluster.set(noteId, cluster.clusterId)
|
||||
|
||||
// Check if this is a bridge note
|
||||
const bridge = bridgeNotes.find(b => b.noteId === noteId)
|
||||
const isBridge = !!bridge
|
||||
|
||||
newNodes.push({
|
||||
id: noteId,
|
||||
type: 'default',
|
||||
position: { x: nx, y: ny },
|
||||
data: {
|
||||
label: bridge?.note?.title || noteId.slice(0, 8)
|
||||
},
|
||||
style: {
|
||||
background: isBridge ? '#FFD700' : color,
|
||||
border: isBridge ? '3px solid #B8860B' : '2px solid rgba(0,0,0,0.1)',
|
||||
borderRadius: '50%',
|
||||
width: isBridge ? 50 : 35,
|
||||
height: isBridge ? 50 : 35,
|
||||
fontSize: '10px',
|
||||
fontWeight: isBridge ? 'bold' : 'normal'
|
||||
},
|
||||
className: `cursor-pointer transition-transform hover:scale-110 ${isBridge ? 'shadow-lg' : ''}`
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Create edges between bridge notes and their connected clusters
|
||||
bridgeNotes.forEach(bridge => {
|
||||
const bridgeNodeId = bridge.noteId
|
||||
const bridgeNode = newNodes.find(n => n.id === bridgeNodeId)
|
||||
|
||||
if (!bridgeNode) return
|
||||
|
||||
bridge.clustersConnected.forEach(clusterId => {
|
||||
const clusterLabelId = `cluster-${clusterId}`
|
||||
if (clusterLabelId !== bridgeNodeId) {
|
||||
newEdges.push({
|
||||
id: `${bridgeNodeId}-${clusterId}`,
|
||||
source: bridgeNodeId,
|
||||
target: clusterLabelId,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: '#FFD700',
|
||||
strokeWidth: 2
|
||||
},
|
||||
label: 'bridge'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
}, [clusters, bridgeNotes, setNodes, setEdges])
|
||||
|
||||
const onNodeClickHandler = useCallback((event: React.MouseEvent, node: Node) => {
|
||||
const id = node.id
|
||||
|
||||
if (id.startsWith('cluster-')) {
|
||||
const clusterId = parseInt(id.replace('cluster-', ''))
|
||||
setSelectedCluster(clusterId === selectedCluster ? null : clusterId)
|
||||
onNodeClick?.(id, 'cluster')
|
||||
} else {
|
||||
onNodeClick?.(id, 'note')
|
||||
}
|
||||
}, [selectedCluster, onNodeClick])
|
||||
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">
|
||||
<div className="text-center">
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p>No clusters to display</p>
|
||||
<p className="text-sm mt-2">Create more notes to generate clusters</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-[600px] border rounded-lg overflow-hidden bg-gray-50">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClickHandler}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
fitView
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
|
||||
{selectedCluster !== null && (
|
||||
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-4 max-w-xs">
|
||||
<h3 className="font-semibold mb-2">
|
||||
{clusters.find(c => c.clusterId === selectedCluster)?.name || `Cluster ${selectedCluster}`}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{clusters.find(c => c.clusterId === selectedCluster)?.noteIds.length || 0} notes
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute top-4 right-4 bg-white rounded-lg shadow-lg p-3">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-yellow-400 border-2 border-yellow-600"></div>
|
||||
<span>Bridge note</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-500"></div>
|
||||
<span>Regular note</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user