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:
210
memento-note/app/(main)/insights/page.tsx
Normal file
210
memento-note/app/(main)/insights/page.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ClusterVisualization } from '@/components/cluster-visualization'
|
||||
import { BridgeNotesDashboard } from '@/components/bridge-notes-dashboard'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface Cluster {
|
||||
clusterId: number
|
||||
name?: string
|
||||
noteIds: string[]
|
||||
}
|
||||
|
||||
interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected: number[]
|
||||
note?: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function InsightsPage() {
|
||||
const router = useRouter()
|
||||
const [clusters, setClusters] = useState<Cluster[]>([])
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [recalculating, setRecalculating] = useState(false)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadClusters()
|
||||
}, [])
|
||||
|
||||
const loadClusters = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/clusters')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setClusters(data.clusters || [])
|
||||
|
||||
if (data.message) {
|
||||
setMessage(data.message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading clusters:', error)
|
||||
setMessage('Failed to load clusters')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const recalculateClusters = async () => {
|
||||
setRecalculating(true)
|
||||
try {
|
||||
const res = await fetch('/api/clusters', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ force: true })
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setClusters(data.clusters || [])
|
||||
setBridgeNotes(data.bridgeNotes || [])
|
||||
setMessage(data.message || 'Clusters recalculated successfully')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error recalculating clusters:', error)
|
||||
setMessage('Failed to recalculate clusters')
|
||||
} finally {
|
||||
setRecalculating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteClick = (noteId: string, type: 'note' | 'cluster') => {
|
||||
if (type === 'note') {
|
||||
router.push(`/home?note=${noteId}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Insights
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Discover thematic clusters and connections in your notes
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Total Clusters</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{clusters.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Bridge Notes</div>
|
||||
<div className="text-2xl font-bold text-gray-900">{bridgeNotes.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="text-sm text-gray-500">Notes Analyzed</div>
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{clusters.reduce((sum, c) => sum + c.noteIds.length, 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<button
|
||||
onClick={recalculateClusters}
|
||||
disabled={recalculating || loading}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{recalculating ? 'Calculating...' : 'Recalculate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-800">{message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="bg-white rounded-lg shadow p-12">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
|
||||
<p className="text-gray-600">Analyzing your notes...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && clusters.length === 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-12">
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<svg className="w-24 h-24 text-gray-400 mb-4" 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>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Not enough notes to analyze
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Create at least 10 notes to start discovering clusters and connections
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/home')}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Create Notes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
{!loading && clusters.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Visualization */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Cluster Visualization</h2>
|
||||
<ClusterVisualization
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNodeClick={handleNoteClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard */}
|
||||
<div className="lg:col-span-1">
|
||||
<BridgeNotesDashboard onNoteClick={(noteId) => handleNoteClick(noteId, 'note')} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cluster List */}
|
||||
{!loading && clusters.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-lg font-semibold mb-4">All Clusters</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{clusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.clusterId}
|
||||
className="bg-white rounded-lg shadow p-4 hover:shadow-md transition-shadow cursor-pointer"
|
||||
>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">
|
||||
{cluster.name || `Cluster ${cluster.clusterId}`}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{cluster.noteIds.length} {cluster.noteIds.length === 1 ? 'note' : 'notes'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
85
memento-note/app/api/bridge-notes/route.ts
Normal file
85
memento-note/app/api/bridge-notes/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||
|
||||
/**
|
||||
* GET /api/bridge-notes
|
||||
* Get all bridge notes for the current user.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const includeDetails = searchParams.get('details') === 'true'
|
||||
|
||||
const bridgeNotes = await bridgeNotesService.getBridgeNotes(userId)
|
||||
|
||||
// Optionally include note details
|
||||
let enrichedNotes = bridgeNotes
|
||||
if (includeDetails && bridgeNotes.length > 0) {
|
||||
const noteIds = bridgeNotes.map(b => b.noteId)
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: noteIds } },
|
||||
select: { id: true, title: true, content: true, notebookId: true }
|
||||
})
|
||||
|
||||
const noteMap = new Map(notes.map(n => [n.id, n]))
|
||||
|
||||
enrichedNotes = bridgeNotes.map(b => ({
|
||||
...b,
|
||||
note: noteMap.get(b.noteId)
|
||||
}))
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
bridgeNotes: enrichedNotes,
|
||||
count: enrichedNotes.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching bridge notes:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch bridge notes' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/bridge-notes
|
||||
* Dismiss a bridge suggestion.
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
const { clusterAId, clusterBId } = body
|
||||
|
||||
if (typeof clusterAId !== 'number' || typeof clusterBId !== 'number') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid cluster IDs' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
await bridgeNotesService.dismissSuggestion(userId, clusterAId, clusterBId)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('Error dismissing suggestion:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to dismiss suggestion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
66
memento-note/app/api/bridge-notes/suggestions/route.ts
Normal file
66
memento-note/app/api/bridge-notes/suggestions/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||
|
||||
/**
|
||||
* GET /api/bridge-notes/suggestions
|
||||
* Get AI-powered bridge note suggestions for unconnected clusters.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const { searchParams } = new URL(request.url)
|
||||
const includeDismissed = searchParams.get('dismissed') === 'true'
|
||||
|
||||
const suggestions = await bridgeNotesService.getBridgeSuggestions(userId, includeDismissed)
|
||||
|
||||
return NextResponse.json({
|
||||
suggestions,
|
||||
count: suggestions.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching bridge suggestions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch suggestions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/bridge-notes/suggestions/generate
|
||||
* Generate new bridge suggestions (force regeneration).
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Generate new suggestions
|
||||
const suggestions = await bridgeNotesService.generateBridgeSuggestions(userId)
|
||||
|
||||
// Save to database
|
||||
await bridgeNotesService.saveBridgeSuggestions(userId, suggestions)
|
||||
|
||||
return NextResponse.json({
|
||||
suggestions,
|
||||
count: suggestions.length,
|
||||
message: `Generated ${suggestions.length} bridge suggestions`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error generating bridge suggestions:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to generate suggestions' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
124
memento-note/app/api/clusters/route.ts
Normal file
124
memento-note/app/api/clusters/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { clusteringService } from '@/lib/ai/services/clustering.service'
|
||||
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||
|
||||
/**
|
||||
* GET /api/clusters
|
||||
* Get all clusters for the current user.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
// Check for cached results
|
||||
const cached = await clusteringService.getCachedClusters(userId)
|
||||
if (cached) {
|
||||
return NextResponse.json({
|
||||
clusters: cached,
|
||||
cached: true,
|
||||
totalNotes: cached.reduce((sum, c) => sum + c.noteIds.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
// No cached results, check if user has enough notes
|
||||
const notesCount = await prisma.note.count({
|
||||
where: { userId, trashedAt: null }
|
||||
})
|
||||
|
||||
if (notesCount < 10) {
|
||||
return NextResponse.json({
|
||||
clusters: [],
|
||||
message: 'Need at least 10 notes to generate clusters',
|
||||
totalNotes: notesCount
|
||||
})
|
||||
}
|
||||
|
||||
// Trigger background recalculation
|
||||
return NextResponse.json({
|
||||
clusters: [],
|
||||
message: 'Calculating clusters... Please check back later',
|
||||
totalNotes: notesCount
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching clusters:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch clusters' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clusters/recalculate
|
||||
* Trigger a full recalculation of clusters and bridge notes.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
const body = await request.json()
|
||||
const force = body.force === true
|
||||
|
||||
// Check if recalculation is needed
|
||||
const shouldRecalc = force || await clusteringService.shouldRecalculate(userId)
|
||||
|
||||
if (!shouldRecalc) {
|
||||
const cached = await clusteringService.getCachedClusters(userId)
|
||||
if (cached) {
|
||||
return NextResponse.json({
|
||||
clusters: cached,
|
||||
cached: true,
|
||||
message: 'Using cached results (data has not changed significantly)'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Perform clustering
|
||||
const results = await clusteringService.clusterNotes(userId)
|
||||
|
||||
if (results.clusters.length === 0) {
|
||||
return NextResponse.json({
|
||||
clusters: [],
|
||||
message: 'Could not generate clusters. Need more diverse notes.',
|
||||
noiseCount: results.noiseCount
|
||||
})
|
||||
}
|
||||
|
||||
// Generate cluster names
|
||||
for (const cluster of results.clusters) {
|
||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, userId)
|
||||
}
|
||||
|
||||
// Save results
|
||||
await clusteringService.saveClusteringResults(userId, results)
|
||||
|
||||
// Detect and save bridge notes
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
||||
|
||||
return NextResponse.json({
|
||||
clusters: results.clusters,
|
||||
bridgeNotes: bridgeNotes.slice(0, 10), // Return top 10
|
||||
totalNotes: results.clusters.reduce((sum, c) => sum + c.noteIds.length, 0) + results.noiseCount,
|
||||
noiseCount: results.noiseCount,
|
||||
message: `Generated ${results.clusters.length} clusters`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error recalculating clusters:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to recalculate clusters' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
224
memento-note/app/api/cron/clusters/route.ts
Normal file
224
memento-note/app/api/cron/clusters/route.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { auth } from '@/auth'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { clusteringService } from '@/lib/ai/services/clustering.service'
|
||||
import { bridgeNotesService } from '@/lib/ai/services/bridge-notes.service'
|
||||
|
||||
/**
|
||||
* Cron endpoint for automatic cluster recalculation.
|
||||
*
|
||||
* This endpoint is designed to be called by a cron job or scheduler.
|
||||
* It processes users who need cluster updates based on:
|
||||
* 1. Data change percentage (> 5%)
|
||||
* 2. Time since last calculation (> 24 hours)
|
||||
*
|
||||
* GET /api/cron/clusters?secret=CRON_SECRET
|
||||
*
|
||||
* The secret parameter must match the environment variable CRON_SECRET.
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// Verify cron secret
|
||||
const { searchParams } = new URL(request.url)
|
||||
const secret = searchParams.get('secret')
|
||||
|
||||
if (secret !== process.env.CRON_SECRET) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Find users who need recalculation
|
||||
// Users who either:
|
||||
// 1. Have no clusters calculated yet
|
||||
// 2. Have data changes > 5% since last calculation
|
||||
// 3. Haven't had calculations in > 24 hours
|
||||
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
|
||||
const usersToProcess = await prisma.user.findMany({
|
||||
where: {
|
||||
// Only active users
|
||||
OR: [
|
||||
// Users who never had clusters calculated
|
||||
{
|
||||
noteClusters: {
|
||||
none: {}
|
||||
}
|
||||
},
|
||||
// Users with stale cluster data
|
||||
{
|
||||
noteClusters: {
|
||||
some: {
|
||||
lastCalculated: {
|
||||
lt: oneDayAgo
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true
|
||||
},
|
||||
take: 50 // Process up to 50 users per run
|
||||
})
|
||||
|
||||
const results = {
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
details: [] as Array<{ userId: string; clusters: number; bridges: number; error?: string }>
|
||||
}
|
||||
|
||||
for (const user of usersToProcess) {
|
||||
try {
|
||||
// Check if recalculation is needed based on data change
|
||||
const shouldRecalc = await clusteringService.shouldRecalculate(user.id)
|
||||
|
||||
if (!shouldRecalc) {
|
||||
results.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Perform clustering
|
||||
const clusterResults = await clusteringService.clusterNotes(user.id)
|
||||
|
||||
if (clusterResults.clusters.length === 0) {
|
||||
results.details.push({
|
||||
userId: user.id,
|
||||
clusters: 0,
|
||||
bridges: 0,
|
||||
error: 'Could not generate clusters'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate cluster names
|
||||
for (const cluster of clusterResults.clusters) {
|
||||
cluster.name = await clusteringService.generateClusterName(cluster.clusterId, user.id)
|
||||
}
|
||||
|
||||
// Save results
|
||||
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
||||
|
||||
// Detect and save bridge notes
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
|
||||
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
|
||||
|
||||
results.processed++
|
||||
results.details.push({
|
||||
userId: user.id,
|
||||
clusters: clusterResults.clusters.length,
|
||||
bridges: bridgeNotes.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Error processing user ${user.id}:`, error)
|
||||
results.errors++
|
||||
results.details.push({
|
||||
userId: user.id,
|
||||
clusters: 0,
|
||||
bridges: 0,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...results,
|
||||
message: `Processed ${results.processed} users, skipped ${results.skipped}, ${results.errors} errors`
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in cluster cron job:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Cron job failed', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/cron/clusters
|
||||
* Manual trigger for cluster recalculation (admin only).
|
||||
* Requires admin authentication.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
})
|
||||
|
||||
if (user?.role !== 'ADMIN') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { userId, forceAll } = body
|
||||
|
||||
if (userId) {
|
||||
// Process specific user
|
||||
const clusterResults = await clusteringService.clusterNotes(userId)
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(userId)
|
||||
|
||||
await clusteringService.saveClusteringResults(userId, clusterResults)
|
||||
await bridgeNotesService.saveBridgeNotes(userId, bridgeNotes)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
userId,
|
||||
clusters: clusterResults.clusters.length,
|
||||
bridges: bridgeNotes.length
|
||||
})
|
||||
}
|
||||
|
||||
if (forceAll) {
|
||||
// Process all users (use with caution)
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true },
|
||||
take: 100 // Limit to 100 users per request
|
||||
})
|
||||
|
||||
const results = {
|
||||
processed: 0,
|
||||
errors: 0
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const clusterResults = await clusteringService.clusterNotes(user.id)
|
||||
const bridgeNotes = await bridgeNotesService.detectBridgeNotes(user.id)
|
||||
|
||||
await clusteringService.saveClusteringResults(user.id, clusterResults)
|
||||
await bridgeNotesService.saveBridgeNotes(user.id, bridgeNotes)
|
||||
|
||||
results.processed++
|
||||
} catch {
|
||||
results.errors++
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...results
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Must provide userId or forceAll=true' },
|
||||
{ status: 400 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error in manual cluster trigger:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to trigger recalculation' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
394
memento-note/lib/ai/services/bridge-notes.service.ts
Normal file
394
memento-note/lib/ai/services/bridge-notes.service.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Bridge Notes Service
|
||||
*
|
||||
* Detects and manages "bridge notes" — notes that connect multiple clusters.
|
||||
* A bridge note has strong similarities (cosine > 0.5) with notes from
|
||||
* at least two different clusters.
|
||||
*
|
||||
* Also generates AI-powered suggestions for creating new bridge notes
|
||||
* to connect isolated clusters.
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { clusteringService } from './clustering.service'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected: number[]
|
||||
clusterNames?: string[]
|
||||
}
|
||||
|
||||
export interface BridgeSuggestion {
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}
|
||||
|
||||
export class BridgeNotesService {
|
||||
private readonly BRIDGE_SIMILARITY_THRESHOLD = 0.5
|
||||
private readonly MIN_CLUSTERS_FOR_BRIDGE = 2
|
||||
|
||||
/**
|
||||
* Get similar notes for a given note across all clusters.
|
||||
* Returns notes grouped by their cluster membership.
|
||||
*/
|
||||
private async getSimilarNotesByCluster(
|
||||
noteId: string,
|
||||
userId: string,
|
||||
threshold: number = this.BRIDGE_SIMILARITY_THRESHOLD
|
||||
): Promise<Map<number, string[]>> {
|
||||
const cosineDistance = 1 - threshold
|
||||
|
||||
const result = await prisma.$queryRawUnsafe<Array<{
|
||||
noteId: string
|
||||
clusterId: number | null
|
||||
}>>(
|
||||
`SELECT similar."noteId", cm."clusterId"
|
||||
FROM (
|
||||
SELECT e2."noteId"
|
||||
FROM "NoteEmbedding" e1
|
||||
CROSS JOIN "NoteEmbedding" e2
|
||||
INNER JOIN "Note" n ON n.id = e2."noteId"
|
||||
WHERE e1."noteId" = $1
|
||||
AND e2."noteId" != e1."noteId"
|
||||
AND n."userId" = $2
|
||||
AND n."trashedAt" IS NULL
|
||||
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3
|
||||
) similar
|
||||
LEFT JOIN "ClusterMember" cm ON cm."noteId" = similar."noteId" AND cm."userId" = $2`,
|
||||
noteId,
|
||||
userId,
|
||||
cosineDistance
|
||||
)
|
||||
|
||||
const clusterMap = new Map<number, string[]>()
|
||||
|
||||
for (const row of result) {
|
||||
const clusterId = row.clusterId ?? -1 // -1 for noise/uncategorized
|
||||
if (!clusterMap.has(clusterId)) {
|
||||
clusterMap.set(clusterId, [])
|
||||
}
|
||||
clusterMap.get(clusterId)!.push(row.noteId)
|
||||
}
|
||||
|
||||
return clusterMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all bridge notes for a user.
|
||||
* A note is a bridge if it has similarities to >= 2 distinct clusters.
|
||||
*/
|
||||
async detectBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||
// Get all user's clusters
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true },
|
||||
orderBy: { clusterId: 'asc' }
|
||||
})
|
||||
|
||||
if (clusters.length < this.MIN_CLUSTERS_FOR_BRIDGE) {
|
||||
return []
|
||||
}
|
||||
|
||||
const maxClusters = clusters.length
|
||||
const bridgeNotes: BridgeNote[] = []
|
||||
|
||||
// Check each note for bridge potential
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { userId, trashedAt: null },
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
for (const note of notes) {
|
||||
const similarByCluster = await this.getSimilarNotesByCluster(note.id, userId)
|
||||
|
||||
// Filter out noise (-1) and get clusters with actual similar notes
|
||||
const clustersWithSimilarNotes: number[] = []
|
||||
for (const [clusterId, similarNotes] of similarByCluster) {
|
||||
if (clusterId !== -1 && similarNotes.length > 0) {
|
||||
clustersWithSimilarNotes.push(clusterId)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this note connects >= 2 clusters
|
||||
if (clustersWithSimilarNotes.length >= this.MIN_CLUSTERS_FOR_BRIDGE) {
|
||||
const bridgeScore = clustersWithSimilarNotes.length / maxClusters
|
||||
|
||||
bridgeNotes.push({
|
||||
noteId: note.id,
|
||||
bridgeScore,
|
||||
clustersConnected: clustersWithSimilarNotes,
|
||||
clusterNames: clustersWithSimilarNotes.map(
|
||||
cid => clusters.find(c => c.clusterId === cid)?.name || `Cluster ${cid}`
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by bridge score (most influential first)
|
||||
return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save bridge notes to database.
|
||||
*/
|
||||
async saveBridgeNotes(userId: string, bridgeNotes: BridgeNote[]): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear existing bridge notes for this user
|
||||
await tx.bridgeNote.deleteMany({ where: { userId } })
|
||||
|
||||
// Insert new bridge notes
|
||||
for (const bridge of bridgeNotes) {
|
||||
await tx.bridgeNote.create({
|
||||
data: {
|
||||
userId,
|
||||
noteId: bridge.noteId,
|
||||
bridgeScore: bridge.bridgeScore,
|
||||
clustersConnected: JSON.stringify(bridge.clustersConnected),
|
||||
lastCalculated: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cluster summaries for AI suggestions.
|
||||
*/
|
||||
private async getClusterSummary(clusterId: number, userId: string): Promise<string> {
|
||||
const notes = await prisma.$queryRawUnsafe<Array<{ title: string | null; content: string }>>(
|
||||
`SELECT n.title, n.content
|
||||
FROM "ClusterMember" cm
|
||||
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||
WHERE cm."clusterId" = $1
|
||||
AND cm."userId" = $2
|
||||
LIMIT 5`,
|
||||
clusterId,
|
||||
userId
|
||||
)
|
||||
|
||||
if (notes.length === 0) return 'No notes available'
|
||||
|
||||
return notes
|
||||
.map(n => `- ${n.title || 'Untitled'}: ${n.content.slice(0, 80)}...`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI-powered suggestions for connecting isolated clusters.
|
||||
*/
|
||||
async generateBridgeSuggestions(userId: string): Promise<BridgeSuggestion[]> {
|
||||
// Get all clusters
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true },
|
||||
orderBy: { clusterId: 'asc' }
|
||||
})
|
||||
|
||||
if (clusters.length < 2) return []
|
||||
|
||||
// Get existing bridges to see which clusters are already connected
|
||||
const existingBridges = await prisma.bridgeNote.findMany({
|
||||
where: { userId },
|
||||
select: { clustersConnected: true }
|
||||
})
|
||||
|
||||
const connectedPairs = new Set<string>()
|
||||
|
||||
for (const bridge of existingBridges) {
|
||||
const clusters = JSON.parse(bridge.clustersConnected) as number[]
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
for (let j = i + 1; j < clusters.length; j++) {
|
||||
const pair = [clusters[i], clusters[j]].sort().join('-')
|
||||
connectedPairs.add(pair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find unconnected cluster pairs
|
||||
const suggestions: BridgeSuggestion[] = []
|
||||
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
for (let j = i + 1; j < clusters.length; j++) {
|
||||
const pair = `${clusters[i].clusterId}-${clusters[j].clusterId}`
|
||||
|
||||
if (connectedPairs.has(pair)) continue // Already connected
|
||||
|
||||
// Generate suggestion for this unconnected pair
|
||||
const suggestion = await this.generateConnectionSuggestion(
|
||||
clusters[i].clusterId,
|
||||
clusters[j].clusterId,
|
||||
clusters[i].name || `Cluster ${clusters[i].clusterId}`,
|
||||
clusters[j].name || `Cluster ${clusters[j].clusterId}`,
|
||||
userId
|
||||
)
|
||||
|
||||
if (suggestion) {
|
||||
suggestions.push(suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a specific connection suggestion between two clusters.
|
||||
*/
|
||||
private async generateConnectionSuggestion(
|
||||
clusterAId: number,
|
||||
clusterBId: number,
|
||||
clusterAName: string,
|
||||
clusterBName: string,
|
||||
userId: string
|
||||
): Promise<BridgeSuggestion | null> {
|
||||
const summaryA = await this.getClusterSummary(clusterAId, userId)
|
||||
const summaryB = await this.getClusterSummary(clusterBId, userId)
|
||||
|
||||
const systemPrompt = `You are a creative assistant that helps users connect ideas.
|
||||
Suggest a "bridge note" that could connect two unrelated topics.
|
||||
Be specific and creative. Your suggestions should help users discover new insights.`
|
||||
|
||||
const userPrompt = `I have two groups of notes that are not connected:
|
||||
|
||||
Group A (${clusterAName}):
|
||||
${summaryA}
|
||||
|
||||
Group B (${clusterBName}):
|
||||
${summaryB}
|
||||
|
||||
Suggest 3 creative bridge note ideas to connect these groups. For each idea, provide:
|
||||
1. A catchy title (max 10 words)
|
||||
2. A brief description of what the note would contain (max 50 words)
|
||||
3. A justification for why this connection is valuable (max 30 words)
|
||||
|
||||
Format as JSON:
|
||||
{
|
||||
"ideas": [
|
||||
{"title": "...", "description": "...", "justification": "..."},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Return ONLY the JSON, no other text.`
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const response = await provider.chat(
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
systemPrompt
|
||||
)
|
||||
|
||||
// Parse JSON response
|
||||
const jsonMatch = response.text.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0])
|
||||
const bestIdea = parsed.ideas?.[0]
|
||||
|
||||
if (!bestIdea) return null
|
||||
|
||||
return {
|
||||
clusterAId,
|
||||
clusterBId,
|
||||
clusterAName,
|
||||
clusterBName,
|
||||
suggestedTitle: bestIdea.title,
|
||||
suggestedContent: bestIdea.description,
|
||||
justification: bestIdea.justification
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating bridge suggestion:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save bridge suggestions to database.
|
||||
*/
|
||||
async saveBridgeSuggestions(userId: string, suggestions: BridgeSuggestion[]): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear existing suggestions
|
||||
await tx.bridgeSuggestion.deleteMany({ where: { userId } })
|
||||
|
||||
// Insert new suggestions
|
||||
for (const suggestion of suggestions) {
|
||||
await tx.bridgeSuggestion.create({
|
||||
data: {
|
||||
userId,
|
||||
clusterAId: suggestion.clusterAId,
|
||||
clusterBId: suggestion.clusterBId,
|
||||
clusterAName: suggestion.clusterAName,
|
||||
clusterBName: suggestion.clusterBName,
|
||||
suggestedTitle: suggestion.suggestedTitle,
|
||||
suggestedContent: suggestion.suggestedContent,
|
||||
justification: suggestion.justification
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bridge notes for a user.
|
||||
*/
|
||||
async getBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||
const bridges = await prisma.bridgeNote.findMany({
|
||||
where: { userId },
|
||||
orderBy: { bridgeScore: 'desc' }
|
||||
})
|
||||
|
||||
return bridges.map(b => ({
|
||||
noteId: b.noteId,
|
||||
bridgeScore: b.bridgeScore,
|
||||
clustersConnected: JSON.parse(b.clustersConnected) as number[]
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bridge suggestions for a user.
|
||||
*/
|
||||
async getBridgeSuggestions(userId: string, includeDismissed: boolean = false): Promise<BridgeSuggestion[]> {
|
||||
const suggestions = await prisma.bridgeSuggestion.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(includeDismissed ? {} : { isDismissed: false })
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
return suggestions.map(s => ({
|
||||
clusterAId: s.clusterAId,
|
||||
clusterBId: s.clusterBId,
|
||||
clusterAName: s.clusterAName,
|
||||
clusterBName: s.clusterBName,
|
||||
suggestedTitle: s.suggestedTitle,
|
||||
suggestedContent: s.suggestedContent,
|
||||
justification: s.justification
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a bridge suggestion.
|
||||
*/
|
||||
async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise<void> {
|
||||
await prisma.bridgeSuggestion.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
clusterAId,
|
||||
clusterBId
|
||||
},
|
||||
data: { isDismissed: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const bridgeNotesService = new BridgeNotesService()
|
||||
436
memento-note/lib/ai/services/clustering.service.ts
Normal file
436
memento-note/lib/ai/services/clustering.service.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* Clustering Service
|
||||
*
|
||||
* Density-based clustering algorithm (DBSCAN variant) for note embeddings.
|
||||
* Groups semantically similar notes into clusters without requiring
|
||||
* a preset number of clusters.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. For each note, find neighbors within epsilon cosine distance
|
||||
* 2. Form clusters from dense regions (min_cluster_size)
|
||||
* 3. Mark outliers as noise (cluster_id = -1)
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
import { embeddingService } from './embedding.service'
|
||||
import { getChatProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
export interface ClusterResult {
|
||||
clusterId: number
|
||||
noteIds: string[]
|
||||
centroid?: number[]
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface ClusteredNote {
|
||||
noteId: string
|
||||
clusterId: number
|
||||
membershipScore: number
|
||||
isCentral: boolean
|
||||
}
|
||||
|
||||
export interface ClusteringOptions {
|
||||
minClusterSize?: number
|
||||
epsilon?: number // Cosine distance threshold (lower = more strict)
|
||||
maxClusters?: number
|
||||
}
|
||||
|
||||
export class ClusteringService {
|
||||
private readonly DEFAULT_MIN_CLUSTER_SIZE = 3
|
||||
private readonly DEFAULT_EPSILON = 0.3 // Cosine distance ~ 1 - similarity
|
||||
private readonly DEFAULT_MAX_CLUSTERS = 50
|
||||
private readonly MIN_NOTES_FOR_CLUSTERING = 10
|
||||
|
||||
/**
|
||||
* Calculate cosine similarity between two embedding vectors.
|
||||
* Uses 1 - cosine_distance where cosine_distance is computed via pgvector.
|
||||
*/
|
||||
private async getCosineSimilarity(
|
||||
noteIdA: string,
|
||||
noteIdB: string
|
||||
): Promise<number> {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ similarity: number }>>(
|
||||
`SELECT 1 - (e1."embedding"::vector <=> e2."embedding"::vector) AS similarity
|
||||
FROM "NoteEmbedding" e1, "NoteEmbedding" e2
|
||||
WHERE e1."noteId" = $1 AND e2."noteId" = $2`,
|
||||
noteIdA,
|
||||
noteIdB
|
||||
)
|
||||
return result[0]?.similarity || 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all neighbors for a note within epsilon similarity threshold.
|
||||
*/
|
||||
private async findNeighbors(
|
||||
noteId: string,
|
||||
allNoteIds: string[],
|
||||
epsilon: number
|
||||
): Promise<string[]> {
|
||||
// Convert epsilon (similarity threshold) to cosine distance
|
||||
const cosineDistance = 1 - epsilon
|
||||
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
||||
`SELECT e2."noteId"
|
||||
FROM "NoteEmbedding" e1
|
||||
CROSS JOIN "NoteEmbedding" e2
|
||||
WHERE e1."noteId" = $1
|
||||
AND e2."noteId" != $1
|
||||
AND e2."noteId" = ANY($2::text[])
|
||||
AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3`,
|
||||
noteId,
|
||||
allNoteIds,
|
||||
cosineDistance
|
||||
)
|
||||
|
||||
return result.map(r => r.noteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a cluster from a seed note using DBSCAN-like algorithm.
|
||||
*/
|
||||
private async expandCluster(
|
||||
noteId: string,
|
||||
neighbors: string[],
|
||||
clusterId: number,
|
||||
visited: Set<string>,
|
||||
clustered: Map<string, number>,
|
||||
allNoteIds: string[],
|
||||
epsilon: number,
|
||||
minClusterSize: number
|
||||
): Promise<string[]> {
|
||||
const clusterMembers: string[] = [noteId]
|
||||
const queue = [...neighbors]
|
||||
clustered.set(noteId, clusterId)
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentNoteId = queue.shift()!
|
||||
|
||||
if (!visited.has(currentNoteId)) {
|
||||
visited.add(currentNoteId)
|
||||
const currentNeighbors = await this.findNeighbors(currentNoteId, allNoteIds, epsilon)
|
||||
|
||||
if (currentNeighbors.length >= minClusterSize) {
|
||||
for (const neighborId of currentNeighbors) {
|
||||
if (!clustered.has(neighborId)) {
|
||||
clustered.set(neighborId, clusterId)
|
||||
clusterMembers.push(neighborId)
|
||||
queue.push(neighborId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clusterMembers
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform density-based clustering on user's note embeddings.
|
||||
*/
|
||||
async clusterNotes(
|
||||
userId: string,
|
||||
options: ClusteringOptions = {}
|
||||
): Promise<{
|
||||
clusters: ClusterResult[]
|
||||
clusteredNotes: ClusteredNote[]
|
||||
noiseCount: number
|
||||
}> {
|
||||
const {
|
||||
minClusterSize = this.DEFAULT_MIN_CLUSTER_SIZE,
|
||||
epsilon = this.DEFAULT_EPSILON,
|
||||
maxClusters = this.DEFAULT_MAX_CLUSTERS
|
||||
} = options
|
||||
|
||||
// Get all user's notes with embeddings
|
||||
const notesWithEmbeddings = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
|
||||
`SELECT ne."noteId"
|
||||
FROM "NoteEmbedding" ne
|
||||
INNER JOIN "Note" n ON n.id = ne."noteId"
|
||||
WHERE n."userId" = $1
|
||||
AND n."trashedAt" IS NULL
|
||||
AND ne."embedding" IS NOT NULL`,
|
||||
userId
|
||||
)
|
||||
|
||||
const allNoteIds = notesWithEmbeddings.map(n => n.noteId)
|
||||
|
||||
if (allNoteIds.length < this.MIN_NOTES_FOR_CLUSTERING) {
|
||||
return {
|
||||
clusters: [],
|
||||
clusteredNotes: [],
|
||||
noiseCount: allNoteIds.length
|
||||
}
|
||||
}
|
||||
|
||||
const visited = new Set<string>()
|
||||
const clustered = new Map<string, number>() // noteId -> clusterId
|
||||
const clusterResults: ClusterResult[] = []
|
||||
let clusterId = 0
|
||||
|
||||
// DBSCAN algorithm
|
||||
for (const noteId of allNoteIds) {
|
||||
if (visited.has(noteId)) continue
|
||||
|
||||
visited.add(noteId)
|
||||
const neighbors = await this.findNeighbors(noteId, allNoteIds, epsilon)
|
||||
|
||||
if (neighbors.length < minClusterSize) {
|
||||
// Mark as noise (cluster_id = -1)
|
||||
clustered.set(noteId, -1)
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand cluster
|
||||
const clusterMembers = await this.expandCluster(
|
||||
noteId,
|
||||
neighbors,
|
||||
clusterId,
|
||||
visited,
|
||||
clustered,
|
||||
allNoteIds,
|
||||
epsilon,
|
||||
minClusterSize
|
||||
)
|
||||
|
||||
if (clusterMembers.length >= minClusterSize && clusterId < maxClusters) {
|
||||
clusterResults.push({
|
||||
clusterId,
|
||||
noteIds: clusterMembers
|
||||
})
|
||||
clusterId++
|
||||
} else {
|
||||
// Too small, mark as noise
|
||||
for (const memberId of clusterMembers) {
|
||||
clustered.set(memberId, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate membership scores and identify central notes
|
||||
const clusteredNotes: ClusteredNote[] = []
|
||||
for (const [noteId, cid] of clustered.entries()) {
|
||||
if (cid === -1) continue // Skip noise
|
||||
|
||||
const cluster = clusterResults[cid]
|
||||
if (!cluster) continue
|
||||
|
||||
// Calculate membership score as average similarity to other cluster members
|
||||
const score = await this.calculateMembershipScore(noteId, cluster.noteIds)
|
||||
const isCentral = await this.isCentralNote(noteId, cluster.noteIds)
|
||||
|
||||
clusteredNotes.push({
|
||||
noteId,
|
||||
clusterId: cid,
|
||||
membershipScore: score,
|
||||
isCentral
|
||||
})
|
||||
}
|
||||
|
||||
const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length
|
||||
|
||||
return {
|
||||
clusters: clusterResults,
|
||||
clusteredNotes,
|
||||
noiseCount
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate membership score for a note within its cluster.
|
||||
* Score = average similarity to all other cluster members.
|
||||
*/
|
||||
private async calculateMembershipScore(noteId: string, clusterMemberIds: string[]): Promise<number> {
|
||||
if (clusterMemberIds.length <= 1) return 1.0
|
||||
|
||||
const similarities: number[] = []
|
||||
for (const memberId of clusterMemberIds) {
|
||||
if (memberId === noteId) continue
|
||||
const sim = await this.getCosineSimilarity(noteId, memberId)
|
||||
similarities.push(sim)
|
||||
}
|
||||
|
||||
return similarities.length > 0
|
||||
? similarities.reduce((a, b) => a + b, 0) / similarities.length
|
||||
: 1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a note is central to its cluster.
|
||||
* A note is central if its average similarity to other members
|
||||
* is above the cluster mean.
|
||||
*/
|
||||
private async isCentralNote(noteId: string, clusterMemberIds: string[]): Promise<boolean> {
|
||||
const allScores: Array<{ memberId: string; score: number }> = []
|
||||
|
||||
for (const memberId of clusterMemberIds) {
|
||||
const score = await this.calculateMembershipScore(memberId, clusterMemberIds)
|
||||
allScores.push({ memberId, score })
|
||||
}
|
||||
|
||||
const meanScore = allScores.reduce((sum, s) => sum + s.score, 0) / allScores.length
|
||||
const noteScore = allScores.find(s => s.memberId === noteId)?.score || 0
|
||||
|
||||
return noteScore >= meanScore
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the N most central notes from a cluster for naming purposes.
|
||||
*/
|
||||
async getCentralNotes(clusterId: number, userId: string, n: number = 5): Promise<Array<{ noteId: string; title: string | null; content: string }>> {
|
||||
const result = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
|
||||
`SELECT DISTINCT n.id AS "noteId", n.title, n.content
|
||||
FROM "ClusterMember" cm
|
||||
INNER JOIN "Note" n ON n.id = cm."noteId"
|
||||
WHERE cm."clusterId" = $1
|
||||
AND cm."userId" = $2
|
||||
AND cm."isCentral" = true
|
||||
LIMIT $3`,
|
||||
clusterId,
|
||||
userId,
|
||||
n
|
||||
)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Save clustering results to database.
|
||||
*/
|
||||
async saveClusteringResults(
|
||||
userId: string,
|
||||
results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] }
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear existing clusters for this user
|
||||
await tx.$executeRawUnsafe(`DELETE FROM "ClusterMember" WHERE "userId" = $1`, userId)
|
||||
await tx.$executeRawUnsafe(`DELETE FROM "NoteCluster" WHERE "userId" = $1`, userId)
|
||||
|
||||
// Insert new clusters
|
||||
for (const cluster of results.clusters) {
|
||||
await tx.noteCluster.create({
|
||||
data: {
|
||||
userId,
|
||||
clusterId: cluster.clusterId,
|
||||
name: cluster.name,
|
||||
noteCount: cluster.noteIds.length,
|
||||
lastCalculated: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Insert cluster members
|
||||
for (const clusteredNote of results.clusteredNotes) {
|
||||
await tx.clusterMember.create({
|
||||
data: {
|
||||
userId,
|
||||
noteId: clusteredNote.noteId,
|
||||
clusterId: clusteredNote.clusterId,
|
||||
membershipScore: clusteredNote.membershipScore,
|
||||
isCentral: clusteredNote.isCentral
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a name for a cluster using the LLM.
|
||||
* Analyzes the 5 most central notes to extract a common theme.
|
||||
*/
|
||||
async generateClusterName(clusterId: number, userId: string): Promise<string> {
|
||||
const centralNotes = await this.getCentralNotes(clusterId, userId, 5)
|
||||
|
||||
if (centralNotes.length === 0) {
|
||||
return `Cluster ${clusterId}`
|
||||
}
|
||||
|
||||
const notesText = centralNotes
|
||||
.map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`)
|
||||
.join('\n')
|
||||
|
||||
const systemPrompt = 'You are a clustering assistant. Provide ONLY a concise name (2-4 words) in English. No punctuation, no explanation.'
|
||||
|
||||
const userPrompt = `Analyze these 5 notes that belong to the same cluster. What is the common theme?\n\n${notesText}\n\nTheme:`
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const response = await provider.chat(
|
||||
[{ role: 'user', content: userPrompt }],
|
||||
systemPrompt
|
||||
)
|
||||
return response.text.trim().slice(0, 50)
|
||||
} catch {
|
||||
return `Cluster ${clusterId}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if recalculation is needed based on data change percentage.
|
||||
*/
|
||||
async shouldRecalculate(userId: string): Promise<boolean> {
|
||||
const lastCluster = await prisma.noteCluster.findFirst({
|
||||
where: { userId },
|
||||
orderBy: { lastCalculated: 'desc' }
|
||||
})
|
||||
|
||||
if (!lastCluster) return true
|
||||
|
||||
// Count notes modified since last calculation
|
||||
const modifiedCount = await prisma.note.count({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ updatedAt: { gt: lastCluster.lastCalculated } },
|
||||
{ contentUpdatedAt: { gt: lastCluster.lastCalculated } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const totalNotes = await prisma.note.count({
|
||||
where: { userId, trashedAt: null }
|
||||
})
|
||||
|
||||
if (totalNotes === 0) return false
|
||||
|
||||
const changePercentage = modifiedCount / totalNotes
|
||||
return changePercentage > 0.05 // More than 5% changed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached clustering results if available and fresh.
|
||||
*/
|
||||
async getCachedClusters(userId: string): Promise<ClusterResult[] | null> {
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
orderBy: { clusterId: 'asc' }
|
||||
})
|
||||
|
||||
if (clusters.length === 0) return null
|
||||
|
||||
// Check if data is still fresh
|
||||
const needsUpdate = await this.shouldRecalculate(userId)
|
||||
if (needsUpdate) return null
|
||||
|
||||
// Get cluster members
|
||||
const result: ClusterResult[] = []
|
||||
for (const cluster of clusters) {
|
||||
const members = await prisma.clusterMember.findMany({
|
||||
where: { clusterId: cluster.clusterId, userId },
|
||||
select: { noteId: true }
|
||||
})
|
||||
|
||||
result.push({
|
||||
clusterId: cluster.clusterId,
|
||||
noteIds: members.map(m => m.noteId),
|
||||
name: cluster.name || undefined
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const clusteringService = new ClusteringService()
|
||||
573
memento-note/package-lock.json
generated
573
memento-note/package-lock.json
generated
@@ -78,6 +78,7 @@
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"jsdom": "^29.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.562.0",
|
||||
"marked": "^18.0.3",
|
||||
@@ -95,6 +96,7 @@
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -120,6 +122,7 @@
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
@@ -3762,11 +3765,350 @@
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
"integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3",
|
||||
"is-glob": "^4.0.3",
|
||||
"node-addon-api": "^7.0.0",
|
||||
"picomatch": "^4.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher-android-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.6",
|
||||
"@parcel/watcher-darwin-x64": "2.5.6",
|
||||
"@parcel/watcher-freebsd-x64": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-arm64-musl": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-glibc": "2.5.6",
|
||||
"@parcel/watcher-linux-x64-musl": "2.5.6",
|
||||
"@parcel/watcher-win32-arm64": "2.5.6",
|
||||
"@parcel/watcher-win32-ia32": "2.5.6",
|
||||
"@parcel/watcher-win32-x64": "2.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-android-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-darwin-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-freebsd-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-arm64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-glibc": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz",
|
||||
"integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-linux-x64-musl": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz",
|
||||
"integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-arm64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz",
|
||||
"integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-ia32": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz",
|
||||
"integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher-win32-x64": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz",
|
||||
"integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
@@ -3800,14 +4142,14 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz",
|
||||
"integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz",
|
||||
"integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -3821,14 +4163,14 @@
|
||||
"version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz",
|
||||
"integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz",
|
||||
"integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0",
|
||||
@@ -3840,7 +4182,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz",
|
||||
"integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "5.22.0"
|
||||
@@ -6709,6 +7051,108 @@
|
||||
"integrity": "sha512-E0KekCFXut0IB8+msh1g4oXz4AXwyfDz9Sqz+tfabnKbI1AkyoFs1tPJB8ViN0Ckq7Da6jYGU8TVNVkirsXBjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
|
||||
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/d3-drag": "^3.0.1",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
@@ -8438,6 +8882,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jszip": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz",
|
||||
"integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jszip": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/katex": {
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||
@@ -8498,7 +8952,6 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -8508,7 +8961,6 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -10141,6 +10593,12 @@
|
||||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -15878,6 +16336,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/node-exports-info": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
|
||||
@@ -16386,7 +16853,7 @@
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
@@ -16405,7 +16872,7 @@
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -16585,7 +17052,7 @@
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -17169,6 +17636,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.14",
|
||||
"@reactflow/controls": "11.2.14",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@reactflow/minimap": "11.7.14",
|
||||
"@reactflow/node-resizer": "2.2.14",
|
||||
"@reactflow/node-toolbar": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -19435,6 +19920,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/chokidar": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -19450,6 +19953,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vitest/node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
@@ -19463,6 +19975,45 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
"integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/sass": {
|
||||
"version": "1.100.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.100.0.tgz",
|
||||
"integrity": "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"immutable": "^5.1.5",
|
||||
"source-map-js": ">=0.6.2 <2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"sass": "sass.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@parcel/watcher": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/vite": {
|
||||
"version": "8.0.9",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.9.tgz",
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"isomorphic-dompurify": "^3.12.0",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"jsdom": "^29.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"katex": "^0.16.27",
|
||||
"lucide-react": "^0.562.0",
|
||||
"marked": "^18.0.3",
|
||||
@@ -116,6 +117,7 @@
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
@@ -141,6 +143,7 @@
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"@types/jszip": "^3.4.0",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^7.0.4",
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
|
||||
@@ -7,7 +7,7 @@ generator client {
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
extensions = [pgvector]
|
||||
extensions = [vector]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -50,6 +50,10 @@ model User {
|
||||
subscription Subscription?
|
||||
usageLogs UsageLog[]
|
||||
apiKeys UserAPIKey[]
|
||||
aiConsentLogs AiConsentLog[]
|
||||
noteClusters NoteCluster[]
|
||||
bridgeNotes BridgeNote[]
|
||||
bridgeSuggestions BridgeSuggestion[]
|
||||
}
|
||||
|
||||
model Account {
|
||||
@@ -185,6 +189,8 @@ model Note {
|
||||
brainstormNoteRefs BrainstormNoteRef[]
|
||||
outgoingLinks NoteLink[] @relation("SourceLinks")
|
||||
incomingLinks NoteLink[] @relation("TargetLinks")
|
||||
clusterMemberships ClusterMember[]
|
||||
bridgeNote BridgeNote?
|
||||
|
||||
@@index([isPinned])
|
||||
@@index([isArchived])
|
||||
@@ -309,6 +315,7 @@ model UserAISettings {
|
||||
noteHistory Boolean @default(false)
|
||||
noteHistoryMode String @default("manual")
|
||||
autoSave Boolean @default(true)
|
||||
aiProcessingConsent Boolean @default(false)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([memoryEcho])
|
||||
@@ -317,6 +324,16 @@ model UserAISettings {
|
||||
@@index([preferredLanguage])
|
||||
}
|
||||
|
||||
model AiConsentLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
consent Boolean @default(true)
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model NoteEmbedding {
|
||||
id String @id @default(cuid())
|
||||
noteId String @unique
|
||||
@@ -747,3 +764,76 @@ model FeatureFlag {
|
||||
metadata String?
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// ===== CLUSTERING & BRIDGE NOTES =====
|
||||
|
||||
model NoteCluster {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
clusterId Int // Cluster number for this user (0, 1, 2, ...)
|
||||
name String? // Auto-generated cluster name
|
||||
centroid Unsupported("vector(1536)")? // Optional: centroid embedding for cluster
|
||||
noteCount Int @default(0)
|
||||
lastCalculated DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, clusterId])
|
||||
@@unique([userId, clusterId])
|
||||
}
|
||||
|
||||
model ClusterMember {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
noteId String
|
||||
clusterId Int // Cluster number
|
||||
membershipScore Float @default(0.0) // How strongly this note belongs to the cluster
|
||||
isCentral Boolean @default(false) // Is this a central note in the cluster?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, clusterId])
|
||||
@@index([noteId])
|
||||
@@unique([noteId, clusterId])
|
||||
}
|
||||
|
||||
model BridgeNote {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
noteId String @unique
|
||||
bridgeScore Float // len(clusters_touched) / max_clusters
|
||||
clustersConnected String // JSON array: ["0", "2", "5"]
|
||||
lastCalculated DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([bridgeScore])
|
||||
}
|
||||
|
||||
model BridgeSuggestion {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
clusterAId Int
|
||||
clusterBId Int
|
||||
clusterAName String
|
||||
clusterBName String
|
||||
suggestedTitle String
|
||||
suggestedContent String // @default(dbgenerated)
|
||||
justification String // Why this connection makes sense
|
||||
isDismissed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, isDismissed])
|
||||
@@index([clusterAId, clusterBId])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user