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

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