import { NextRequest, NextResponse } from 'next/server' import prisma from '@/lib/prisma' import { auth } from '@/auth' // ── Stopwords FR + EN ───────────────────────────────────────────────────────── const STOPWORDS = new Set([ 'le','la','les','de','du','des','un','une','et','en','au','aux','ce','se', 'sa','son','ses','mon','ma','mes','ton','ta','tes','que','qui','quoi','dont', 'il','elle','ils','elles','nous','vous','je','tu','on','par','pour','sur', 'sous','avec','dans','est','sont','pas','ne','plus','très','tout','comme', 'mais','donc','car','cet','cette','ces','leur','leurs','note','notes', 'the','a','an','and','or','but','in','on','at','to','for','of','with','by', 'from','is','are','was','were','be','been','have','has','had','do','does', 'did','will','would','could','should','may','might','this','that','these', 'those','it','its','they','them','their','he','she','we','you','not','no', 'so','if','as','up','out','about','also','just','can','all','any','get', ]) function stripHtml(html: string): string { return html.replace(/<[^>]+>/g, ' ').replace(/&\w+;/g, ' ') } function extractKeywords(text: string): Set { return new Set( stripHtml(text) .toLowerCase() .split(/[\s\p{P}]+/u) .filter(w => w.length >= 3 && !STOPWORDS.has(w) && !/^\d+$/.test(w)) ) } function jaccardSimilarity(a: Set, b: Set): number { if (a.size === 0 || b.size === 0) return 0 let intersection = 0 for (const w of a) if (b.has(w)) intersection++ return intersection / (a.size + b.size - intersection) } type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo' interface GraphEdge { source: string; target: string; weight: number; type: EdgeType } // GET /api/graph — connexions automatiques à 3 niveaux export async function GET(request: NextRequest) { const session = await auth() if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const userId = session.user.id const { searchParams } = new URL(request.url) const notebookId = searchParams.get('notebookId') || undefined const notes = await prisma.note.findMany({ where: { userId, trashedAt: null, ...(notebookId ? { notebookId } : {}) }, select: { id: true, title: true, content: true, notebookId: true, createdAt: true, labelRelations: { select: { id: true } }, notebook: { select: { id: true, name: true } }, }, }) if (notes.length === 0) return NextResponse.json({ nodes: [], edges: [] }) const ids = notes.map(n => n.id) // Query NoteLink manually created relationships const noteLinks = await (prisma as any).noteLink.findMany({ where: { sourceNoteId: { in: ids }, targetNoteId: { in: ids } }, select: { sourceNoteId: true, targetNoteId: true, contextSnippet: true } }) // Query MemoryEchoInsight semantic relationships const echoInsights = await (prisma as any).memoryEchoInsight.findMany({ where: { userId, dismissed: false, note1Id: { in: ids }, note2Id: { in: ids } }, select: { note1Id: true, note2Id: true, similarityScore: true, insight: true } }) // Pré-calcul const keywordsMap = new Map>() const labelMap = new Map>() for (const note of notes) { keywordsMap.set(note.id, extractKeywords(`${note.title ?? ''} ${note.content}`)) labelMap.set(note.id, new Set(note.labelRelations.map((l: any) => l.id))) } const EDGE_TYPE_PRIORITY: Record = { explicit_link: 5, semantic_echo: 4, title_mention: 3, shared_label: 2, jaccard: 1, } const edgeMap = new Map() function upsertEdge(a: string, b: string, weight: number, type: EdgeType) { const key = a < b ? `${a}--${b}` : `${b}--${a}` const ex = edgeMap.get(key) if (!ex) { edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type }) } else { const exPriority = EDGE_TYPE_PRIORITY[ex.type] || 0 const curPriority = EDGE_TYPE_PRIORITY[type] || 0 if (curPriority > exPriority || (curPriority === exPriority && weight > ex.weight)) { edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type }) } } } // ── Niveau 1 : Title Mention (comme Obsidian "unlinked mentions") ────────── for (const noteA of notes) { const title = (noteA.title ?? '').trim().toLowerCase() if (title.length < 3) continue const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const re = new RegExp(`(? lb.has(l)).length if (shared > 0) upsertEdge(ids[i], ids[j], Math.min(0.5 + shared * 0.15, 0.9), 'shared_label') } } // ── Niveau 3 : Jaccard (désactivé > 500 notes) ─────────────────────────── if (notes.length <= 500) { for (let i = 0; i < ids.length; i++) { const kwI = keywordsMap.get(ids[i])! const candidates: { j: number; score: number }[] = [] for (let j = i + 1; j < ids.length; j++) { const score = jaccardSimilarity(kwI, keywordsMap.get(ids[j])!) if (score >= 0.12) candidates.push({ j, score }) } candidates.sort((a, b) => b.score - a.score).slice(0, 10) .forEach(({ j, score }) => upsertEdge(ids[i], ids[j], score * 0.8, 'jaccard')) } } // ── Niveau 4 : WikiLinks explicites (NoteLink) ───────────────────────────── for (const link of noteLinks) { upsertEdge(link.sourceNoteId, link.targetNoteId, 1.0, 'explicit_link') } // ── Niveau 5 : Échos sémantiques IA (MemoryEchoInsight) ──────────────────── for (const echo of echoInsights) { upsertEdge(echo.note1Id, echo.note2Id, echo.similarityScore, 'semantic_echo') } const degreeMap = new Map() for (const e of edgeMap.values()) { degreeMap.set(e.source, (degreeMap.get(e.source) ?? 0) + 1) degreeMap.set(e.target, (degreeMap.get(e.target) ?? 0) + 1) } const nodes = notes.map(n => ({ id: n.id, title: n.title || 'Sans titre', notebookId: n.notebookId, createdAt: n.createdAt, degree: degreeMap.get(n.id) ?? 0, })) // Build clusters (notebooks) const notebookMap = new Map() for (const n of notes) { if (n.notebook) notebookMap.set(n.notebook.id, n.notebook.name) } const clusters = [...notebookMap.entries()].map(([id, name]) => ({ id, name })) return NextResponse.json({ nodes, edges: Array.from(edgeMap.values()), clusters }) }