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' 'use client'
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo, useCallback } from 'react'
import { NetworkGraph } from '@/components/network-graph' import { NetworkGraph } from '@/components/network-graph'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence } from 'motion/react' import { motion, AnimatePresence } from 'motion/react'
import { import {
Sparkles, Sparkles,
@@ -60,6 +61,13 @@ const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#
export default function InsightsPage() { export default function InsightsPage() {
const router = useRouter() 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 [notes, setNotes] = useState<Note[]>([])
const [clusters, setClusters] = useState<Cluster[]>([]) const [clusters, setClusters] = useState<Cluster[]>([])
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]) const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([])
@@ -97,8 +105,12 @@ export default function InsightsPage() {
}, [clusters, bridgeNotes]) }, [clusters, bridgeNotes])
const bridgeList = useMemo( 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 ────────────────────────────────────────────────────── // ─── Chargement initial ──────────────────────────────────────────────────────
@@ -137,9 +149,7 @@ export default function InsightsPage() {
setSuggestions(suggestionsData.suggestions || []) setSuggestions(suggestionsData.suggestions || [])
} }
setLastSyncTime( setLastSyncTime(formatSyncTime(new Date()))
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') { if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes }) setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
} }
@@ -167,9 +177,7 @@ export default function InsightsPage() {
indexed: data.count ?? prev?.indexed ?? 0, indexed: data.count ?? prev?.indexed ?? 0,
total: data.total ?? prev?.total ?? notes.length, total: data.total ?? prev?.total ?? notes.length,
})) }))
setLastSyncTime( setLastSyncTime(formatSyncTime(new Date()))
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
setIsStale(true) setIsStale(true)
} catch (error) { } catch (error) {
console.error('Error reindexing embeddings:', error) console.error('Error reindexing embeddings:', error)
@@ -207,9 +215,7 @@ export default function InsightsPage() {
setSuggestions(suggestionsData.suggestions || []) setSuggestions(suggestionsData.suggestions || [])
} }
setLastSyncTime( setLastSyncTime(formatSyncTime(new Date()))
new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
)
if (data.notes?.length) { if (data.notes?.length) {
setEmbeddingStats(prev => ({ setEmbeddingStats(prev => ({
indexed: prev?.indexed ?? data.notes.length, indexed: prev?.indexed ?? data.notes.length,
@@ -241,11 +247,11 @@ export default function InsightsPage() {
<Sparkles size={18} /> <Sparkles size={18} />
</div> </div>
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink"> <h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">
Analyses & Cartographie {t('insightsView.title')}
</h1> </h1>
</div> </div>
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold"> <p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">
Modèles sémantiques & clusters de connaissances {t('insightsView.subtitle')}
</p> </p>
</div> </div>
@@ -260,7 +266,7 @@ export default function InsightsPage() {
: 'text-concrete' : 'text-concrete'
}`} }`}
> >
Réseau {t('insightsView.viewGraph')}
</button> </button>
<button <button
onClick={() => setViewMode('dashboard')} onClick={() => setViewMode('dashboard')}
@@ -270,7 +276,7 @@ export default function InsightsPage() {
: 'text-concrete' : 'text-concrete'
}`} }`}
> >
Analyses {t('insightsView.viewDashboard')}
</button> </button>
</div> </div>
@@ -284,7 +290,7 @@ export default function InsightsPage() {
) : ( ) : (
<RefreshCw size={13} /> <RefreshCw size={13} />
)} )}
{isCalculating ? 'Calcul...' : 'Re-analyser'} {isCalculating ? t('insightsView.mapping') : t('insightsView.resync')}
</button> </button>
</div> </div>
</div> </div>
@@ -298,7 +304,7 @@ export default function InsightsPage() {
className="text-center space-y-4" className="text-center space-y-4"
> >
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-ochre mx-auto" /> <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> </motion.div>
</div> </div>
)} )}
@@ -315,11 +321,10 @@ export default function InsightsPage() {
<Sparkles size={32} className="text-ochre/60" /> <Sparkles size={32} className="text-ochre/60" />
</div> </div>
<h3 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-3"> <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> </h3>
<p className="text-sm text-concrete leading-relaxed mb-6"> <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 {t('insightsView.emptyDescription')}
et les connexions cachées entre vos idées.
</p> </p>
<button <button
onClick={performAnalysis} 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" 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} /> <RefreshCw size={14} />
Analyser mes notes {t('insightsView.analyzeNow')}
</button> </button>
</motion.div> </motion.div>
</div> </div>
@@ -346,10 +351,10 @@ export default function InsightsPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold text-ink dark:text-dark-ink"> <p className="text-sm font-semibold text-ink dark:text-dark-ink">
Analyse en cours... {t('insightsView.mappingTitle')}
</p> </p>
<p className="text-xs text-concrete leading-relaxed"> <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> </p>
</div> </div>
</motion.div> </motion.div>
@@ -394,14 +399,14 @@ export default function InsightsPage() {
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<AlertCircle size={16} className="shrink-0 text-amber-500" /> <AlertCircle size={16} className="shrink-0 text-amber-500" />
<span> <span>
Vos notes ont é modifiées. Mettez à jour vos insights pour une cartographie sémantique précise. {t('insightsView.staleResults')}
</span> </span>
</div> </div>
<button <button
onClick={performAnalysis} 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" 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> </button>
</motion.div> </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="flex items-center justify-between gap-4 mb-4 pl-3">
<div className="space-y-0.5"> <div className="space-y-0.5">
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre"> <span className="text-[9px] font-bold uppercase tracking-widest text-ochre">
Focus Cluster Activé {t('insightsView.focusCluster.title')}
</span> </span>
<h3 className="text-base font-serif font-semibold text-ink dark:text-dark-ink"> <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> </h3>
</div> </div>
<button <button
onClick={() => setSelectedClusterId(null)} 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" 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> </button>
</div> </div>
<div className="pl-3 space-y-3"> <div className="pl-3 space-y-3">
<p className="text-xs text-concrete"> <p className="text-xs text-concrete">
Cet ensemble thématique réunit{' '} {t('insightsView.focusCluster.description', {
<span className="font-semibold text-ink dark:text-dark-ink"> count: selectedClusterNotes.length,
{selectedClusterNotes.length} notes })}
</span>
. Cliquez pour accéder directement :
</p> </p>
<div className="space-y-1.5 max-h-[180px] overflow-y-auto custom-scrollbar pr-1"> <div className="space-y-1.5 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
{selectedClusterNotes.map(note => ( {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" 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"> <span className="truncate group-hover:translate-x-0.5 transition-transform">
{note.title || 'Note sans titre'} {note.title || t('insightsView.unknownNote')}
</span> </span>
<ChevronRight size={12} className="text-concrete shrink-0" /> <ChevronRight size={12} className="text-concrete shrink-0" />
</button> </button>
@@ -468,7 +472,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 text-indigo-500 mb-2"> <div className="flex items-center gap-2 text-indigo-500 mb-2">
<Layers size={14} /> <Layers size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest"> <span className="text-[10px] font-bold uppercase tracking-widest">
Clusters Actifs {t('insightsView.stats.clusters')}
</span> </span>
</div> </div>
<div> <div>
@@ -476,7 +480,7 @@ export default function InsightsPage() {
{clusters.length} {clusters.length}
</div> </div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1"> <p className="text-[9px] text-concrete font-medium uppercase mt-1">
Détectés sans à priori {t('insightsView.graphNotesLabel')}
</p> </p>
</div> </div>
</div> </div>
@@ -484,7 +488,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 text-ochre mb-2"> <div className="flex items-center gap-2 text-ochre mb-2">
<Trophy size={14} /> <Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest"> <span className="text-[10px] font-bold uppercase tracking-widest">
Notes-Ponts {t('insightsView.stats.bridgeNotes')}
</span> </span>
</div> </div>
<div> <div>
@@ -492,7 +496,7 @@ export default function InsightsPage() {
{bridgeNotes.length} {bridgeNotes.length}
</div> </div>
<p className="text-[9px] text-concrete font-medium uppercase mt-1"> <p className="text-[9px] text-concrete font-medium uppercase mt-1">
Passerelles d&apos;idées {t('insightsView.bridgeCount')}
</p> </p>
</div> </div>
</div> </div>
@@ -504,22 +508,22 @@ export default function InsightsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sliders size={15} className="text-ochre" /> <Sliders size={15} className="text-ochre" />
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink"> <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> </h4>
</div> </div>
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase"> <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> </span>
</div> </div>
<div className="grid grid-cols-2 gap-4 pt-1"> <div className="grid grid-cols-2 gap-4 pt-1">
<div className="space-y-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"> <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> </p>
</div> </div>
<div className="space-y-1"> <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"> <p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
{lastSyncTime || '—'} {lastSyncTime || '—'}
</p> </p>
@@ -527,17 +531,22 @@ export default function InsightsPage() {
</div> </div>
<div className="pt-2 border-t border-border/10 space-y-3"> <div className="pt-2 border-t border-border/10 space-y-3">
<div className="flex justify-between items-center text-[10px]"> <div className="flex justify-between items-center text-[10px]">
<span className="text-concrete">Notes indexées (texte complet) :</span> <span className="text-concrete">
<span className="font-bold font-mono text-ink dark:text-dark-ink"> {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
? `${embeddingStats.indexed} / ${embeddingStats.total}` ? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'} : '—'}
</span> </span>
</div> </div>
<p className="text-[8px] text-concrete italic block leading-relaxed"> <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 {t('insightsView.tipClusters')}
nécessaire (ex. 17&nbsp;679 caractères plusieurs vecteurs fusionnés). Aucune
limite artificielle à 200 ou 800 caractères pour la similarité.
</p> </p>
<button <button
type="button" type="button"
@@ -550,10 +559,10 @@ export default function InsightsPage() {
) : ( ) : (
<Database size={13} /> <Database size={13} />
)} )}
{isReindexing ? 'Indexation…' : 'Recalculer les embeddings'} {isReindexing ? t('insightsView.mapping') : t('insightsView.resync')}
</button> </button>
<span className="text-[8px] text-concrete italic block leading-relaxed"> <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> </span>
</div> </div>
</section> </section>
@@ -564,10 +573,10 @@ export default function InsightsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle size={15} className="text-rose-400" /> <AlertCircle size={15} className="text-rose-400" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink"> <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> </h3>
</div> </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>
<div className="space-y-2"> <div className="space-y-2">
{isolatedClusters.map(c => ( {isolatedClusters.map(c => (
@@ -580,17 +589,17 @@ export default function InsightsPage() {
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} /> <div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-xs font-medium text-ink dark:text-dark-ink"> <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> </span>
</div> </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"> <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> </span>
</motion.div> </motion.div>
))} ))}
{isolatedClusters.length === 0 && ( {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"> <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>
)} )}
</div> </div>
@@ -601,7 +610,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 px-1"> <div className="flex items-center gap-2 px-1">
<Zap size={16} className="text-ochre" /> <Zap size={16} className="text-ochre" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink"> <h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">
Notes-Ponts Influentes {t('insightsView.bridgeNotes.title')}
</h3> </h3>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -617,7 +626,9 @@ export default function InsightsPage() {
{bridge.title} {bridge.title}
</h4> </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"> <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> </span>
</div> </div>
<div className="flex flex-wrap gap-1.5 pt-1.5 border-t border-black/5 dark:border-white/5"> <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' }} style={{ backgroundColor: cluster?.color || '#cbd5e1' }}
/> />
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider"> <span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">
{cluster?.name || `Cluster ${cid}`} {cluster?.name || t('insightsView.clusterFallback', { index: cid })}
</span> </span>
</div> </div>
) )
@@ -647,8 +658,7 @@ export default function InsightsPage() {
))} ))}
{bridgeList.length === 0 && !isCalculating && ( {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"> <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 {t('insightsView.bridgeNotes.empty')}
transversales pour forger de nouveaux liens créatifs.
</div> </div>
)} )}
</div> </div>
@@ -659,7 +669,7 @@ export default function InsightsPage() {
<div className="flex items-center gap-2 px-1"> <div className="flex items-center gap-2 px-1">
<Lightbulb size={16} className="text-indigo-500" /> <Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink"> <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> </h3>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@@ -678,7 +688,10 @@ export default function InsightsPage() {
</div> </div>
</div> </div>
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate"> <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> </span>
</div> </div>
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2"> <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 && ( {!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"> <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>
)} )}
</div> </div>

