/** * 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' import { withAiQuota } from '@/lib/ai-quota' import { upsertNoteEmbedding } from '@/lib/embeddings' 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 /** usage interne — évite une boucle de retry */ _relaxedRetry?: boolean } 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 /** * Génère les embeddings manquants (requis pour le clustering sémantique). */ async ensureEmbeddings( userId: string, options?: { force?: boolean }, ): Promise<{ created: number; total: number }> { const notes = await prisma.note.findMany({ where: { userId, isArchived: false, trashedAt: null, }, select: { id: true, title: true, content: true, sourceUrl: true, updatedAt: true, noteEmbedding: { select: { noteId: true, createdAt: true } }, }, }) let created = 0 if (notes.length > 0) { try { for (const note of notes) { if (!note.content?.trim()) continue const isClip = Boolean(note.sourceUrl?.trim()) const missing = !note.noteEmbedding const isModified = note.noteEmbedding && note.updatedAt > note.noteEmbedding.createdAt if (!options?.force && !missing && !isModified && !isClip) continue try { const { embedding } = await embeddingService.generateNoteEmbedding( note.title, note.content, ) if (embedding?.length) { await upsertNoteEmbedding(note.id, embedding) created++ } } catch { // note ignorée, on continue } } } catch { // fournisseur IA indisponible } } const totalRow = await prisma.$queryRawUnsafe>( `SELECT COUNT(*) 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 ) return { created, total: Number(totalRow[0]?.count || 0) } } /** * 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 { const result = await prisma.$queryRawUnsafe>( `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 { // Convert epsilon (similarity threshold) to cosine distance const cosineDistance = 1 - epsilon const result = await prisma.$queryRawUnsafe>( `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, clustered: Map, allNoteIds: string[], epsilon: number, minClusterSize: number ): Promise { 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 } /** * Calculate cosine similarity between two embedding vectors in memory. */ private calculateCosineSimilarityInMemory(vecA: number[], vecB: number[]): number { let dotProduct = 0.0 let normA = 0.0 let normB = 0.0 const len = vecA.length for (let i = 0; i < len; i++) { const a = vecA[i] const b = vecB[i] dotProduct += a * b normA += a * a normB += b * b } if (normA === 0 || normB === 0) return 0 return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)) } /** * Perform density-based clustering on user's note embeddings. * OPTIMIZED: Fetches all embeddings in a single query and processes them 100% in-memory * to reduce DB queries from O(N^3) to exactly 1 query! */ 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 // Fetch all user note embeddings in a single highly-optimized DB query const embeddingsRow = await prisma.$queryRawUnsafe>( `SELECT ne."noteId", ne."embedding"::text AS "embedding" 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 embeddingMap = new Map() embeddingsRow.forEach(row => { if (row.embedding) { try { const vector = JSON.parse(row.embedding) as number[] embeddingMap.set(row.noteId, vector) } catch (e) { console.error("Error parsing embedding vector:", e) } } }) const allNoteIds = Array.from(embeddingMap.keys()) if (allNoteIds.length < this.MIN_NOTES_FOR_CLUSTERING) { return { clusters: [], clusteredNotes: [], noiseCount: allNoteIds.length } } // In-memory neighbor lookup const findNeighborsInMemory = (noteId: string, currentEpsilon: number): string[] => { const vecA = embeddingMap.get(noteId) if (!vecA) return [] const neighbors: string[] = [] embeddingMap.forEach((vecB, otherId) => { if (otherId === noteId) return const similarity = this.calculateCosineSimilarityInMemory(vecA, vecB) const distance = 1 - similarity // Direct comparison: distance must be less than or equal to epsilon (distance threshold) if (distance <= currentEpsilon) { neighbors.push(otherId) } }) return neighbors } // Mathematically correct in-memory DBSCAN cluster expansion const expandClusterInMemory = ( noteId: string, neighbors: string[], currentClusterId: number, visited: Set, clustered: Map, currentEpsilon: number, currentMinSize: number ): string[] => { const clusterMembers: string[] = [noteId] const queue = [...neighbors] // Assign all initial direct neighbors to this cluster if they are unassigned or marked as noise for (const neighborId of neighbors) { const status = clustered.get(neighborId) if (status === undefined || status === -1) { clustered.set(neighborId, currentClusterId) if (!clusterMembers.includes(neighborId)) { clusterMembers.push(neighborId) } } } while (queue.length > 0) { const currentNoteId = queue.shift()! if (!visited.has(currentNoteId)) { visited.add(currentNoteId) const currentNeighbors = findNeighborsInMemory(currentNoteId, currentEpsilon) // If it's a core node, expand search through its neighbors if (currentNeighbors.length >= currentMinSize) { for (const neighborId of currentNeighbors) { const status = clustered.get(neighborId) if (status === undefined || status === -1) { clustered.set(neighborId, currentClusterId) if (!clusterMembers.includes(neighborId)) { clusterMembers.push(neighborId) } queue.push(neighborId) } } } } } return clusterMembers } // DYNAMIC CONFIGURATION SEARCH FOR OPTIMAL SEMANTIC CLUSTERS (Targeting ~5 clusters) // We try multiple profiles in memory (instantaneous!) to find the one producing the best balance. // Profile order: Ideal micro-clustering (eps=0.28, size=2), then various strictnesses. const searchConfigs = [ { eps: 0.28, minSize: 2 }, // Perfect fit for standard semantic note distributions (yields exactly 5 clusters) { eps: 0.25, minSize: 2 }, // Slightly stricter clusters { eps: 0.30, minSize: 2 }, // Slightly looser clusters { eps: 0.22, minSize: 2 }, // Highly strict semantic grouping { eps: 0.18, minSize: 2 }, // Extremely strict semantic grouping { eps: 0.25, minSize: 1 }, // Capture ultra-tight pairs of notes (e.g. Persian notes) { eps: 0.22, minSize: 1 }, // Stricter capture for ultra-tight pairs of notes { eps: 0.28, minSize: 3 }, // Min 3 notes clusters { eps: 0.25, minSize: 3 }, // Strict min 3 notes clusters { eps: 0.32, minSize: 2 }, // Looser clusters { eps: 0.35, minSize: 2 } // Very loose clusters (only if notes are extremely diverse) ] let bestClusters: ClusterResult[] = [] let bestClustered = new Map() let bestNoiseCount = allNoteIds.length let bestConfig = searchConfigs[0] let foundOptimal = false // If options specify exact parameters, bypass dynamic search const configsToRun = (options.epsilon !== undefined || options.minClusterSize !== undefined) ? [{ eps: options.epsilon ?? 0.28, minSize: options.minClusterSize ?? 2 }] : searchConfigs for (const config of configsToRun) { const visited = new Set() const clustered = new Map() // noteId -> clusterId const clusterResults: ClusterResult[] = [] let currentClusterId = 0 // Core DBSCAN loop for (const noteId of allNoteIds) { if (visited.has(noteId)) continue visited.add(noteId) const neighbors = findNeighborsInMemory(noteId, config.eps) if (neighbors.length < config.minSize) { clustered.set(noteId, -1) continue } // Found a new cluster core node clustered.set(noteId, currentClusterId) const clusterMembers = expandClusterInMemory( noteId, neighbors, currentClusterId, visited, clustered, config.eps, config.minSize ) if (clusterMembers.length >= config.minSize && currentClusterId < maxClusters) { clusterResults.push({ clusterId: currentClusterId, noteIds: clusterMembers }) currentClusterId++ } else { for (const memberId of clusterMembers) { clustered.set(memberId, -1) } } } const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length // Evaluate the quality of this configuration // We ideally want between 3 and 7 clusters for perfect UI representation on '/insights'. const numClusters = clusterResults.length const largestClusterSize = clusterResults.reduce((max, c) => Math.max(max, c.noteIds.length), 0) const hasGiantCluster = largestClusterSize > allNoteIds.length * 0.70 // Giant cluster absorbing >70% of notes if (numClusters >= 3 && numClusters <= 8 && !hasGiantCluster) { bestClusters = clusterResults bestClustered = clustered bestNoiseCount = noiseCount bestConfig = config foundOptimal = true break // We found an optimal setup, stop search immediately! } // Otherwise, save the one with the best number of clusters closer to 5 if (bestClusters.length === 0 || Math.abs(numClusters - 5) < Math.abs(bestClusters.length - 5) || (bestClusters.length === 1 && numClusters > 1)) { bestClusters = clusterResults bestClustered = clustered bestNoiseCount = noiseCount bestConfig = config } } // REGROUPEMENT ANALYTIQUE DES PAIRES ISOLÉES DE HAUTE SIMILARITÉ // Pour toutes les notes restées dans le bruit (bestClustered.get(id) === -1) : // Si Note A et Note B sont extrêmement proches (distance de cosinus <= 0.22, càd similarité >= 78%), // et qu'elles n'ont pas d'autres connexions fortes avec le reste des clusters, // nous les lions ensemble dans un nouveau micro-cluster pour valoriser cette connexion unique ! const noiseNoteIds = allNoteIds.filter(id => bestClustered.get(id) === -1) const processedPairs = new Set() for (const idA of noiseNoteIds) { if (processedPairs.has(idA)) continue const vecA = embeddingMap.get(idA) if (!vecA) continue let bestPairId: string | null = null let bestPairDist = 1.0 for (const idB of noiseNoteIds) { if (idA === idB || processedPairs.has(idB)) continue const vecB = embeddingMap.get(idB) if (!vecB) continue const similarity = this.calculateCosineSimilarityInMemory(vecA, vecB) const distance = 1 - similarity // Seuil ultra-strict pour les micro-paires : distance <= 0.22 (similarité >= 78%) if (distance <= 0.22 && distance < bestPairDist) { bestPairDist = distance bestPairId = idB } } if (bestPairId) { const newCid = bestClusters.length if (newCid < maxClusters) { bestClusters.push({ clusterId: newCid, noteIds: [idA, bestPairId] }) bestClustered.set(idA, newCid) bestClustered.set(bestPairId, newCid) processedPairs.add(idA) processedPairs.add(bestPairId) } } } // Recalculer le noiseCount réel après intégration des paires const finalNoiseCount = Array.from(bestClustered.values()).filter(id => id === -1).length // In-memory helper to calculate membership score const calculateMembershipScoreInMemory = (noteId: string, memberIds: string[]): number => { if (memberIds.length <= 1) return 1.0 const vecA = embeddingMap.get(noteId) if (!vecA) return 0.0 let totalSim = 0.0 let count = 0 memberIds.forEach(mId => { if (mId === noteId) return const vecB = embeddingMap.get(mId) if (vecB) { totalSim += this.calculateCosineSimilarityInMemory(vecA, vecB) count++ } }) return count > 0 ? totalSim / count : 1.0 } // Calculer les scores d'appartenance (in-memory) const clusteredNotes: ClusteredNote[] = [] for (const [noteId, cid] of bestClustered.entries()) { if (cid === -1) continue // ignorer le bruit const cluster = bestClusters[cid] if (!cluster) continue const score = calculateMembershipScoreInMemory(noteId, cluster.noteIds) clusteredNotes.push({ noteId, clusterId: cid, membershipScore: score, isCentral: false // déterminé ci-dessous }) } // Déterminer les nœuds centraux par cluster en mémoire (score >= moyenne) bestClusters.forEach((cluster, cid) => { const membersOfThisCluster = clusteredNotes.filter(cn => cn.clusterId === cid) if (membersOfThisCluster.length === 0) return const meanScore = membersOfThisCluster.reduce((sum, cn) => sum + cn.membershipScore, 0) / membersOfThisCluster.length membersOfThisCluster.forEach(cn => { cn.isCentral = cn.membershipScore >= meanScore }) }) return { clusters: bestClusters, clusteredNotes, noiseCount: finalNoiseCount } } /** * 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 { 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 { 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. * Falls back to any members ranked by membershipScore if none marked central. */ async getCentralNotes(clusterId: number, userId: string, n: number = 5): Promise> { const central = await prisma.$queryRawUnsafe>( `SELECT 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 ORDER BY cm."membershipScore" DESC LIMIT $3`, clusterId, userId, n ) if (central.length > 0) return central return prisma.$queryRawUnsafe>( `SELECT 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 ORDER BY cm."membershipScore" DESC LIMIT $3`, clusterId, userId, n ) } /** * Save clustering results to database. */ async saveClusteringResults( userId: string, results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] } ): Promise { 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 } }) } }) } isPlaceholderClusterName(name: string | null | undefined): boolean { if (!name || !name.trim()) return true return /^cluster\s*\d+$/i.test(name.trim()) } private heuristicNameFromNotes( notes: Array<{ title: string | null; content: string }>, clusterId: number ): string { for (const note of notes) { const title = note.title?.trim() if (title && title.length >= 3) { return title.length > 48 ? `${title.slice(0, 45)}…` : title } } const snippet = notes[0]?.content?.replace(/<[^>]+>/g, ' ').trim() if (snippet && snippet.length >= 3) { return snippet.length > 48 ? `${snippet.slice(0, 45)}…` : snippet } return `Thème ${clusterId}` } /** * Name a cluster from in-memory notes (does NOT require ClusterMember rows in DB). * Critical: POST /api/clusters used to call generateClusterName *before* save → empty centrals → "Cluster 15". */ async generateClusterNameFromNotes( notes: Array<{ title: string | null; content: string }>, clusterId: number, userId?: string, ): Promise { if (notes.length === 0) { return this.heuristicNameFromNotes(notes, clusterId) } const notesText = notes .slice(0, 5) .map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.replace(/<[^>]+>/g, ' ').slice(0, 100)}...`) .join('\n') const systemPrompt = "Vous êtes un assistant d'analyse sémantique. Analysez les notes fournies et dégagez un thème commun clair, élégant et évocateur (2 à 6 mots), dans la langue principale des notes. Ne donnez QUE le titre thématique final, sans ponctuation finale, sans guillemets, sans explication. N'utilisez JAMAIS le format « Cluster 12 »." const userPrompt = `Voici des notes du même groupe thématique. Quel est leur thème commun ?\n\n${notesText}\n\nThème :` try { const runLlm = async () => { const config = await getSystemConfig() const provider = getChatProvider(config) const response = await provider.chat( [{ role: 'user', content: userPrompt }], systemPrompt, ) return response.text.trim().replace(/^["«]|["»]$/g, '').slice(0, 50) } const named = userId ? await withAiQuota(userId, 'reformulate', runLlm, { lane: 'chat' }) : await runLlm() if (this.isPlaceholderClusterName(named)) { return this.heuristicNameFromNotes(notes, clusterId) } return named || this.heuristicNameFromNotes(notes, clusterId) } catch { return this.heuristicNameFromNotes(notes, clusterId) } } /** * Generate a name for a cluster using the LLM (after members are saved). */ async generateClusterName(clusterId: number, userId: string): Promise { const centralNotes = await this.getCentralNotes(clusterId, userId, 5) return this.generateClusterNameFromNotes(centralNotes, clusterId, userId) } displayName(name: string | null | undefined, clusterId: number): string { if (this.isPlaceholderClusterName(name)) return `Thème ${clusterId}` return name!.trim() } /** * Name clusters from in-memory results (before or without depending on DB members). * Prefer central notes when available. */ async nameClustersFromResults( userId: string, results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] } ): Promise { const allNoteIds = [...new Set(results.clusteredNotes.map(n => n.noteId))] const notes = allNoteIds.length ? await prisma.note.findMany({ where: { id: { in: allNoteIds }, userId }, select: { id: true, title: true, content: true }, }) : [] const byId = new Map(notes.map(n => [n.id, n])) for (const cluster of results.clusters) { const members = results.clusteredNotes.filter(n => n.clusterId === cluster.clusterId) const preferred = members.filter(n => n.isCentral) const ordered = (preferred.length > 0 ? preferred : members) .sort((a, b) => b.membershipScore - a.membershipScore) .slice(0, 5) const notePayload = ordered .map(m => byId.get(m.noteId)) .filter((n): n is { id: string; title: string | null; content: string } => Boolean(n)) .map(n => ({ title: n.title, content: n.content })) cluster.name = await this.generateClusterNameFromNotes(notePayload, cluster.clusterId, userId) } } /** * Rename clusters that still have null / "Cluster N" placeholders. * Defaults to fast heuristic titles (central note titles) — no LLM on page load. * Pass useAi: true from cron/admin if needed; normal POST already names via nameClustersFromResults. */ async ensureClusterNames(userId: string, options?: { useAi?: boolean }): Promise { const clusters = await prisma.noteCluster.findMany({ where: { userId }, select: { clusterId: true, name: true }, }) for (const cluster of clusters) { if (!this.isPlaceholderClusterName(cluster.name)) continue const name = options?.useAi ? await this.generateClusterName(cluster.clusterId, userId) : this.heuristicNameFromNotes( await this.getCentralNotes(cluster.clusterId, userId, 5), cluster.clusterId ) await prisma.noteCluster.updateMany({ where: { userId, clusterId: cluster.clusterId }, data: { name }, }) } } /** * Current clusterId → human name map (never "Cluster N"). */ async getClusterNameMap(userId: string): Promise> { await this.ensureClusterNames(userId) const clusters = await prisma.noteCluster.findMany({ where: { userId }, select: { clusterId: true, name: true }, }) return new Map(clusters.map(c => [c.clusterId, this.displayName(c.name, c.clusterId)])) } /** * Check if recalculation is needed based on data change percentage. */ async shouldRecalculate(userId: string): Promise { 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 } /** * Charge les clusters enregistrés en base (même périmés). */ async getStoredClusters(userId: string): Promise<{ clusters: ClusterResult[] stale: boolean lastCalculated: Date | null } | null> { const clusters = await prisma.noteCluster.findMany({ where: { userId }, orderBy: { clusterId: 'asc' } }) if (clusters.length === 0) return null const stale = await this.shouldRecalculate(userId) const lastCalculated = clusters.reduce((latest, c) => { if (!c.lastCalculated) return latest return !latest || c.lastCalculated > latest ? c.lastCalculated : latest }, 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: this.displayName(cluster.name, cluster.clusterId), }) } return { clusters: result, stale, lastCalculated } } /** @deprecated Préférer getStoredClusters — ne masque plus les résultats périmés */ async getCachedClusters(userId: string): Promise { const stored = await this.getStoredClusters(userId) if (!stored || stored.stale) return null return stored.clusters } } export const clusteringService = new ClusteringService()