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() const membershipScores: Record = {} const clusterToNotes = new Map() 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 } ) } }