feat(graph): improve note graph relationships by integrating wikilinks and semantic AI echo insights
This commit is contained in:
@@ -36,7 +36,7 @@ function jaccardSimilarity(a: Set<string>, b: Set<string>): number {
|
||||
return intersection / (a.size + b.size - intersection)
|
||||
}
|
||||
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard'
|
||||
type EdgeType = 'title_mention' | 'shared_label' | 'jaccard' | 'explicit_link' | 'semantic_echo'
|
||||
|
||||
interface GraphEdge { source: string; target: string; weight: number; type: EdgeType }
|
||||
|
||||
@@ -62,6 +62,35 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
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>>()
|
||||
@@ -70,11 +99,27 @@ export async function GET(request: NextRequest) {
|
||||
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 || ex.weight < weight) edgeMap.set(key, { source: a < b ? a : b, target: a < b ? b : a, weight, type })
|
||||
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") ──────────
|
||||
@@ -113,6 +158,16 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
|
||||
@@ -104,12 +104,35 @@ export function NoteGraphView() {
|
||||
})),
|
||||
links: rawData.edges
|
||||
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color: e.type === 'title_mention' ? '#f59e0b' : e.type === 'shared_label' ? '#6366f1' : '#e2e8f0',
|
||||
width: e.type === 'title_mention' ? 2 : e.type === 'shared_label' ? 1.5 : 0.6,
|
||||
})),
|
||||
.map(e => {
|
||||
let color = '#e2e8f0'
|
||||
let width = 0.6
|
||||
let dash = false
|
||||
|
||||
if (e.type === 'explicit_link') {
|
||||
color = '#10b981' // Green
|
||||
width = 2.2
|
||||
} else if (e.type === 'semantic_echo') {
|
||||
color = '#a78bfa' // Purple
|
||||
width = 1.8
|
||||
dash = true
|
||||
} else if (e.type === 'title_mention') {
|
||||
color = '#f59e0b' // Amber/Orange
|
||||
width = 1.6
|
||||
} else if (e.type === 'shared_label') {
|
||||
color = '#3b82f6' // Blue
|
||||
width = 1.2
|
||||
}
|
||||
|
||||
return {
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color,
|
||||
width,
|
||||
dash,
|
||||
type: e.type,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [rawData, searchFilter, colorMap])
|
||||
|
||||
@@ -252,6 +275,7 @@ export function NoteGraphView() {
|
||||
nodeLabel="name"
|
||||
linkColor="color"
|
||||
linkWidth="width"
|
||||
linkLineDash={(link: any) => link.dash ? [4, 3] : null}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={(node: any) => {
|
||||
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
|
||||
@@ -313,6 +337,35 @@ export function NoteGraphView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend of relationship types */}
|
||||
{!loading && !error && graphData.nodes.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs">
|
||||
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">Types de liaisons</h3>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">WikiLink (Manuel)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 border-t-2 border-dashed shrink-0 border-[#a78bfa]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Memory Echo (IA)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#f59e0b]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Mention de titre</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-0.5 rounded shrink-0 bg-[#3b82f6]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Tags partagés</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-[1px] rounded shrink-0 bg-[#e2e8f0]" />
|
||||
<span className="text-[10px] font-medium text-concrete/70">Similarité sémantique</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note detail panel */}
|
||||
{selectedNode && (
|
||||
<div className="absolute inset-y-0 right-0 w-80 bg-white border-l border-border/50 flex flex-col shadow-xl z-20">
|
||||
|
||||
Reference in New Issue
Block a user