Files
Momento/memento-note/lib/ai/services/bridge-notes.service.ts
Antigravity 4fe31ebc99
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m55s
CI / Deploy production (on server) (push) Successful in 36s
fix(quotas): unifier le décompte IA (BYOK, rollback) et combler les fuites
Centralise la réserve via ai-quota, corrige admin unavailable (-1), brancher les routes sans quota et le host-pays brainstorm, avec usage-meter élargi, noms de clusters, MCP et ajustements dashboard/insights.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:42:25 +00:00

633 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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.650.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
/** 01 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<ClusterAffinity[]> {
const cosineDistance = 1 - threshold
const result = await prisma.$queryRawUnsafe<Array<{
noteId: string
clusterId: number | null
similarity: number
}>>(
`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<number, { maxSimilarity: number; hitCount: number }>()
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<BridgeNote[]> {
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<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 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<BridgeSuggestion[]> {
const MAX_SUGGESTIONS = 3
/** Near-miss only — wide band (0.300.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<string>()
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<number, number[]>()
const summaries = new Map<number, string>()
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<number[] | null> {
const rows = await prisma.$queryRawUnsafe<Array<{ embedding: string }>>(
`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<BridgeSuggestion | null> {
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<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 — 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<BridgeSuggestion[]> {
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<void> {
await prisma.bridgeSuggestion.updateMany({
where: { userId, clusterAId, clusterBId },
data: { isDismissed: true }
})
}
}
export const bridgeNotesService = new BridgeNotesService()