From eab4b3e27b118744acad3186c6a02b1504b6b6d4 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sat, 20 Jun 2026 17:09:34 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Memory=20Echo=20chunk-level=20=E2=80=94?= =?UTF-8?q?=20d=C3=A9tecte=20connexions=20au=20niveau=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- memento-note/app/(main)/insights/page.tsx | 141 ++++++++++-------- .../lib/ai/services/memory-echo.service.ts | 47 ++++-- 2 files changed, 115 insertions(+), 73 deletions(-) diff --git a/memento-note/app/(main)/insights/page.tsx b/memento-note/app/(main)/insights/page.tsx index 1b28191..7722afd 100644 --- a/memento-note/app/(main)/insights/page.tsx +++ b/memento-note/app/(main)/insights/page.tsx @@ -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([]) const [clusters, setClusters] = useState([]) const [bridgeNotes, setBridgeNotes] = useState([]) @@ -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() {

- Analyses & Cartographie + {t('insightsView.title')}

- Modèles sémantiques & clusters de connaissances + {t('insightsView.subtitle')}

@@ -260,7 +266,7 @@ export default function InsightsPage() { : 'text-concrete' }`} > - Réseau + {t('insightsView.viewGraph')} @@ -284,7 +290,7 @@ export default function InsightsPage() { ) : ( )} - {isCalculating ? 'Calcul...' : 'Re-analyser'} + {isCalculating ? t('insightsView.mapping') : t('insightsView.resync')} @@ -298,7 +304,7 @@ export default function InsightsPage() { className="text-center space-y-4" >
-

Chargement de vos clusters...

+

{t('insightsView.loading')}

)} @@ -315,11 +321,10 @@ export default function InsightsPage() {

- Vos notes forment des thèmes + {t('insightsView.emptyTitle')}

- 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')}

@@ -346,10 +351,10 @@ export default function InsightsPage() {

- Analyse en cours... + {t('insightsView.mappingTitle')}

- Calcul des similarités sémantiques et détection des clusters de connaissances + {t('insightsView.mappingHint')}

@@ -394,14 +399,14 @@ export default function InsightsPage() {
- Vos notes ont été modifiées. Mettez à jour vos insights pour une cartographie sémantique précise. + {t('insightsView.staleResults')}
)} @@ -422,26 +427,25 @@ export default function InsightsPage() {
- Focus Cluster Activé + {t('insightsView.focusCluster.title')}

- {selectedCluster.name || `Cluster ${selectedCluster.clusterId}`} + {selectedCluster.name || + t('insightsView.clusterFallback', { index: selectedCluster.clusterId })}

- Cet ensemble thématique réunit{' '} - - {selectedClusterNotes.length} notes - - . Cliquez pour accéder directement : + {t('insightsView.focusCluster.description', { + count: selectedClusterNotes.length, + })}

{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" > - {note.title || 'Note sans titre'} + {note.title || t('insightsView.unknownNote')} @@ -468,7 +472,7 @@ export default function InsightsPage() {
- Clusters Actifs + {t('insightsView.stats.clusters')}
@@ -476,7 +480,7 @@ export default function InsightsPage() { {clusters.length}

- Détectés sans à priori + {t('insightsView.graphNotesLabel')}

@@ -484,7 +488,7 @@ export default function InsightsPage() {
- Notes-Ponts + {t('insightsView.stats.bridgeNotes')}
@@ -492,7 +496,7 @@ export default function InsightsPage() { {bridgeNotes.length}

- Passerelles d'idées + {t('insightsView.bridgeCount')}

@@ -504,22 +508,22 @@ export default function InsightsPage() {

- Système de Recalcul + {t('insightsView.clusters.title')}

- Synchronisé + {t('insightsView.resync')}
- CRON PLANIFIÉ + {t('insightsView.mapping')}

- Quotidien (04:00) + 04:00

- DERNIÈRE SYNC + {t('insightsView.resync')}

{lastSyncTime || '—'}

@@ -527,17 +531,22 @@ export default function InsightsPage() {
- Notes indexées (texte complet) : - + + {embeddingStats + ? t('insightsView.embeddingsHint', { + indexed: embeddingStats.indexed, + total: embeddingStats.total, + }) + : '—'} + + {embeddingStats ? `${embeddingStats.indexed} / ${embeddingStats.total}` : '—'}

- 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')}

- « Re-analyser » réindexe aussi les embeddings puis regénère les clusters. + {t('insightsView.tipClustersAction')}
@@ -564,10 +573,10 @@ export default function InsightsPage() {

- Clusters Isolés ({isolatedClusters.length}) + {t('insightsView.isolatedClusters.title', { count: isolatedClusters.length })}

- Sans points d'accroche + {t('insightsView.tipIsolatedAction')}
{isolatedClusters.map(c => ( @@ -580,17 +589,17 @@ export default function InsightsPage() {
- {c.name || `Cluster ${c.clusterId}`} + {c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
- Non connecté + {t('insightsView.isolatedClusters.badge')} ))} {isolatedClusters.length === 0 && (
- Tous les clusters thématiques sont liés par au moins un point de passage sémantique ! + {t('insightsView.isolatedClusters.empty')}
)}
@@ -601,7 +610,7 @@ export default function InsightsPage() {

- Notes-Ponts Influentes + {t('insightsView.bridgeNotes.title')}

@@ -617,7 +626,9 @@ export default function InsightsPage() { {bridge.title} - Lien : {(bridge.bridgeScore * 100).toFixed(0)}% + {t('insightsView.bridgeNotes.score', { + score: (bridge.bridgeScore * 100).toFixed(0), + })}
@@ -637,7 +648,7 @@ export default function InsightsPage() { style={{ backgroundColor: cluster?.color || '#cbd5e1' }} /> - {cluster?.name || `Cluster ${cid}`} + {cluster?.name || t('insightsView.clusterFallback', { index: cid })}
) @@ -647,8 +658,7 @@ export default function InsightsPage() { ))} {bridgeList.length === 0 && !isCalculating && (
- 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')}
)}
@@ -659,7 +669,7 @@ export default function InsightsPage() {

- Opportunités de Connexion + {t('insightsView.suggestions.title')}

@@ -678,7 +688,10 @@ export default function InsightsPage() {
- Relier {s.clusterAName} & {s.clusterBName} + {t('insightsView.suggestions.bridging', { + clusterA: s.clusterAName, + clusterB: s.clusterBName, + })}

@@ -705,7 +718,7 @@ export default function InsightsPage() { )} {!isCalculating && suggestions.length === 0 && (
- Toutes vos thématiques clés sont déjà formidablement interconnectées ! + {t('insightsView.suggestions.emptyDescription')}
)} diff --git a/memento-note/lib/ai/services/memory-echo.service.ts b/memento-note/lib/ai/services/memory-echo.service.ts index 25e1ce5..8aa802c 100644 --- a/memento-note/lib/ai/services/memory-echo.service.ts +++ b/memento-note/lib/ai/services/memory-echo.service.ts @@ -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 } : {}) }) } }