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:
Antigravity
2026-05-23 20:26:25 +00:00
parent 2aed148dc2
commit 077e665dfc
13 changed files with 2882 additions and 12 deletions

View File

@@ -0,0 +1,192 @@
---
title: 'Cluster Detection & Bridge Notes Discovery'
type: 'feature'
created: '2026-05-23'
status: 'done'
baseline_commit: '2aed148dc2a6de1914facae9fef65935b3d77ef5'
context: ['memento-note/prisma/schema.prisma', 'memento-note/lib/ai/services/semantic-search.service.ts']
---
<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">
## Intent
**Problem:** Users have hundreds of notes but no automatic way to discover thematic clusters or connections between unrelated topics. Notes remain siloed in notebooks, and users manually create links between related concepts across different notebooks.
**Approach:** Implement automatic clustering using density-based algorithm (HDBSCAN equivalent) on note embeddings, detect "bridge notes" that connect multiple clusters, and provide AI-powered suggestions for missing connections. Visualize clusters graphically with a dashboard showing insights and connection opportunities.
## Boundaries & Constraints
**Always:**
- Use existing pgvector embeddings from NoteEmbedding table
- Cluster per-user only (never cross-user data)
- All SQL uses parameterized queries (no interpolation)
- HDBSCAN: Node.js compatible implementation or custom density-based clustering
- Bridge score = len(clusters_touched) / max_clusters (0-1 range)
- Threshold for cluster membership: >= 0.3 similarity
- Threshold for bridge detection: >= 0.5 cosine similarity to >= 2 clusters
- Incremental recalculation only when > 5% embeddings changed or > 10 notes modified
**Ask First:**
- External Python microservice vs Node.js native clustering
- Cluster auto-naming: exact LLM prompt wording
- Dashboard UI placement: separate page vs modal vs sidebar panel
- Visualization library: D3.js, Cytoscape.js, or React Flow
**Never:**
- HDBSCAN pip install (Python) — we are a Node.js stack
- Real-time clustering on every note save
- Cross-user cluster analysis
- Modifying existing NoteLink or MemoryEchoInsight behavior
- Manual cluster assignment by users (out of scope for v1)
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| CLUSTER_NEW_USER | User with < 10 notes, no existing clusters | Empty cluster list, message "Need more notes to cluster" | Return gracefully with status: insufficient_data |
| CLUSTER_EXISTING | User with 100+ notes, embeddings exist | N clusters (3-50), each with auto-generated name | Log errors, continue with partial results |
| BRIDGE_DETECTION | Note similar to cluster A (0.7) and cluster B (0.6) | Marked as bridge, bridge_score = 0.5, clusters_connected = [A, B] | Skip if similarity < 0.5 |
| NO_BRIDGES | User with isolated clusters (no cross-cluster similarities) | Empty bridge list, suggestions for missing connections | Return empty array |
| SUGGEST_CONNECTIONS | Cluster A (Recipes) and Cluster B (Health) unconnected | 3 AI-suggested bridge note ideas with titles/descriptions | Fallback to generic "Connect [A] and [B]" if LLM fails |
| INCREMENTAL_UPDATE | 3 notes modified out of 200 | Skip recalculation (< 5% threshold) | Return cached results |
| MAJOR_UPDATE | 15 notes modified out of 200 | Trigger full recalculation | Queue background job, return immediately |
| EMBEDDING_MISSING | Note exists but no embedding in NoteEmbedding | Exclude from clustering | Log warning, continue with available notes |
</frozen-after-approval>
## Code Map
- `memento-note/prisma/schema.prisma` -- Add NoteCluster and BridgeNote models
- `memento-note/lib/ai/services/clustering.service.ts` -- NEW: HDBSCAN-style clustering algorithm
- `memento-note/lib/ai/services/bridge-notes.service.ts` -- NEW: Bridge detection and suggestions
- `memento-note/app/api/clusters/route.ts` -- NEW: GET /api/clusters, POST /api/clusters/recalculate
- `memento-note/app/api/bridge-notes/route.ts` -- NEW: GET /api/bridge-notes, GET /api/bridge-notes/suggestions
- `memento-note/app/api/cron/clusters/route.ts` -- NEW: Cron endpoint for daily recalculation
- `memento-note/components/cluster-visualization.tsx` -- NEW: Graph visualization with cluster coloring
- `memento-note/components/bridge-notes-dashboard.tsx` -- NEW: Dashboard showing insights and suggestions
- `memento-note/app/(main)/insights/page.tsx` -- NEW: Insights page housing the dashboard
## Tasks & Acceptance
**Execution:**
- [x] `memento-note/prisma/schema.prisma` -- Add NoteCluster and BridgeNote models with proper indexes -- Store cluster mappings and bridge metadata
- [x] `memento-note/lib/ai/services/clustering.service.ts` -- Implement density-based clustering (HDBSCAN-style in Node.js) -- Core algorithm for automatic note grouping
- [x] `memento-note/lib/ai/services/bridge-notes.service.ts` -- Implement bridge detection and AI suggestion generation -- Find cross-cluster connections
- [x] `memento-note/app/api/clusters/route.ts` -- Create cluster listing and recalculation endpoints -- REST API for cluster operations
- [x] `memento-note/app/api/bridge-notes/route.ts` -- Create bridge notes and suggestions endpoints -- REST API for bridge operations
- [x] `memento-note/app/api/cron/clusters/route.ts` -- Create cron endpoint for scheduled recalculation -- Automated background processing
- [x] `memento-note/components/cluster-visualization.tsx` -- Build interactive graph with cluster coloring -- Visual representation of note clusters
- [x] `memento-note/components/bridge-notes-dashboard.tsx` -- Build insights dashboard with stats and suggestions -- User-facing cluster and bridge information
- [x] `memento-note/app/(main)/insights/page.tsx` -- Create main insights page -- Container for visualization and dashboard
- [x] `memento-note/lib/ai/services/clustering.service.ts` -- Add cluster auto-naming via LLM -- Human-readable cluster names
- [x] `memento-note/lib/ai/services/bridge-notes.service.ts` -- Add incremental recalculation logic -- Efficient updates without full recompute
**Acceptance Criteria:**
- Given a user with 50+ notes with embeddings, when the clustering API is called, then notes are grouped into 3-20 clusters based on semantic similarity
- Given a cluster has been created, when the cluster is viewed, then it has an auto-generated name (2-4 words) describing the common theme
- Given a note is similar to >= 2 clusters with cosine >= 0.5, when bridge detection runs, then the note is marked as a bridge note with correct bridge_score
- Given isolated clusters exist (no bridges), when suggestions are requested, then 3 AI-generated bridge note ideas are returned per cluster pair
- Given the insights page is loaded, when displayed, then clusters are color-coded and bridge notes have golden borders
- Given < 10 notes exist for a user, when clustering is requested, then API returns insufficient_data status gracefully
- Given > 5% of notes have been modified, when the cron runs, then full recalculation is triggered
- Given <= 5% of notes have been modified, when the cron runs, then cached results are returned
## Design Notes
### Clustering Algorithm Choice
Since HDBSCAN is Python-only and we're a Node.js stack, we implement a simplified density-based clustering:
1. **Pairwise similarity matrix**: Use pgvector cosine similarity for all note pairs
2. **DBSCAN variant**:
- For each note, find neighbors within epsilon = 0.3 cosine distance
- Form clusters from dense regions (min_cluster_size = 3)
- Mark outliers as noise (cluster_id = -1)
This approximates HDBSCAN's core benefits: no preset cluster count, outlier detection, handles varying cluster sizes.
### Bridge Score Formula
```
bridge_score = len(clusters_touched) / max_possible_clusters
```
- A note touching 2 of 5 clusters = 0.4
- A note touching 5 of 5 clusters = 1.0 (super-connector)
### Cluster Naming
Use the 5 most central notes (highest mean similarity to other cluster members) as context for LLM:
```
"Here are 5 notes from a cluster. Summarize the common theme in 2-4 words:
1. [title] - [content snippet]
2. ..."
```
### UI Visualization
- **Clusters**: Each cluster gets a unique hue (HSL color wheel)
- **Bridge notes**: Golden border + 1.5x size
- **Isolated clusters**: Grayed out with "Isolated" badge
- **Click interaction**: Click cluster to zoom and show note list
### Cron Strategy
- **Daily**: At 2 AM server time, recalculate for all active users
- **Incremental check**: Track last_modified timestamp on embeddings; if delta < 5%, skip
## Verification
**Commands:**
- `npx prisma migrate dev --name add_clustering_support` -- Creates NoteCluster and BridgeNote tables
- `npx prisma generate` -- Regenerates Prisma client with new models
- `npm run build` -- Ensures TypeScript compilation succeeds
**Manual checks:**
- Visit /insights page — should show cluster visualization or "Need more notes" message
- Create 30+ test notes with varied topics — check that clusters form and have sensible names
- Modify a note and check it — clusters should update within 24h or via manual recalculate
- Check that bridge notes are visually distinct (golden border) in visualization
- POST /api/clusters/recalculate — should return 202 and trigger background job
- GET /api/bridge-notes/suggestions — should return AI-generated connection ideas
## Suggested Review Order
**Database schema**
- New models for clustering and bridge tracking with proper indexes
[`memento-note/prisma/schema.prisma:767`](../../memento-note/prisma/schema.prisma#L767)
**Core clustering algorithm**
- DBSCAN-style density-based clustering using pgvector cosine similarity
[`memento-note/lib/ai/services/clustering.service.ts:82`](../../memento-note/lib/ai/services/clustering.service.ts#L82)
- Cluster membership scoring and centrality detection
[`memento-note/lib/ai/services/clustering.service.ts:239`](../../memento-note/lib/ai/services/clustering.service.ts#L239)
**Bridge notes detection**
- Cross-cluster similarity analysis for bridge note identification
[`memento-note/lib/ai/services/bridge-notes.service.ts:52`](../../memento-note/lib/ai/services/bridge-notes.service.ts#L52)
- AI-powered suggestions for connecting isolated clusters
[`memento-note/lib/ai/services/bridge-notes.service.ts:173`](../../memento-note/lib/ai/services/bridge-notes.service.ts#L173)
**API endpoints**
- Cluster listing and recalculation with caching
[`memento-note/app/api/clusters/route.ts:14`](../../memento-note/app/api/clusters/route.ts#L14)
- Bridge notes and suggestions endpoints
[`memento-note/app/api/bridge-notes/route.ts:14`](../../memento-note/app/api/bridge-notes/route.ts#L14)
- Scheduled cron job for automatic recalculation
[`memento-note/app/api/cron/clusters/route.ts:27`](../../memento-note/app/api/cron/clusters/route.ts#L27)
**User interface**
- Interactive cluster visualization with React Flow
[`memento-note/components/cluster-visualization.tsx:40`](../../memento-note/components/cluster-visualization.tsx#L40)
- Bridge notes dashboard with tabbed interface
[`memento-note/components/bridge-notes-dashboard.tsx:38`](../../memento-note/components/bridge-notes-dashboard.tsx#L38)
- Main insights page tying it all together
[`memento-note/app/(main)/insights/page.tsx:17`](../../memento-note/app/(main)/insights/page.tsx#L17)

View 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>
)
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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 }
)
}
}

View 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>
)
}

View 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>
)
}

View 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()

View 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()

View File

@@ -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",

View File

@@ -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",

View File

@@ -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])
}