194 lines
7.3 KiB
TypeScript
194 lines
7.3 KiB
TypeScript
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<string> {
|
|
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<string>, b: Set<string>): 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<string, Set<string>>()
|
|
const labelMap = new Map<string, Set<string>>()
|
|
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<EdgeType, number> = {
|
|
explicit_link: 5,
|
|
semantic_echo: 4,
|
|
title_mention: 3,
|
|
shared_label: 2,
|
|
jaccard: 1,
|
|
}
|
|
|
|
const edgeMap = new Map<string, GraphEdge>()
|
|
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(`\\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'))
|
|
}
|
|
}
|
|
|
|
// ── 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<string, number>()
|
|
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<string, string>()
|
|
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 })
|
|
}
|