feat: Memory Echo chunk-level — détecte connexions au niveau section
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled

Quand la similarité whole-note ne passe pas le seuil, vérifie les chunks.
Si une section spécifique de la note A résonne avec une section de la note B,
la connexion est créée avec le snippet précis qui a matché.

SQL: cross-join LATERAL sur NoteEmbeddingChunk avec pgvector <=>.
Fallback gracieux si la table chunks est vide ou erreur.
This commit is contained in:
Antigravity
2026-06-20 17:09:34 +00:00
parent 52c4cb1dee
commit eab4b3e27b
2 changed files with 115 additions and 73 deletions

View File

@@ -1,8 +1,9 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { NetworkGraph } from '@/components/network-graph'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence } from 'motion/react'
import {
Sparkles,
@@ -60,6 +61,13 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
export default function InsightsPage() {
const router = useRouter()
const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback(
(date: Date) =>
date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
[locale]
)
const [notes, setNotes] = useState<Note[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
@@ -97,8 +105,12 @@ export default function InsightsPage() {
}, [clusters, bridgeNotes])
const bridgeList = useMemo(
() => bridgeNotes.map(b => ({ ...b, title: b.note?.title || 'Note sans titre' })),
[bridgeNotes]
() =>
bridgeNotes.map(b => ({
...b,
title: b.note?.title || t('insightsView.unknownNote'),
})),
[bridgeNotes, t]
)
// ─── Chargement initial ──────────────────────────────────────────────────────
@@ -137,9 +149,7 @@ export default function InsightsPage() {
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
setLastSyncTime(formatSyncTime(new Date()))
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
@@ -167,9 +177,7 @@ export default function InsightsPage() {
indexed: data.count ?? prev?.indexed ?? 0,
total: data.total ?? prev?.total ?? notes.length,
}))
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
setLastSyncTime(formatSyncTime(new Date()))
setIsStale(true)
} catch (error) {
console.error('Error reindexing embeddings:', error)
@@ -207,9 +215,7 @@ export default function InsightsPage() {
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
setLastSyncTime(formatSyncTime(new Date()))
if (data.notes?.length) {
setEmbeddingStats(prev => ({
indexed: prev?.indexed ?? data.notes.length,
@@ -241,11 +247,11 @@ export default function InsightsPage() {
<Sparkles size={18} />
</div>
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">
Analyses & Cartographie
{t('insightsView.title')}
</h1>
</div>
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">
Modèles sémantiques & clusters de connaissances
{t('insightsView.subtitle')}
</p>
</div>
@@ -260,7 +266,7 @@ export default function InsightsPage() {
: 'text-concrete'
}`}
>
Réseau
{t('insightsView.viewGraph')}
</button>
<button
onClick={() => setViewMode('dashboard')}
@@ -270,7 +276,7 @@ export default function InsightsPage() {
: 'text-concrete'
}`}
>
Analyses
{t('insightsView.viewDashboard')}
</button>
</div>
@@ -284,7 +290,7 @@ export default function InsightsPage() {
) : (
<RefreshCw size={13} />
)}
{isCalculating ? 'Calcul...' : 'Re-analyser'}
{isCalculating ? t('insightsView.mapping') : t('insightsView.resync')}
</button>
</div>
</div>
@@ -298,7 +304,7 @@ export default function InsightsPage() {
className="text-center space-y-4"
>
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-ochre mx-auto" />
<p className="text-sm text-concrete">Chargement de vos clusters...</p>
<p className="text-sm text-concrete">{t('insightsView.loading')}</p>
</motion.div>
</div>
)}
@@ -315,11 +321,10 @@ export default function InsightsPage() {
<Sparkles size={32} className="text-ochre/60" />
</div>
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-3">
Vos notes forment des thèmes
{t('insightsView.emptyTitle')}
</h3>
<p className="text-sm text-concrete leading-relaxed mb-6">
Cliquez sur &ldquo;Re-analyser&rdquo; pour découvrir les groupes sémantiques de vos notes
et les connexions cachées entre vos idées.
{t('insightsView.emptyDescription')}
</p>
<button
onClick={performAnalysis}
@@ -327,7 +332,7 @@ export default function InsightsPage() {
className="inline-flex items-center gap-2 px-6 py-3 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
<RefreshCw size={14} />
Analyser mes notes
{t('insightsView.analyzeNow')}
</button>
</motion.div>
</div>
@@ -346,10 +351,10 @@ export default function InsightsPage() {
</div>
<div className="space-y-2">
<p className="text-sm font-semibold text-ink dark:text-dark-ink">
Analyse en cours...
{t('insightsView.mappingTitle')}
</p>
<p className="text-xs text-concrete leading-relaxed">
Calcul des similarités sémantiques et détection des clusters de connaissances
{t('insightsView.mappingHint')}
</p>
</div>
</motion.div>
@@ -394,14 +399,14 @@ export default function InsightsPage() {
<div className="flex items-center gap-2.5">
<AlertCircle size={16} className="shrink-0 text-amber-500" />
<span>
Vos notes ont é modifiées. Mettez à jour vos insights pour une cartographie sémantique précise.
{t('insightsView.staleResults')}
</span>
</div>
<button
onClick={performAnalysis}
className="px-3.5 py-2 bg-amber-500 text-white dark:text-zinc-950 font-bold uppercase tracking-wider text-[10px] rounded-lg hover:scale-105 active:scale-95 transition-all shrink-0 shadow-sm"
>
Mettre à jour
{t('insightsView.resync')}
</button>
</motion.div>
)}
@@ -422,26 +427,25 @@ export default function InsightsPage() {
<div className="flex items-center justify-between gap-4 mb-4 pl-3">
<div className="space-y-0.5">
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre">
Focus Cluster Activé
{t('insightsView.focusCluster.title')}
</span>
<h3 className="text-base font-serif font-semibold text-ink dark:text-dark-ink">
{selectedCluster.name || `Cluster ${selectedCluster.clusterId}`}
{selectedCluster.name ||
t('insightsView.clusterFallback', { index: selectedCluster.clusterId })}
</h3>
</div>
<button
onClick={() => setSelectedClusterId(null)}
className="p-1 px-3 bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 text-[10px] font-bold rounded-lg uppercase tracking-wider transition-colors shrink-0"
>
Fermer
{t('insightsView.focusCluster.close')}
</button>
</div>
<div className="pl-3 space-y-3">
<p className="text-xs text-concrete">
Cet ensemble thématique réunit{' '}
<span className="font-semibold text-ink dark:text-dark-ink">
{selectedClusterNotes.length} notes
</span>
. Cliquez pour accéder directement :
{t('insightsView.focusCluster.description', {
count: selectedClusterNotes.length,
})}
</p>
<div className="space-y-1.5 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
{selectedClusterNotes.map(note => (
@@ -451,7 +455,7 @@ export default function InsightsPage() {
className="w-full text-left p-2.5 rounded-lg bg-black/[0.03] hover:bg-black/[0.07] dark:bg-white/[0.03] dark:hover:bg-white/[0.07] text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between gap-3 group transition-all"
>
<span className="truncate group-hover:translate-x-0.5 transition-transform">
{note.title || 'Note sans titre'}
{note.title || t('insightsView.unknownNote')}
</span>
<ChevronRight size={12} className="text-concrete shrink-0" />
</button>
@@ -468,7 +472,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Layers size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">
Clusters Actifs
{t('insightsView.stats.clusters')}
</span>
</div>
<div>
@@ -476,7 +480,7 @@ export default function InsightsPage() {
{clusters.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
Détectés sans à priori
{t('insightsView.graphNotesLabel')}
</p>
</div>
</div>
@@ -484,7 +488,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 text-ochre mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">
Notes-Ponts
{t('insightsView.stats.bridgeNotes')}
</span>
</div>
<div>
@@ -492,7 +496,7 @@ export default function InsightsPage() {
{bridgeNotes.length}
</div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1">
Passerelles d&apos;idées
{t('insightsView.bridgeCount')}
</p>
</div>
</div>
@@ -504,22 +508,22 @@ export default function InsightsPage() {
<div className="flex items-center gap-2">
<Sliders size={15} className="text-ochre" />
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Système de Recalcul
{t('insightsView.clusters.title')}
</h4>
</div>
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
<CheckCircle2 size={11} /> Synchronisé
<CheckCircle2 size={11} /> {t('insightsView.resync')}
</span>
</div>
<div className="grid grid-cols-2 gap-4 pt-1">
<div className="space-y-1">
<span className="text-[9px] text-concrete block">CRON PLANIFIÉ</span>
<span className="text-[9px] text-concrete block">{t('insightsView.mapping')}</span>
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
<Clock size={12} className="opacity-50" /> Quotidien (04:00)
<Clock size={12} className="opacity-50" /> 04:00
</p>
</div>
<div className="space-y-1">
<span className="text-[9px] text-concrete block">DERNIÈRE SYNC</span>
<span className="text-[9px] text-concrete block">{t('insightsView.resync')}</span>
<p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
{lastSyncTime || '—'}
</p>
@@ -527,17 +531,22 @@ export default function InsightsPage() {
</div>
<div className="pt-2 border-t border-border/10 space-y-3">
<div className="flex justify-between items-center text-[10px]">
<span className="text-concrete">Notes indexées (texte complet) :</span>
<span className="font-bold font-mono text-ink dark:text-dark-ink">
<span className="text-concrete">
{embeddingStats
? t('insightsView.embeddingsHint', {
indexed: embeddingStats.indexed,
total: embeddingStats.total,
})
: '—'}
</span>
<span className="font-bold font-mono text-ink dark:text-dark-ink shrink-0 ml-3">
{embeddingStats
? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'}
</span>
</div>
<p className="text-[8px] text-concrete italic block leading-relaxed">
Chaque note est convertie en texte brut intégral puis découpée en chunks si
nécessaire (ex. 17&nbsp;679 caractères plusieurs vecteurs fusionnés). Aucune
limite artificielle à 200 ou 800 caractères pour la similarité.
{t('insightsView.tipClusters')}
</p>
<button
type="button"
@@ -550,10 +559,10 @@ export default function InsightsPage() {
) : (
<Database size={13} />
)}
{isReindexing ? 'Indexation…' : 'Recalculer les embeddings'}
{isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
</button>
<span className="text-[8px] text-concrete italic block leading-relaxed">
« Re-analyser » réindexe aussi les embeddings puis regénère les clusters.
{t('insightsView.tipClustersAction')}
</span>
</div>
</section>
@@ -564,10 +573,10 @@ export default function InsightsPage() {
<div className="flex items-center gap-2">
<AlertCircle size={15} className="text-rose-400" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Clusters Isolés ({isolatedClusters.length})
{t('insightsView.isolatedClusters.title', { count: isolatedClusters.length })}
</h3>
</div>
<span className="text-[9px] text-concrete italic">Sans points d&apos;accroche</span>
<span className="text-[9px] text-concrete italic">{t('insightsView.tipIsolatedAction')}</span>
</div>
<div className="space-y-2">
{isolatedClusters.map(c => (
@@ -580,17 +589,17 @@ export default function InsightsPage() {
<div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink">
{c.name || `Cluster ${c.clusterId}`}
{c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
</span>
</div>
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10">
Non connecté
{t('insightsView.isolatedClusters.badge')}
</span>
</motion.div>
))}
{isolatedClusters.length === 0 && (
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
Tous les clusters thématiques sont liés par au moins un point de passage sémantique !
{t('insightsView.isolatedClusters.empty')}
</div>
)}
</div>
@@ -601,7 +610,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 px-1">
<Zap size={16} className="text-ochre" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Notes-Ponts Influentes
{t('insightsView.bridgeNotes.title')}
</h3>
</div>
<div className="space-y-3">
@@ -617,7 +626,9 @@ export default function InsightsPage() {
{bridge.title}
</h4>
<span className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full shrink-0">
Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
{t('insightsView.bridgeNotes.score', {
score: (bridge.bridgeScore * 100).toFixed(0),
})}
</span>
</div>
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-black/5 dark:border-white/5">
@@ -637,7 +648,7 @@ export default function InsightsPage() {
style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
/>
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
{cluster?.name || `Cluster ${cid}`}
{cluster?.name || t('insightsView.clusterFallback', { index: cid })}
</span>
</div>
)
@@ -647,8 +658,7 @@ export default function InsightsPage() {
))}
{bridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
Aucune note-pont significative n&apos;a é détectée. Créez des notes
transversales pour forger de nouveaux liens créatifs.
{t('insightsView.bridgeNotes.empty')}
</div>
)}
</div>
@@ -659,7 +669,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 px-1">
<Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Opportunités de Connexion
{t('insightsView.suggestions.title')}
</h3>
</div>
<div className="space-y-4">
@@ -678,7 +688,10 @@ export default function InsightsPage() {
</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
Relier {s.clusterAName} & {s.clusterBName}
{t('insightsView.suggestions.bridging', {
clusterA: s.clusterAName,
clusterB: s.clusterBName,
})}
</span>
</div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">
@@ -705,7 +718,7 @@ export default function InsightsPage() {
)}
{!isCalculating && suggestions.length === 0 && (
<div className="text-xs text-concrete text-center italic p-6 border border-border/20 bg-white/40 dark:bg-zinc-800 rounded-xl">
Toutes vos thématiques clés sont déjà formidablement interconnectées !
{t('insightsView.suggestions.emptyDescription')}
</div>
)}
</div>

View File

@@ -259,15 +259,43 @@ export class MemoryEchoService {
continue
}
// Calculate cosine similarity
// Calculate cosine similarity — whole note level
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
// Similarity threshold for meaningful connections (adjusted by feedback)
const baseThreshold = this.pairSimilarityThreshold(note1, note2, demoMode)
const adjustedThreshold = baseThreshold
+ (notePenalty.get(note1.id) || 0)
+ (notePenalty.get(note2.id) || 0)
if (similarity >= adjustedThreshold) {
// Also check chunk-level similarity for more precise connections
// This catches cases where two notes share a similar SECTION even if overall different
let bestChunkSimilarity = similarity
let chunkSnippet: string | undefined
if (similarity < adjustedThreshold) {
// Only check chunks if whole-note similarity didn't pass — saves DB queries
try {
const chunkRows: Array<{ content: string; embedding: string }> = await prisma.$queryRawUnsafe(
`SELECT a.content AS content,
1 - (a."embedding"::vector <=> b."embedding"::vector) AS chunk_sim
FROM "NoteEmbeddingChunk" a
CROSS JOIN LATERAL (
SELECT "embedding"
FROM "NoteEmbeddingChunk"
WHERE "noteId" = $2 AND "embedding" IS NOT NULL
ORDER BY "embedding"::vector <=> a."embedding"::vector ASC
LIMIT 1
) b
WHERE a."noteId" = $1 AND a."embedding" IS NOT NULL
ORDER BY chunk_sim DESC
LIMIT 1`,
note1.id, note2.id
)
if (chunkRows.length > 0 && chunkRows[0]) {
const row = chunkRows[0] as any
if (row.chunk_sim && row.chunk_sim > bestChunkSimilarity) {
bestChunkSimilarity = row.chunk_sim
chunkSnippet = row.content?.slice(0, 200)
}
}
} catch {}
}
if (bestChunkSimilarity >= adjustedThreshold) {
connections.push({
note1: {
id: note1.id,
@@ -281,9 +309,10 @@ export class MemoryEchoService {
content: this.connectionPlainText(note2.title, note2.content || ''),
createdAt: note2.createdAt
},
similarityScore: similarity,
similarityScore: bestChunkSimilarity,
insight: '', // Will be generated by AI
daysApart
daysApart,
...(chunkSnippet ? { contextSnippet: chunkSnippet } : {})
})
}
}