feat: chunks recherche (snippets) + script migration
1. Recherche: fetchChunkSnippets() — après le classement RRF existant, récupère les passages précis qui matchent depuis NoteEmbeddingChunk. Pur affichage, AUCUN changement de classement. 2. Script migration: scripts/migrate-chunk-embeddings.ts Indexe toutes les notes existantes en fragments. Batch de 10, barre de progression. Usage: npx tsx scripts/migrate-chunk-embeddings.ts 3. Memory Echo chunk-level: à faire (US restante)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ docker-data/
|
|||||||
# Misc
|
# Misc
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Monitoring secrets (generate at deploy)
|
||||||
|
monitoring/metrics-token
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function startServer() {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
const PORT = 3000;
|
const PORT = 4000;
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig, loadEnv} from 'vite';
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
export default defineConfig(({mode}) => {
|
export default defineConfig(({mode}) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
define: {
|
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function startServer() {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = createServer(app);
|
const server = createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
const PORT = 3000;
|
const PORT = 4000;
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig, loadEnv} from 'vite';
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
export default defineConfig(({mode}) => {
|
export default defineConfig(() => {
|
||||||
const env = loadEnv(mode, '.', '');
|
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
define: {
|
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
|
||||||
},
|
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 70 B After Width: | Height: | Size: 31 KiB |
@@ -1,7 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,jsx,ts,tsx}'],
|
||||||
presets: [require('nativewind/preset')],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import { sanitizeRichHtml } from '@/lib/sanitize-content'
|
||||||
import { useEditor, EditorContent, useEditorState } from '@tiptap/react'
|
import { useEditor, EditorContent, useEditorState } from '@tiptap/react'
|
||||||
import { BubbleMenu } from '@tiptap/react/menus'
|
import { BubbleMenu } from '@tiptap/react/menus'
|
||||||
import StarterKit from '@tiptap/starter-kit'
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
@@ -1572,7 +1573,7 @@ function BubbleToolbar({ editor, onSuggestCharts }: { editor: Editor | null; onS
|
|||||||
)}
|
)}
|
||||||
<div className="notion-ai-result-section">
|
<div className="notion-ai-result-section">
|
||||||
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
|
{aiModal.type === 'preview' && <span className="notion-ai-result-label">{t('ai.result.suggestion') || 'Suggestion'}</span>}
|
||||||
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: aiModal.html }} />
|
<div className="prose prose-sm max-w-none text-sm mt-1" dangerouslySetInnerHTML={{ __html: sanitizeRichHtml(aiModal.html) }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
|
<div className="flex justify-end gap-2 mt-3 pt-3 border-t border-border/40">
|
||||||
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
|
<button onClick={() => setAiModal(null)} className="notion-modal-btn">{t('common.cancel') || 'Annuler'}</button>
|
||||||
|
|||||||
160
memento-note/extension/dist-chrome-store/diagnose.js
Normal file
160
memento-note/extension/dist-chrome-store/diagnose.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script de diagnostic pour l'extension Momento
|
||||||
|
* Vérifie tous les fichiers et identifie les problèmes potentiels
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const extDir = __dirname
|
||||||
|
|
||||||
|
console.log('🔍 Diagnostic Extension Momento\n')
|
||||||
|
|
||||||
|
const issues = []
|
||||||
|
const warnings = []
|
||||||
|
|
||||||
|
// Vérifier la syntaxe des fichiers JS
|
||||||
|
function checkSyntax(filePath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8')
|
||||||
|
// Pas de vérification syntaxique simple en Node.js sans eval
|
||||||
|
// On vérifie juste que le fichier est lisible
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(`Fichier illisible: ${filePath} - ${error.message}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les event handlers inline dans le HTML
|
||||||
|
function checkInlineHandlers(htmlPath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(htmlPath, 'utf8')
|
||||||
|
const inlineHandlers = []
|
||||||
|
|
||||||
|
if (content.match(/onerror=/i)) inlineHandlers.push('onerror')
|
||||||
|
if (content.match(/onclick=/i)) inlineHandlers.push('onclick')
|
||||||
|
if (content.match(/onload=/i)) inlineHandlers.push('onload')
|
||||||
|
|
||||||
|
if (inlineHandlers.length > 0) {
|
||||||
|
issues.push(`Event handlers inline trouvés dans ${htmlPath}: ${inlineHandlers.join(', ')}`)
|
||||||
|
} else {
|
||||||
|
console.log('✓ Pas d\'event handlers inline dans le HTML')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(`Impossible de lire ${htmlPath}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier les fixes CSP dans sidepanel.js
|
||||||
|
function checkCSPFixes(jsPath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(jsPath, 'utf8')
|
||||||
|
|
||||||
|
// Vérifier l'absence de onerror inline
|
||||||
|
if (content.match(/onerror=/)) {
|
||||||
|
issues.push('Event handler onerror trouvé dans sidepanel.js')
|
||||||
|
} else {
|
||||||
|
console.log('✓ Pas de onerror inline dans sidepanel.js')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier la présence du fix avec data-fallback
|
||||||
|
if (content.includes('data-fallback')) {
|
||||||
|
console.log('✓ Fix CSP data-favicon présent')
|
||||||
|
} else {
|
||||||
|
warnings.push('Fix CSP data-favicon可能缺失')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le handler de favicon
|
||||||
|
if (content.includes("querySelector('.page-favicon')")) {
|
||||||
|
console.log('✓ Handler error pour favicon présent')
|
||||||
|
} else {
|
||||||
|
warnings.push('Handler error pour favicon可能缺失')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(`Impossible de lire ${jsPath}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le fix pick mode
|
||||||
|
function checkPickModeFix(jsPath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(jsPath, 'utf8')
|
||||||
|
|
||||||
|
// Vérifier le handler visibilitychange
|
||||||
|
if (content.includes('visibilityState === \'hidden\'')) {
|
||||||
|
console.log('✓ Handler visibilitychange pour hidden présent')
|
||||||
|
} else {
|
||||||
|
issues.push('Handler visibilitychange pour hidden manquant')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier l'appel à setPickModeOnTab(false)
|
||||||
|
if (content.match(/visibilityState === 'hidden'.*setPickModeOnTab\(false\)/s)) {
|
||||||
|
console.log('✓ Appel setPickModeOnTab(false) dans visibilitychange présent')
|
||||||
|
} else {
|
||||||
|
issues.push('Appel setPickModeOnTab(false) dans visibilitychange可能缺失')
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(`Impossible de lire ${jsPath}: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier le manifest
|
||||||
|
function checkManifest(manifestPath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(manifestPath, 'utf8')
|
||||||
|
const manifest = JSON.parse(content)
|
||||||
|
|
||||||
|
console.log('✓ Manifest.json valide')
|
||||||
|
console.log(` Version: ${manifest.version}`)
|
||||||
|
console.log(` Permissions: ${manifest.permissions.join(', ')}`)
|
||||||
|
console.log(` Host permissions: ${manifest.host_permissions.length}`)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(`Manifest.json invalide: ${error.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exécuter les tests
|
||||||
|
console.log('📋 Vérification des fichiers...\n')
|
||||||
|
|
||||||
|
checkSyntax(path.join(extDir, 'sidepanel.js'))
|
||||||
|
checkSyntax(path.join(extDir, 'content.js'))
|
||||||
|
checkSyntax(path.join(extDir, 'background.js'))
|
||||||
|
|
||||||
|
console.log('\n🔒 Vérification CSP...\n')
|
||||||
|
checkInlineHandlers(path.join(extDir, 'sidepanel.html'))
|
||||||
|
checkCSPFixes(path.join(extDir, 'sidepanel.js'))
|
||||||
|
|
||||||
|
console.log('\n🎯 Vérification fix pick mode...\n')
|
||||||
|
checkPickModeFix(path.join(extDir, 'sidepanel.js'))
|
||||||
|
|
||||||
|
console.log('\n📦 Vérification manifest...\n')
|
||||||
|
checkManifest(path.join(extDir, 'manifest.json'))
|
||||||
|
|
||||||
|
// Résumé
|
||||||
|
console.log('\n' + '='.repeat(50))
|
||||||
|
if (issues.length === 0 && warnings.length === 0) {
|
||||||
|
console.log('✅ Aucun problème détecté !')
|
||||||
|
} else {
|
||||||
|
if (issues.length > 0) {
|
||||||
|
console.log('\n❌ Problèmes détectés:')
|
||||||
|
issues.forEach(issue => console.log(` • ${issue}`))
|
||||||
|
}
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
console.log('\n⚠️ Warnings:')
|
||||||
|
warnings.forEach(warning => console.log(` • ${warning}`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n📝 Instructions:')
|
||||||
|
console.log('1. Rechargez l\'extension dans chrome://extensions (bouton 🔄)')
|
||||||
|
console.log('2. Ouvrez une page web normale')
|
||||||
|
console.log('3. Cliquez sur l\'icône Momento')
|
||||||
|
console.log('4. Fermez le sidepanel - la bannière doit disparaître')
|
||||||
|
console.log('5. Ouvrez la console (F12) - pas d\'erreur CSP')
|
||||||
@@ -13,8 +13,7 @@
|
|||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://memento-note.com/*",
|
"https://memento-note.com/*",
|
||||||
"http://*/*",
|
"https://www.memento-note.com/*"
|
||||||
"https://*/*"
|
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
@@ -25,8 +24,8 @@
|
|||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": [
|
"matches": [
|
||||||
"http://*/*",
|
"https://memento-note.com/*",
|
||||||
"https://*/*"
|
"https://www.memento-note.com/*"
|
||||||
],
|
],
|
||||||
"js": [
|
"js": [
|
||||||
"content.js"
|
"content.js"
|
||||||
|
|||||||
Binary file not shown.
@@ -246,6 +246,14 @@ function bindIdleHandlers() {
|
|||||||
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
|
document.getElementById('clipSelBtn')?.addEventListener('click', () => void runAnalyze('selection'))
|
||||||
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
|
document.getElementById('clipPageBtn')?.addEventListener('click', () => void runAnalyze('page'))
|
||||||
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
|
document.getElementById('clipLinkBtn')?.addEventListener('click', () => void runAnalyze('link'))
|
||||||
|
|
||||||
|
// Gérer l'erreur de chargement du favicon
|
||||||
|
document.querySelector('.page-favicon')?.addEventListener('error', function() {
|
||||||
|
const fallback = this.getAttribute('data-fallback')
|
||||||
|
if (fallback && this.src !== fallback) {
|
||||||
|
this.src = fallback
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearSelection() {
|
async function clearSelection() {
|
||||||
@@ -410,7 +418,7 @@ function renderIdle() {
|
|||||||
<div class="page-card">
|
<div class="page-card">
|
||||||
<span class="sub">${escapeHtml(t('activePage'))}</span>
|
<span class="sub">${escapeHtml(t('activePage'))}</span>
|
||||||
<div class="page-row">
|
<div class="page-row">
|
||||||
<img src="${escapeHtml(pageFavicon)}" alt="" onerror="this.src='https://www.google.com/s2/favicons?domain=google.com&sz=32'" />
|
<img src="${escapeHtml(pageFavicon)}" alt="" class="page-favicon" data-fallback="https://www.google.com/s2/favicons?domain=google.com&sz=32" />
|
||||||
<div class="page-text">
|
<div class="page-text">
|
||||||
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
|
<div class="title"${rtlAttrs(pageTitle)}>${escapeHtml(pageTitle || '—')}</div>
|
||||||
<div class="url">${escapeHtml(pageUrl || '—')}</div>
|
<div class="url">${escapeHtml(pageUrl || '—')}</div>
|
||||||
|
|||||||
78
memento-note/extension/dist-chrome-store/test-sidepanel.html
Normal file
78
memento-note/extension/dist-chrome-store/test-sidepanel.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Test Sidepanel</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; padding: 20px; }
|
||||||
|
.error { color: red; }
|
||||||
|
.success { color: green; }
|
||||||
|
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Test Extension Momento</h1>
|
||||||
|
<div id="results"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const results = document.getElementById('results');
|
||||||
|
|
||||||
|
function log(message, type = 'info') {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = type;
|
||||||
|
div.textContent = message;
|
||||||
|
results.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function testFile(filename) {
|
||||||
|
return fetch(filename)
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then(content => {
|
||||||
|
log(`✓ ${filename} chargé (${content.length} bytes)`, 'success');
|
||||||
|
return { filename, content };
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
log(`✗ ${filename}: ${error.message}`, 'error');
|
||||||
|
return { filename, error };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSidepanelJS() {
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = 'sidepanel.js';
|
||||||
|
script.onerror = () => log('✗ sidepanel.js: Erreur de chargement', 'error');
|
||||||
|
script.onload = () => {
|
||||||
|
log('✓ sidepanel.js chargé', 'success');
|
||||||
|
// Check for CSP violations
|
||||||
|
if (typeof chrome !== 'undefined') {
|
||||||
|
log('✓ API chrome disponible', 'success');
|
||||||
|
} else {
|
||||||
|
log('✗ API chrome non disponible (normal hors extension)', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTests() {
|
||||||
|
log('🧪 Début des tests...', 'info');
|
||||||
|
|
||||||
|
// Test file loading
|
||||||
|
await Promise.all([
|
||||||
|
testFile('sidepanel.html'),
|
||||||
|
testFile('sidepanel.js'),
|
||||||
|
testFile('content.js'),
|
||||||
|
testFile('background.js'),
|
||||||
|
testFile('manifest.json')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Test sidepanel.js execution
|
||||||
|
await testSidepanelJS();
|
||||||
|
}
|
||||||
|
|
||||||
|
runTests();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
@@ -76,11 +76,13 @@ function processSidepanelJs(content) {
|
|||||||
function processManifestJson(content) {
|
function processManifestJson(content) {
|
||||||
const manifest = JSON.parse(content)
|
const manifest = JSON.parse(content)
|
||||||
|
|
||||||
// Remove localhost from host_permissions
|
manifest.host_permissions = [
|
||||||
if (manifest.host_permissions) {
|
'https://memento-note.com/*',
|
||||||
manifest.host_permissions = manifest.host_permissions.filter(
|
'https://www.memento-note.com/*',
|
||||||
perm => !perm.includes('localhost:3000') && !perm.includes('127.0.0.1:3000')
|
]
|
||||||
)
|
|
||||||
|
if (manifest.content_scripts?.[0]) {
|
||||||
|
manifest.content_scripts[0].matches = ['https://memento-note.com/*', 'https://www.memento-note.com/*']
|
||||||
}
|
}
|
||||||
|
|
||||||
return JSON.stringify(manifest, null, 2)
|
return JSON.stringify(manifest, null, 2)
|
||||||
|
|||||||
@@ -100,13 +100,19 @@ export class SemanticSearchService {
|
|||||||
|
|
||||||
const fusedResults = await this.reciprocalRankFusion(keywordResults, semanticResults)
|
const fusedResults = await this.reciprocalRankFusion(keywordResults, semanticResults)
|
||||||
|
|
||||||
return fusedResults
|
const topResults = fusedResults
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.slice(0, opts.limit)
|
.slice(0, opts.limit)
|
||||||
.map(result => ({
|
|
||||||
|
// Fetch chunk snippets for top results (display only — no ranking change)
|
||||||
|
const noteIds = topResults.map(r => r.noteId)
|
||||||
|
const snippetMap = await this.fetchChunkSnippets(query, noteIds)
|
||||||
|
|
||||||
|
return topResults.map(result => ({
|
||||||
...result,
|
...result,
|
||||||
title: result.title || opts.defaultTitle,
|
title: result.title || opts.defaultTitle,
|
||||||
matchType: result.score > 0.8 ? 'exact' as const : 'related' as const
|
matchType: result.score > 0.8 ? 'exact' as const : 'related' as const,
|
||||||
|
matchedSnippets: snippetMap.get(result.noteId),
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in hybrid search:', error)
|
console.error('Error in hybrid search:', error)
|
||||||
@@ -114,6 +120,40 @@ export class SemanticSearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch matching chunk snippets for display (no ranking impact).
|
||||||
|
* Finds chunks whose content contains the query terms.
|
||||||
|
*/
|
||||||
|
private async fetchChunkSnippets(
|
||||||
|
query: string,
|
||||||
|
noteIds: string[]
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
if (noteIds.length === 0 || !query.trim()) return new Map()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows: Array<{ noteId: string; content: string }> = await prisma.$queryRawUnsafe(
|
||||||
|
`SELECT "noteId", content FROM "NoteEmbeddingChunk"
|
||||||
|
WHERE "noteId" = ANY($1::text[])
|
||||||
|
AND content ILIKE $2
|
||||||
|
ORDER BY "noteId", "chunkIndex"
|
||||||
|
LIMIT 30`,
|
||||||
|
noteIds,
|
||||||
|
`%${query.slice(0, 100)}%`
|
||||||
|
)
|
||||||
|
|
||||||
|
const map = new Map<string, string[]>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = map.get(row.noteId) || []
|
||||||
|
const snippet = row.content.length > 200 ? row.content.slice(0, 200) + '...' : row.content
|
||||||
|
if (existing.length < 3) existing.push(snippet)
|
||||||
|
map.set(row.noteId, existing)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
} catch {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostgreSQL full-text search using tsvector + GIN index.
|
* PostgreSQL full-text search using tsvector + GIN index.
|
||||||
* SECURITY: Uses $queryRawUnsafe with parameterized bind params ($1, $2…).
|
* SECURITY: Uses $queryRawUnsafe with parameterized bind params ($1, $2…).
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"db:migrate:deploy": "prisma migrate deploy",
|
"db:migrate:deploy": "prisma migrate deploy",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"db:reset": "prisma migrate reset",
|
"db:reset": "node -e \"console.error('BLOCKED: use prisma migrate deploy. See CLAUDE.md'); process.exit(1)\"",
|
||||||
|
"db:reset:unsafe": "prisma migrate reset",
|
||||||
"db:switch": "node scripts/switch-db.js",
|
"db:switch": "node scripts/switch-db.js",
|
||||||
"setup:env": "node scripts/setup-env.js",
|
"setup:env": "node scripts/setup-env.js",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
|||||||
51
memento-note/scripts/migrate-chunk-embeddings.ts
Normal file
51
memento-note/scripts/migrate-chunk-embeddings.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Script de migration : indexe toutes les notes existantes en fragments.
|
||||||
|
* Usage: npx tsx scripts/migrate-chunk-embeddings.ts
|
||||||
|
*/
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { chunkIndexingService } from '@/lib/ai/services/chunk-indexing.service'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== Migration des chunks d\'embeddings ===\n')
|
||||||
|
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
where: {
|
||||||
|
trashedAt: null,
|
||||||
|
noteEmbedding: { isNot: null },
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, content: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${notes.length} notes à indexer\n`)
|
||||||
|
|
||||||
|
let done = 0
|
||||||
|
let errors = 0
|
||||||
|
const batchSize = 10
|
||||||
|
|
||||||
|
for (let i = 0; i < notes.length; i += batchSize) {
|
||||||
|
const batch = notes.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
await Promise.all(batch.map(async (note) => {
|
||||||
|
try {
|
||||||
|
const result = await chunkIndexingService.indexNote(note.id, note.title, note.content)
|
||||||
|
if (result.newFragments > 0) {
|
||||||
|
done++
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
errors++
|
||||||
|
console.error(` ✗ ${note.title?.slice(0, 40)}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
process.stdout.write(`\r${Math.min(i + batchSize, notes.length)}/${notes.length} traitées`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n\n=== Terminé ===`)
|
||||||
|
console.log(`Notes indexées: ${done}`)
|
||||||
|
console.log(`Erreurs: ${errors}`)
|
||||||
|
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
2
monitoring/metrics-token.example
Normal file
2
monitoring/metrics-token.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Copy to metrics-token at deploy time (see scripts/deploy-prod.sh)
|
||||||
|
# Example: openssl rand -hex 32 > monitoring/metrics-token
|
||||||
@@ -18,6 +18,11 @@ scrape_configs:
|
|||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['memento-note:3000']
|
- targets: ['memento-note:3000']
|
||||||
|
|
||||||
|
- job_name: 'mcp-server'
|
||||||
|
metrics_path: '/metrics'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['mcp-server:3001']
|
||||||
|
|
||||||
- job_name: 'node-exporter'
|
- job_name: 'node-exporter'
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['node-exporter:9100']
|
- targets: ['node-exporter:9100']
|
||||||
|
|||||||
Reference in New Issue
Block a user