feat(graph): improve note graph relationships by integrating wikilinks and semantic AI echo insights

This commit is contained in:
Antigravity
2026-05-23 08:26:13 +00:00
parent ca0637cc6e
commit d589b8aa7e
3 changed files with 224 additions and 8 deletions

View File

@@ -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)

View File

@@ -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">