View File

@@ -259,15 +259,43 @@ export class MemoryEchoService {
continue continue
} }
// Calculate cosine similarity // Calculate cosine similarity — whole note level
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!) const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
// Similarity threshold for meaningful connections (adjusted by feedback) // Also check chunk-level similarity for more precise connections
const baseThreshold = this.pairSimilarityThreshold(note1, note2, demoMode) // This catches cases where two notes share a similar SECTION even if overall different
const adjustedThreshold = baseThreshold let bestChunkSimilarity = similarity
+ (notePenalty.get(note1.id) || 0) let chunkSnippet: string | undefined
+ (notePenalty.get(note2.id) || 0) if (similarity < adjustedThreshold) {
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({ connections.push({
note1: { note1: {
id: note1.id, id: note1.id,
@@ -281,9 +309,10 @@ export class MemoryEchoService {
content: this.connectionPlainText(note2.title, note2.content || ''), content: this.connectionPlainText(note2.title, note2.content || ''),
createdAt: note2.createdAt createdAt: note2.createdAt
}, },
similarityScore: similarity, similarityScore: bestChunkSimilarity,
insight: '', // Will be generated by AI insight: '', // Will be generated by AI
daysApart daysApart,
...(chunkSnippet ? { contextSnippet: chunkSnippet } : {})
}) })
} }
} }