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>
633 lines
21 KiB
TypeScript
633 lines
21 KiB
TypeScript
/**
|
||
* 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<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.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<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()
|