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

@@ -0,0 +1,108 @@
---
title: 'Improve Note Graph Relationships'
type: 'feature'
created: '2026-05-23T08:25:00Z'
status: 'done'
baseline_commit: 'ca0637cc6eac9685946a62458d7b9d9dc5232a77'
context: []
---
## Intent
**Problem:** La vue en graphe (`NoteGraphView`) ne tire parti que de balayages de texte rudimentaires (mentions de titre, tags partagés et similarité Jaccard en mémoire qui est désactivée au-delà de 500 notes) pour tracer les liaisons. Elle ignore totalement :
1. Les liaisons explicites manuelles créées par l'utilisateur via des WikiLinks (`[[Note]]`) et enregistrées dans la table `NoteLink` de la base de données.
2. Les connexions sémantiques riches générées en arrière-plan par l'IA ("Memory Echo") et enregistrées dans la table `MemoryEchoInsight` de la base de données.
Cela donne à la vue en graphe un aspect incomplet et décousu, limitant grandement la sérendipité et la découverte de connaissances.
**Approach:**
1. Modifier la route API `/api/graph` pour requêter et fusionner les liaisons manuelles (`NoteLink`) et les connexions sémantiques saines non rejetées (`MemoryEchoInsight` avec `dismissed = false`).
2. Introduire des types de liaisons explicites (`explicit_link` et `semantic_echo`) dans le flux de données de l'API.
3. Mettre à jour le composant de rendu React `<NoteGraphView />` pour styliser et différencier visuellement ces relations clés avec des couleurs HSL premium, des épaisseurs de trait adaptées et des motifs (pleins vs. pointillés) pour enrichir l'expérience utilisateur et clarifier la structure du graphe.
## Boundaries & Constraints
**Always:**
- Filtrer rigoureusement toutes les requêtes Prisma par `userId` pour garantir l'étanchéité des données utilisateur.
- Exclure toutes les liaisons (sources ou cibles) pointant vers des notes archivées ou envoyées à la corbeille (`trashedAt !== null`).
- Exclure les connexions sémantiques marquées comme rejetées (`dismissed: true`).
- Assurer un fort contraste visuel sur le graphe en harmonie avec la charte graphique premium existante (pas de couleurs agressives non maîtrisées).
**Ask First:**
- Faut-il plafonner le nombre total de liens sémantiques ou Jaccard affichés simultanément pour éviter de surcharger visuellement les graphes de grande taille (au-delà de 200 notes) ?
**Never:**
- Modifier la structure de la base de données Prisma (pas de changement de schéma).
- Lancer de commande destructive sur la base de données (`prisma db push --force-reset` ou équivalent).
- Supprimer ou désactiver les liaisons textuelles existantes (`title_mention`, `shared_label`, `jaccard`), qui restent d'excellents compléments de découverte.
## I/O & Edge-Case Matrix
| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| HAPPY_PATH | L'utilisateur possède des notes connectées par WikiLinks et des Memory Echo Insights | L'API `/api/graph` retourne un tableau fusionné contenant des arêtes de types `explicit_link`, `semantic_echo`, `title_mention`, etc. | N/A |
| EMPTY_STATE | Aucune liaison, aucune note | L'API retourne `{ nodes: [], edges: [], clusters: [] }` de manière propre sans planter. | Retourne des tableaux vides de manière sécurisée |
| TRASHED_NOTES | Des liaisons (`NoteLink` ou `MemoryEchoInsight`) pointent vers des notes supprimées | Les arêtes correspondantes sont filtrées et exclues du graphe final. | Filtrage en amont lors de la construction des listes |
| STUB_LINKS | Des wikilinks pointent vers des notes inexistantes (stubs) | Filtrage des arêtes orphelines (seuls les couples de notes existantes et valides sont renvoyés). | Ignorer l'arête si l'ID source ou cible n'est pas dans le set des notes chargées |
## Code Map
- `memento-note/app/api/graph/route.ts` -- Route API GET principale assemblant la liste des nœuds, des arêtes et des clusters de carnets.
- `memento-note/components/note-graph-view.tsx` -- Composant de rendu 2D interactif utilisant `react-force-graph-2d` pour dessiner les arêtes et les groupes.
- `memento-note/prisma/schema.prisma` -- Fichier de schéma Prisma contenant les définitions des tables `NoteLink` et `MemoryEchoInsight`.
## Tasks & Acceptance
**Execution:**
- [x] `memento-note/app/api/graph/route.ts` -- Modifier la route pour interroger en parallèle `prisma.noteLink` et `prisma.memoryEchoInsight`, filtrer les éléments invalides/corrompus/supprimés, et les injecter avec les types `explicit_link` et `semantic_echo`.
- [x] `memento-note/components/note-graph-view.tsx` -- Enrichir la logique de mappage des arêtes du graphe pour attribuer des styles visuels premium à chaque type de lien (WikiLinks en vert émeraude épais et plein, Échos sémantiques IA en violet pointillés, etc.).
**Acceptance Criteria:**
- **Given** une note A contenant un WikiLink vers une note B (`NoteLink` enregistré) et une note C sémantiquement proche de la note A (`MemoryEchoInsight` enregistré)
- **When** l'utilisateur charge la vue en graphe
- **Then** l'API renvoie les relations avec les types `explicit_link` et `semantic_echo`
- **And** le visualiseur dessine le lien A-B en trait vert plein et le lien A-C en trait violet pointillé.
## Design Notes
### Configuration Visuelle des Liens dans `note-graph-view.tsx`
Nous utiliserons les associations de couleurs et d'épaisseurs suivantes pour offrir une lisibilité optimale sur fond clair/sombre :
```typescript
// Exemple de configuration stylistique
const LINK_STYLES = {
explicit_link: { color: '#10b981', width: 2.2, dash: false }, // Vert émeraude (WikiLink manuel)
semantic_echo: { color: '#a78bfa', width: 1.8, dash: true }, // Violet pastel (Écho sémantique IA)
title_mention: { color: '#f59e0b', width: 1.6, dash: false }, // Orange ambré (Mention de titre automatique)
shared_label: { color: '#3b82f6', width: 1.2, dash: false }, // Bleu vif (Tags partagés)
jaccard: { color: '#cbd5e1', width: 0.8, dash: false } // Gris clair (Similarité sémantique simple)
}
```
Pour les pointillés (dash) dans `react-force-graph-2d`, nous adapterons l'objet du graphe ou appliquerons la configuration de rendu des lignes Canvas via l'API Native si nécessaire, ou plus simplement en les configurant élégamment dans le dessin.
## Verification
**Commands:**
- `npx tsc --noEmit` -- Vérification de l'absence totale d'erreurs de typage TypeScript dans le projet après modification.
## Suggested Review Order
**Backend API & Relationships Integration**
- Requête Prisma sécurisée pour charger en parallèle les liaisons WikiLinks (`NoteLink`) et les échos sémantiques IA (`MemoryEchoInsight`).
[`route.ts:66`](../../memento-note/app/api/graph/route.ts#L66)
- Priorisation des liaisons qualitatives sur les mentions textuelles et assemblage des arêtes du graphe.
[`route.ts:161`](../../memento-note/app/api/graph/route.ts#L161)
**Frontend Visualizations & Legend**
- Mappage des arêtes vers les nouvelles couleurs de liaison et gestion de la propriété de pointillés.
[`note-graph-view.tsx:112`](../../memento-note/components/note-graph-view.tsx#L112)
- Configuration de la propriété de rendu en pointillés `linkLineDash` sur le composant ForceGraph2D.
[`note-graph-view.tsx:278`](../../memento-note/components/note-graph-view.tsx#L278)
- Rendu du panneau de légende des liaisons dans le coin inférieur gauche pour une expérience utilisateur premium.
[`note-graph-view.tsx:340`](../../memento-note/components/note-graph-view.tsx#L340)

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 => ({
.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: 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,
})),
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">