/** * Bridge Notes Service * * Detects and manages "bridge notes" — notes that connect multiple thematic clusters. * * Scientific grounding (semantic similarity networks / brokerage): * - A bridge document is valued when it spans *distinct communities*, typically the * strongest link between a *pair* of clusters — not weak ties to every theme * (see semantic similarity network bridging; betweenness / cross-community brokerage). * - Literature commonly uses cosine thresholds ≈ 0.65–0.85 for semantic edges * (Vec2GC, PMC semantic graphs ~0.65; looser 0.5 floods the graph with false bridges). * - Score = mean affinity to the top-2 clusters (not "% of all themes touched"). */ import prisma from '@/lib/prisma' import { clusteringService } from './clustering.service' import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import { withAiQuota } from '@/lib/ai-quota' export interface BridgeNote { noteId: string /** 0–1 mean cosine affinity to the top bridge clusters (typically a pair). */ bridgeScore: number /** Cluster ids ranked by affinity (strongest first). Prefer length 2. */ clustersConnected: number[] clusterNames?: string[] /** Per-cluster max cosine affinities, aligned with clustersConnected. */ clusterAffinities?: number[] } export interface BridgeSuggestion { clusterAId: number clusterBId: number clusterAName: string clusterBName: string suggestedTitle: string suggestedContent: string justification: string } interface ClusterAffinity { clusterId: number maxSimilarity: number hitCount: number } export class BridgeNotesService { /** Cosine similarity floor for semantic edges (literature ≈ 0.65). */ private readonly BRIDGE_SIMILARITY_THRESHOLD = 0.65 private readonly MIN_CLUSTERS_FOR_BRIDGE = 2 /** A true bridge is primarily a pair of communities (brokerage). */ private readonly MAX_BRIDGED_CLUSTERS = 2 /** * For one note, return max cosine similarity to each cluster that clears the threshold. */ private async getClusterAffinities( noteId: string, userId: string, threshold: number = this.BRIDGE_SIMILARITY_THRESHOLD ): Promise { const cosineDistance = 1 - threshold const result = await prisma.$queryRawUnsafe>( `SELECT e2."noteId", cm."clusterId", (1 - (e1."embedding"::vector <=> e2."embedding"::vector))::float8 AS similarity FROM "NoteEmbedding" e1 CROSS JOIN "NoteEmbedding" e2 INNER JOIN "Note" n ON n.id = e2."noteId" LEFT JOIN "ClusterMember" cm ON cm."noteId" = e2."noteId" AND cm."userId" = $2 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`, noteId, userId, cosineDistance ) const byCluster = new Map() for (const row of result) { const clusterId = row.clusterId if (clusterId === null || clusterId === -1) continue const sim = Number(row.similarity) if (!Number.isFinite(sim) || sim < threshold) continue const prev = byCluster.get(clusterId) if (!prev) { byCluster.set(clusterId, { maxSimilarity: sim, hitCount: 1 }) } else { prev.maxSimilarity = Math.max(prev.maxSimilarity, sim) prev.hitCount += 1 } } return [...byCluster.entries()] .map(([clusterId, v]) => ({ clusterId, maxSimilarity: v.maxSimilarity, hitCount: v.hitCount, })) .sort((a, b) => b.maxSimilarity - a.maxSimilarity || b.hitCount - a.hitCount) } /** * Detect bridge notes: notes with strong affinity to at least two clusters. * Keeps only the top-2 clusters (pair brokerage) and scores by their mean affinity. */ async detectBridgeNotes(userId: string): Promise { 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 nameById = new Map( clusters.map(c => [c.clusterId, clusteringService.displayName(c.name, c.clusterId)]) ) const bridgeNotes: BridgeNote[] = [] const notes = await prisma.note.findMany({ where: { userId, trashedAt: null }, select: { id: true } }) for (const note of notes) { const affinities = await this.getClusterAffinities(note.id, userId) if (affinities.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue const top = affinities.slice(0, this.MAX_BRIDGED_CLUSTERS) // Both legs of the bridge must be strong if (top.length < this.MIN_CLUSTERS_FOR_BRIDGE) continue if (top.some(a => a.maxSimilarity < this.BRIDGE_SIMILARITY_THRESHOLD)) continue const bridgeScore = top.reduce((sum, a) => sum + a.maxSimilarity, 0) / top.length const clustersConnected = top.map(a => a.clusterId) bridgeNotes.push({ noteId: note.id, bridgeScore, clustersConnected, clusterNames: clustersConnected.map( cid => nameById.get(cid) || clusteringService.displayName(null, cid) ), clusterAffinities: top.map(a => a.maxSimilarity), }) } return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore) } /** * Save bridge notes to database. */ async saveBridgeNotes(userId: string, bridgeNotes: BridgeNote[]): Promise { 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 { const notes = await prisma.$queryRawUnsafe>( `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 suggestions for *plausible* missing links between clusters. * * Scientific grounding (link prediction / KG completion): * - Do NOT propose a link for every unconnected pair (combinatorial explosion → * forced metaphors like "Stripe ↔ gas dynamics"). * - Pre-filter by centroid cosine similarity: keep "near-miss" pairs that are * related enough to deserve a bridge, but not already tightly fused. * - Cap candidate count; rank by structural proximity before spending AI calls. * - Prompt: grounded synthesis only; return null if no real shared object/method. */ async generateBridgeSuggestions(userId: string): Promise { const MAX_SUGGESTIONS = 3 /** Near-miss only — wide band (0.30–0.62) still let Stripe↔gaz through. */ const MIN_PAIR_SIMILARITY = 0.45 const MAX_PAIR_SIMILARITY = 0.58 const clusters = await prisma.noteCluster.findMany({ where: { userId }, select: { clusterId: true, name: true }, orderBy: { clusterId: 'asc' } }) if (clusters.length < 2) return [] const existingBridges = await prisma.bridgeNote.findMany({ where: { userId }, select: { clustersConnected: true } }) const connectedPairs = new Set() for (const bridge of existingBridges) { const ids = JSON.parse(bridge.clustersConnected) as number[] for (let i = 0; i < ids.length; i++) { for (let j = i + 1; j < ids.length; j++) { connectedPairs.add([ids[i], ids[j]].sort((a, b) => a - b).join('-')) } } } const centroids = new Map() const summaries = new Map() await Promise.all( clusters.map(async c => { const [centroid, summary] = await Promise.all([ this.getClusterCentroid(c.clusterId, userId), this.getClusterSummary(c.clusterId, userId), ]) if (centroid) centroids.set(c.clusterId, centroid) summaries.set(c.clusterId, summary) }) ) type RankedPair = { a: typeof clusters[0] b: typeof clusters[0] similarity: number } const ranked: RankedPair[] = [] for (let i = 0; i < clusters.length; i++) { for (let j = i + 1; j < clusters.length; j++) { const a = clusters[i] const b = clusters[j] const pairKey = [a.clusterId, b.clusterId].sort((x, y) => x - y).join('-') if (connectedPairs.has(pairKey)) continue const ca = centroids.get(a.clusterId) const cb = centroids.get(b.clusterId) if (!ca || !cb) continue const similarity = this.cosineSimilarity(ca, cb) if (similarity < MIN_PAIR_SIMILARITY || similarity > MAX_PAIR_SIMILARITY) continue // Lexical gate: no shared vocabulary → no suggestion (blocks Stripe↔gaz) if (!this.hasLexicalOverlap(summaries.get(a.clusterId) || '', summaries.get(b.clusterId) || '')) { continue } ranked.push({ a, b, similarity }) } } ranked.sort((x, y) => y.similarity - x.similarity) const candidates = ranked.slice(0, MAX_SUGGESTIONS) const suggestions: BridgeSuggestion[] = [] for (const { a, b, similarity } of candidates) { const suggestion = await this.generateConnectionSuggestion( a.clusterId, b.clusterId, clusteringService.displayName(a.name, a.clusterId), clusteringService.displayName(b.name, b.clusterId), userId, similarity ) if (suggestion && !this.looksLikeForcedMetaphor(suggestion)) { suggestions.push(suggestion) } } return suggestions } /** Shared tokens in note snippets — blocks ornamental cross-domain pairs. */ private hasLexicalOverlap(textA: string, textB: string): boolean { const stop = new Set([ 'the', 'and', 'for', 'with', 'that', 'this', 'from', 'are', 'was', 'were', 'have', 'has', 'les', 'des', 'une', 'dans', 'pour', 'avec', 'que', 'qui', 'sur', 'par', 'est', 'sont', 'not', 'note', 'notes', 'untitled', 'sans', 'titre', 'cette', 'plus', 'comme', ]) const tokens = (text: string) => new Set( text .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .split(/[^a-z0-9]+/i) .filter(t => t.length >= 4 && !stop.has(t)) ) const a = tokens(textA) const b = tokens(textB) if (a.size === 0 || b.size === 0) return false let shared = 0 for (const t of a) { if (b.has(t)) shared += 1 if (shared >= 2) return true } return false } private looksLikeForcedMetaphor(s: BridgeSuggestion): boolean { const blob = `${s.suggestedTitle} ${s.suggestedContent} ${s.justification}`.toLowerCase() const redFlags = [ 'analogie', 'analogy', 'métaphore', 'metaphor', 'comme si', 'as if', 'parallel between', 'parallèle entre', 'friction', 'entropie', 'entropy', 'fluide', 'fluid dynamics', 'rate limit', 'rate-limiting', 'adiabatique', 'viscosity', 'viscosité', 'théorie de la friction', 'meets digital', ] return redFlags.some(f => blob.includes(f)) } private cosineSimilarity(a: number[], b: number[]): number { if (a.length === 0 || a.length !== b.length) return 0 let dot = 0 let na = 0 let nb = 0 for (let i = 0; i < a.length; i++) { dot += a[i] * b[i] na += a[i] * a[i] nb += b[i] * b[i] } const den = Math.sqrt(na) * Math.sqrt(nb) return den === 0 ? 0 : dot / den } private async getClusterCentroid(clusterId: number, userId: string): Promise { const rows = await prisma.$queryRawUnsafe>( `SELECT e."embedding"::text AS embedding FROM "ClusterMember" cm INNER JOIN "NoteEmbedding" e ON e."noteId" = cm."noteId" WHERE cm."clusterId" = $1 AND cm."userId" = $2 AND e."embedding" IS NOT NULL LIMIT 40`, clusterId, userId ) const vectors: number[][] = [] for (const row of rows) { try { const v = JSON.parse(row.embedding) as number[] if (Array.isArray(v) && v.length > 0) vectors.push(v) } catch { /* skip bad vector */ } } if (vectors.length === 0) return null const dim = vectors[0].length const centroid = new Array(dim).fill(0) for (const v of vectors) { if (v.length !== dim) continue for (let i = 0; i < dim; i++) centroid[i] += v[i] } for (let i = 0; i < dim; i++) centroid[i] /= vectors.length return centroid } /** * Generate a grounded connection suggestion between two near-miss clusters. */ private async generateConnectionSuggestion( clusterAId: number, clusterBId: number, clusterAName: string, clusterBName: string, userId: string, pairSimilarity: number ): Promise { const summaryA = await this.getClusterSummary(clusterAId, userId) const summaryB = await this.getClusterSummary(clusterBId, userId) const systemPrompt = `You help a personal knowledge base propose MISSING LINKS (link prediction). Rules: - ONLY suggest a bridge when both themes share a concrete object, tool, method, dataset, or decision visible in the notes. - NEVER invent witty analogies between unrelated domains (billing ≠ thermodynamics, hosting ≠ air conditioning). - If themes are about different products/domains with no shared practice, return {"viable": false}. - Write in the same language as the note summaries. - Be concise and actionable.` const userPrompt = `Two thematic clusters are unconnected but somewhat related (centroid cosine ≈ ${pairSimilarity.toFixed(2)}). Theme A (${clusterAName}): ${summaryA} Theme B (${clusterBName}): ${summaryB} Propose ONE bridge note that a thoughtful researcher would actually write — a synthesis, comparison with a shared criterion, or decision note — NOT a witty forced analogy. Return ONLY JSON: { "viable": true, "title": "≤12 words", "description": "≤40 words: what the note covers", "justification": "≤25 words: why this link is real (shared object/method)" } or {"viable": false}` try { const response = await withAiQuota(userId, 'reformulate', async () => { const config = await getSystemConfig() const provider = getChatProvider(config) return provider.chat( [{ role: 'user', content: userPrompt }], systemPrompt, ) }, { lane: 'chat' }) const jsonMatch = response.text.match(/\{[\s\S]*\}/) if (!jsonMatch) return null const parsed = JSON.parse(jsonMatch[0]) as { viable?: boolean title?: string description?: string justification?: string ideas?: Array<{ title: string; description: string; justification: string }> } if (parsed.viable === false) return null const idea = parsed.title && parsed.description ? { title: parsed.title, description: parsed.description, justification: parsed.justification || '', } : parsed.ideas?.[0] if (!idea?.title || !idea?.description) return null return { clusterAId, clusterBId, clusterAName, clusterBName, suggestedTitle: idea.title, suggestedContent: idea.description, justification: idea.justification || '', } } catch (error) { console.error('Error generating bridge suggestion:', error) return null } } /** * Save bridge suggestions to database. */ async saveBridgeSuggestions(userId: string, suggestions: BridgeSuggestion[]): Promise { 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 { 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 — purge ornamental leftovers from older algorithms. * Resolve cluster display names from NoteCluster (fixes frozen "Cluster 15" in BridgeSuggestion rows). */ async getBridgeSuggestions(userId: string, includeDismissed: boolean = false): Promise { const nameById = await clusteringService.getClusterNameMap(userId) const suggestions = await prisma.bridgeSuggestion.findMany({ where: { userId, ...(includeDismissed ? {} : { isDismissed: false }) }, orderBy: { createdAt: 'desc' }, take: 40, }) const mapped = suggestions.map(s => { const clusterAName = nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId) const clusterBName = nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId) return { clusterAId: s.clusterAId, clusterBId: s.clusterBId, clusterAName, clusterBName, suggestedTitle: s.suggestedTitle, suggestedContent: s.suggestedContent, justification: s.justification } }) // Persist corrected names when suggestions still store placeholders const nameFixes = suggestions.filter(s => { const resolvedA = nameById.get(s.clusterAId) const resolvedB = nameById.get(s.clusterBId) return ( (resolvedA && clusteringService.isPlaceholderClusterName(s.clusterAName)) || (resolvedB && clusteringService.isPlaceholderClusterName(s.clusterBName)) ) }) if (nameFixes.length > 0) { await Promise.all( nameFixes.map(s => prisma.bridgeSuggestion.updateMany({ where: { userId, clusterAId: s.clusterAId, clusterBId: s.clusterBId }, data: { clusterAName: nameById.get(s.clusterAId) || clusteringService.displayName(s.clusterAName, s.clusterAId), clusterBName: nameById.get(s.clusterBId) || clusteringService.displayName(s.clusterBName, s.clusterBId), }, }) ) ) } const keep: BridgeSuggestion[] = [] const dropIds: Array<{ clusterAId: number; clusterBId: number }> = [] for (const s of mapped) { if (this.looksLikeForcedMetaphor(s)) { dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }) continue } const [summaryA, summaryB, ca, cb] = await Promise.all([ this.getClusterSummary(s.clusterAId, userId), this.getClusterSummary(s.clusterBId, userId), this.getClusterCentroid(s.clusterAId, userId), this.getClusterCentroid(s.clusterBId, userId), ]) if (!ca || !cb) { dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }) continue } const sim = this.cosineSimilarity(ca, cb) if (sim < 0.45 || sim > 0.58 || !this.hasLexicalOverlap(summaryA, summaryB)) { dropIds.push({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }) continue } keep.push(s) } if (dropIds.length > 0) { await Promise.all( dropIds.map(({ clusterAId, clusterBId }) => prisma.bridgeSuggestion.deleteMany({ where: { userId, clusterAId, clusterBId } }) ) ) } return keep.slice(0, 5) } /** * Dismiss a bridge suggestion. */ async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise { await prisma.bridgeSuggestion.updateMany({ where: { userId, clusterAId, clusterBId }, data: { isDismissed: true } }) } } export const bridgeNotesService = new BridgeNotesService()