331 lines
9.8 KiB
TypeScript
331 lines
9.8 KiB
TypeScript
/**
|
|
* 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()
|