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' 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) // 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 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 || ex.weight < 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(`\\b${escaped}\\b`, 'i') for (const noteB of notes) { if (noteA.id === noteB.id) continue if (re.test(stripHtml(noteB.content))) upsertEdge(noteA.id, noteB.id, 1.0, 'title_mention') } } // ── Niveau 2 : Labels partagés ──────────────────────────────────────────── for (let i = 0; i < ids.length; i++) { for (let j = i + 1; j < ids.length; j++) { const la = labelMap.get(ids[i])! const lb = labelMap.get(ids[j])! const shared = [...la].filter(l => 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')) } } 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 }) }