feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Bridge Notes Service
|
||||
*
|
||||
* Detects notes that connect multiple clusters (bridge notes)
|
||||
* and generates AI-powered suggestions for missing connections.
|
||||
*/
|
||||
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export interface BridgeNote {
|
||||
noteId: string
|
||||
bridgeScore: number
|
||||
clustersConnected: number[]
|
||||
clusterNames?: string[]
|
||||
}
|
||||
|
||||
export interface ConnectionSuggestion {
|
||||
clusterAId: number
|
||||
clusterBId: number
|
||||
clusterAName: string
|
||||
clusterBName: string
|
||||
suggestedTitle: string
|
||||
suggestedContent: string
|
||||
justification: string
|
||||
}
|
||||
|
||||
export class BridgeNotesService {
|
||||
private readonly BRIDGE_THRESHOLD = 0.5 // Cosine similarity threshold
|
||||
|
||||
/**
|
||||
* Detect bridge notes for a user.
|
||||
* A bridge note is a note that has strong connections (>= 0.5 similarity)
|
||||
* to at least 2 different clusters.
|
||||
*/
|
||||
async detectBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||
// Get all clusters for the user
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true }
|
||||
})
|
||||
|
||||
if (clusters.length < 2) return []
|
||||
|
||||
// Get cluster memberships
|
||||
const clusterMembers = await prisma.clusterMember.findMany({
|
||||
where: { userId },
|
||||
select: { noteId: true, clusterId: true }
|
||||
})
|
||||
|
||||
// Group notes by cluster
|
||||
const notesByCluster = new Map<number, string[]>()
|
||||
for (const cluster of clusters) {
|
||||
notesByCluster.set(
|
||||
cluster.clusterId,
|
||||
clusterMembers
|
||||
.filter(cm => cm.clusterId === cluster.clusterId)
|
||||
.map(cm => cm.noteId)
|
||||
)
|
||||
}
|
||||
|
||||
const bridgeNotes: BridgeNote[] = []
|
||||
const processedNotes = new Set<string>()
|
||||
|
||||
// For each note, check if it connects to multiple clusters
|
||||
for (const [clusterId, noteIds] of notesByCluster) {
|
||||
for (const noteId of noteIds) {
|
||||
if (processedNotes.has(noteId)) continue
|
||||
processedNotes.add(noteId)
|
||||
|
||||
// Check which other clusters this note is similar to
|
||||
const connectedClusters: number[] = []
|
||||
|
||||
for (const [otherClusterId, otherNoteIds] of notesByCluster) {
|
||||
if (otherClusterId === clusterId) continue
|
||||
|
||||
// Check similarity to notes in other cluster
|
||||
const hasStrongConnection = await this.hasStrongLinkToCluster(
|
||||
noteId,
|
||||
otherNoteIds
|
||||
)
|
||||
|
||||
if (hasStrongConnection) {
|
||||
connectedClusters.push(otherClusterId)
|
||||
}
|
||||
}
|
||||
|
||||
// If connected to >= 2 clusters, it's a bridge note
|
||||
if (connectedClusters.length >= 1) {
|
||||
// Include the original cluster
|
||||
connectedClusters.unshift(clusterId)
|
||||
|
||||
bridgeNotes.push({
|
||||
noteId,
|
||||
bridgeScore: connectedClusters.length / Math.max(clusters.length, 1),
|
||||
clustersConnected: connectedClusters,
|
||||
clusterNames: connectedClusters
|
||||
.map(id => clusters.find(c => c.clusterId === id)?.name)
|
||||
.filter(Boolean) as string[]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a note has strong links (similarity >= threshold) to any note in a cluster.
|
||||
*/
|
||||
private async hasStrongLinkToCluster(
|
||||
noteId: string,
|
||||
clusterNoteIds: string[]
|
||||
): Promise<boolean> {
|
||||
if (clusterNoteIds.length === 0) return false
|
||||
|
||||
for (const otherNoteId of clusterNoteIds) {
|
||||
const similarity = await this.getCosineSimilarity(noteId, otherNoteId)
|
||||
if (similarity >= this.BRIDGE_THRESHOLD) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cosine similarity between two notes using 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved bridge notes for a user.
|
||||
*/
|
||||
async getBridgeNotes(userId: string): Promise<BridgeNote[]> {
|
||||
const bridges = await prisma.bridgeNote.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
clusters: {
|
||||
include: {
|
||||
cluster: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return bridges.map(b => ({
|
||||
noteId: b.noteId,
|
||||
bridgeScore: b.bridgeScore,
|
||||
clustersConnected: b.clusters.map(c => c.clusterId),
|
||||
clusterNames: b.clusters.map(c => c.cluster.name)
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Save bridge notes to the database.
|
||||
*/
|
||||
async saveBridgeNotes(userId: string, bridgeNotes: BridgeNote[]): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear existing bridge notes for this user
|
||||
await tx.$executeRawUnsafe(`DELETE FROM "BridgeNoteCluster" WHERE "userId" = $1`, userId)
|
||||
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,
|
||||
clusters: {
|
||||
create: bridge.clustersConnected.map(clusterId => ({
|
||||
userId,
|
||||
clusterId
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI-powered suggestions for connecting isolated clusters.
|
||||
*/
|
||||
async generateConnectionSuggestions(
|
||||
userId: string
|
||||
): Promise<ConnectionSuggestion[]> {
|
||||
const clusters = await prisma.noteCluster.findMany({
|
||||
where: { userId },
|
||||
select: { clusterId: true, name: true }
|
||||
})
|
||||
|
||||
if (clusters.length < 2) return []
|
||||
|
||||
const suggestions: ConnectionSuggestion[] = []
|
||||
|
||||
// Generate suggestions for cluster pairs (limit to 5 pairs)
|
||||
for (let i = 0; i < Math.min(clusters.length, 3); i++) {
|
||||
for (let j = i + 1; j < Math.min(clusters.length, 4); j++) {
|
||||
const clusterA = clusters[i]
|
||||
const clusterB = clusters[j]
|
||||
|
||||
// Get sample notes from each cluster
|
||||
const notesA = 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 3`,
|
||||
clusterA.clusterId,
|
||||
userId
|
||||
)
|
||||
|
||||
const notesB = 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 3`,
|
||||
clusterB.clusterId,
|
||||
userId
|
||||
)
|
||||
|
||||
const summaryA = notesA.map(n => n.title || 'Untitled').join(', ')
|
||||
const summaryB = notesB.map(n => n.title || 'Untitled').join(', ')
|
||||
|
||||
const suggestion = await this.generateBridgeSuggestion(
|
||||
clusterA.name || `Cluster ${clusterA.clusterId}`,
|
||||
clusterB.name || `Cluster ${clusterB.clusterId}`,
|
||||
summaryA,
|
||||
summaryB
|
||||
)
|
||||
|
||||
suggestions.push({
|
||||
clusterAId: clusterA.clusterId,
|
||||
clusterBId: clusterB.clusterId,
|
||||
clusterAName: clusterA.name || `Cluster ${clusterA.clusterId}`,
|
||||
clusterBName: clusterB.name || `Cluster ${clusterB.clusterId}`,
|
||||
...suggestion
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single bridge suggestion using the LLM.
|
||||
*/
|
||||
private async generateBridgeSuggestion(
|
||||
clusterAName: string,
|
||||
clusterBName: string,
|
||||
summaryA: string,
|
||||
summaryB: string
|
||||
): Promise<Omit<ConnectionSuggestion, 'clusterAId' | 'clusterBId' | 'clusterAName' | 'clusterBName'>> {
|
||||
const prompt = `Cluster A ("${clusterAName}") contains notes about: ${summaryA}
|
||||
Cluster B ("${clusterBName}") contains notes about: ${summaryB}
|
||||
|
||||
These clusters are not directly connected. Suggest ONE creative "bridge note" idea that could connect them.
|
||||
|
||||
Provide your response as a JSON object with these fields:
|
||||
- title: A concise title for the bridge note (2-6 words)
|
||||
- description: What this note would explore (1-2 sentences)
|
||||
- justification: Why this connection makes sense (1 sentence)
|
||||
|
||||
JSON:`
|
||||
|
||||
try {
|
||||
const { getChatProvider } = await import('@/lib/ai/factory')
|
||||
const { getSystemConfig } = await import('@/lib/config')
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
const response = await provider.chat([{ role: 'user', content: prompt }], '')
|
||||
|
||||
const text = response.text.trim()
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/)
|
||||
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0])
|
||||
}
|
||||
|
||||
// Fallback if JSON parsing fails
|
||||
return {
|
||||
suggestedTitle: `Connecting ${clusterAName} and ${clusterBName}`,
|
||||
suggestedContent: `Explore the relationships between concepts from ${clusterAName} and ${clusterBName}.`,
|
||||
justification: 'These topics may share underlying principles or applications.'
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
suggestedTitle: `Connecting ${clusterAName} and ${clusterBName}`,
|
||||
suggestedContent: `Explore the relationships between concepts from ${clusterAName} and ${clusterBName}.`,
|
||||
justification: 'These topics may share underlying principles or applications.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a connection suggestion.
|
||||
*/
|
||||
async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise<void> {
|
||||
await prisma.bridgeSuggestion.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
clusterAId,
|
||||
clusterBId
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const bridgeNotesService = new BridgeNotesService()
|
||||
410
memento-note/memento-note/lib/ai/services/clustering.service.ts
Normal file
410
memento-note/memento-note/lib/ai/services/clustering.service.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Clustering Service
|
||||
*
|
||||
* Density-based clustering algorithm (DBSCAN variant) for note embeddings.
|
||||
* Groups semantically similar notes into clusters without requiring
|
||||
* a preset number of clusters.
|
||||
*/
|
||||
|
||||
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 note IDs using 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[]> {
|
||||
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>()
|
||||
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) {
|
||||
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
|
||||
|
||||
const cluster = clusterResults[cid]
|
||||
if (!cluster) continue
|
||||
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async generateClusterName(clusterId: number, userId: string): Promise<string> {
|
||||
const centralNotes = 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 5`,
|
||||
clusterId,
|
||||
userId
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
const needsUpdate = await this.shouldRecalculate(userId)
|
||||
if (needsUpdate) return null
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user