feat: Memory Echo chunk-level — détecte connexions au niveau section
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:
@@ -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 “Re-analyser” 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 été 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'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 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'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'a été 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>
|
||||
|
||||
Reference in New Issue
Block a user