Files
Momento/memento-note/app/api/insights/graph/route.ts
Antigravity e881004c77
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m7s
CI / Deploy production (on server) (push) Has been skipped
feat(insights): fix DBSCAN, Persian embeddings crash, D3 physics layouts, and D3 node not found runtime error
2026-05-24 18:57:33 +00:00

135 lines
4.6 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
/**
* GET /api/insights/graph
*
* Retourne les similarités cosinus pairwise calculées depuis les embeddings pgvector
* pour TOUS les membres des clusters (intra-cluster) + les échos Memory Echo (inter-cluster).
*
* Structure de réponse :
* {
* pairs: [{ sourceId, targetId, similarity, type: 'cluster' | 'echo' }],
* membershipScores: { [noteId]: number }
* }
*
* - pairs.cluster : paires au sein du même cluster, score = similarité cosinus réelle
* - pairs.echo : paires Memory Echo non-rejetées, score = similarityScore stocké
* - membershipScores : score de centralité de chaque note dans son cluster (de ClusterMember)
*/
export async function GET(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
// 1. Charger les membres de clusters avec leur score de centralité
const clusterMembers = await prisma.clusterMember.findMany({
where: { userId },
select: { noteId: true, clusterId: true, membershipScore: true }
})
if (clusterMembers.length === 0) {
return NextResponse.json({ pairs: [], membershipScores: {} })
}
// Construire la map noteId -> clusterId
const noteToCluster = new Map<string, number>()
const membershipScores: Record<string, number> = {}
const clusterToNotes = new Map<number, string[]>()
for (const m of clusterMembers) {
noteToCluster.set(m.noteId, m.clusterId)
membershipScores[m.noteId] = m.membershipScore
if (!clusterToNotes.has(m.clusterId)) clusterToNotes.set(m.clusterId, [])
clusterToNotes.get(m.clusterId)!.push(m.noteId)
}
const allNoteIds = clusterMembers.map(m => m.noteId)
// 2. Calculer les similarités cosinus pairwise intra-cluster via pgvector
// On utilise une requête SQL qui calcule toutes les paires d'un même cluster en une fois
const intraClusterPairs = await prisma.$queryRawUnsafe<
Array<{ sourceId: string; targetId: string; similarity: number; clusterId: number }>
>(
`SELECT
e1."noteId" AS "sourceId",
e2."noteId" AS "targetId",
1 - (e1.embedding::vector <=> e2.embedding::vector) AS similarity,
cm1."clusterId" AS "clusterId"
FROM "NoteEmbedding" e1
INNER JOIN "NoteEmbedding" e2 ON e1."noteId" < e2."noteId"
INNER JOIN "ClusterMember" cm1 ON cm1."noteId" = e1."noteId" AND cm1."userId" = $1
INNER JOIN "ClusterMember" cm2 ON cm2."noteId" = e2."noteId" AND cm2."userId" = $1
WHERE cm1."clusterId" = cm2."clusterId"
AND e1."noteId" = ANY($2::text[])
AND e2."noteId" = ANY($2::text[])`,
userId,
allNoteIds
)
// 3. Récupérer les échos Memory Echo non-rejetés entre notes clusterisées
const echoInsights = await prisma.memoryEchoInsight.findMany({
where: {
userId,
dismissed: false,
note1Id: { in: allNoteIds },
note2Id: { in: allNoteIds }
},
select: {
note1Id: true,
note2Id: true,
similarityScore: true
}
})
// 4. Construire la liste finale des paires
const pairs: Array<{
sourceId: string
targetId: string
similarity: number
type: 'cluster' | 'echo'
clusterId?: number
}> = []
// Paires intra-cluster
for (const p of intraClusterPairs) {
pairs.push({
sourceId: p.sourceId,
targetId: p.targetId,
similarity: Math.max(0, Math.min(1, p.similarity)),
type: 'cluster',
clusterId: p.clusterId
})
}
// Paires Memory Echo (entre clusters différents souvent, mais peut être intra aussi)
const existingPairKeys = new Set(pairs.map(p => `${p.sourceId}--${p.targetId}`))
for (const echo of echoInsights) {
const key1 = `${echo.note1Id}--${echo.note2Id}`
const key2 = `${echo.note2Id}--${echo.note1Id}`
// Ajouter uniquement si pas déjà couvert par intra-cluster
if (!existingPairKeys.has(key1) && !existingPairKeys.has(key2)) {
pairs.push({
sourceId: echo.note1Id,
targetId: echo.note2Id,
similarity: Math.max(0, Math.min(1, echo.similarityScore)),
type: 'echo'
})
}
}
return NextResponse.json({ pairs, membershipScores })
} catch (error) {
console.error('[/api/insights/graph] Error:', error)
return NextResponse.json(
{ error: 'Failed to compute semantic graph', details: String(error) },
{ status: 500 }
)
}
}