From 714a3f55c795637b5720f8e0501a4a99a49fc7de Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 6 Nov 2025 16:24:50 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package.json | 2 +- backend/routes/auth.js | 2 +- backend/routes/pages.js | 121 +- backend/routes/settings.js | 29 + backend/scripts/debug-file-monitor.js | 207 --- backend/services/UniversalGuestService.js | 10 + backend/services/ai-assistant.js | 203 ++- backend/services/ai-cache.js | 228 ++- backend/services/ai-queue.js | 54 +- .../services/aiAssistantSettingsService.js | 67 +- backend/services/aiConfigService.js | 399 +++++ backend/services/consentService.js | 2 +- backend/services/encryptedDatabaseService.js | 26 +- .../services/messageDeduplicationService.js | 40 +- backend/services/multiSourceSearchService.js | 828 ++++++++++ backend/services/ollamaConfig.js | 335 ++-- backend/services/profileAnalysisService.js | 610 +++++++ backend/services/ragService.js | 790 +++++++-- backend/services/semanticChunkingService.js | 361 +++++ backend/services/unifiedMessageProcessor.js | 61 +- backend/services/userContextService.js | 275 ++++ backend/services/vectorSearchClient.js | 41 +- backend/tests/ragService.test.js | 72 - backend/tests/ragServiceFull.test.js | 158 -- backend/tests/vectorSearchClient.test.js | 47 - backend/utils/ollamaRequestBuilder.js | 82 + frontend/src/assets/styles/home.css.bak | 1422 ----------------- .../components/ai-assistant/RuleEditor.vue | 376 ++++- frontend/src/components/tables/TableCell.vue | 2 +- frontend/src/services/wallet.js | 2 +- .../src/views/contacts/ContactDetailsView.vue | 13 + .../src/views/content/PublishedListView.vue | 66 +- .../views/settings/AI/AiAssistantSettings.vue | 926 ++++++++++- frontend/vite.config.js | 12 +- 34 files changed, 5436 insertions(+), 2433 deletions(-) delete mode 100644 backend/scripts/debug-file-monitor.js create mode 100644 backend/services/aiConfigService.js create mode 100644 backend/services/multiSourceSearchService.js create mode 100644 backend/services/profileAnalysisService.js create mode 100644 backend/services/semanticChunkingService.js create mode 100644 backend/services/userContextService.js delete mode 100644 backend/tests/ragService.test.js delete mode 100644 backend/tests/ragServiceFull.test.js delete mode 100644 backend/tests/vectorSearchClient.test.js create mode 100644 backend/utils/ollamaRequestBuilder.js delete mode 100644 frontend/src/assets/styles/home.css.bak diff --git a/backend/package.json b/backend/package.json index 71f4f2a..f3a2b09 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,7 +12,7 @@ "server": "nodemon server.js --signal SIGUSR2", "migrate": "node scripts/run-migrations.js", "prod": "NODE_ENV=production node server.js", - "test": "mocha test/**/*.test.js", + "test": "mocha tests/**/*.test.js", "check-ollama": "node scripts/check-ollama-models.js", "check-ethers": "node scripts/check-ethers-v6-compatibility.js", "lint": "eslint .", diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 63c021f..2bd1b48 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -194,7 +194,7 @@ router.post('/verify', async (req, res) => { // Добавляем ссылки на документы в resources documents.forEach(doc => { - resources.push(`${origin}/public/page/${doc.id}`); + resources.push(`${origin}/content/published/${doc.id}`); }); } diff --git a/backend/routes/pages.js b/backend/routes/pages.js index f67eed0..bc83491 100644 --- a/backend/routes/pages.js +++ b/backend/routes/pages.js @@ -145,32 +145,8 @@ router.post('/', upload.single('file'), async (req, res) => { const { rows } = await db.getQuery()(sql, values); const created = rows[0]; - // Индексация в vector-search (только для HTML, если есть текст) - try { - if (created && (created.format === 'html' || pageData.format === 'html')) { - const text = stripHtml(created.content || pageData.content || ''); - if (text && text.length > 0) { - const url = created.visibility === 'public' && created.status === 'published' - ? `/public/page/${created.id}` - : `/content/page/${created.id}`; - await vectorSearchClient.upsert('legal_docs', [{ - row_id: created.id, - text, - metadata: { - doc_id: created.id, - title: created.title, - url, - visibility: created.visibility || pageData.visibility, - required_permission: created.required_permission || pageData.required_permission, - format: created.format || pageData.format, - updated_at: created.updated_at || null - } - }]); - } - } - } catch (e) { - console.error('[pages] vector upsert error:', e.message); - } + // Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex) + // Автоматическая индексация при создании отключена res.json(created); }); @@ -280,6 +256,58 @@ router.post('/:id/reindex', async (req, res) => { const url = page.visibility === 'public' && page.status === 'published' ? `/public/page/${page.id}` : `/content/page/${page.id}`; + + // Удаляем старые чанки документа перед реиндексацией + // Удаляем возможные чанки (doc_id_chunk_0, doc_id_chunk_1, ...) и сам документ (doc_id) + const oldRowIds = [String(page.id)]; // Удаляем основной документ + // Также удаляем возможные чанки (до 100 чанков на документ) + for (let i = 0; i < 100; i++) { + oldRowIds.push(`${page.id}_chunk_${i}`); + } + + try { + await vectorSearchClient.remove('legal_docs', oldRowIds); + console.log(`[pages] Удалены старые чанки документа ${page.id} перед реиндексацией`); + } catch (removeError) { + console.warn(`[pages] Ошибка удаления старых чанков (продолжаем индексацию):`, removeError.message); + // Продолжаем индексацию даже если удаление не удалось + } + + // Используем Semantic Chunking для разбивки документа + const semanticChunkingService = require('../services/semanticChunkingService'); + const docLength = text.length; + const useLLM = docLength <= 8000; + + const chunks = await semanticChunkingService.chunkDocument(text, { + maxChunkSize: 1500, + overlap: 200, + useLLM + }); + + // Индексируем каждый чанк отдельно + const rowsToUpsert = chunks.map((chunk, index) => ({ + row_id: `${page.id}_chunk_${index}`, + text: chunk.text, + metadata: { + doc_id: page.id, + chunk_index: index, + section: chunk.metadata?.section || 'Документ', + parent_doc_id: page.id, + title: page.title, + url: `${url}#chunk_${index}`, + visibility: page.visibility, + required_permission: page.required_permission, + format: page.format, + updated_at: page.updated_at || null, + isComplete: chunk.metadata?.isComplete || false + } + })); + + if (chunks.length > 1) { + console.log(`[pages] Документ ${page.id} разбит на ${chunks.length} чанков при реиндексации`); + await vectorSearchClient.upsert('legal_docs', rowsToUpsert); + } else { + // Если чанк один, индексируем как раньше await vectorSearchClient.upsert('legal_docs', [{ row_id: page.id, text, @@ -293,7 +321,9 @@ router.post('/:id/reindex', async (req, res) => { updated_at: page.updated_at || null } }]); - res.json({ success: true }); + } + + res.json({ success: true, chunksCount: chunks.length }); } catch (e) { console.error('[pages] manual reindex error:', e.message); res.status(500).json({ error: 'Ошибка индексации' }); @@ -346,32 +376,8 @@ router.patch('/:id', upload.single('file'), async (req, res) => { if (!rows.length) return res.status(404).json({ error: 'Page not found' }); const updated = rows[0]; - // Индексация для HTML - try { - if (updated && (updated.format === 'html')) { - const text = stripHtml(updated.content || ''); - if (text) { - const url = updated.visibility === 'public' && updated.status === 'published' - ? `/public/page/${updated.id}` - : `/content/page/${updated.id}`; - await vectorSearchClient.upsert('legal_docs', [{ - row_id: updated.id, - text, - metadata: { - doc_id: updated.id, - title: updated.title, - url, - visibility: updated.visibility, - required_permission: updated.required_permission, - format: updated.format, - updated_at: updated.updated_at || null - } - }]); - } - } - } catch (e) { - console.error('[pages] vector upsert (update) error:', e.message); - } + // Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex) + // Автоматическая индексация при обновлении отключена res.json(updated); }); @@ -406,7 +412,14 @@ router.delete('/:id', async (req, res) => { const deleted = rows[0]; try { if (deleted && deleted.format === 'html') { - await vectorSearchClient.remove('legal_docs', [deleted.id]); + // Удаляем документ и все его чанки + const rowIdsToDelete = [String(deleted.id)]; // Основной документ + // Удаляем возможные чанки (до 100 чанков на документ) + for (let i = 0; i < 100; i++) { + rowIdsToDelete.push(`${deleted.id}_chunk_${i}`); + } + await vectorSearchClient.remove('legal_docs', rowIdsToDelete); + console.log(`[pages] Удалены документ ${deleted.id} и все его чанки из векторного поиска`); } } catch (e) { console.error('[pages] vector remove error:', e.message); diff --git a/backend/routes/settings.js b/backend/routes/settings.js index 488b854..4a99ce7 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -482,6 +482,35 @@ router.put('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => { } }); +// ============================================ +// AI CONFIG (централизованные настройки) +// ============================================ + +// Получить все настройки AI Config +router.get('/ai-config', requireAdmin, async (req, res, next) => { + try { + const aiConfigService = require('../services/aiConfigService'); + const config = await aiConfigService.getConfig(); + res.json({ success: true, config }); + } catch (error) { + logger.error('Ошибка при получении AI Config:', error); + next(error); + } +}); + +// Обновить настройки AI Config +router.put('/ai-config', requireAdmin, async (req, res, next) => { + try { + const aiConfigService = require('../services/aiConfigService'); + const userId = req.session.userId || null; + const updated = await aiConfigService.updateConfig(req.body, userId); + res.json({ success: true, config: updated }); + } catch (error) { + logger.error('Ошибка при обновлении AI Config:', error); + next(error); + } +}); + // Удалить набор правил router.delete('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => { try { diff --git a/backend/scripts/debug-file-monitor.js b/backend/scripts/debug-file-monitor.js deleted file mode 100644 index dea46df..0000000 --- a/backend/scripts/debug-file-monitor.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Отладочный скрипт для мониторинга файлов в процессе деплоя - * Copyright (c) 2024-2025 Тарабанов Александр Викторович - */ - -const fs = require('fs'); -const path = require('path'); - -console.log('🔍 ОТЛАДОЧНЫЙ МОНИТОР: Отслеживание файлов current-params.json'); -console.log('=' .repeat(70)); - -class FileMonitor { - constructor() { - this.watchedFiles = new Map(); - this.isMonitoring = false; - } - - startMonitoring() { - console.log('🚀 Запуск мониторинга файлов...'); - this.isMonitoring = true; - - const deployDir = path.join(__dirname, './deploy'); - const tempDir = path.join(__dirname, '../temp'); - - // Мониторим директории - this.watchDirectory(deployDir, 'deploy'); - this.watchDirectory(tempDir, 'temp'); - - // Проверяем существующие файлы - this.checkExistingFiles(); - - console.log('✅ Мониторинг запущен. Нажмите Ctrl+C для остановки.'); - } - - watchDirectory(dirPath, label) { - if (!fs.existsSync(dirPath)) { - console.log(`📁 Директория ${label} не существует: ${dirPath}`); - return; - } - - console.log(`📁 Мониторим директорию ${label}: ${dirPath}`); - - try { - const watcher = fs.watch(dirPath, (eventType, filename) => { - if (filename && filename.includes('current-params')) { - const filePath = path.join(dirPath, filename); - const timestamp = new Date().toISOString(); - - console.log(`\n🔔 ${timestamp} - ${label.toUpperCase()}:`); - console.log(` Событие: ${eventType}`); - console.log(` Файл: ${filename}`); - console.log(` Путь: ${filePath}`); - - if (eventType === 'rename' && filename) { - // Файл создан или удален - setTimeout(() => { - const exists = fs.existsSync(filePath); - console.log(` Статус: ${exists ? 'СУЩЕСТВУЕТ' : 'УДАЛЕН'}`); - - if (exists) { - try { - const stats = fs.statSync(filePath); - console.log(` Размер: ${stats.size} байт`); - console.log(` Изменен: ${stats.mtime}`); - } catch (statError) { - console.log(` Ошибка получения статистики: ${statError.message}`); - } - } - }, 100); - } - } - }); - - this.watchedFiles.set(dirPath, watcher); - console.log(`✅ Мониторинг ${label} запущен`); - - } catch (watchError) { - console.log(`❌ Ошибка мониторинга ${label}: ${watchError.message}`); - } - } - - checkExistingFiles() { - console.log('\n🔍 Проверка существующих файлов...'); - - const pathsToCheck = [ - path.join(__dirname, './deploy/current-params.json'), - path.join(__dirname, '../temp'), - path.join(__dirname, './deploy') - ]; - - pathsToCheck.forEach(checkPath => { - try { - if (fs.existsSync(checkPath)) { - const stats = fs.statSync(checkPath); - if (stats.isFile()) { - console.log(`📄 Файл найден: ${checkPath}`); - console.log(` Размер: ${stats.size} байт`); - console.log(` Создан: ${stats.birthtime}`); - console.log(` Изменен: ${stats.mtime}`); - } else if (stats.isDirectory()) { - console.log(`📁 Директория найдена: ${checkPath}`); - const files = fs.readdirSync(checkPath); - const currentParamsFiles = files.filter(f => f.includes('current-params')); - if (currentParamsFiles.length > 0) { - console.log(` Файлы current-params: ${currentParamsFiles.join(', ')}`); - } else { - console.log(` Файлы current-params: не найдены`); - } - } - } else { - console.log(`❌ Не найден: ${checkPath}`); - } - } catch (error) { - console.log(`⚠️ Ошибка проверки ${checkPath}: ${error.message}`); - } - }); - } - - stopMonitoring() { - console.log('\n🛑 Остановка мониторинга...'); - this.isMonitoring = false; - - this.watchedFiles.forEach((watcher, path) => { - try { - watcher.close(); - console.log(`✅ Мониторинг остановлен: ${path}`); - } catch (error) { - console.log(`❌ Ошибка остановки мониторинга ${path}: ${error.message}`); - } - }); - - this.watchedFiles.clear(); - console.log('✅ Мониторинг полностью остановлен'); - } - - // Метод для периодической проверки - startPeriodicCheck(intervalMs = 5000) { - console.log(`⏰ Запуск периодической проверки (каждые ${intervalMs}ms)...`); - - const checkInterval = setInterval(() => { - if (!this.isMonitoring) { - clearInterval(checkInterval); - return; - } - - this.performPeriodicCheck(); - }, intervalMs); - - return checkInterval; - } - - performPeriodicCheck() { - const timestamp = new Date().toISOString(); - console.log(`\n⏰ ${timestamp} - Периодическая проверка:`); - - const filesToCheck = [ - path.join(__dirname, './deploy/current-params.json'), - path.join(__dirname, './deploy'), - path.join(__dirname, '../temp') - ]; - - filesToCheck.forEach(filePath => { - try { - if (fs.existsSync(filePath)) { - const stats = fs.statSync(filePath); - if (stats.isFile()) { - console.log(` 📄 ${path.basename(filePath)}: ${stats.size} байт`); - } else if (stats.isDirectory()) { - const files = fs.readdirSync(filePath); - const currentParamsFiles = files.filter(f => f.includes('current-params')); - console.log(` 📁 ${path.basename(filePath)}: ${files.length} файлов, current-params: ${currentParamsFiles.length}`); - } - } else { - console.log(` ❌ ${path.basename(filePath)}: не существует`); - } - } catch (error) { - console.log(` ⚠️ ${path.basename(filePath)}: ошибка ${error.message}`); - } - }); - } -} - -// Создаем экземпляр монитора -const monitor = new FileMonitor(); - -// Обработка сигналов завершения -process.on('SIGINT', () => { - console.log('\n🛑 Получен сигнал SIGINT...'); - monitor.stopMonitoring(); - process.exit(0); -}); - -process.on('SIGTERM', () => { - console.log('\n🛑 Получен сигнал SIGTERM...'); - monitor.stopMonitoring(); - process.exit(0); -}); - -// Запускаем мониторинг -monitor.startMonitoring(); -monitor.startPeriodicCheck(3000); // Проверка каждые 3 секунды - -console.log('\n💡 Инструкции:'); -console.log(' - Запустите этот скрипт в отдельном терминале'); -console.log(' - Затем запустите деплой DLE в другом терминале'); -console.log(' - Наблюдайте за изменениями файлов в реальном времени'); -console.log(' - Нажмите Ctrl+C для остановки мониторинга'); diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index 2dbd85a..fb9746d 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -424,6 +424,16 @@ class UniversalGuestService { } }); + if (aiResponse && aiResponse.disabled) { + logger.info(`[UniversalGuestService] AI ассистент отключен для канала ${channel}. Ответ не формируется.`); + return { + success: true, + identifier, + aiResponse: null, + assistantDisabled: true + }; + } + if (!aiResponse || !aiResponse.success) { logger.warn(`[UniversalGuestService] AI не вернул ответ для ${identifier}`); return { diff --git a/backend/services/ai-assistant.js b/backend/services/ai-assistant.js index d73da14..2b25286 100644 --- a/backend/services/ai-assistant.js +++ b/backend/services/ai-assistant.js @@ -13,6 +13,7 @@ const logger = require('../utils/logger'); const ollamaConfig = require('./ollamaConfig'); const { shouldProcessWithAI } = require('../utils/languageFilter'); +const userContextService = require('./userContextService'); /** * AI Assistant - тонкая обёртка для работы с Ollama и RAG @@ -86,6 +87,7 @@ class AIAssistant { const messageDeduplicationService = require('./messageDeduplicationService'); const aiAssistantSettingsService = require('./aiAssistantSettingsService'); const aiAssistantRulesService = require('./aiAssistantRulesService'); + const profileAnalysisService = require('./profileAnalysisService'); const { ragAnswer } = require('./ragService'); // 1. Проверяем дедупликацию через хеш @@ -95,7 +97,7 @@ class AIAssistant { channel }; - const isDuplicate = messageDeduplicationService.isDuplicate(messageForDedup); + const isDuplicate = await messageDeduplicationService.isDuplicate(messageForDedup); if (isDuplicate) { logger.info(`[AIAssistant] Сообщение уже обработано - пропускаем`); @@ -103,33 +105,196 @@ class AIAssistant { } // Помечаем как обработанное - messageDeduplicationService.markAsProcessed(messageForDedup); + await messageDeduplicationService.markAsProcessed(messageForDedup); + + // 1.5. Анализ профиля пользователя и автоматическое обновление (если не гость) + let userTags = null; + let userNameForProfile = null; + let shouldAskForName = false; + let profileAnalysis = null; + if (userId && (typeof userId !== 'string' || !userId.toString().startsWith('guest_'))) { + try { + profileAnalysis = await profileAnalysisService.analyzeUserMessage(userId, userQuestion); + const tagsDisplay = profileAnalysis.currentTagNames && profileAnalysis.currentTagNames.length > 0 + ? profileAnalysis.currentTagNames.join(', ') + : 'нет тегов'; + logger.info(`[AIAssistant] Анализ профиля: имя=${profileAnalysis.name || 'null'}, теги=${tagsDisplay}`); + + // Получаем текущие теги пользователя для передачи в generateLLMResponse + if (profileAnalysis.currentTagNames && profileAnalysis.currentTagNames.length > 0) { + userTags = profileAnalysis.currentTagNames; + } else if (profileAnalysis.suggestedTags && profileAnalysis.suggestedTags.length > 0) { + userTags = profileAnalysis.suggestedTags; + } + + userNameForProfile = profileAnalysis.currentName || profileAnalysis.name || null; + shouldAskForName = Boolean(profileAnalysis?.nameMissing); + } catch (error) { + logger.error(`[AIAssistant] Ошибка анализа профиля:`, { + message: error.message, + stack: error.stack + }); + // Продолжаем работу даже при ошибке анализа, но пытаемся получить теги из БД + try { + const currentTagIds = await userContextService.getUserTags(userId); + if (currentTagIds && currentTagIds.length > 0) { + userTags = await userContextService.getTagNames(currentTagIds); + logger.info(`[AIAssistant] Получены теги пользователя из БД после ошибки анализа: ${userTags.join(', ')}`); + } + const fallbackContext = await userContextService.getUserContext(userId); + if (fallbackContext?.name) { + userNameForProfile = fallbackContext.name; + shouldAskForName = false; + } else if (!userNameForProfile) { + shouldAskForName = true; + } + } catch (tagError) { + logger.warn(`[AIAssistant] Не удалось получить теги пользователя:`, { + message: tagError.message, + stack: tagError.stack + }); + } + } + } // 2. Получаем настройки AI ассистента + logger.info(`[AIAssistant] Получение настроек AI ассистента...`); const aiSettings = await aiAssistantSettingsService.getSettings(); + logger.info(`[AIAssistant] Настройки получены, selected_rag_tables: ${aiSettings?.selected_rag_tables?.length || 0}`); + + const defaultChannelState = { web: true, telegram: true, email: true }; + const enabledChannels = { + ...defaultChannelState, + ...(aiSettings?.enabled_channels || {}) + }; + const normalizedChannel = ['web', 'telegram', 'email'].includes(channel) ? channel : 'web'; + + if (enabledChannels[normalizedChannel] === false) { + logger.info(`[AIAssistant] Ассистент отключен для канала ${normalizedChannel} — пропускаем генерацию.`); + return { + success: false, + reason: 'channel_disabled', + disabled: true, + channel: normalizedChannel + }; + } + let rules = null; if (aiSettings && aiSettings.rules_id) { + logger.info(`[AIAssistant] Загрузка правил по ID: ${aiSettings.rules_id}`); rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id); } - // 3. Определяем tableId для RAG - let tableId = ragTableId; - if (!tableId && aiSettings && aiSettings.selected_rag_tables && aiSettings.selected_rag_tables.length > 0) { - tableId = aiSettings.selected_rag_tables[0]; - } + // 3. Определяем tableIds для RAG (может быть несколько таблиц) + const tableIds = aiSettings && aiSettings.selected_rag_tables && aiSettings.selected_rag_tables.length > 0 + ? aiSettings.selected_rag_tables + : (ragTableId ? [ragTableId] : []); + + logger.info(`[AIAssistant] Определены tableIds для RAG: ${JSON.stringify(tableIds)}`); - // 4. Выполняем RAG поиск если есть tableId - let ragResult = null; - if (tableId) { + // 4. Выполняем мульти-источниковый поиск (таблицы + документы) + logger.info(`[AIAssistant] Начало мульти-источникового поиска...`); + const multiSourceSearchService = require('./multiSourceSearchService'); + const ragConfig = await (require('./aiConfigService')).getRAGConfig(); + logger.info(`[AIAssistant] RAG конфигурация получена, метод поиска: ${ragConfig.searchMethod || 'hybrid'}`); + + let searchResults = null; + let ragResult = null; // Для обратной совместимости + + if (tableIds.length > 0 || true) { // Всегда ищем в документах, если включено + try { + logger.info(`[AIAssistant] Вызов multiSourceSearchService.search для запроса: "${userQuestion.substring(0, 50)}..."`); + const searchStartTime = Date.now(); + searchResults = await multiSourceSearchService.search({ + query: userQuestion, + tableIds: tableIds, + searchInDocuments: true, // Поиск в документах включен + searchMethod: ragConfig.searchMethod || 'hybrid', // 'semantic', 'keyword', 'hybrid' + userId: userId, + maxResultsPerSource: ragConfig.maxResults || 10, + totalMaxResults: (ragConfig.maxResults || 10) * 2 // Увеличиваем для объединения + }); + const searchDuration = Date.now() - searchStartTime; + logger.info(`[AIAssistant] Мульти-источниковый поиск завершен за ${searchDuration}ms, найдено результатов: ${searchResults?.results?.length || 0}`); + + // Формируем объединенный результат для обратной совместимости + if (searchResults.results && searchResults.results.length > 0) { + // Берем лучший результат + const bestResult = searchResults.results[0]; + ragResult = { + answer: bestResult.text, + context: bestResult.context || '', + product: bestResult.metadata?.product || null, + priority: bestResult.metadata?.priority || null, + date: bestResult.metadata?.date || null, + score: bestResult.score || 0 + }; + + // Формируем контекст из всех результатов для LLM + const allResultsContext = searchResults.results + .slice(0, 3) // Берем топ-3 результатов + .map((r, idx) => { + const sourceLabel = r.sourceType === 'table' ? 'Таблица' : 'Документ'; + const fallbackText = (r.metadata?.answer && String(r.metadata.answer).trim()) + || (r.metadata?.title && String(r.metadata.title).trim()) + || '(текст отсутствует)'; + const text = (r.text && r.text.trim()) || fallbackText; + const snippetLimit = 300; + const truncatedText = text.length > snippetLimit + ? `${text.slice(0, snippetLimit)}...` + : text; + return `[${idx + 1}] ${sourceLabel}: ${truncatedText}`; + }) + .join('\n\n'); + + ragResult.context = allResultsContext; + } + } catch (error) { + logger.error(`[AIAssistant] Ошибка мульти-источникового поиска:`, error); + // Fallback на старый метод, если новый не работает + if (tableIds.length > 0) { + const { ragAnswer } = require('./ragService'); ragResult = await ragAnswer({ - tableId, - userQuestion - // threshold использует дефолтное значение 300 из ragService - }); + tableId: tableIds[0], + userQuestion, + userId: userId + }); + } + } } // 5. Генерируем LLM ответ const { generateLLMResponse } = require('./ragService'); + // Получаем актуальную информацию о пользователе для LLM + if (!userNameForProfile && userId && (typeof userId !== 'string' || !userId.toString().startsWith('guest_'))) { + try { + const userContext = await userContextService.getUserContext(userId); + if (userContext) { + userNameForProfile = userNameForProfile || userContext.name || null; + if (!userTags && userContext.tagNames && userContext.tagNames.length > 0) { + userTags = userContext.tagNames; + } + if (!userNameForProfile) { + shouldAskForName = true; + } + } + } catch (contextError) { + logger.warn(`[AIAssistant] Не удалось получить контекст пользователя:`, { + message: contextError.message, + stack: contextError.stack + }); + } + } + + const userProfile = { + id: userId, + name: userNameForProfile || null, + tags: Array.isArray(userTags) ? userTags : [], + nameMissing: shouldAskForName, + suggestedTags: profileAnalysis?.suggestedTags || [] + }; + + logger.info(`[AIAssistant] Вызов generateLLMResponse для пользователя ${userId}...`); const aiResponse = await generateLLMResponse({ userQuestion, context: ragResult?.context || '', @@ -138,15 +303,21 @@ class AIAssistant { history: conversationHistory, model: aiSettings ? aiSettings.model : undefined, rules: rules ? rules.rules : null, - selectedRagTables: aiSettings ? aiSettings.selected_rag_tables : [] + selectedRagTables: aiSettings ? aiSettings.selected_rag_tables : [], + userId: userId, // Передаем userId для function calling + multiSourceResults: searchResults, // Передаем результаты мульти-поиска + userTags: userTags, + userProfile }); + logger.info(`[AIAssistant] generateLLMResponse вернул ответ типа: ${typeof aiResponse}, длина: ${aiResponse ? (typeof aiResponse === 'string' ? aiResponse.length : JSON.stringify(aiResponse).length) : 0}`); + if (!aiResponse) { logger.warn(`[AIAssistant] Пустой ответ от AI для пользователя ${userId}`); return { success: false, reason: 'empty_response' }; } - logger.info(`[AIAssistant] AI ответ успешно сгенерирован для пользователя ${userId}`); + logger.info(`[AIAssistant] AI ответ успешно сгенерирован для пользователя ${userId}, длина: ${typeof aiResponse === 'string' ? aiResponse.length : JSON.stringify(aiResponse).length} символов`); return { success: true, diff --git a/backend/services/ai-cache.js b/backend/services/ai-cache.js index 8206012..3bf8cca 100644 --- a/backend/services/ai-cache.js +++ b/backend/services/ai-cache.js @@ -1,38 +1,94 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + /** * Кэширование AI ответов для ускорения работы + * Использует настройки из aiConfigService */ const crypto = require('crypto'); const logger = require('../utils/logger'); const ollamaConfig = require('./ollamaConfig'); +const aiConfigService = require('./aiConfigService'); class AICache { constructor() { - const timeouts = ollamaConfig.getTimeouts(); - + // Загружаем настройки из aiConfigService this.cache = new Map(); - this.maxSize = timeouts.cacheMax; // Из централизованных настроек - this.ttl = timeouts.cacheLLM; // 24 часа (для LLM) - this.ragTtl = timeouts.cacheRAG; // 5 минут (для RAG результатов) + this._loadSettings(); } - // Генерация ключа кэша на основе запроса - generateKey(messages, options = {}) { + /** + * Загружает настройки кэша из aiConfigService + * @private + */ + async _loadSettings() { + try { + const cacheConfig = await aiConfigService.getCacheConfig(); + this.maxSize = cacheConfig.maxSize || 1000; + this.ttl = cacheConfig.llmTTL || 86400000; // 24 часа + this.ragTtl = cacheConfig.ragTTL || 300000; // 5 минут + } catch (error) { + logger.warn('[AICache] Ошибка загрузки настроек, используем дефолты:', error.message); + // Дефолтные значения + const timeouts = ollamaConfig.getTimeouts(); + this.maxSize = timeouts.cacheMax || 1000; + this.ttl = timeouts.cacheLLM || 86400000; + this.ragTtl = timeouts.cacheRAG || 300000; + } + } + + /** + * Получает актуальные настройки (перезагружает из БД) + */ + async _getSettings() { + await this._loadSettings(); + return { + maxSize: this.maxSize, + ttl: this.ttl, + ragTtl: this.ragTtl + }; + } + + /** + * Генерация ключа кэша на основе запроса + * Использует параметры LLM из настроек для генерации ключа + */ + async generateKey(messages, options = {}) { + // Загружаем актуальные параметры LLM для ключа + const llmParams = await aiConfigService.getLLMParameters(); + const content = JSON.stringify({ messages: messages.map(m => ({ role: m.role, content: m.content })), - temperature: options.temperature || 0.3, - maxTokens: options.num_predict || 150 + temperature: options.temperature || llmParams.temperature, + maxTokens: options.num_predict || llmParams.maxTokens }); return crypto.createHash('md5').update(content).digest('hex'); } - // ✨ НОВОЕ: Генерация ключа для RAG результатов - generateKeyForRAG(tableId, userQuestion, product = null) { - const content = JSON.stringify({ tableId, userQuestion, product }); + /** + * Генерация ключа для RAG результатов + * Включает tagIds для учета фильтрации по тегам + */ + generateKeyForRAG(tableId, userQuestion, product = null, userId = null, tagIds = null) { + // Сортируем tagIds для стабильности ключа (одинаковый порядок = одинаковый ключ) + const sortedTagIds = tagIds ? [...tagIds].sort((a, b) => a - b) : null; + const content = JSON.stringify({ tableId, userQuestion, product, userId, tagIds: sortedTagIds }); return crypto.createHash('md5').update(content).digest('hex'); } - // Получение ответа из кэша + /** + * Получение ответа из кэша (LLM) + */ get(key) { const cached = this.cache.get(key); if (!cached) return null; @@ -47,14 +103,15 @@ class AICache { return cached.response; } - // ✨ НОВОЕ: Получение с учетом типа кэша (RAG или LLM) + /** + * Получение с учетом типа кэша (RAG или LLM) + */ getWithTTL(key, type = 'llm') { const cached = this.cache.get(key); if (!cached) return null; - // Выбираем TTL в зависимости от типа const ttl = type === 'rag' ? this.ragTtl : this.ttl; - + // Проверяем TTL if (Date.now() - cached.timestamp > ttl) { this.cache.delete(key); @@ -65,101 +122,110 @@ class AICache { return cached.response; } - // Сохранение ответа в кэш - set(key, response) { - // Очищаем старые записи если кэш переполнен + /** + * Сохранение в кэш + */ + set(key, value, type = 'llm') { + // Проверяем размер кэша if (this.cache.size >= this.maxSize) { - const oldestKey = this.cache.keys().next().value; + // Удаляем самую старую запись + const oldestKey = Array.from(this.cache.keys())[0]; this.cache.delete(oldestKey); + logger.warn(`[AICache] Кэш переполнен, удалена старая запись: ${oldestKey.substring(0, 8)}...`); } this.cache.set(key, { - response, - timestamp: Date.now() - }); - - logger.info(`[AICache] Cached response for key: ${key.substring(0, 8)}...`); - } - - // ✨ НОВОЕ: Сохранение с указанием типа (rag или llm) - setWithType(key, response, type = 'llm') { - // Очищаем старые записи если кэш переполнен - if (this.cache.size >= this.maxSize) { - const oldestKey = this.cache.keys().next().value; - this.cache.delete(oldestKey); - } - - this.cache.set(key, { - response, + response: value, timestamp: Date.now(), - type: type // Сохраняем тип для статистики + type }); - logger.info(`[AICache] Cached ${type} response for key: ${key.substring(0, 8)}...`); + logger.debug(`[AICache] Сохранено в кэш (${type}): ${key.substring(0, 8)}...`); } - // Очистка кэша + /** + * Сохранение с указанием типа + */ + setWithType(key, value, type = 'llm') { + this.set(key, value, type); + } + + /** + * Очистка кэша + */ clear() { + const size = this.cache.size; this.cache.clear(); - logger.info('[AICache] Cache cleared'); + logger.info(`[AICache] Кэш очищен. Удалено записей: ${size}`); + return size; } - // Очистка старых записей по времени - cleanup(maxAge = 3600000) { // По умолчанию 1 час - const now = Date.now(); - let deletedCount = 0; - - for (const [key, value] of this.cache.entries()) { - if (now - value.timestamp > maxAge) { - this.cache.delete(key); - deletedCount++; - } - } - - if (deletedCount > 0) { - logger.info(`[AICache] Cleaned up ${deletedCount} old entries`); - } - } - - // Статистика кэша + /** + * Получение статистики + */ getStats() { - return { + const stats = { size: this.cache.size, maxSize: this.maxSize, - hitRate: this.calculateHitRate() + ttl: this.ttl, + ragTtl: this.ragTtl }; - } - calculateHitRate() { - // Простая реализация - в реальности нужно отслеживать hits/misses - if (this.maxSize === 0) return 0; - return this.cache.size / this.maxSize; - } - - // ✨ НОВОЕ: Статистика по типу кэша - getStatsByType() { - const stats = { rag: 0, llm: 0, other: 0 }; + // Подсчитываем по типам + let llmCount = 0; + let ragCount = 0; for (const [key, value] of this.cache.entries()) { - const type = value.type || 'other'; - stats[type] = (stats[type] || 0) + 1; + if (value.type === 'rag') { + ragCount++; + } else { + llmCount++; + } } + + stats.llmCount = llmCount; + stats.ragCount = ragCount; + return stats; } - // ✨ НОВОЕ: Инвалидация по префиксу (для очистки RAG кэша при обновлении таблиц) - invalidateByPrefix(prefix) { - let deletedCount = 0; + /** + * Получение статистики по типам + */ + getStatsByType() { + const stats = { + llm: { count: 0, size: 0 }, + rag: { count: 0, size: 0 } + }; + for (const [key, value] of this.cache.entries()) { + const type = value.type || 'llm'; + stats[type].count++; + stats[type].size += JSON.stringify(value.response).length; + } + + return stats; + } + + /** + * Инвалидация кэша по префиксу + */ + invalidateByPrefix(prefix) { + let count = 0; + for (const key of this.cache.keys()) { if (key.startsWith(prefix)) { this.cache.delete(key); - deletedCount++; + count++; } } - if (deletedCount > 0) { - logger.info(`[AICache] Инвалидировано ${deletedCount} записей с префиксом: ${prefix}`); + if (count > 0) { + logger.info(`[AICache] Инвалидировано записей с префиксом ${prefix}: ${count}`); } - return deletedCount; + return count; } } -module.exports = new AICache(); \ No newline at end of file +// Экспортируем singleton экземпляр +const aiCache = new AICache(); + +module.exports = aiCache; + diff --git a/backend/services/ai-queue.js b/backend/services/ai-queue.js index 1197876..0a1e369 100644 --- a/backend/services/ai-queue.js +++ b/backend/services/ai-queue.js @@ -15,6 +15,8 @@ const logger = require('../utils/logger'); const axios = require('axios'); const ollamaConfig = require('./ollamaConfig'); const aiCache = require('./ai-cache'); +const aiConfigService = require('./aiConfigService'); +const { buildOllamaRequest } = require('../utils/ollamaRequestBuilder'); class AIQueue extends EventEmitter { constructor() { @@ -237,25 +239,56 @@ class AIQueue extends EventEmitter { return; } - // 2. Вызываем Ollama API + // 2. Загружаем параметры LLM и qwen из настроек + const llmParameters = task.request.llmParameters || await aiConfigService.getLLMParameters(); + const qwenParameters = task.request.qwenParameters || await aiConfigService.getQwenSpecificParameters(); + const ollamaConfig_data = await ollamaConfig.getConfigAsync(); + + // 3. Формируем тело запроса (используем утилиту) + const requestBody = buildOllamaRequest({ + messages: task.request.messages, + model: task.request.model, + llmParameters: llmParameters, + qwenParameters: qwenParameters, + defaultModel: ollamaConfig_data.defaultModel, + tools: task.request.tools || null, + tool_choice: task.request.tool_choice || null, + stream: false + }); + + // 4. Вызываем Ollama API const ollamaUrl = ollamaConfig.getBaseUrl(); const timeouts = ollamaConfig.getTimeouts(); - const response = await axios.post(`${ollamaUrl}/api/chat`, { - model: task.request.model || ollamaConfig.getDefaultModel(), - messages: task.request.messages, - stream: false - }, { + logger.info(`[AIQueue] Отправка запроса в Ollama с параметрами:`, { + model: requestBody.model, + temperature: requestBody.temperature, + num_predict: requestBody.num_predict, + format: requestBody.format || 'не задан', + hasTools: !!requestBody.tools + }); + + const response = await axios.post(`${ollamaUrl}/api/chat`, requestBody, { timeout: timeouts.ollamaChat }); - const result = response.data.message.content; + // Обработка function calls (если есть) + // ВАЖНО: Function calling в очереди не поддерживается, т.к. нужен userId + // Если ИИ запросил функции - возвращаем ответ без их выполнения + let result; + if (response.data.message.tool_calls && response.data.message.tool_calls.length > 0) { + logger.warn(`[AIQueue] ИИ запросил выполнение ${response.data.message.tool_calls.length} функций, но function calling в очереди не поддерживается`); + result = response.data.message.content || 'Функции не выполнены (не поддерживается в очереди)'; + } else { + result = response.data.message.content; + } + const responseTime = Date.now() - startTime; - // 3. Сохраняем в кэш + // 4. Сохраняем в кэш aiCache.set(cacheKey, result); - // 4. Обновляем статус + // 5. Обновляем статус this.updateRequestStatus(task.id, 'completed', result, null, responseTime); this.emit(`task_${task.id}_completed`, { response: result, fromCache: false }); @@ -273,4 +306,5 @@ class AIQueue extends EventEmitter { } } -module.exports = AIQueue; \ No newline at end of file +module.exports = AIQueue; + diff --git a/backend/services/aiAssistantSettingsService.js b/backend/services/aiAssistantSettingsService.js index 7800e1e..89a2654 100644 --- a/backend/services/aiAssistantSettingsService.js +++ b/backend/services/aiAssistantSettingsService.js @@ -28,10 +28,6 @@ async function getSettings() { return null; } - // Получаем ключ шифрования через унифицированную утилиту - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - // Обрабатываем selected_rag_tables if (setting.selected_rag_tables) { try { @@ -64,13 +60,37 @@ async function getSettings() { } } + const defaultChannelState = { web: true, telegram: true, email: true }; + let enabledChannels = setting.enabled_channels; + if (typeof enabledChannels === 'string') { + try { + enabledChannels = JSON.parse(enabledChannels); + } catch (parseError) { + logger.error('[aiAssistantSettingsService] Error parsing enabled_channels:', parseError); + enabledChannels = null; + } + } + if (!enabledChannels || typeof enabledChannels !== 'object') { + enabledChannels = { ...defaultChannelState }; + } else { + enabledChannels = { + ...defaultChannelState, + ...Object.keys(enabledChannels).reduce((acc, key) => { + acc[key] = Boolean(enabledChannels[key]); + return acc; + }, {}) + }; + } + setting.enabled_channels = enabledChannels; + logger.info(`[aiAssistantSettingsService] Final settings result:`, { id: setting.id, selected_rag_tables: setting.selected_rag_tables, rules_id: setting.rules_id, hasSupportEmail: setting.hasSupportEmail, hasTelegramBot: setting.hasTelegramBot, - timestamp: setting.timestamp + timestamp: setting.timestamp, + enabled_channels: setting.enabled_channels }); return setting; @@ -80,12 +100,37 @@ async function getSettings() { } } -async function upsertSettings({ system_prompt, selected_rag_tables, model, embedding_model, rules, updated_by, telegram_settings_id, email_settings_id, system_message }) { +async function upsertSettings({ + system_prompt, + selected_rag_tables, + model, + embedding_model, + rules, + updated_by, + telegram_settings_id, + email_settings_id, + system_message, + enabled_channels +}) { + const defaultChannelState = { web: true, telegram: true, email: true }; + let channelsPayload = enabled_channels; + if (!channelsPayload || typeof channelsPayload !== 'object') { + channelsPayload = { ...defaultChannelState }; + } else { + channelsPayload = { + ...defaultChannelState, + ...Object.keys(channelsPayload).reduce((acc, key) => { + acc[key] = Boolean(channelsPayload[key]); + return acc; + }, {}) + }; + } + const data = { id: 1, system_prompt, selected_rag_tables, - languages: ['ru'], // Устанавливаем русский язык по умолчанию + languages: ['ru'], model, embedding_model, rules, @@ -93,17 +138,15 @@ async function upsertSettings({ system_prompt, selected_rag_tables, model, embed updated_by, telegram_settings_id, email_settings_id, - system_message + system_message, + enabled_channels: channelsPayload }; - // Проверяем, существует ли запись const existing = await encryptedDb.getData(TABLE, { id: 1 }, 1); - + if (existing.length > 0) { - // Обновляем существующую запись return await encryptedDb.saveData(TABLE, data, { id: 1 }); } else { - // Создаем новую запись return await encryptedDb.saveData(TABLE, data); } } diff --git a/backend/services/aiConfigService.js b/backend/services/aiConfigService.js new file mode 100644 index 0000000..4bb018a --- /dev/null +++ b/backend/services/aiConfigService.js @@ -0,0 +1,399 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +/** + * Централизованный сервис для управления всеми настройками AI + * + * Принципы: + * - Единый источник истины (таблица ai_config) + * - Кэширование в памяти (TTL: 1 минута) + * - Автоматическая инвалидация при изменении + * - Приоритет источников: БД > ENV > хардкод + */ + +const db = require('../db'); +const logger = require('../utils/logger'); + +class AIConfigService { + constructor() { + // Кэш для настроек + this.cache = null; + this.cacheTimestamp = 0; + this.CACHE_TTL = 60000; // 1 минута + + // Дефолтные значения (fallback) + this.defaults = { + ollama_base_url: process.env.OLLAMA_BASE_URL || 'http://ollama:11434', + ollama_llm_model: process.env.OLLAMA_MODEL || 'qwen2.5:7b', + ollama_embedding_model: process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large:latest', + vector_search_url: process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001', + embedding_parameters: { + batch_size: 32, + normalize: true, + dimension: null, + pooling: 'mean' + }, + llm_parameters: { + temperature: 0.3, + maxTokens: 150, + top_p: 0.9, + top_k: 40, + repeat_penalty: 1.1 + }, + qwen_specific_parameters: { + format: null + }, + rag_settings: { + threshold: 300, + maxResults: 3, + searchMethod: 'hybrid', + relevanceThreshold: 0.1, + keywordExtraction: { + enabled: true, + minWordLength: 3, + maxKeywords: 10, + removeStopWords: true, + language: 'ru' + }, + searchWeights: { + semantic: 70, + keyword: 30 + }, + advanced: { + enableFuzzySearch: true, + enableStemming: true, + enableSynonyms: false + } + }, + cache_settings: { + enabled: true, + llmTTL: 86400000, + ragTTL: 300000, + maxSize: 1000 + }, + queue_settings: { + enabled: true, + timeout: 180000, + maxSize: 100, + interval: 100 + }, + timeouts: { + ollamaChat: 600000, + ollamaEmbedding: 90000, + vectorSearch: 90000, + vectorUpsert: 600000, + vectorHealth: 5000, + ollamaHealth: 5000, + ollamaTags: 10000 + }, + rag_behavior: { + upsertOnQuery: false, + autoIndexOnTableChange: true + }, + deduplication_settings: { + enabled: true, + ttl: 300000 + } + }; + } + + /** + * Проверка актуальности кэша + * @returns {boolean} + */ + _isCacheValid() { + if (!this.cache) return false; + const now = Date.now(); + return (now - this.cacheTimestamp) < this.CACHE_TTL; + } + + /** + * Загрузить все настройки из БД + * @returns {Promise} Полный объект настроек + */ + async loadConfig() { + try { + const query = db.getQuery(); + const result = await query( + 'SELECT * FROM ai_config WHERE id = 1 LIMIT 1' + ); + + if (result.rows.length === 0) { + logger.warn('[aiConfigService] Таблица ai_config пуста, используем дефолтные значения'); + // Создаем дефолтную запись + await this._createDefaultConfig(); + return this.defaults; + } + + const config = result.rows[0]; + + // Парсим JSONB поля + const parsedConfig = { + ...config, + embedding_parameters: config.embedding_parameters || this.defaults.embedding_parameters, + llm_parameters: config.llm_parameters || this.defaults.llm_parameters, + qwen_specific_parameters: config.qwen_specific_parameters || this.defaults.qwen_specific_parameters, + rag_settings: config.rag_settings || this.defaults.rag_settings, + cache_settings: config.cache_settings || this.defaults.cache_settings, + queue_settings: config.queue_settings || this.defaults.queue_settings, + timeouts: config.timeouts || this.defaults.timeouts, + rag_behavior: config.rag_behavior || this.defaults.rag_behavior, + deduplication_settings: config.deduplication_settings || this.defaults.deduplication_settings + }; + + // Объединяем таймауты с дефолтами и при необходимости обновляем БД + const existingTimeouts = parsedConfig.timeouts || {}; + const mergedTimeouts = { ...existingTimeouts }; + + for (const [key, defaultValue] of Object.entries(this.defaults.timeouts)) { + const rawValue = existingTimeouts[key]; + const numericValue = Number(rawValue); + + if (!Number.isFinite(numericValue) || numericValue < defaultValue) { + mergedTimeouts[key] = defaultValue; + } else { + mergedTimeouts[key] = numericValue; + } + } + + const shouldPersistTimeouts = JSON.stringify(existingTimeouts) !== JSON.stringify(mergedTimeouts); + parsedConfig.timeouts = mergedTimeouts; + + // Обновляем кэш + this.cache = parsedConfig; + this.cacheTimestamp = Date.now(); + + if (shouldPersistTimeouts) { + try { + await query( + 'UPDATE ai_config SET timeouts = $1::jsonb, updated_at = NOW() WHERE id = 1', + [JSON.stringify(mergedTimeouts)] + ); + logger.info('[aiConfigService] Таймауты обновлены до актуальных значений по умолчанию'); + } catch (updateError) { + logger.warn('[aiConfigService] Не удалось обновить таймауты в БД:', updateError.message); + } + } + + logger.info('[aiConfigService] Настройки загружены из БД'); + return parsedConfig; + } catch (error) { + logger.error('[aiConfigService] Ошибка загрузки настроек из БД:', error.message); + // Возвращаем дефолтные значения в случае ошибки + return this.defaults; + } + } + + /** + * Создать дефолтную запись в БД + * @private + */ + async _createDefaultConfig() { + try { + const query = db.getQuery(); + await query( + `INSERT INTO ai_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING` + ); + logger.info('[aiConfigService] Создана дефолтная запись в ai_config'); + } catch (error) { + logger.error('[aiConfigService] Ошибка создания дефолтной записи:', error.message); + } + } + + /** + * Получить все настройки (с кэшированием) + * @returns {Promise} Настройки + */ + async getConfig() { + if (this._isCacheValid()) { + return this.cache; + } + return await this.loadConfig(); + } + + /** + * Обновить настройки + * @param {Object} updates - Обновления + * @param {number} userId - ID пользователя (опционально) + * @returns {Promise} Обновленные настройки + */ + async updateConfig(updates, userId = null) { + try { + const query = db.getQuery(); + const fields = []; + const values = []; + let paramIndex = 1; + + // Строим SET часть запроса + for (const [key, value] of Object.entries(updates)) { + if (key === 'id' || key === 'updated_at' || key === 'updated_by') continue; + + if (typeof value === 'object' && value !== null) { + // JSONB поля + fields.push(`${key} = $${paramIndex}::jsonb`); + values.push(JSON.stringify(value)); + } else { + fields.push(`${key} = $${paramIndex}`); + values.push(value); + } + paramIndex++; + } + + // Добавляем updated_at и updated_by + if (fields.length > 0) { + fields.push(`updated_at = NOW()`); + if (userId) { + fields.push(`updated_by = $${paramIndex}`); + values.push(userId); + } + + const sql = `UPDATE ai_config SET ${fields.join(', ')} WHERE id = 1`; + await query(sql, values); + + // Инвалидируем кэш + this.invalidateCache(); + + logger.info('[aiConfigService] Настройки обновлены'); + return await this.loadConfig(); + } + + return await this.getConfig(); + } catch (error) { + logger.error('[aiConfigService] Ошибка обновления настроек:', error.message); + throw error; + } + } + + /** + * Инвалидация кэша (принудительная перезагрузка) + */ + invalidateCache() { + this.cache = null; + this.cacheTimestamp = 0; + logger.debug('[aiConfigService] Кэш инвалидирован'); + } + + // ============================================ + // МЕТОДЫ ДЛЯ КОНКРЕТНЫХ КАТЕГОРИЙ + // ============================================ + + /** + * Получить настройки Ollama + * @returns {Promise} + */ + async getOllamaConfig() { + const config = await this.getConfig(); + return { + baseUrl: config.ollama_base_url || this.defaults.ollama_base_url, + llmModel: config.ollama_llm_model || this.defaults.ollama_llm_model, + embeddingModel: config.ollama_embedding_model || this.defaults.ollama_embedding_model + }; + } + + /** + * Получить RAG настройки + * @returns {Promise} + */ + async getRAGConfig() { + const config = await this.getConfig(); + return config.rag_settings || this.defaults.rag_settings; + } + + /** + * Получить LLM параметры (общие) + * @returns {Promise} + */ + async getLLMParameters() { + const config = await this.getConfig(); + return config.llm_parameters || this.defaults.llm_parameters; + } + + /** + * Получить специфичные параметры qwen + * @returns {Promise} + */ + async getQwenSpecificParameters() { + const config = await this.getConfig(); + return config.qwen_specific_parameters || this.defaults.qwen_specific_parameters; + } + + /** + * Получить настройки кэша + * @returns {Promise} + */ + async getCacheConfig() { + const config = await this.getConfig(); + return config.cache_settings || this.defaults.cache_settings; + } + + /** + * Получить настройки очереди + * @returns {Promise} + */ + async getQueueConfig() { + const config = await this.getConfig(); + return config.queue_settings || this.defaults.queue_settings; + } + + /** + * Получить таймауты + * @returns {Promise} + */ + async getTimeouts() { + const config = await this.getConfig(); + return config.timeouts || this.defaults.timeouts; + } + + /** + * Получить настройки дедупликации + * @returns {Promise} + */ + async getDeduplicationConfig() { + const config = await this.getConfig(); + return config.deduplication_settings || this.defaults.deduplication_settings; + } + + /** + * Получить настройки embedding модели + * @returns {Promise} + */ + async getEmbeddingParameters() { + const config = await this.getConfig(); + return config.embedding_parameters || this.defaults.embedding_parameters; + } + + /** + * Получить настройки Vector Search + * @returns {Promise} + */ + async getVectorSearchConfig() { + const config = await this.getConfig(); + return { + url: config.vector_search_url || this.defaults.vector_search_url + }; + } + + /** + * Получить настройки RAG поведения + * @returns {Promise} + */ + async getRAGBehavior() { + const config = await this.getConfig(); + return config.rag_behavior || this.defaults.rag_behavior; + } +} + +// Экспортируем singleton экземпляр +const aiConfigService = new AIConfigService(); + +module.exports = aiConfigService; + diff --git a/backend/services/consentService.js b/backend/services/consentService.js index 5ca7149..c610127 100644 --- a/backend/services/consentService.js +++ b/backend/services/consentService.js @@ -118,7 +118,7 @@ async function getConsentDocuments(missingConsents = []) { title: doc.title, summary: doc.summary, consentType: DOCUMENT_CONSENT_MAP[doc.title], - url: `/public/page/${doc.id}` + url: `/content/published/${doc.id}` })); } catch (error) { logger.error('[ConsentService] Ошибка получения документов:', error); diff --git a/backend/services/encryptedDatabaseService.js b/backend/services/encryptedDatabaseService.js index 263e7a0..894fe97 100644 --- a/backend/services/encryptedDatabaseService.js +++ b/backend/services/encryptedDatabaseService.js @@ -236,16 +236,16 @@ class EncryptedDataService { console.log(`🔐 Будем шифровать ${key} -> ${key}_encrypted`); } else if (unencryptedColumn) { // Если есть незашифрованная колонка, сохраняем как есть - // Проверяем, что значение не пустое перед сохранением (кроме role и sender_type) + // Проверяем, что значение не пустое перед сохранением (кроме role, sender_type и user_id) if ((value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) && - key !== 'role' && key !== 'sender_type') { - // Пропускаем пустые значения, кроме role и sender_type + key !== 'role' && key !== 'sender_type' && key !== 'user_id') { + // Пропускаем пустые значения, кроме role, sender_type и user_id // console.log(`⚠️ Пропускаем пустое незашифрованное поле ${key}`); continue; } filteredData[key] = value; // Добавляем в отфильтрованные данные unencryptedData[key] = `$${paramIndex++}`; - // console.log(`✅ Добавили незашифрованное поле ${key} в filteredData и unencryptedData`); + console.log(`✅ Добавили незашифрованное поле ${key} в filteredData и unencryptedData`); } else { // Если колонка не найдена, пропускаем // console.warn(`⚠️ Колонка ${key} не найдена в таблице ${tableName}`); @@ -254,6 +254,11 @@ class EncryptedDataService { const allData = { ...unencryptedData, ...encryptedData }; + console.log(`🔍 allData:`, JSON.stringify(allData, null, 2)); + console.log(`🔍 filteredData:`, JSON.stringify(filteredData, null, 2)); + console.log(`🔍 unencryptedData:`, JSON.stringify(unencryptedData, null, 2)); + console.log(`🔍 encryptedData:`, JSON.stringify(encryptedData, null, 2)); + // Проверяем, есть ли данные для сохранения if (Object.keys(allData).length === 0) { // console.warn(`⚠️ Нет данных для сохранения в таблице ${tableName} - все значения пустые`); @@ -310,29 +315,36 @@ class EncryptedDataService { // Проходим по колонкам в порядке allData и добавляем соответствующие значения for (const key of Object.keys(allData)) { const placeholder = allData[key].toString(); + console.log(`🔍 Обрабатываем ключ: ${key}, placeholder: ${placeholder}`); // Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text) const paramMatches = placeholder.match(/\$(\d+)/g); + console.log(`🔍 paramMatches для ${key}:`, paramMatches); if (paramMatches) { // Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.) // Для незашифрованных - первый параметр ($2, $3 и т.д.) if (encryptedData[key]) { - // Это зашифрованная колонка - берем второй параметр (первый это $1 - ключ шифрования) + // Это зашифрованная колонка - берем первый параметр (это значение для шифрования) const originalKey = key.replace('_encrypted', ''); + console.log(`🔍 Это зашифрованная колонка, originalKey: ${originalKey}, filteredData[originalKey]:`, filteredData[originalKey]); if (filteredData[originalKey] !== undefined && paramMatches.length > 0) { - // Последний параметр это значение для шифрования - const valueParam = paramMatches[paramMatches.length - 1]; + // Первый параметр это значение для шифрования + const valueParam = paramMatches[0]; const paramNum = parseInt(valueParam.substring(1)); + console.log(`🔍 Устанавливаем paramMap[${paramNum}] =`, filteredData[originalKey]); paramMap.set(paramNum, filteredData[originalKey]); } } else if (unencryptedData[key]) { // Это незашифрованная колонка - берем параметр из плейсхолдера const valueParam = paramMatches[0]; const paramNum = parseInt(valueParam.substring(1)); + console.log(`🔍 Это незашифрованная колонка, устанавливаем paramMap[${paramNum}] =`, filteredData[key]); paramMap.set(paramNum, filteredData[key]); } } } + console.log(`🔍 paramMap после цикла:`, Array.from(paramMap.entries())); + // Создаем массив параметров в правильном порядке (от $1 до максимального номера) const maxParamNum = Math.max(...Array.from(paramMap.keys())); const params = []; diff --git a/backend/services/messageDeduplicationService.js b/backend/services/messageDeduplicationService.js index 80b7315..f78b42c 100644 --- a/backend/services/messageDeduplicationService.js +++ b/backend/services/messageDeduplicationService.js @@ -12,6 +12,7 @@ const crypto = require('crypto'); const logger = require('../utils/logger'); +const aiConfigService = require('./aiConfigService'); /** * Сервис дедупликации сообщений @@ -21,8 +22,22 @@ const logger = require('../utils/logger'); // Хранилище хешей обработанных сообщений (в памяти) const processedMessages = new Map(); -// Время жизни записи о сообщении (5 минут) -const MESSAGE_TTL = 5 * 60 * 1000; +// Время жизни записи о сообщении (загружается из aiConfigService) +let MESSAGE_TTL = null; + +// Инициализация настроек (асинхронная загрузка) +async function loadSettings() { + try { + const dedupConfig = await aiConfigService.getDeduplicationConfig(); + MESSAGE_TTL = dedupConfig.ttl || 5 * 60 * 1000; // Дефолт 5 минут + } catch (error) { + logger.warn('[MessageDeduplication] Ошибка загрузки настроек, используем дефолт:', error.message); + MESSAGE_TTL = 5 * 60 * 1000; // Дефолт 5 минут + } +} + +// Инициализируем настройки при загрузке модуля +loadSettings().catch(err => logger.warn('[MessageDeduplication] Ошибка инициализации:', err.message)); /** * Создать хеш сообщения @@ -48,7 +63,12 @@ function createMessageHash(messageData) { * @param {Object} messageData - Данные сообщения * @returns {boolean} true если сообщение уже обрабатывалось */ -function isDuplicate(messageData) { +async function isDuplicate(messageData) { + // Загружаем актуальные настройки, если они не загружены + if (MESSAGE_TTL === null) { + await loadSettings(); + } + const hash = createMessageHash(messageData); if (processedMessages.has(hash)) { @@ -72,7 +92,12 @@ function isDuplicate(messageData) { * Пометить сообщение как обработанное * @param {Object} messageData - Данные сообщения */ -function markAsProcessed(messageData) { +async function markAsProcessed(messageData) { + // Загружаем актуальные настройки, если они не загружены + if (MESSAGE_TTL === null) { + await loadSettings(); + } + const hash = createMessageHash(messageData); processedMessages.set(hash, { @@ -91,11 +116,14 @@ function markAsProcessed(messageData) { * Очистить старые записи из хранилища */ function cleanupOldEntries() { + // Если настройки не загружены, используем дефолт + const ttl = MESSAGE_TTL || 5 * 60 * 1000; + const now = Date.now(); let cleanedCount = 0; for (const [hash, entry] of processedMessages.entries()) { - if (now - entry.timestamp > MESSAGE_TTL) { + if (now - entry.timestamp > ttl) { processedMessages.delete(hash); cleanedCount++; } @@ -113,7 +141,7 @@ function cleanupOldEntries() { function getStats() { return { totalTracked: processedMessages.size, - ttl: MESSAGE_TTL + ttl: MESSAGE_TTL || 5 * 60 * 1000 }; } diff --git a/backend/services/multiSourceSearchService.js b/backend/services/multiSourceSearchService.js new file mode 100644 index 0000000..cfcb343 --- /dev/null +++ b/backend/services/multiSourceSearchService.js @@ -0,0 +1,828 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +const vectorSearch = require('./vectorSearchClient'); +const ragService = require('./ragService'); +const aiConfigService = require('./aiConfigService'); +const userContextService = require('./userContextService'); +const encryptedDb = require('./encryptedDatabaseService'); +const db = require('../db'); +const logger = require('../utils/logger'); + +const DOCUMENT_SNIPPET_LENGTH = 350; + +function resolveDocumentIdFromResult(result) { + if (!result) { + return null; + } + + const metadata = result.metadata || {}; + const candidates = [metadata.doc_id, metadata.parent_doc_id]; + + for (const value of candidates) { + const parsed = parseInt(value, 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + + if (typeof result.row_id === 'string') { + const match = result.row_id.match(/^(\d+)(?:_chunk_\d+)?$/); + if (match) { + const parsed = parseInt(match[1], 10); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + } + + if (typeof result.row_id === 'number' && Number.isFinite(result.row_id)) { + return result.row_id; + } + + return null; +} + +function extractPlainText(content, format = 'text') { + if (!content || typeof content !== 'string') { + return ''; + } + + let plain = content; + + if (format === 'html' || /<[^>]+>/.test(content)) { + plain = plain + .replace(/]*>[\s\S]*?<\/script>/gi, ' ') + .replace(/]*>[\s\S]*?<\/style>/gi, ' ') + .replace(/<[^>]+>/g, ' '); + } + + plain = plain + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/\s+/g, ' ') + .trim(); + + return plain; +} + +function buildSnippet(text, maxLength = DOCUMENT_SNIPPET_LENGTH) { + if (!text || typeof text !== 'string') { + return ''; + } + + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength)}...`; +} + +/** + * Сервис для параллельного поиска в таблицах и документах + * с комбинацией нескольких методов анализа + */ +class MultiSourceSearchService { + constructor() { + this.searchMethods = { + semantic: 'semantic', + keyword: 'keyword', + hybrid: 'hybrid' + }; + } + + /** + * Основной метод поиска в нескольких источниках параллельно + * @param {Object} options - Опции поиска + * @param {string} options.query - Поисковый запрос + * @param {Array} options.tableIds - ID таблиц для поиска + * @param {boolean} options.searchInDocuments - Поиск в документах (legal_docs) + * @param {string} options.searchMethod - Метод поиска: 'semantic', 'keyword', 'hybrid' + * @param {number} options.userId - ID пользователя (для фильтрации по тегам) + * @param {number} options.maxResultsPerSource - Максимум результатов из каждого источника + * @param {number} options.totalMaxResults - Максимум результатов всего + * @returns {Promise} Объединенные результаты поиска + */ + async search({ + query, + tableIds = [], + searchInDocuments = true, + searchMethod = 'hybrid', + userId = null, + maxResultsPerSource = 10, + totalMaxResults = 20 + }) { + logger.info(`[MultiSourceSearch] Поиск: query="${query}", tableIds=${tableIds.join(',')}, searchInDocuments=${searchInDocuments}, method=${searchMethod}`); + + try { + // Загружаем настройки RAG + const ragConfig = await aiConfigService.getRAGConfig(); + const finalMaxResults = maxResultsPerSource || ragConfig.maxResults || 10; + + // Параллельно запускаем поиск в разных источниках + const searchPromises = []; + + // 1. Поиск в таблицах (user_tables) + if (tableIds && tableIds.length > 0) { + for (const tableId of tableIds) { + searchPromises.push( + this.searchInTable({ + tableId, + query, + searchMethod, + userId, + maxResults: finalMaxResults + }).catch(err => { + logger.error(`[MultiSourceSearch] Ошибка поиска в таблице ${tableId}:`, err.message); + return { source: 'table', tableId, results: [], error: err.message }; + }) + ); + } + } + + // 2. Поиск в документах (legal_docs) + if (searchInDocuments) { + searchPromises.push( + this.searchInDocuments({ + query, + searchMethod, + maxResults: finalMaxResults + }).catch(err => { + logger.error(`[MultiSourceSearch] Ошибка поиска в документах:`, err.message); + return { source: 'documents', results: [], error: err.message }; + }) + ); + } + + // Ждем результаты из всех источников + logger.info(`[MultiSourceSearch] Ожидание результатов из ${searchPromises.length} источников...`); + const promiseStartTime = Date.now(); + const searchResults = await Promise.all(searchPromises); + const promiseDuration = Date.now() - promiseStartTime; + logger.info(`[MultiSourceSearch] Получены результаты из всех источников за ${promiseDuration}ms, всего: ${searchResults.length}`); + + // Объединяем результаты + logger.info(`[MultiSourceSearch] Объединение результатов...`); + const mergedResults = this.mergeResults(searchResults, { + totalMaxResults, + searchMethod + }); + + logger.info(`[MultiSourceSearch] Найдено результатов: ${mergedResults.results.length} из ${searchResults.length} источников`); + + return mergedResults; + } catch (error) { + logger.error(`[MultiSourceSearch] Ошибка поиска:`, error); + throw error; + } + } + + /** + * Поиск в конкретной таблице + * @param {Object} options - Опции поиска + * @returns {Promise} Результаты поиска + */ + async searchInTable({ + tableId, + query, + searchMethod, + userId, + maxResults + }) { + logger.info(`[MultiSourceSearch] Поиск в таблице ${tableId}, метод: ${searchMethod}`); + const startTime = Date.now(); + + let result; + switch (searchMethod) { + case this.searchMethods.semantic: + result = await this.semanticSearchInTable(tableId, query, userId, maxResults); + break; + + case this.searchMethods.keyword: + result = await this.keywordSearchInTable(tableId, query, userId, maxResults); + break; + + case this.searchMethods.hybrid: + default: + result = await this.hybridSearchInTable(tableId, query, userId, maxResults); + break; + } + + const duration = Date.now() - startTime; + logger.info(`[MultiSourceSearch] Поиск в таблице ${tableId} завершен за ${duration}ms, найдено: ${result?.results?.length || 0} результатов`); + return result; + } + + /** + * Семантический поиск в таблице (векторный) + */ + async semanticSearchInTable(tableId, query, userId, maxResults) { + try { + // Используем векторный поиск напрямую для получения нескольких результатов + const vectorResults = await vectorSearch.search(tableId, query, maxResults); + + // Получаем данные таблицы для формирования полных результатов + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + const db = require('../db'); + + // Фильтруем по тегам пользователя, если указан + let filteredRowIds = null; + if (userId) { + // Используем логику из ragService для фильтрации + const userTagIds = await userContextService.getUserTags(userId); + if (userTagIds && userTagIds.length > 0) { + const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); + const tagsColumn = columns.find(col => + col.options?.purpose === 'userTags' && + (col.type === 'multiselect-relation' || col.type === 'relation') + ); + + if (tagsColumn) { + const result = await db.getQuery()(` + SELECT DISTINCT from_row_id + FROM user_table_relations + WHERE column_id = $1 + AND to_row_id = ANY($2) + `, [tagsColumn.id, userTagIds]); + filteredRowIds = result.rows.map(row => row.from_row_id); + } + } + } + + // Формируем результаты + const results = []; + for (const vectorResult of vectorResults) { + const rowId = parseInt(vectorResult.row_id); + + // Пропускаем, если фильтрация по тегам и строка не подходит + if (filteredRowIds !== null && filteredRowIds.length > 0 && !filteredRowIds.includes(rowId)) { + continue; + } + + results.push({ + source: 'table', + sourceId: tableId, + rowId: rowId, + text: vectorResult.metadata?.answer || vectorResult.metadata?.text || '', + context: vectorResult.metadata?.context || '', + score: vectorResult.score || 0, + metadata: { + answer: vectorResult.metadata?.answer, + context: vectorResult.metadata?.context, + product: vectorResult.metadata?.product, + priority: vectorResult.metadata?.priority, + date: vectorResult.metadata?.date, + userTags: vectorResult.metadata?.userTags + } + }); + } + + return { + source: 'table', + tableId, + method: 'semantic', + results, + count: results.length + }; + } catch (error) { + logger.error(`[MultiSourceSearch] Ошибка семантического поиска в таблице ${tableId}:`, error); + return { + source: 'table', + tableId, + method: 'semantic', + results: [], + count: 0, + error: error.message + }; + } + } + + /** + * Поиск по ключевым словам в таблице + */ + async keywordSearchInTable(tableId, query, userId, maxResults) { + // Извлекаем ключевые слова из запроса + const keywords = this.extractKeywords(query); + + if (keywords.length === 0) { + return { + source: 'table', + tableId, + method: 'keyword', + results: [], + count: 0 + }; + } + + // Используем RAG сервис для получения данных таблицы + const encryptedDb = require('./encryptedDatabaseService'); + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + const db = require('../db'); + + // Получаем строки таблицы + const rowsResult = await db.getQuery()( + 'SELECT id FROM user_rows WHERE table_id = $1', + [tableId] + ); + const rows = rowsResult.rows; + + // Фильтруем по тегам пользователя, если указан + let filteredRowIds = rows.map(r => r.id); + if (userId) { + const userTagIds = await userContextService.getUserTags(userId); + if (userTagIds && userTagIds.length > 0) { + // Получаем строки с тегами пользователя через user_table_relations + const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); + const tagsColumn = columns.find(col => + col.options?.purpose === 'userTags' && + (col.type === 'multiselect-relation' || col.type === 'relation') + ); + + if (tagsColumn) { + const filteredRowsResult = await db.getQuery()( + `SELECT DISTINCT from_row_id as id + FROM user_table_relations + WHERE column_id = $1 + AND to_row_id = ANY($2)`, + [tagsColumn.id, userTagIds] + ); + filteredRowIds = filteredRowsResult.rows.map(r => r.id); + } + } + } + + // Получаем данные ячеек для каждой строки + const results = []; + for (const rowId of filteredRowIds) { + const cellsResult = await db.getQuery()( + `SELECT + uc.id as column_id, + decrypt_text(uc.name_encrypted, $1) as column_name, + decrypt_text(ucv.value_encrypted, $1) as value + FROM user_cell_values ucv + JOIN user_columns uc ON uc.id = ucv.column_id + WHERE ucv.row_id = $2 AND uc.table_id = $3`, + [encryptionKey, rowId, tableId] + ); + const cells = cellsResult.rows; + + // Формируем текст строки из всех ячеек + const rowText = cells + .map(cell => `${cell.column_name}: ${cell.value}`) + .join(' '); + + // Вычисляем совпадение по ключевым словам + const matchScore = this.calculateKeywordMatch(rowText, keywords); + + if (matchScore > 0) { + // Ищем столбец с ответом (purpose: 'answer') + // Получаем столбцы для проверки purpose + const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); + const answerColumn = columns.find(col => col.options?.purpose === 'answer'); + const answerColumnId = answerColumn ? answerColumn.id : null; + + const answerCell = answerColumnId + ? cells.find(c => c.column_id === answerColumnId) + : cells.find(c => { + // Fallback: ищем по названию + return c.column_name && ( + c.column_name.toLowerCase().includes('ответ') || + c.column_name.toLowerCase().includes('answer') + ); + }); + + const questionColumn = columns.find(col => col.options?.purpose === 'question'); + const questionColumnId = questionColumn ? questionColumn.id : null; + const questionCell = questionColumnId + ? cells.find(c => c.column_id === questionColumnId) + : null; + + results.push({ + source: 'table', + sourceId: tableId, + rowId: rowId, + text: answerCell ? answerCell.value : rowText, + context: questionCell ? questionCell.value : rowText, + score: matchScore, + metadata: { + method: 'keyword', + keywords: keywords, + rowData: cells.reduce((acc, cell) => { + acc[cell.column_name] = cell.value; + return acc; + }, {}) + } + }); + } + } + + // Сортируем по релевантности и берем топ-N + results.sort((a, b) => b.score - a.score); + const topResults = results.slice(0, maxResults); + + return { + source: 'table', + tableId, + method: 'keyword', + results: topResults, + count: topResults.length + }; + } + + /** + * Гибридный поиск в таблице (семантический + ключевые слова) + */ + async hybridSearchInTable(tableId, query, userId, maxResults) { + // Параллельно выполняем оба типа поиска + const [semanticResults, keywordResults] = await Promise.all([ + this.semanticSearchInTable(tableId, query, userId, maxResults * 2), + this.keywordSearchInTable(tableId, query, userId, maxResults * 2) + ]); + + // Объединяем результаты с весами + const semanticWeight = 0.7; + const keywordWeight = 0.3; + + const combined = this.combineSearchResults( + semanticResults.results, + keywordResults.results, + semanticWeight, + keywordWeight + ); + + // Сортируем и берем топ-N + combined.sort((a, b) => b.combinedScore - a.combinedScore); + const topResults = combined.slice(0, maxResults); + + return { + source: 'table', + tableId, + method: 'hybrid', + results: topResults.map(r => ({ + ...r, + score: r.combinedScore + })), + count: topResults.length, + semanticCount: semanticResults.count, + keywordCount: keywordResults.count + }; + } + + /** + * Поиск в документах (legal_docs) + */ + async searchInDocuments({ + query, + searchMethod, + maxResults + }) { + logger.info(`[MultiSourceSearch] Поиск в документах, метод: ${searchMethod}`); + const startTime = Date.now(); + + const tableId = 'legal_docs'; + + try { + // Векторный поиск в документах + const vectorResults = await vectorSearch.search(tableId, query, maxResults * 2); + + const documentIds = new Set(); + for (const result of vectorResults) { + const docId = resolveDocumentIdFromResult(result); + if (docId !== null) { + documentIds.add(docId); + } + } + + const documentSnippets = new Map(); + if (documentIds.size > 0) { + const idsArray = Array.from(documentIds); + try { + const queryFn = db.getQuery(); + const { rows } = await queryFn( + `SELECT id, content, format FROM admin_pages_simple WHERE id = ANY($1::int[])`, + [idsArray] + ); + + for (const row of rows) { + const snippet = buildSnippet(extractPlainText(row.content, row.format)); + documentSnippets.set(String(row.id), snippet); + } + } catch (dbError) { + logger.warn(`[MultiSourceSearch] Не удалось загрузить содержимое документов: ${dbError.message}`); + } + } + + // Формируем результаты + const results = vectorResults.map(result => { + const metadata = result.metadata || {}; + const docId = resolveDocumentIdFromResult(result); + const docKey = docId !== null ? String(docId) : null; + + const chunkText = buildSnippet(result.text || metadata.content || metadata.text || ''); + const fallbackText = docKey ? documentSnippets.get(docKey) : ''; + const finalText = chunkText || fallbackText || ''; + + const contextValue = metadata.title || metadata.section || ''; + + return { + source: 'document', + sourceId: tableId, + rowId: result.row_id, + text: finalText, + context: contextValue, + score: result.score || 0, + metadata: { + doc_id: metadata.doc_id || docId, + title: metadata.title, + url: metadata.url, + format: metadata.format, + visibility: metadata.visibility, + section: metadata.section, + chunk_index: metadata.chunk_index, + snippetSource: chunkText ? 'chunk' : (fallbackText ? 'document' : 'unknown') + } + }; + }); + + // Если гибридный поиск, добавляем поиск по ключевым словам + if (searchMethod === this.searchMethods.hybrid) { + const keywordResults = await this.keywordSearchInDocuments(query, maxResults); + + // Объединяем результаты + const combined = this.combineSearchResults( + results, + keywordResults, + 0.7, // вес для семантического + 0.3 // вес для ключевых слов + ); + + combined.sort((a, b) => b.combinedScore - a.combinedScore); + const topResults = combined.slice(0, maxResults); + + return { + source: 'documents', + method: 'hybrid', + results: topResults.map(r => ({ + ...r, + score: r.combinedScore + })), + count: topResults.length + }; + } + + // Сортируем по релевантности + results.sort((a, b) => b.score - a.score); + const topResults = results.slice(0, maxResults); + + const duration = Date.now() - startTime; + logger.info(`[MultiSourceSearch] Поиск в документах завершен за ${duration}ms, найдено: ${topResults.length} результатов`); + + return { + source: 'documents', + method: searchMethod, + results: topResults, + count: topResults.length + }; + } catch (error) { + const duration = Date.now() - startTime; + logger.error(`[MultiSourceSearch] Ошибка поиска в документах за ${duration}ms:`, error); + return { + source: 'documents', + method: searchMethod, + results: [], + count: 0, + error: error.message + }; + } + } + + /** + * Поиск по ключевым словам в документах + */ + async keywordSearchInDocuments(query, maxResults) { + // Для поиска по ключевым словам в документах нужно получить все документы + // и фильтровать по ключевым словам. Это может быть медленно для больших объемов. + // В будущем можно добавить индекс для ключевых слов. + + const keywords = this.extractKeywords(query); + + // Пока возвращаем пустой результат, т.к. для документов векторный поиск обычно достаточно + // Для полноценной реализации нужно добавить индекс ключевых слов + return []; + } + + /** + * Объединение результатов из разных источников + */ + mergeResults(searchResults, options = {}) { + const { totalMaxResults = 20, searchMethod = 'hybrid' } = options; + + const allResults = []; + + // Собираем все результаты из всех источников + for (const searchResult of searchResults) { + if (searchResult.results && searchResult.results.length > 0) { + allResults.push(...searchResult.results.map(result => ({ + ...result, + sourceType: searchResult.source, + sourceId: searchResult.tableId || searchResult.sourceId + }))); + } + } + + // Удаляем дубликаты (по rowId и sourceType) + const uniqueResults = this.removeDuplicates(allResults); + + // Сортируем по релевантности + uniqueResults.sort((a, b) => { + // Приоритет: таблицы > документы (можно настроить) + const sourcePriority = { + table: 1.0, + document: 0.9 + }; + + const priorityA = sourcePriority[a.sourceType] || 0.8; + const priorityB = sourcePriority[b.sourceType] || 0.8; + + // Комбинируем релевантность и приоритет источника + const scoreA = (a.score || 0) * priorityA; + const scoreB = (b.score || 0) * priorityB; + + return scoreB - scoreA; + }); + + // Берем топ-N результатов + const topResults = uniqueResults.slice(0, totalMaxResults); + + // Группируем по источникам для статистики + const sourcesStats = {}; + for (const result of topResults) { + const sourceKey = `${result.sourceType}_${result.sourceId || 'unknown'}`; + if (!sourcesStats[sourceKey]) { + sourcesStats[sourceKey] = { + source: result.sourceType, + sourceId: result.sourceId, + count: 0 + }; + } + sourcesStats[sourceKey].count++; + } + + return { + results: topResults, + totalCount: topResults.length, + sourcesCount: searchResults.length, + sourcesStats: Object.values(sourcesStats), + searchMethod + }; + } + + /** + * Объединение результатов семантического и ключевого поиска + */ + combineSearchResults(semanticResults, keywordResults, semanticWeight, keywordWeight) { + const combined = new Map(); + + // Нормализуем скоры для семантического поиска + const normalizedSemantic = this.normalizeScores(semanticResults); + + // Нормализуем скоры для поиска по ключевым словам + const normalizedKeyword = this.normalizeScores(keywordResults); + + // Добавляем результаты семантического поиска + normalizedSemantic.forEach(result => { + const key = `${result.source}_${result.sourceId}_${result.rowId || 'unknown'}`; + combined.set(key, { + ...result, + semanticScore: result.score, + keywordScore: 0, + combinedScore: result.score * semanticWeight + }); + }); + + // Добавляем результаты поиска по ключевым словам + normalizedKeyword.forEach(result => { + const key = `${result.source}_${result.sourceId}_${result.rowId || 'unknown'}`; + const existing = combined.get(key); + + if (existing) { + // Объединяем скоры + existing.keywordScore = result.score; + existing.combinedScore = (existing.semanticScore * semanticWeight) + (result.score * keywordWeight); + } else { + // Новый результат + combined.set(key, { + ...result, + semanticScore: 0, + keywordScore: result.score, + combinedScore: result.score * keywordWeight + }); + } + }); + + return Array.from(combined.values()); + } + + /** + * Нормализация скоров (0-1) + */ + normalizeScores(results) { + if (results.length === 0) return []; + + const scores = results.map(r => Math.abs(r.score || 0)); + const maxScore = Math.max(...scores); + const minScore = Math.min(...scores); + const range = maxScore - minScore || 1; + + return results.map(result => ({ + ...result, + score: range > 0 ? (Math.abs(result.score || 0) - minScore) / range : 0.5 + })); + } + + /** + * Извлечение ключевых слов из запроса + */ + extractKeywords(query) { + if (!query || typeof query !== 'string') return []; + + // Удаляем стоп-слова + const stopWords = new Set([ + 'как', 'что', 'где', 'когда', 'почему', 'кто', 'куда', 'откуда', + 'для', 'при', 'над', 'под', 'перед', 'после', 'через', + 'и', 'или', 'но', 'а', 'да', 'нет', 'не', + 'в', 'на', 'с', 'со', 'из', 'к', 'от', 'до', 'по', 'о', 'об', 'обо', + 'это', 'этот', 'эта', 'эти', 'этот', 'тот', 'та', 'те', 'то', + 'быть', 'есть', 'был', 'была', 'было', 'были' + ]); + + // Разбиваем на слова (сохраняем кириллицу и латиницу) + const words = query + .toLowerCase() + .replace(/[^\w\s\u0400-\u04FF]/g, ' ') // \u0400-\u04FF - диапазон кириллицы + .split(/\s+/) + .filter(word => word.length > 2 && !stopWords.has(word)); + + return words; + } + + /** + * Расчет совпадения по ключевым словам + */ + calculateKeywordMatch(text, keywords) { + if (!text || !keywords || keywords.length === 0) return 0; + + const textLower = text.toLowerCase(); + let matchCount = 0; + + for (const keyword of keywords) { + if (textLower.includes(keyword.toLowerCase())) { + matchCount++; + } + } + + // Возвращаем процент совпадения + return keywords.length > 0 ? matchCount / keywords.length : 0; + } + + + /** + * Удаление дубликатов из результатов + */ + removeDuplicates(results) { + const seen = new Set(); + const unique = []; + + for (const result of results) { + const key = `${result.sourceType}_${result.sourceId}_${result.rowId || 'unknown'}`; + if (!seen.has(key)) { + seen.add(key); + unique.push(result); + } + } + + return unique; + } +} + +// Создаем singleton экземпляр +const multiSourceSearchService = new MultiSourceSearchService(); + +module.exports = multiSourceSearchService; + diff --git a/backend/services/ollamaConfig.js b/backend/services/ollamaConfig.js index f00a9b1..211a20e 100644 --- a/backend/services/ollamaConfig.js +++ b/backend/services/ollamaConfig.js @@ -12,57 +12,67 @@ /** * Конфигурационный сервис для Ollama и AI инфраструктуры - * Централизует все настройки, URL и таймауты для: - * - Ollama API - * - Vector Search - * - AI Cache - * - AI Queue + * Обёртка над aiConfigService для обратной совместимости * - * ВАЖНО: Настройки берутся из таблицы ai_providers_settings (через aiProviderSettingsService) + * ВАЖНО: Все настройки теперь берутся из ai_config через aiConfigService */ const logger = require('../utils/logger'); +const aiConfigService = require('./aiConfigService'); -// Кэш для настроек из БД -let settingsCache = null; +// Кэш для синхронных методов (для обратной совместимости) +let syncCache = null; +let syncCacheTimestamp = 0; +const SYNC_CACHE_TTL = 60000; // 1 минута /** - * Загружает настройки Ollama из базы данных - * @returns {Promise} Настройки Ollama провайдера + * Обновляет синхронный кэш из aiConfigService + * @private */ -async function loadSettingsFromDb() { +async function _updateSyncCache() { try { - const aiProviderSettingsService = require('./aiProviderSettingsService'); - const settings = await aiProviderSettingsService.getProviderSettings('ollama'); - - if (settings) { - settingsCache = settings; - logger.info(`[ollamaConfig] Loaded settings from DB: model=${settings.selected_model}, base_url=${settings.base_url}`); - } - - return settings; + const ollamaConfig = await aiConfigService.getOllamaConfig(); + syncCache = { + baseUrl: ollamaConfig.baseUrl, + defaultModel: ollamaConfig.llmModel, + embeddingModel: ollamaConfig.embeddingModel + }; + syncCacheTimestamp = Date.now(); } catch (error) { - logger.error('[ollamaConfig] Ошибка загрузки настроек Ollama из БД:', error.message); - return null; + logger.warn('[ollamaConfig] Failed to update sync cache:', error.message); + // Используем дефолты + syncCache = { + baseUrl: process.env.OLLAMA_BASE_URL || 'http://ollama:11434', + defaultModel: process.env.OLLAMA_MODEL || 'qwen2.5:7b', + embeddingModel: process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large:latest' + }; } } /** - * Внутренняя функция: определяет base URL из доступных источников - * Приоритет: кэш из БД > переменная окружения > Docker дефолт - * @returns {string} Базовый URL Ollama + * Получает значение из синхронного кэша или обновляет его + * @private */ -function _getBaseUrlFromSources() { - // Приоритет 1: кэш из БД - if (settingsCache && settingsCache.base_url) { - return settingsCache.base_url; +function _getFromSyncCache(key) { + const now = Date.now(); + if (!syncCache || (now - syncCacheTimestamp) > SYNC_CACHE_TTL) { + // Обновляем кэш асинхронно (не блокируя) + _updateSyncCache().catch(err => logger.warn('[ollamaConfig] Sync cache update failed:', err.message)); } - // Приоритет 2: переменная окружения - if (process.env.OLLAMA_BASE_URL) { - return process.env.OLLAMA_BASE_URL; + + // Если кэш есть - используем его + if (syncCache && syncCache[key]) { + return syncCache[key]; } - // Приоритет 3: Docker дефолт - return 'http://ollama:11434'; + + // Иначе используем дефолты + const defaults = { + baseUrl: process.env.OLLAMA_BASE_URL || 'http://ollama:11434', + defaultModel: process.env.OLLAMA_MODEL || 'qwen2.5:7b', + embeddingModel: process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large:latest' + }; + + return defaults[key] || defaults.baseUrl; } /** @@ -70,7 +80,7 @@ function _getBaseUrlFromSources() { * @returns {string} Базовый URL Ollama */ function getBaseUrl() { - return _getBaseUrlFromSources(); + return _getFromSyncCache('baseUrl'); } /** @@ -78,15 +88,8 @@ function getBaseUrl() { * @returns {Promise} Базовый URL Ollama */ async function getBaseUrlAsync() { - try { - if (!settingsCache) { - await loadSettingsFromDb(); - } - } catch (error) { - logger.warn('[ollamaConfig] Failed to load base_url from DB, using default'); - } - - return _getBaseUrlFromSources(); + const config = await aiConfigService.getOllamaConfig(); + return config.baseUrl; } /** @@ -104,12 +107,7 @@ function getApiUrl(endpoint) { * @returns {string} Название модели */ function getDefaultModel() { - // Приоритет: кэш из БД > дефолт - if (settingsCache && settingsCache.selected_model) { - return settingsCache.selected_model; - } - // Дефолтное значение если БД недоступна - return 'qwen2.5:7b'; + return _getFromSyncCache('defaultModel'); } /** @@ -117,19 +115,8 @@ function getDefaultModel() { * @returns {Promise} Название модели из БД */ async function getDefaultModelAsync() { - try { - if (!settingsCache) { - await loadSettingsFromDb(); - } - - if (settingsCache && settingsCache.selected_model) { - logger.info(`[ollamaConfig] Using model from DB: ${settingsCache.selected_model}`); - return settingsCache.selected_model; - } - } catch (error) { - logger.warn('[ollamaConfig] Failed to load model from DB, using default'); - } - return 'qwen2.5:7b'; + const config = await aiConfigService.getOllamaConfig(); + return config.llmModel; } /** @@ -137,59 +124,116 @@ async function getDefaultModelAsync() { * @returns {Promise} Название embedding модели из БД */ async function getEmbeddingModel() { + const config = await aiConfigService.getOllamaConfig(); + return config.embeddingModel; +} + +// Кэш для таймаутов (синхронный доступ) +let timeoutsCache = null; +let timeoutsCacheTimestamp = 0; + +/** + * Обновляет кэш таймаутов из aiConfigService + * @private + */ +async function _updateTimeoutsCache() { try { - if (!settingsCache) { - await loadSettingsFromDb(); - } + const timeouts = await aiConfigService.getTimeouts(); + const cacheConfig = await aiConfigService.getCacheConfig(); + const queueConfig = await aiConfigService.getQueueConfig(); - if (settingsCache && settingsCache.embedding_model) { - logger.info(`[ollamaConfig] Using embedding model from DB: ${settingsCache.embedding_model}`); - return settingsCache.embedding_model; - } + timeoutsCache = { + // Ollama API - таймауты запросов + ollamaChat: timeouts.ollamaChat, + ollamaEmbedding: timeouts.ollamaEmbedding, + ollamaHealth: timeouts.ollamaHealth, + ollamaTags: timeouts.ollamaTags, + + // Vector Search - таймауты запросов + vectorSearch: timeouts.vectorSearch, + vectorUpsert: timeouts.vectorUpsert, + vectorHealth: timeouts.vectorHealth, + + // AI Cache - TTL (Time To Live) для кэширования + cacheLLM: cacheConfig.llmTTL, + cacheRAG: cacheConfig.ragTTL, + cacheMax: cacheConfig.maxSize, + + // AI Queue - параметры очереди + queueTimeout: queueConfig.timeout, + queueMaxSize: queueConfig.maxSize, + queueInterval: queueConfig.interval, + + // Default для совместимости + default: timeouts.ollamaChat + }; + timeoutsCacheTimestamp = Date.now(); } catch (error) { - logger.warn('[ollamaConfig] Failed to load embedding model from DB, using default'); + logger.warn('[ollamaConfig] Failed to update timeouts cache:', error.message); + // Используем дефолты + timeoutsCache = { + ollamaChat: 600000, + ollamaEmbedding: 90000, + ollamaHealth: 5000, + ollamaTags: 10000, + vectorSearch: 90000, + vectorUpsert: 600000, + vectorHealth: 5000, + cacheLLM: 86400000, + cacheRAG: 300000, + cacheMax: 1000, + queueTimeout: 180000, + queueMaxSize: 100, + queueInterval: 100, + default: 180000 + }; } - return 'mxbai-embed-large:latest'; } /** * Централизованные таймауты для Ollama и AI сервисов + * Синхронная версия с кэшированием (для обратной совместимости) * @returns {Object} Объект с различными таймаутами */ function getTimeouts() { + const now = Date.now(); + if (!timeoutsCache || (now - timeoutsCacheTimestamp) > SYNC_CACHE_TTL) { + // Обновляем кэш асинхронно (не блокируя) + _updateTimeoutsCache().catch(err => logger.warn('[ollamaConfig] Timeouts cache update failed:', err.message)); + } + + // Если кэш есть - используем его + if (timeoutsCache) { + return timeoutsCache; + } + + // Иначе используем дефолты return { - // Ollama API - таймауты запросов - ollamaChat: 180000, // 180 сек (3 мин) - генерация ответов LLM (увеличено для сложных запросов) - ollamaEmbedding: 90000, // 90 сек (1.5 мин) - генерация embeddings (увеличено) - ollamaHealth: 5000, // 5 сек - health check - ollamaTags: 10000, // 10 сек - список моделей - - // Vector Search - таймауты запросов - vectorSearch: 90000, // 90 сек - поиск по векторам (увеличено для больших баз) - vectorUpsert: 90000, // 90 сек - индексация данных (увеличено) - vectorHealth: 5000, // 5 сек - health check - - // AI Cache - TTL (Time To Live) для кэширования - cacheLLM: 24 * 60 * 60 * 1000, // 24 часа - LLM ответы - cacheRAG: 5 * 60 * 1000, // 5 минут - RAG результаты - cacheMax: 1000, // Максимум записей в кэше - - // AI Queue - параметры очереди - queueTimeout: 180000, // 180 сек - таймаут задачи в очереди (увеличено) - queueMaxSize: 100, // Максимум задач в очереди - queueInterval: 100, // 100 мс - интервал проверки очереди - - // Default для совместимости - default: 180000 // 180 сек (увеличено с 120) + ollamaChat: 600000, + ollamaEmbedding: 90000, + ollamaHealth: 5000, + ollamaTags: 10000, + vectorSearch: 90000, + vectorUpsert: 600000, + vectorHealth: 5000, + cacheLLM: 86400000, + cacheRAG: 300000, + cacheMax: 1000, + queueTimeout: 180000, + queueMaxSize: 100, + queueInterval: 100, + default: 180000 }; } /** * Получает timeout для запросов к Ollama (обратная совместимость) + * Синхронная версия (для обратной совместимости) * @returns {number} Timeout в миллисекундах */ function getTimeout() { - return getTimeouts().ollamaChat; // 120 секунд (2 минуты) - для генерации длинных ответов + const timeouts = getTimeouts(); + return timeouts.ollamaChat; } /** @@ -197,36 +241,13 @@ function getTimeout() { * @returns {Object} Объект с конфигурацией */ function getConfig() { - return { - baseUrl: getBaseUrl(), - defaultModel: getDefaultModel(), - timeout: getTimeout(), - apiUrl: { - tags: getApiUrl('tags'), - generate: getApiUrl('generate'), - chat: getApiUrl('chat'), - models: getApiUrl('models'), - show: getApiUrl('show'), - pull: getApiUrl('pull'), - push: getApiUrl('push') - } - }; -} - -/** - * Получает все конфигурационные параметры Ollama (асинхронная версия) - * @returns {Promise} Объект с конфигурацией - */ -async function getConfigAsync() { - const baseUrl = await getBaseUrlAsync(); - const defaultModel = await getDefaultModelAsync(); - const embeddingModel = await getEmbeddingModel(); + const baseUrl = getBaseUrl(); + const defaultModel = getDefaultModel(); return { baseUrl, defaultModel, - embeddingModel, - timeout: getTimeout(), + timeout: null, // Теперь асинхронный apiUrl: { tags: `${baseUrl}/api/tags`, generate: `${baseUrl}/api/generate`, @@ -239,11 +260,60 @@ async function getConfigAsync() { }; } +/** + * Получает все конфигурационные параметры Ollama (асинхронная версия) + * @returns {Promise} Объект с конфигурацией + */ +async function getConfigAsync() { + const ollamaConfig = await aiConfigService.getOllamaConfig(); + const timeout = await getTimeout(); + + return { + baseUrl: ollamaConfig.baseUrl, + defaultModel: ollamaConfig.llmModel, + embeddingModel: ollamaConfig.embeddingModel, + timeout, + apiUrl: { + tags: `${ollamaConfig.baseUrl}/api/tags`, + generate: `${ollamaConfig.baseUrl}/api/generate`, + chat: `${ollamaConfig.baseUrl}/api/chat`, + models: `${ollamaConfig.baseUrl}/api/models`, + show: `${ollamaConfig.baseUrl}/api/show`, + pull: `${ollamaConfig.baseUrl}/api/pull`, + push: `${ollamaConfig.baseUrl}/api/push` + } + }; +} + +/** + * Загружает настройки Ollama из базы данных (для обратной совместимости) + * @returns {Promise} Настройки Ollama провайдера + */ +async function loadSettingsFromDb() { + try { + const config = await aiConfigService.getOllamaConfig(); + // Обновляем синхронный кэш + await _updateSyncCache(); + return { + base_url: config.baseUrl, + selected_model: config.llmModel, + embedding_model: config.embeddingModel + }; + } catch (error) { + logger.error('[ollamaConfig] Ошибка загрузки настроек Ollama из БД:', error.message); + return null; + } +} + /** * Очищает кэш настроек (для перезагрузки) */ function clearCache() { - settingsCache = null; + syncCache = null; + syncCacheTimestamp = 0; + timeoutsCache = null; + timeoutsCacheTimestamp = 0; + aiConfigService.invalidateCache(); logger.info('[ollamaConfig] Settings cache cleared'); } @@ -253,7 +323,7 @@ function clearCache() { */ async function checkHealth() { try { - const baseUrl = getBaseUrl(); + const baseUrl = await getBaseUrlAsync(); const response = await fetch(`${baseUrl}/api/tags`); if (!response.ok) { @@ -265,10 +335,12 @@ async function checkHealth() { } const data = await response.json(); + const defaultModel = await getDefaultModelAsync(); + return { status: 'ok', baseUrl, - model: getDefaultModel(), + model: defaultModel, availableModels: data.models?.length || 0 }; } catch (error) { @@ -280,6 +352,14 @@ async function checkHealth() { } } +// Инициализация синхронного кэша при загрузке модуля +_updateSyncCache().catch(err => { + logger.warn('[ollamaConfig] Initial sync cache update failed:', err.message); +}); +_updateTimeoutsCache().catch(err => { + logger.warn('[ollamaConfig] Initial timeouts cache update failed:', err.message); +}); + module.exports = { getBaseUrl, getBaseUrlAsync, @@ -287,11 +367,12 @@ module.exports = { getDefaultModel, getDefaultModelAsync, getEmbeddingModel, - getTimeout, // Обратная совместимость (возвращает ollamaChat timeout) - getTimeouts, // ✨ НОВОЕ: Централизованные таймауты для всех сервисов + getTimeout, // Синхронная версия (для обратной совместимости) + getTimeouts, // Синхронная версия с кэшированием (для обратной совместимости) getConfig, getConfigAsync, loadSettingsFromDb, clearCache, checkHealth }; + diff --git a/backend/services/profileAnalysisService.js b/backend/services/profileAnalysisService.js new file mode 100644 index 0000000..fdf8500 --- /dev/null +++ b/backend/services/profileAnalysisService.js @@ -0,0 +1,610 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +/** + * Profile Analysis Service + * Анализирует сообщения пользователя и автоматически обновляет профиль + */ + +const axios = require('axios'); +const logger = require('../utils/logger'); +const ollamaConfig = require('./ollamaConfig'); +const aiConfigService = require('./aiConfigService'); +const userContextService = require('./userContextService'); +const encryptedDb = require('./encryptedDatabaseService'); +const db = require('../db'); + +/** + * Извлечь имя пользователя из сообщения через LLM (JSON mode) + * @param {string} message - Сообщение пользователя + * @returns {Promise} { name: string|null, should_update_name: boolean } + */ +async function extractName(message) { + try { + logger.info(`[ProfileAnalysis] Начало извлечения имени из сообщения: "${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"`); + + const ollamaUrl = await ollamaConfig.getBaseUrlAsync(); + const llmModel = await ollamaConfig.getDefaultModelAsync(); + const llmParameters = await aiConfigService.getLLMParameters(); + const timeouts = await aiConfigService.getTimeouts(); + + const nameExtractionTimeout = Math.min(timeouts.ollamaChat || 60000, 60000); + logger.info(`[ProfileAnalysis] Параметры для извлечения имени: model=${llmModel}, timeout=${nameExtractionTimeout/1000}с`); + + const prompt = `Определи, указал ли пользователь своё имя в сообщении. + +Сообщение: "${message}" + +Требования: +- Пользователь пишет по-русски, возможны формулировки вроде "я Алекс", "меня зовут Анна", "это Иван". +- Если явно видишь имя, нормализуй его (первая буква прописная, без лишних слов) и установи "should_update_name": true. +- Если имя не указано или есть сомнения, верни name=null и should_update_name=false. +- Если уверенность ниже 0.7, не обновляй имя. +- Верни строго JSON-объект одной строкой: {"name":string|null,"should_update_name":boolean,"confidence":number}.`; + + const extractStartTime = Date.now(); + logger.info(`[ProfileAnalysis] Отправка запроса к Ollama для извлечения имени...`); + + const response = await axios.post(`${ollamaUrl}/api/chat`, { + model: llmModel, + messages: [ + { + role: 'user', + content: prompt + } + ], + temperature: llmParameters.temperature, + num_predict: llmParameters.maxTokens, + top_p: llmParameters.top_p, + top_k: llmParameters.top_k, + repeat_penalty: llmParameters.repeat_penalty, + format: 'json', // Используем JSON mode для structured output + stream: false + }, { + timeout: nameExtractionTimeout + }); + + const extractDuration = Date.now() - extractStartTime; + logger.info(`[ProfileAnalysis] Получен ответ от Ollama для извлечения имени за ${extractDuration}ms, статус: ${response.status}`); + + // Приводим ответ к объекту (учитываем возможную строку при потоковом ответе) + let payload = response.data; + if (!payload) { + logger.error('[ProfileAnalysis] Ответ от Ollama не содержит data:', response); + return { name: null, should_update_name: false, confidence: 0 }; + } + + if (typeof payload === 'string') { + try { + const lines = payload.trim().split('\n').filter(Boolean); + const lastLine = lines.length > 0 ? lines[lines.length - 1] : payload; + payload = JSON.parse(lastLine); + } catch (parseError) { + logger.error('[ProfileAnalysis] Не удалось распарсить строковый ответ Ollama:', { + error: parseError.message, + preview: payload.substring(0, 200) + }); + return { name: null, should_update_name: false, confidence: 0 }; + } + } + + if (Array.isArray(payload)) { + payload = payload[payload.length - 1] || {}; + } + + const messagePayload = payload.message || payload.response || null; + if (!messagePayload || !messagePayload.content) { + logger.error('[ProfileAnalysis] Ответ от Ollama не содержит message.content:', payload); + return { name: null, should_update_name: false, confidence: 0 }; + } + + let result; + try { + result = JSON.parse(messagePayload.content); + } catch (parseError) { + logger.error('[ProfileAnalysis] Ошибка парсинга JSON из ответа Ollama:', { + content: messagePayload.content, + error: parseError.message + }); + return { name: null, should_update_name: false, confidence: 0 }; + } + + logger.info(`[ProfileAnalysis] Результат извлечения имени: name=${result.name || 'null'}, should_update=${result.should_update_name}, confidence=${result.confidence || 'N/A'}`); + + let normalizedName = typeof result.name === 'string' ? result.name.trim() : ''; + if (normalizedName) { + normalizedName = normalizedName.replace(/\s+/g, ' '); + normalizedName = normalizedName.replace(/^["'«»]+|["'«»]+$/g, ''); + if (normalizedName.length === 1) { + normalizedName = normalizedName.toUpperCase(); + } else { + normalizedName = normalizedName.charAt(0).toUpperCase() + normalizedName.slice(1); + } + } + + const rawConfidence = Number(result.confidence); + const confidence = Number.isFinite(rawConfidence) + ? Math.max(0, Math.min(1, rawConfidence)) + : (normalizedName ? 1 : 0); + + if (confidence < 0.7 || !normalizedName) { + if (normalizedName && confidence < 0.7) { + logger.debug(`[ProfileAnalysis] Низкая уверенность в имени (${confidence}), не обновляем`); + } + return { name: null, should_update_name: false, confidence }; + } + + const shouldUpdate = typeof result.should_update_name === 'boolean' + ? result.should_update_name + : !!normalizedName; + + const finalResult = { + name: normalizedName || null, + should_update_name: shouldUpdate, + confidence + }; + + logger.debug(`[ProfileAnalysis] Финальный результат извлечения имени:`, finalResult); + return finalResult; + } catch (error) { + logger.error('[ProfileAnalysis] Ошибка извлечения имени:', { + message: error.message, + stack: error.stack, + response: error.response?.data ? JSON.stringify(error.response.data).substring(0, 200) : undefined + }); + return { name: null, should_update_name: false, confidence: 0 }; + } +} + +/** + * Найти таблицу по названию + * @param {string} tableName - Название таблицы + * @returns {Promise} Таблица или null + */ +async function findTableByName(tableName) { + try { + const tables = await encryptedDb.getData('user_tables', {}); + return tables.find(table => table.name === tableName) || null; + } catch (error) { + logger.error(`[ProfileAnalysis] Ошибка поиска таблицы "${tableName}":`, error.message); + return null; + } +} + +/** + * Получить столбец по названию + * @param {number} tableId - ID таблицы + * @param {string} columnName - Название столбца + * @returns {Promise} Столбец или null + */ +async function getColumnByName(tableId, columnName) { + try { + const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); + return columns.find(col => col.name === columnName) || null; + } catch (error) { + logger.error(`[ProfileAnalysis] Ошибка поиска столбца "${columnName}":`, error.message); + return null; + } +} + +/** + * Получить значение ячейки + * @param {number} rowId - ID строки + * @param {number} columnId - ID столбца + * @returns {Promise} Значение ячейки или null + */ +async function getCellValue(rowId, columnId) { + try { + const cellValues = await encryptedDb.getData('user_cell_values', { + row_id: rowId, + column_id: columnId + }, 1); + return cellValues && cellValues.length > 0 ? cellValues[0].value : null; + } catch (error) { + logger.error(`[ProfileAnalysis] Ошибка получения значения ячейки:`, error.message); + return null; + } +} + +/** + * Получить строки таблицы + * @param {number} tableId - ID таблицы + * @returns {Promise} Массив строк + */ +async function getTableRows(tableId) { + try { + return await encryptedDb.getData('user_rows', { table_id: tableId }); + } catch (error) { + logger.error(`[ProfileAnalysis] Ошибка получения строк таблицы:`, error.message); + return []; + } +} + +/** + * Парсить теги из ячейки (multiselect-relation или строка) + * @param {*} tagsCell - Значение ячейки с тегами + * @returns {Array} Массив названий тегов + */ +function parseTagsFromCell(tagsCell) { + if (!tagsCell) return []; + + // Если это массив + if (Array.isArray(tagsCell)) { + return tagsCell.map(String); + } + + // Если это строка JSON + if (typeof tagsCell === 'string') { + try { + const parsed = JSON.parse(tagsCell); + if (Array.isArray(parsed)) { + return parsed.map(String); + } + } catch (e) { + // Не JSON, возможно просто строка с названиями через запятую + return tagsCell.split(',').map(t => t.trim()).filter(Boolean); + } + } + + return []; +} + +/** + * Найти ключевые слова в сообщении + * @param {string} message - Сообщение пользователя + * @param {number} keywordsTableId - ID таблицы "Ключевые слова и теги" + * @returns {Promise} Массив найденных строк с ключевыми словами + */ +async function findKeywordsInMessage(message, keywordsTableId) { + try { + const rows = await getTableRows(keywordsTableId); + + // Получаем столбец "Ключевое слово" + const keywordColumn = await getColumnByName(keywordsTableId, 'Ключевое слово'); + if (!keywordColumn) { + logger.warn('[ProfileAnalysis] Столбец "Ключевое слово" не найден'); + return []; + } + + const messageLower = message.toLowerCase(); + const foundKeywords = []; + + for (const row of rows) { + const keywordsCell = await getCellValue(row.id, keywordColumn.id); + if (!keywordsCell) continue; + + // Парсим ключевые слова (разделены запятой) + const keywords = keywordsCell.split(',').map(k => k.trim().toLowerCase()); + + // Проверяем, есть ли хотя бы одно ключевое слово в сообщении + if (keywords.some(kw => messageLower.includes(kw))) { + foundKeywords.push(row); + } + } + + return foundKeywords; + } catch (error) { + logger.error('[ProfileAnalysis] Ошибка поиска ключевых слов:', error.message); + return []; + } +} + +/** + * Получить теги по ключевым словам из таблицы + * @param {Array} foundKeywordRows - Массив строк с найденными ключевыми словами + * @param {number} keywordsTableId - ID таблицы "Ключевые слова и теги" + * @returns {Promise} Массив объектов { tagNames: Array, action: string } + */ +async function getTagsByKeywords(foundKeywordRows, keywordsTableId) { + try { + const tagsColumn = await getColumnByName(keywordsTableId, 'Теги'); + const actionColumn = await getColumnByName(keywordsTableId, 'Действие'); + + const results = []; + + for (const row of foundKeywordRows) { + const tagsCell = tagsColumn ? await getCellValue(row.id, tagsColumn.id) : null; + const action = actionColumn ? await getCellValue(row.id, actionColumn.id) : null; + + const tagNames = parseTagsFromCell(tagsCell); + + if (tagNames.length > 0) { + results.push({ + tagNames, + action: action || 'добавить' + }); + } + } + + return results; + } catch (error) { + logger.error('[ProfileAnalysis] Ошибка получения тегов по ключевым словам:', error.message); + return []; + } +} + +/** + * Найти tagIds по названиям тегов + * @param {Array} tagNames - Массив названий тегов + * @returns {Promise>} Массив tagIds + */ +async function getTagIdsByNames(tagNames) { + if (!tagNames || tagNames.length === 0) { + return []; + } + + try { + // Находим таблицу "Теги клиентов" + const tagsTable = await findTableByName('Теги клиентов'); + if (!tagsTable) { + logger.warn('[ProfileAnalysis] Таблица "Теги клиентов" не найдена'); + return []; + } + + const allColumns = await encryptedDb.getData('user_columns', { table_id: tagsTable.id }); + + // Подбираем колонку с названием тега по приоритету + const nameColumn = + allColumns.find(col => col.options && col.options.purpose === 'userTags') || + allColumns.find(col => col.name === 'Название') || + allColumns.find(col => col.name === 'Список тегов') || + allColumns.find(col => col.type === 'text'); + + if (!nameColumn) { + logger.warn('[ProfileAnalysis] Столбец с названием тега не найден'); + return []; + } + + const rows = await getTableRows(tagsTable.id); + + const tagIds = []; + for (const row of rows) { + const cellValue = await getCellValue(row.id, nameColumn.id); + if (!cellValue) { + continue; + } + + const normalizedNames = parseTagsFromCell(cellValue); + if (normalizedNames.some(name => tagNames.includes(name))) { + tagIds.push(row.id); + } + } + + return tagIds; + } catch (error) { + logger.error('[ProfileAnalysis] Ошибка поиска tagIds по названиям:', error.message); + return []; + } +} + +/** + * Обработать действие (заменить/добавить теги) + * @param {string} action - Действие (например, "заменить клиент" или "добавить") + * @param {Array} currentTagNames - Текущие названия тегов + * @param {Array} newTagNames - Новые названия тегов + * @returns {Array} Результирующие названия тегов + */ +function processAction(action, currentTagNames, newTagNames) { + if (!action || action === 'добавить') { + // Добавляем новые теги к существующим + return [...new Set([...currentTagNames, ...newTagNames])]; + } + + // Обработка действия "заменить <тег>" + if (action.startsWith('заменить')) { + const tagToReplace = action.replace('заменить', '').trim(); + + // Удаляем тег для замены + const filtered = currentTagNames.filter(tag => tag !== tagToReplace); + + // Добавляем новые теги + return [...new Set([...filtered, ...newTagNames])]; + } + + // По умолчанию - добавляем + return [...new Set([...currentTagNames, ...newTagNames])]; +} + +/** + * Анализировать сообщение пользователя и обновить профиль + * @param {number} userId - ID пользователя + * @param {string} message - Сообщение пользователя + * @returns {Promise} { name: string|null, suggestedTags: Array } + */ +async function analyzeUserMessage(userId, message) { + try { + logger.info(`[ProfileAnalysis] Анализ сообщения пользователя ${userId}`); + logger.info(`[ProfileAnalysis] Сообщение пользователя ${userId}: "${message.substring(0, 100)}${message.length > 100 ? '...' : ''}"`); + + const DEFAULT_TAG_NAME = 'Без лицензии'; + const isGuest = typeof userId === 'string' && userId.startsWith('guest_'); + + let currentContext = null; + let currentName = null; + let currentTagIds = []; + let currentTagNames = []; + let suggestedTags = []; + let nameMissing = false; + + if (!isGuest) { + currentContext = await userContextService.getUserContext(userId); + currentName = currentContext?.name || null; + currentTagIds = Array.isArray(currentContext?.tags) ? [...currentContext.tags] : await userContextService.getUserTags(userId); + currentTagNames = Array.isArray(currentContext?.tagNames) && currentContext.tagNames.length > 0 + ? [...currentContext.tagNames] + : await userContextService.getTagNames(currentTagIds); + + if (!currentTagIds || currentTagIds.length === 0) { + const defaultTagIds = await getTagIdsByNames([DEFAULT_TAG_NAME]); + if (defaultTagIds.length > 0) { + await updateUserTagsInternal(userId, defaultTagIds); + userContextService.invalidateUserCache(userId); + currentTagIds = [...defaultTagIds]; + currentTagNames = [DEFAULT_TAG_NAME]; + logger.info(`[ProfileAnalysis] Пользователю ${userId} автоматически назначен тег по умолчанию: ${DEFAULT_TAG_NAME}`); + } else { + logger.warn(`[ProfileAnalysis] Тег по умолчанию "${DEFAULT_TAG_NAME}" не найден в базе`); + } + } + + suggestedTags = [...currentTagNames]; + nameMissing = !currentName; + } + + // 1. Извлечение имени из сообщения + logger.info(`[ProfileAnalysis] Начало извлечения имени для пользователя ${userId}...`); + const nameResult = await extractName(message); + logger.info(`[ProfileAnalysis] Извлечение имени завершено: name=${nameResult.name || 'null'}, should_update=${nameResult.should_update_name}`); + + // 2. Обновление имени пользователя (если нужно) + if (!isGuest && nameResult.name) { + const shouldUpdateName = nameResult.should_update_name || !currentName; + if (shouldUpdateName && (currentName || '') !== nameResult.name) { + logger.info(`[ProfileAnalysis] Обновление имени пользователя ${userId}: "${currentName || ''}" → "${nameResult.name}"`); + await updateUserNameInternal(userId, nameResult.name); + userContextService.invalidateUserCache(userId); + currentName = nameResult.name; + nameMissing = false; + } + } + + // 3. Поиск ключевых слов в таблице "Ключевые слова и теги" + const keywordsTable = await findTableByName('Ключевые слова и теги'); + + if (!isGuest && keywordsTable) { + const foundKeywords = await findKeywordsInMessage(message, keywordsTable.id); + + if (foundKeywords.length > 0) { + // Получаем теги по ключевым словам + const tagsByKeywords = await getTagsByKeywords(foundKeywords, keywordsTable.id); + + // Обрабатываем действия и собираем новые теги + let allTagNames = [...currentTagNames]; + + for (const { tagNames, action } of tagsByKeywords) { + allTagNames = processAction(action, allTagNames, tagNames); + } + + // Находим tagIds по названиям + const tagIds = await getTagIdsByNames(allTagNames); + + // Обновляем теги пользователя (если есть изменения) + const hasChanges = tagIds.length !== currentTagIds.length || !tagIds.every(id => currentTagIds.includes(id)); + if (hasChanges) { + logger.info(`[ProfileAnalysis] Обновление тегов пользователя ${userId}: ${currentTagNames.join(', ') || '—'} → ${allTagNames.join(', ')}`); + await updateUserTagsInternal(userId, tagIds); + userContextService.invalidateUserCache(userId); + currentTagIds = [...tagIds]; + currentTagNames = [...allTagNames]; + } + + suggestedTags = [...currentTagNames]; + } + } + + return { + name: nameResult.name, + should_update_name: !isGuest && nameResult.name ? (nameResult.should_update_name || !currentName) : false, + suggestedTags, + currentName, + currentTagNames, + nameMissing: !isGuest ? nameMissing : false + }; + } catch (error) { + logger.error('[ProfileAnalysis] Ошибка анализа сообщения:', error.message); + return { name: null, should_update_name: false, suggestedTags: [], currentName: null, currentTagNames: [], nameMissing: false }; + } +} + +/** + * Внутренняя функция для обновления имени пользователя (без проверки прав) + * @param {number} userId - ID пользователя + * @param {string} name - Новое имя + */ +async function updateUserNameInternal(userId, name) { + try { + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + const nameParts = name.trim().split(' '); + const firstName = nameParts[0] || ''; + const lastName = nameParts.slice(1).join(' ') || ''; + + await db.getQuery()( + `UPDATE users + SET first_name_encrypted = encrypt_text($1, $3), + last_name_encrypted = encrypt_text($2, $3) + WHERE id = $4`, + [firstName, lastName, encryptionKey, userId] + ); + + // Инвалидируем кэш пользователя + userContextService.invalidateUserCache(userId); + + // Отправляем WebSocket уведомление об обновлении контакта + const { broadcastContactsUpdate } = require('../wsHub'); + broadcastContactsUpdate(); + + logger.info(`[ProfileAnalysis] Имя пользователя ${userId} обновлено: "${name}"`); + } catch (error) { + logger.error(`[ProfileAnalysis] Ошибка обновления имени пользователя ${userId}:`, error.message); + throw error; + } +} + +/** + * Внутренняя функция для обновления тегов пользователя (без проверки прав) + * @param {number} userId - ID пользователя + * @param {Array} tagIds - Массив tagIds + */ +async function updateUserTagsInternal(userId, tagIds) { + try { + // Удаляем старые связи + await db.getQuery()('DELETE FROM user_tag_links WHERE user_id = $1', [userId]); + + // Добавляем новые связи + for (const tagId of tagIds) { + await db.getQuery()( + 'INSERT INTO user_tag_links (user_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [userId, tagId] + ); + } + + // Отправляем WebSocket уведомление + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(null, userId); + + // Инвалидируем кэш пользователя + userContextService.invalidateUserCache(userId); + + logger.info(`[ProfileAnalysis] Теги пользователя ${userId} обновлены: ${tagIds.join(', ')}`); + } catch (error) { + logger.error(`[ProfileAnalysis] Ошибка обновления тегов пользователя ${userId}:`, error.message); + throw error; + } +} + +module.exports = { + analyzeUserMessage, + extractName, + findKeywordsInMessage, + getTagsByKeywords, + getTagIdsByNames, + processAction, + updateUserNameInternal, + updateUserTagsInternal, + findTableByName, + getColumnByName, + getCellValue, + getTableRows +}; + diff --git a/backend/services/ragService.js b/backend/services/ragService.js index 277f73c..e62637a 100644 --- a/backend/services/ragService.js +++ b/backend/services/ragService.js @@ -17,12 +17,64 @@ const axios = require('axios'); const ollamaConfig = require('./ollamaConfig'); const aiCache = require('./ai-cache'); const AIQueue = require('./ai-queue'); +const aiConfigService = require('./aiConfigService'); +const userContextService = require('./userContextService'); +const profileAnalysisService = require('./profileAnalysisService'); +const { buildOllamaRequest } = require('../utils/ollamaRequestBuilder'); const logger = require('../utils/logger'); +const db = require('../db'); -// console.log('[RAG] ragService.js loaded'); +// Кэш для плейсхолдеров таблиц +const tablePlaceholdersCache = { + data: null, + timestamp: 0 +}; +const TABLE_PLACEHOLDERS_CACHE_TTL = 10 * 60 * 1000; // 10 минут + +/** + * Генерация плейсхолдера из названия (транслитерация) + * @param {string} name - Название таблицы или столбца + * @returns {string} Плейсхолдер + */ +function generatePlaceholder(name) { + if (!name || typeof name !== 'string') { + return 'placeholder'; + } + + // Транслитерация (упрощённая) + const cyrillicToLatinMap = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', + и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', + с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', + щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya', + А: 'A', Б: 'B', В: 'V', Г: 'G', Д: 'D', Е: 'E', Ё: 'E', Ж: 'Zh', З: 'Z', + И: 'I', Й: 'Y', К: 'K', Л: 'L', М: 'M', Н: 'N', О: 'O', П: 'P', Р: 'R', + С: 'S', Т: 'T', У: 'U', Ф: 'F', Х: 'H', Ц: 'Ts', Ч: 'Ch', Ш: 'Sh', + Щ: 'Sch', Ъ: '', Ы: 'Y', Ь: '', Э: 'E', Ю: 'Yu', Я: 'Ya' + }; + + let translit = name.toLowerCase().split('').map(ch => { + if (cyrillicToLatinMap[ch]) return cyrillicToLatinMap[ch]; + if (/[a-z0-9]/.test(ch)) return ch; + if (ch === ' ') return '_'; + if (ch === '-') return '_'; + return ''; + }).join(''); + + // Удаляем множественные подчеркивания и подчеркивания в начале/конце + translit = translit.replace(/_+/g, '_').replace(/^_+|_+$/g, ''); + + // Если translit пустой, используем fallback + if (!translit) { + translit = 'placeholder'; + } + + return translit; +} // Управляет поведением: выполнять ли upsert всех строк на каждый запрос поиска -const UPSERT_ON_QUERY = process.env.RAG_UPSERT_ON_QUERY === 'true'; +// Теперь из настроек ai_config +let RAG_BEHAVIOR = null; // Флаги для включения/выключения Queue и Cache const USE_AI_CACHE = process.env.USE_AI_CACHE !== 'false'; // default: true @@ -31,20 +83,21 @@ const USE_AI_QUEUE = process.env.USE_AI_QUEUE !== 'false'; // default: true // Создаем экземпляр очереди const aiQueue = new AIQueue(); +// Загружаем RAG поведение из настроек +async function getRAGBehavior() { + if (!RAG_BEHAVIOR) { + RAG_BEHAVIOR = await aiConfigService.getRAGBehavior(); + } + return RAG_BEHAVIOR; +} + async function getTableData(tableId) { - // console.log(`[RAG] getTableData called for tableId: ${tableId}`); - const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); - // console.log(`[RAG] Found ${columns.length} columns:`, columns.map(col => ({ id: col.id, name: col.name, purpose: col.options?.purpose }))); - const rows = await encryptedDb.getData('user_rows', { table_id: tableId }); - // console.log(`[RAG] Found ${rows.length} rows:`, rows.map(row => ({ id: row.id, name: row.name }))); - // Исправление: проверяем что есть строки перед запросом cell_values const cellValues = rows.length > 0 ? await encryptedDb.getData('user_cell_values', { row_id: { $in: rows.map(row => row.id) } }) : []; - // console.log(`[RAG] Found ${cellValues.length} cell values`); const getColId = purpose => columns.find(col => col.options?.purpose === purpose)?.id; const questionColId = getColId('question'); @@ -54,15 +107,6 @@ async function getTableData(tableId) { const priorityColId = getColId('priority'); const dateColId = getColId('date'); - // console.log(`[RAG] Column IDs:`, { - // question: questionColId, - // answer: answerColId, - // context: contextColId, - // product: productColId, - // priority: priorityColId, - // date: dateColId - // }); - const data = rows.map(row => { const cells = cellValues.filter(cell => cell.row_id === row.id); const result = { @@ -75,41 +119,127 @@ async function getTableData(tableId) { priority: cells.find(c => c.column_id === priorityColId)?.value, date: cells.find(c => c.column_id === dateColId)?.value, }; - // console.log(`[RAG] Processed row ${row.id}:`, result); return result; }); return data; } -async function ragAnswer({ tableId, userQuestion, product = null, threshold = 300, forceReindex = false }) { - // console.log(`[RAG] ragAnswer called: tableId=${tableId}, userQuestion="${userQuestion}"`); +/** + * Получить строки таблицы с фильтрацией по тегам пользователя + * @param {number} tableId - ID таблицы + * @param {number} userId - ID пользователя (опционально) + * @returns {Promise>} Массив rowIds отфильтрованных строк + */ +async function getFilteredRowIdsByTags(tableId, userId = null) { + if (!userId) { + return null; // Без фильтрации + } + + try { + // Получаем теги пользователя + const tagIds = await userContextService.getUserTags(userId); + if (!tagIds || tagIds.length === 0) { + return null; // Нет тегов - без фильтрации + } + + // Получаем столбцы таблицы + const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); + + // Находим столбец "Теги" с purpose='userTags' и типом multiselect-relation + const tagsColumn = columns.find(col => + col.options?.purpose === 'userTags' && + (col.type === 'multiselect-relation' || col.type === 'relation') + ); + + if (!tagsColumn) { + // Нет столбца с тегами - без фильтрации + return null; + } + + // Фильтруем строки по тегам через user_table_relations + // Логика: найти строки, где хотя бы один тег пользователя совпадает с тегами строки + const query = db.getQuery(); + const result = await query(` + SELECT DISTINCT from_row_id + FROM user_table_relations + WHERE column_id = $1 + AND to_row_id = ANY($2) + `, [tagsColumn.id, tagIds]); + + const filteredRowIds = result.rows.map(row => row.from_row_id); + + if (filteredRowIds.length === 0) { + // Нет строк с тегами пользователя - возвращаем пустой массив + return []; + } + + console.log(`[RAG] Фильтрация по тегам: найдено ${filteredRowIds.length} строк с тегами пользователя`); + return filteredRowIds; + } catch (error) { + logger.error('[RAG] Ошибка фильтрации по тегам:', error.message); + return null; // При ошибке - без фильтрации + } +} + +async function ragAnswer({ tableId, userQuestion, product = null, threshold = null, forceReindex = false, userId = null }) { + // Загружаем настройки RAG из ai_config + const ragConfig = await aiConfigService.getRAGConfig(); + const ragBehavior = await getRAGBehavior(); + + // Используем настройки из БД, если не переданы явно + const finalThreshold = threshold !== null ? threshold : ragConfig.threshold; + const maxResults = ragConfig.maxResults || 3; + const upsertOnQuery = ragBehavior.upsertOnQuery || false; + + // Получаем теги пользователя для включения в ключ кэша + let userTagIds = null; + if (userId) { + userTagIds = await userContextService.getUserTags(userId); + // Если тегов нет, сохраняем null (не пустой массив, чтобы различать "нет тегов" и "не проверяли") + if (userTagIds && userTagIds.length === 0) { + userTagIds = null; + } + } // Проверяем кэш (используем ai-cache вместо ragCache) + // Включаем tagIds в ключ кэша для учета фильтрации по тегам if (USE_AI_CACHE) { - const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product); + const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product, userId, userTagIds); const cached = aiCache.getWithTTL(cacheKey, 'rag'); if (cached) { - console.log(`[RAG] Возврат RAG результата из кэша`); + console.log(`[RAG] Возврат RAG результата из кэша (userId=${userId}, tagIds=${userTagIds ? userTagIds.join(',') : 'null'})`); return cached; } } + // Фильтрация по тегам пользователя ДО получения данных + const filteredRowIds = await getFilteredRowIdsByTags(tableId, userId); + const data = await getTableData(tableId); - // console.log(`[RAG] Got ${data.length} rows from database`); - // Подробное логирование данных - data.forEach((row, index) => { - // console.log(`[RAG] Row ${index}:`, { - // id: row.id, - // question: row.question, - // answer: row.answer, - // product: row.product - // }); - }); + // Применяем фильтрацию по тегам, если есть отфильтрованные строки + let filteredData = data; + if (filteredRowIds !== null) { + if (filteredRowIds.length === 0) { + // Нет строк с тегами пользователя - возвращаем пустой результат + console.log(`[RAG] Нет строк с тегами пользователя`); + return { + answer: null, + context: null, + product: null, + priority: null, + date: null, + score: null + }; + } + // Фильтруем данные по rowIds + filteredData = data.filter(row => filteredRowIds.includes(row.id)); + console.log(`[RAG] Фильтрация по тегам: ${data.length} -> ${filteredData.length} строк`); + } - const questions = data.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question); + const questions = filteredData.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question); // Фильтруем только строки с непустым вопросом (text) - const rowsForUpsert = data + const rowsForUpsert = filteredData .filter(row => row.id && row.question && String(row.question).trim().length > 0) .map(row => ({ row_id: row.id, @@ -124,21 +254,17 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 30 } })); - // console.log(`[RAG] Prepared ${rowsForUpsert.length} rows for upsert`); - // console.log(`[RAG] First row:`, rowsForUpsert[0]); - - // Выполняем upsert ТОЛЬКО если явно разрешено флагом/параметром. - if ((UPSERT_ON_QUERY || forceReindex) && rowsForUpsert.length > 0) { + // Выполняем upsert ТОЛЬКО если явно разрешено настройками или параметром + if ((upsertOnQuery || forceReindex) && rowsForUpsert.length > 0) { await vectorSearch.upsert(tableId, rowsForUpsert); } // Поиск let results = []; if (rowsForUpsert.length > 0 && userQuestion && userQuestion.trim()) { - results = await vectorSearch.search(tableId, userQuestion, 3); // Увеличиваем до 3 результатов для лучшего поиска + results = await vectorSearch.search(tableId, userQuestion, maxResults); console.log(`[RAG] Search completed, got ${results.length} results`); - // Подробное логирование результатов поиска results.forEach((result, index) => { console.log(`[RAG] Search result ${index}:`, { row_id: result.row_id, @@ -150,34 +276,31 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 30 console.log(`[RAG] No data in table, skipping search`); } - // Фильтрация по тегам/продукту + // Фильтрация по тегам пользователя (если была применена) + // Если мы уже отфильтровали данные по тегам, нужно отфильтровать результаты векторного поиска let filtered = results; - // console.log(`[RAG] Before filtering: ${filtered.length} results`); + if (filteredRowIds !== null && filteredRowIds.length > 0) { + // Фильтруем результаты векторного поиска по отфильтрованным rowIds + filtered = filtered.filter(result => filteredRowIds.includes(Number(result.row_id))); + console.log(`[RAG] Фильтрация результатов векторного поиска по тегам: ${results.length} -> ${filtered.length} результатов`); + } + + // Фильтрация по продукту if (product) { - // console.log(`[RAG] Filtering by product:`, product); filtered = filtered.filter(row => Array.isArray(row.metadata.product) ? row.metadata.product.includes(product) : row.metadata.product === product); - // console.log(`[RAG] After product filtering: ${filtered.length} results`); } // Берём ближайший результат с учётом порога (по модулю) - console.log(`[RAG] Looking for best result with abs(threshold): ${threshold}`); + console.log(`[RAG] Looking for best result with abs(threshold): ${finalThreshold}`); const best = filtered.reduce((acc, row) => { - if (Math.abs(row.score) <= threshold && (acc === null || Math.abs(row.score) < Math.abs(acc.score))) { + if (Math.abs(row.score) <= finalThreshold && (acc === null || Math.abs(row.score) < Math.abs(acc.score))) { return row; } return acc; }, null); console.log(`[RAG] Best result:`, best); - // Логируем все результаты с их score для диагностики - if (filtered.length > 0) { - // console.log(`[RAG] All filtered results with scores:`); - // filtered.forEach((result, index) => { - // console.log(`[RAG] ${index}: score=${result.score}, meets_threshold=${Math.abs(result.score) <= threshold}`); - // }); - } - const result = { answer: best?.metadata?.answer, context: best?.metadata?.context, @@ -190,14 +313,89 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 30 console.log(`[RAG] Final result:`, result); // Кэшируем результат (используем ai-cache вместо ragCache) + // Используем те же tagIds, что и для проверки кэша if (USE_AI_CACHE) { - const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product); + const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product, userId, userTagIds); aiCache.setWithType(cacheKey, result, 'rag'); + console.log(`[RAG] Результат сохранен в кэш (userId=${userId}, tagIds=${userTagIds ? userTagIds.join(',') : 'null'})`); } return result; } +/** + * Получить плейсхолдеры для всех таблиц (генерируются на лету) + * Плейсхолдеры таблиц НЕ хранятся в БД, генерируются из названий таблиц + * Возвращает объект: { placeholderName: tableName, ... } + * Пример: { svyaz_tegov_i_pravil: "Связь тегов и правил", faq: "FAQ" } + * @returns {Promise} Объект с плейсхолдерами таблиц + */ +async function getTablePlaceholders() { + try { + // Проверяем кэш + const now = Date.now(); + if (tablePlaceholdersCache.data && (now - tablePlaceholdersCache.timestamp) < TABLE_PLACEHOLDERS_CACHE_TTL) { + logger.debug('[RAG] Плейсхолдеры таблиц загружены из кэша'); + return tablePlaceholdersCache.data; + } + + logger.info('[RAG] Генерация плейсхолдеров таблиц...'); + + // Получаем все электронные таблицы (user_tables) + const tables = await encryptedDb.getData('user_tables', {}); + logger.info(`[RAG] Получено таблиц: ${tables.length}`); + + // Генерируем плейсхолдеры из названий таблиц + const placeholders = {}; + const existingPlaceholders = []; + + for (const table of tables) { + if (!table.name || typeof table.name !== 'string') { + continue; + } + + // Генерируем плейсхолдер из названия таблицы + let placeholderName = generatePlaceholder(table.name); + + // Проверяем уникальность и добавляем суффикс если нужно + let candidate = placeholderName; + let i = 1; + while (existingPlaceholders.includes(candidate)) { + candidate = `${placeholderName}_${i}`; + i++; + if (i > 1000) { + candidate = `${placeholderName}_${Date.now()}`; + break; + } + } + + placeholders[candidate] = table.name; + existingPlaceholders.push(candidate); + + logger.debug(`[RAG] Таблица "${table.name}" → плейсхолдер: {${candidate}}`); + } + + // Сохраняем в кэш + tablePlaceholdersCache.data = placeholders; + tablePlaceholdersCache.timestamp = now; + + logger.info(`[RAG] Сгенерировано плейсхолдеров таблиц: ${Object.keys(placeholders).length}`); + return placeholders; + } catch (error) { + logger.error('[RAG] Ошибка генерации плейсхолдеров таблиц:', error.message); + return {}; + } +} + +/** + * Инвалидация кэша плейсхолдеров таблиц + */ +function invalidateTablePlaceholdersCache() { + tablePlaceholdersCache.data = null; + tablePlaceholdersCache.timestamp = 0; + logger.info('[RAG] Кэш плейсхолдеров таблиц инвалидирован'); +} + /** * Загрузка всех плейсхолдеров и их значений из пользовательских таблиц * Возвращает объект: { placeholder1: value1, placeholder2: value2, ... } @@ -275,6 +473,101 @@ function parseIfArray(val) { return Array.isArray(val) ? val : (val ? [val] : []); } +/** + * Выполнить function call (tool call) от ИИ + * Только функции для обновления имени и тегов пользователя + * @param {Object} toolCall - Вызов функции от ИИ + * @param {number} userId - ID пользователя (для функций обновления профиля) + * @returns {Promise} Результат выполнения функции + */ +async function executeToolCall(toolCall, userId) { + const { name, arguments: args } = toolCall.function; + + try { + logger.info(`[RAG] Выполнение function call: ${name}`, args); + + if (!userId) { + return { error: 'userId required for function calling' }; + } + + switch (name) { + case 'update_user_name': + // Обновление имени пользователя + const resultName = await profileAnalysisService.updateUserNameInternal(userId, args.name); + return { + success: true, + message: `Имя пользователя обновлено: ${args.name}`, + name: args.name + }; + + case 'update_user_tags': + // Обновление тегов пользователя + // args.tagNames - массив названий тегов + const tagIds = await profileAnalysisService.getTagIdsByNames(args.tagNames || []); + const resultTags = await profileAnalysisService.updateUserTagsInternal(userId, tagIds); + return { + success: true, + message: `Теги пользователя обновлены: ${args.tagNames.join(', ')}`, + tagNames: args.tagNames, + tagIds: tagIds + }; + + default: + logger.warn(`[RAG] Unknown function call: ${name}`); + return { error: `Unknown function: ${name}. Available functions: update_user_name, update_user_tags` }; + } + } catch (error) { + logger.error(`[RAG] Ошибка выполнения function call ${name}:`, error.message); + return { error: error.message }; + } +} + +/** + * Получить определения функций для Function Calling + * Только функции для обновления имени и тегов пользователя + * @param {number} userId - ID пользователя (для функций обновления профиля) + * @returns {Array} Массив определений функций + */ +function getFunctionDefinitions(userId) { + return [ + { + type: "function", + function: { + name: "update_user_name", + description: "Обновить имя пользователя в профиле. Используй когда пользователь называет свое имя в сообщении. Пример: пользователь говорит 'Меня зовут Иван Петров' → вызывай update_user_name с name='Иван Петров'.", + parameters: { + type: "object", + properties: { + name: { + type: "string", + description: "Полное имя пользователя (например, 'Иван Петров' или 'Мария Иванова')" + } + }, + required: ["name"] + } + } + }, + { + type: "function", + function: { + name: "update_user_tags", + description: "Обновить теги пользователя. Используй когда нужно добавить или изменить теги пользователя на основе контекста беседы. Пример: пользователь говорит 'Я купил ваш продукт' → можно добавить тег 'клиент' или 'холдер'. Пример: пользователь спрашивает про VIP программу → можно добавить тег 'VIP'.", + parameters: { + type: "object", + properties: { + tagNames: { + type: "array", + items: { type: "string" }, + description: "Массив названий тегов для добавления/обновления (например, ['VIP', 'клиент'] или ['холдер'])" + } + }, + required: ["tagNames"] + } + } + } + ]; +} + async function generateLLMResponse({ userQuestion, context, @@ -289,7 +582,10 @@ async function generateLLMResponse({ rules, history, model, - selectedRagTables + selectedRagTables, + userId = null, // Добавляем userId для function calling + multiSourceResults = null, // Результаты мульти-источникового поиска + userProfile = null }) { console.log(`[RAG] generateLLMResponse called with:`, { userQuestion, @@ -301,7 +597,12 @@ async function generateLLMResponse({ priority, date, model, - historyLength: history ? history.length : 0 + historyLength: history ? history.length : 0, + userProfile: userProfile ? { + name: userProfile.name, + tags: userProfile.tags, + nameMissing: userProfile.nameMissing + } : null }); try { @@ -318,16 +619,49 @@ async function generateLLMResponse({ date }, 'generateLLMResponse'); + const conversationSummary = buildConversationSummary(history, { + maxMessages: 12, + maxChars: 700, + snippetLength: 160 + }); + + const summaryPrefix = conversationSummary + ? `Краткая сводка предыдущего диалога:\n${conversationSummary}\n\n` + : ''; + // Формируем улучшенный промпт для LLM с учетом найденной информации - let prompt = `Вопрос пользователя: ${userQuestion}`; + let prompt = ''; - // Добавляем найденную информацию из RAG - if (answer) { + // Если есть результаты мульти-источникового поиска, используем их + if (multiSourceResults && multiSourceResults.results && multiSourceResults.results.length > 0) { + const sourcesInfo = multiSourceResults.results + .slice(0, 3) // Берем топ-3 результатов + .map((r, idx) => { + const sourceName = r.sourceType === 'table' ? `Таблица (ID: ${r.sourceId})` : `Документ: ${r.metadata?.title || r.context || 'Без названия'}`; + const fallbackText = (r.metadata?.answer && String(r.metadata.answer).trim()) + || (r.metadata?.title && String(r.metadata.title).trim()) + || '(текст отсутствует)'; + const sourceText = (r.text && r.text.trim()) || fallbackText; + const snippetLimit = 300; + const truncatedText = sourceText.length > snippetLimit + ? `${sourceText.slice(0, snippetLimit)}...` + : sourceText; + const contextPart = r.context ? `\nКонтекст: ${r.context}` : ''; + return `[Источник ${idx + 1}: ${sourceName}]\n${truncatedText}${contextPart}`; + }) + .join('\n\n---\n\n'); + + prompt = `${summaryPrefix}База знаний содержит следующую информацию из разных источников:\n\n${sourcesInfo}\n\nВопрос пользователя: ${userQuestion}\n\nПроанализируй информацию из всех источников и дай пользователю полный и точный ответ.`; + } else if (answer) { // Формат: делаем RAG ответ главным, вопрос - контекстом - prompt = `База знаний содержит ответ:\n"${answer}"\n\nВопрос пользователя: ${userQuestion}\n\nДай пользователю этот ответ из базы знаний.`; + prompt = `${summaryPrefix}База знаний содержит ответ:\n"${answer}"\n\nВопрос пользователя: ${userQuestion}\n\nДай пользователю этот ответ из базы знаний.`; } - if (context) { + if (!prompt) { + prompt = `${summaryPrefix}Вопрос пользователя: ${userQuestion}`; + } + + if (context && !multiSourceResults) { prompt += `\n\nДополнительный контекст: ${context}`; } @@ -343,15 +677,45 @@ async function generateLLMResponse({ prompt += `\n\nДата: ${date}`; } + if (userTags && Array.isArray(userTags) && userTags.length > 0) { + prompt += `\n\nТеги пользователя: ${userTags.join(', ')}`; + } + // --- ДОБАВЛЕНО: подстановка плейсхолдеров --- let finalSystemPrompt = systemPrompt; if (systemPrompt && systemPrompt.includes('{')) { - const placeholders = await getAllPlaceholdersWithValues(selectedRagTables); - finalSystemPrompt = replacePlaceholders(systemPrompt, placeholders); - console.log(`[RAG] Подставлены плейсхолдеры в системный промпт`); + // Подставляем плейсхолдеры таблиц (переменные для ИИ) + const tablePlaceholders = await getTablePlaceholders(); + finalSystemPrompt = replacePlaceholders(finalSystemPrompt, tablePlaceholders); + + // Подставляем плейсхолдеры столбцов (значения из первой строки) + const columnPlaceholders = await getAllPlaceholdersWithValues(selectedRagTables); + finalSystemPrompt = replacePlaceholders(finalSystemPrompt, columnPlaceholders); + + console.log(`[RAG] Подставлены плейсхолдеры таблиц и столбцов в системный промпт`); } // --- КОНЕЦ ДОБАВЛЕНИЯ --- + if (userProfile) { + const profileLines = []; + if (userProfile.name) { + profileLines.push(`Имя пользователя: ${userProfile.name}`); + } else if (userProfile.nameMissing) { + profileLines.push('Имя пользователя неизвестно. Вежливо спросите, как к нему обращаться, и дождитесь ответа (например: "Подскажите, пожалуйста, как я могу к вам обращаться?").'); + } + + if (Array.isArray(userProfile.tags) && userProfile.tags.length > 0) { + profileLines.push(`Активные теги пользователя: ${userProfile.tags.join(', ')}`); + } + + if (profileLines.length > 0) { + const profileBlock = `Информация о пользователе:\n${profileLines.join('\n')}`; + finalSystemPrompt = finalSystemPrompt + ? `${finalSystemPrompt}\n\n${profileBlock}` + : profileBlock; + } + } + // Системный промпт полностью настраивается пользователем в /settings/ai/assistant // RAG ответ уже добавлен в prompt выше @@ -365,7 +729,8 @@ async function generateLLMResponse({ if (finalSystemPrompt) { messages.push({ role: 'system', content: finalSystemPrompt }); } - for (const h of (history || [])) { + const historyForLLM = Array.isArray(history) ? history.slice(-4) : []; + for (const h of historyForLLM) { if (h && h.content) { const role = h.role === 'assistant' ? 'assistant' : 'user'; messages.push({ role, content: h.content }); @@ -373,14 +738,45 @@ async function generateLLMResponse({ } messages.push({ role: 'user', content: prompt }); + // Загружаем параметры LLM и qwen из настроек + const llmParameters = await aiConfigService.getLLMParameters(); + const qwenParameters = await aiConfigService.getQwenSpecificParameters(); + const ollamaConfig_data = await ollamaConfig.getConfigAsync(); + + // Формируем тело запроса для Ollama API (используем утилиту) + const requestBodyOptions = { + messages: messages, + model: model, + llmParameters: llmParameters, + qwenParameters: qwenParameters, + defaultModel: ollamaConfig_data.defaultModel, + stream: false + }; + + // Добавляем tools для function calling (если userId передан) + if (userId) { + const tools = getFunctionDefinitions(userId); + requestBodyOptions.tools = tools; + requestBodyOptions.tool_choice = "auto"; + } + + const requestBody = buildOllamaRequest(requestBodyOptions); + + // Получаем настройки Ollama заранее (нужны для всех путей выполнения) + const ollamaUrl = ollamaConfig.getBaseUrl(); + const timeouts = ollamaConfig.getTimeouts(); + try { // ✨ НОВОЕ: Используем очередь (если включена) - if (USE_AI_QUEUE) { + // ВАЖНО: Function calling не поддерживается в очереди, поэтому если tools нужны - используем прямой вызов + if (USE_AI_QUEUE && !userId) { try { llmResponse = await aiQueue.addTask({ messages, - model - // Приоритет не используется - все запросы обрабатываются FIFO + model: requestBody.model, + // Передаем параметры для очереди + llmParameters, + qwenParameters }); console.log('[RAG] LLM response from queue:', llmResponse ? llmResponse.substring(0, 100) + '...' : 'null'); @@ -400,27 +796,156 @@ async function generateLLMResponse({ } // Прямой вызов Ollama (если очередь отключена или ошибка очереди) - const ollamaUrl = ollamaConfig.getBaseUrl(); - const timeouts = ollamaConfig.getTimeouts(); // Логируем размер промпта для отладки const promptSize = JSON.stringify(messages).length; - console.log(`[RAG] Отправка запроса в Ollama. Размер промпта: ${promptSize} символов, таймаут: ${timeouts.ollamaChat/1000}с`); + const systemPromptSize = messages.find(m => m.role === 'system')?.content?.length || 0; + const userPromptSize = messages.find(m => m.role === 'user')?.content?.length || 0; + const historySize = messages.filter(m => m.role !== 'system' && m.role !== 'user').reduce((sum, m) => sum + (m.content?.length || 0), 0); + + logger.info(`[RAG] Отправка запроса в Ollama. Размер промпта: ${promptSize} символов (система: ${systemPromptSize}, пользователь: ${userPromptSize}, история: ${historySize}), таймаут: ${timeouts.ollamaChat/1000}с`); + logger.info(`[RAG] Параметры LLM:`, JSON.stringify(llmParameters)); + if (qwenParameters.format) { + logger.info(`[RAG] Qwen параметр format: ${qwenParameters.format}`); + } // Проверяем размер промпта и предупреждаем, если он большой if (promptSize > 10000) { - console.warn(`[RAG] ⚠️ Большой промпт (${promptSize} символов). Возможны проблемы с производительностью.`); + logger.warn(`[RAG] ⚠️ Большой промпт (${promptSize} символов). Возможны проблемы с производительностью.`); + } + if (promptSize > 50000) { + logger.error(`[RAG] ⚠️⚠️ ОЧЕНЬ БОЛЬШОЙ промпт (${promptSize} символов). Модель может не справиться.`); } - const response = await axios.post(`${ollamaUrl}/api/chat`, { - model: model || ollamaConfig.getDefaultModel(), - messages: messages, - stream: false - }, { - timeout: timeouts.ollamaChat + // Логируем информацию о function calling (если включен) + if (requestBody.tools) { + logger.info(`[RAG] Function calling включен, доступно ${requestBody.tools.length} функций`); + } + + logger.info(`[RAG] Отправка запроса в Ollama (${ollamaUrl}/api/chat) в ${new Date().toISOString()}...`); + const requestStartTime = Date.now(); + + // Добавляем промежуточное логирование для длительных запросов + const progressInterval = setInterval(() => { + const elapsed = Date.now() - requestStartTime; + const elapsedSeconds = Math.round(elapsed/1000); + if (elapsed > 30000) { // 30 секунд + logger.warn(`[RAG] Запрос к Ollama выполняется уже ${elapsedSeconds}с...`, { + model: requestBody.model, + promptSize, + timeout: timeouts.ollamaChat / 1000, + elapsedSeconds, + remainingTimeout: Math.round((timeouts.ollamaChat - elapsed) / 1000) + }); + } + // Критическое предупреждение если осталось менее 30 секунд до таймаута + if (elapsed > timeouts.ollamaChat - 30000) { + logger.error(`[RAG] ⚠️⚠️ КРИТИЧНО: Запрос к Ollama выполняется ${elapsedSeconds}с, до таймаута осталось ~${Math.round((timeouts.ollamaChat - elapsed) / 1000)}с!`, { + model: requestBody.model, + promptSize, + timeout: timeouts.ollamaChat / 1000 + }); + } + }, 15000); // Проверяем каждые 15 секунд (чаще для лучшего мониторинга) + + let response; + try { + response = await axios.post(`${ollamaUrl}/api/chat`, requestBody, { + timeout: timeouts.ollamaChat + }); + } finally { + clearInterval(progressInterval); + } + + const requestDuration = Date.now() - requestStartTime; + const durationSeconds = Math.round(requestDuration/1000); + logger.info(`[RAG] Получен ответ от Ollama в ${new Date().toISOString()}, статус: ${response.status}, время выполнения: ${requestDuration}ms (${durationSeconds}с)`, { + model: requestBody.model, + promptSize, + timeout: timeouts.ollamaChat / 1000, + responseLength: response.data?.message?.content?.length || 0 }); - llmResponse = response.data.message.content; + // Предупреждение если запрос занял слишком много времени + if (requestDuration > 60000) { // Больше минуты + logger.warn(`[RAG] ⚠️ Запрос к Ollama занял ${durationSeconds}с - это слишком долго. Возможные причины: большой промпт (${promptSize} символов), перегруженная модель или медленная система.`); + } + + // ✨ НОВОЕ: Обработка function calls + if (response.data.message.tool_calls && response.data.message.tool_calls.length > 0) { + logger.info(`[RAG] ИИ запросил выполнение ${response.data.message.tool_calls.length} функций`); + + const toolResults = []; + + // Выполняем все function calls + for (const toolCall of response.data.message.tool_calls) { + const result = await executeToolCall(toolCall, userId); + toolResults.push({ + tool_call_id: toolCall.id, + role: 'tool', + name: toolCall.function.name, + content: JSON.stringify(result) + }); + } + + // Добавляем результаты в историю сообщений + messages.push(response.data.message); // Сообщение с tool_calls + messages.push(...toolResults); // Результаты выполнения функций + + // Повторяем запрос с результатами функций + const finalRequestBody = { + ...requestBody, + messages: messages + }; + + // Убираем tools из финального запроса (они уже не нужны) + delete finalRequestBody.tools; + delete finalRequestBody.tool_choice; + + logger.info(`[RAG] Отправка финального запроса в Ollama после выполнения function calls...`); + const finalRequestStartTime = Date.now(); + const finalPromptSize = JSON.stringify(finalRequestBody.messages).length; + + // Мониторинг второго запроса + const finalProgressInterval = setInterval(() => { + const elapsed = Date.now() - finalRequestStartTime; + const elapsedSeconds = Math.round(elapsed/1000); + if (elapsed > 30000) { + logger.warn(`[RAG] Финальный запрос к Ollama (после function calls) выполняется уже ${elapsedSeconds}с...`, { + model: finalRequestBody.model, + promptSize: finalPromptSize, + timeout: timeouts.ollamaChat / 1000, + elapsedSeconds + }); + } + }, 15000); + + let finalResponse; + try { + finalResponse = await axios.post(`${ollamaUrl}/api/chat`, finalRequestBody, { + timeout: timeouts.ollamaChat + }); + } finally { + clearInterval(finalProgressInterval); + } + + const finalRequestDuration = Date.now() - finalRequestStartTime; + const finalDurationSeconds = Math.round(finalRequestDuration/1000); + + llmResponse = finalResponse.data.message.content; + logger.info(`[RAG] Получен финальный ответ после выполнения function calls, длина: ${llmResponse ? llmResponse.length : 0} символов, время выполнения: ${finalRequestDuration}ms (${finalDurationSeconds}с)`, { + model: finalRequestBody.model, + promptSize: finalPromptSize, + responseLength: llmResponse?.length || 0 + }); + + if (finalRequestDuration > 60000) { + logger.warn(`[RAG] ⚠️ Финальный запрос к Ollama (после function calls) занял ${finalDurationSeconds}с - это слишком долго.`); + } + } else { + llmResponse = response.data.message.content; + logger.info(`[RAG] Получен ответ от Ollama, длина: ${llmResponse ? llmResponse.length : 0} символов`); + } } catch (error) { const isTimeout = error.message && ( @@ -429,15 +954,22 @@ async function generateLLMResponse({ error.message.includes('ECONNABORTED') ); + logger.error(`[RAG] Ошибка при вызове Ollama:`, { + message: error.message, + code: error.code, + isTimeout, + stack: error.stack + }); + if (isTimeout) { - console.warn(`[RAG] Ollama timeout после ${timeouts.ollamaChat/1000}с. Возможно, модель перегружена или контекст слишком большой.`); + logger.warn(`[RAG] Ollama timeout после ${timeouts.ollamaChat/1000}с. Возможно, модель перегружена или контекст слишком большой.`); } else { - console.error(`[RAG] Error in Ollama call:`, error.message); + logger.error(`[RAG] Error in Ollama call:`, error.message, error.stack); } // Финальный fallback - возврат ответа из RAG if (answer) { - console.log('[RAG] Возврат прямого ответа из RAG (ошибка Ollama)'); + logger.info('[RAG] Возврат прямого ответа из RAG (ошибка Ollama)'); return answer; } @@ -457,6 +989,59 @@ async function generateLLMResponse({ } } + +function buildConversationSummary(history, options = {}) { + const { + maxMessages = 10, + maxChars = 700, + snippetLength = 160 + } = options; + + if (!Array.isArray(history) || history.length === 0) { + return null; + } + + const recentMessages = history.slice(-Math.max(maxMessages, 1)); + const roleLabels = { + assistant: 'Ассистент', + system: 'Система', + tool: 'Инструмент' + }; + + const lines = []; + let totalLength = 0; + + for (const message of recentMessages) { + if (!message || typeof message.content !== 'string') { + continue; + } + + const roleLabel = roleLabels[message.role] || 'Пользователь'; + let text = message.content.replace(/\s+/g, ' ').trim(); + if (!text) { + continue; + } + + if (text.length > snippetLength) { + text = `${text.slice(0, snippetLength)}...`; + } + + const line = `${roleLabel}: ${text}`; + if (totalLength + line.length > maxChars) { + break; + } + + lines.push(line); + totalLength += line.length + 1; + if (totalLength >= maxChars) { + break; + } + } + + return lines.length > 0 ? lines.join('\n') : null; +} + + /** * Создает контекст беседы с RAG данными */ @@ -499,15 +1084,19 @@ async function ragAnswerWithConversation({ tableId, userQuestion, product = null, - threshold = 300, + threshold = null, history = [], conversationId = null, forceReindex = false }) { - console.log(`[RAG] ragAnswerWithConversation: tableId=${tableId}, question="${userQuestion}", historyLength=${history.length}`); + // Загружаем настройки RAG для threshold + const ragConfig = await aiConfigService.getRAGConfig(); + const finalThreshold = threshold !== null ? threshold : ragConfig.threshold; + + console.log(`[RAG] ragAnswerWithConversation: tableId=${tableId}, question="${userQuestion}", historyLength=${history.length}, userId=${userId}`); - // Получаем базовый RAG результат - const ragResult = await ragAnswer({ tableId, userQuestion, product, threshold, forceReindex }); + // Получаем базовый RAG результат (с фильтрацией по тегам, если userId передан) + const ragResult = await ragAnswer({ tableId, userQuestion, product, threshold: finalThreshold, forceReindex, userId }); // Анализируем контекст беседы const conversationContext = createConversationContext({ @@ -525,7 +1114,7 @@ async function ragAnswerWithConversation({ console.log(`[RAG] Обнаружен уточняющий вопрос с RAG данными`); // Проверяем, есть ли точный ответ в первом поиске - if (ragResult.answer && typeof ragResult.score === 'number' && Math.abs(ragResult.score) <= threshold) { + if (ragResult.answer && typeof ragResult.score === 'number' && Math.abs(ragResult.score) <= finalThreshold) { console.log(`[RAG] Найден точный ответ (score=${ragResult.score}), возвращаем ответ из базы без модификаций`); return { ...ragResult, @@ -544,8 +1133,9 @@ async function ragAnswerWithConversation({ tableId, userQuestion: contextualQuestion, product, - threshold, - forceReindex + threshold: finalThreshold, + forceReindex, + userId }); // Объединяем результаты @@ -598,8 +1188,14 @@ module.exports = { getTableData, generateLLMResponse, ragAnswerWithConversation, - startQueueWorker, // ✨ НОВОЕ - stopQueueWorker, // ✨ НОВОЕ - getQueueStats, // ✨ НОВОЕ - getCacheStats // ✨ НОВОЕ -}; \ No newline at end of file + startQueueWorker, + stopQueueWorker, + getQueueStats, + getCacheStats, + getAllPlaceholdersWithValues, // Плейсхолдеры столбцов (значения из первой строки) + getTablePlaceholders, // Плейсхолдеры таблиц (генерируются на лету) + invalidateTablePlaceholdersCache, // Инвалидация кэша плейсхолдеров таблиц + replacePlaceholders, // Функция подстановки плейсхолдеров + generatePlaceholder // Функция генерации плейсхолдера из названия +}; + diff --git a/backend/services/semanticChunkingService.js b/backend/services/semanticChunkingService.js new file mode 100644 index 0000000..b3e8849 --- /dev/null +++ b/backend/services/semanticChunkingService.js @@ -0,0 +1,361 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +const ollamaConfig = require('./ollamaConfig'); +const logger = require('../utils/logger'); +const axios = require('axios'); + +/** + * Сервис для интеллектуальной разбивки документов на смысловые части (Semantic Chunking) + */ +class SemanticChunkingService { + constructor() { + this.defaultMaxChunkSize = 1000; // Максимальный размер чанка в символах + this.defaultOverlap = 200; // Перекрытие между чанками в символах + this.minChunkSize = 100; // Минимальный размер чанка + } + + /** + * Разбить документ на смысловые чанки + * @param {string} text - Текст документа + * @param {Object} options - Опции разбивки + * @param {number} options.maxChunkSize - Максимальный размер чанка + * @param {number} options.overlap - Перекрытие между чанками + * @param {boolean} options.useLLM - Использовать LLM для определения структуры + * @returns {Promise} Массив чанков с метаданными + */ + async chunkDocument(text, options = {}) { + if (!text || typeof text !== 'string' || text.trim().length === 0) { + return []; + } + + const { + maxChunkSize = this.defaultMaxChunkSize, + overlap = this.defaultOverlap, + useLLM = true + } = options; + + logger.info(`[SemanticChunking] Разбивка документа: длина=${text.length}, maxChunkSize=${maxChunkSize}, useLLM=${useLLM}`); + + try { + // Если документ небольшой, возвращаем как один чанк + if (text.length <= maxChunkSize) { + return [{ + text: text.trim(), + index: 0, + start: 0, + end: text.length, + metadata: { + section: 'Документ', + isComplete: true + } + }]; + } + + let chunks = []; + + if (useLLM) { + // Используем LLM для определения структуры + chunks = await this.chunkWithLLM(text, maxChunkSize, overlap); + } else { + // Простая разбивка по параграфам и предложениям + chunks = await this.chunkByStructure(text, maxChunkSize, overlap); + } + + logger.info(`[SemanticChunking] Получено чанков: ${chunks.length}`); + return chunks; + } catch (error) { + logger.error(`[SemanticChunking] Ошибка разбивки документа:`, error); + // Fallback на простую разбивку + return this.chunkByStructure(text, maxChunkSize, overlap); + } + } + + /** + * Разбивка с использованием LLM для определения структуры + */ + async chunkWithLLM(text, maxChunkSize, overlap) { + try { + // Получаем конфигурацию Ollama + const ollamaConfig_data = await ollamaConfig.getConfigAsync(); + const baseUrl = ollamaConfig_data.baseUrl || 'http://ollama:11434'; + const model = ollamaConfig_data.defaultModel || 'qwen2.5:7b'; + + // Если текст очень большой, анализируем первые 10000 символов для определения структуры + const analysisText = text.length > 10000 ? text.substring(0, 10000) : text; + + const prompt = `Проанализируй структуру следующего документа и определи логические разделы. + +Документ: +${analysisText}${text.length > 10000 ? '\n\n[Документ продолжается...]' : ''} + +Верни JSON с разделами в следующем формате: +{ + "sections": [ + { + "title": "Название раздела (например, 'Общие условия', 'Стоимость')", + "start": позиция_начала_раздела_в_тексте, + "end": позиция_конца_раздела_в_тексте, + "text": "полный_текст_раздела" + } + ] +} + +Важно: +- Разделы должны быть логически завершенными (законченная мысль) +- Если раздел слишком большой (больше ${maxChunkSize} символов), раздели его на подразделы +- Не обрезай предложения посередине +- Если не можешь определить структуру, верни один раздел с title: "Документ"`; + + const response = await axios.post(`${baseUrl}/api/chat`, { + model: model, + messages: [ + { + role: 'user', + content: prompt + } + ], + format: 'json', + options: { + temperature: 0.1, // Низкая температура для точности + num_predict: 2000 + } + }, { + timeout: 30000 + }); + + const content = response.data.message?.content || response.data.response || ''; + let structure; + + try { + // Парсим JSON из ответа (может быть обернут в markdown) + const jsonMatch = content.match(/\{[\s\S]*\}/); + if (jsonMatch) { + structure = JSON.parse(jsonMatch[0]); + } else { + structure = JSON.parse(content); + } + } catch (parseError) { + logger.warn(`[SemanticChunking] Ошибка парсинга JSON от LLM, используем простую разбивку:`, parseError); + return this.chunkByStructure(text, maxChunkSize, overlap); + } + + // Формируем чанки из разделов + const chunks = []; + + if (structure.sections && Array.isArray(structure.sections)) { + for (let i = 0; i < structure.sections.length; i++) { + const section = structure.sections[i]; + const sectionText = section.text || text.substring(section.start || 0, section.end || text.length); + + // Если раздел большой, разбиваем его на подразделы + if (sectionText.length > maxChunkSize) { + const subChunks = this.splitLargeSection(sectionText, section.title || 'Раздел', maxChunkSize, overlap); + chunks.push(...subChunks.map((chunk, idx) => ({ + ...chunk, + index: chunks.length + idx, + metadata: { + ...chunk.metadata, + parentSection: section.title + } + }))); + } else { + chunks.push({ + text: sectionText.trim(), + index: chunks.length, + start: section.start || 0, + end: section.end || sectionText.length, + metadata: { + section: section.title || `Раздел ${i + 1}`, + isComplete: true + } + }); + } + } + } + + // Если LLM не определил структуру, используем простую разбивку + if (chunks.length === 0) { + return this.chunkByStructure(text, maxChunkSize, overlap); + } + + return chunks; + } catch (error) { + logger.error(`[SemanticChunking] Ошибка LLM разбивки:`, error); + // Fallback на простую разбивку + return this.chunkByStructure(text, maxChunkSize, overlap); + } + } + + /** + * Разбивка по структуре (параграфы, предложения) + */ + async chunkByStructure(text, maxChunkSize, overlap) { + const chunks = []; + + // Разбиваем на параграфы (двойной перенос строки) + const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0); + + let currentChunk = ''; + let currentStart = 0; + let chunkIndex = 0; + + for (let i = 0; i < paragraphs.length; i++) { + const paragraph = paragraphs[i].trim(); + + // Если параграф сам по себе больше maxChunkSize, разбиваем его + if (paragraph.length > maxChunkSize) { + // Сохраняем текущий чанк, если есть + if (currentChunk.length > 0) { + chunks.push({ + text: currentChunk.trim(), + index: chunkIndex++, + start: currentStart, + end: currentStart + currentChunk.length, + metadata: { + section: `Параграф ${chunkIndex}`, + isComplete: true + } + }); + currentChunk = ''; + } + + // Разбиваем большой параграф + const subChunks = this.splitLargeSection(paragraph, `Параграф ${i + 1}`, maxChunkSize, overlap); + chunks.push(...subChunks.map((chunk, idx) => ({ + ...chunk, + index: chunkIndex++, + metadata: { + ...chunk.metadata, + parentParagraph: i + 1 + } + }))); + continue; + } + + // Проверяем, поместится ли параграф в текущий чанк + const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + paragraph; + + if (potentialChunk.length <= maxChunkSize) { + // Добавляем параграф к текущему чанку + if (currentChunk.length === 0) { + currentStart = text.indexOf(paragraph); + } + currentChunk = potentialChunk; + } else { + // Сохраняем текущий чанк и начинаем новый + if (currentChunk.length > 0) { + chunks.push({ + text: currentChunk.trim(), + index: chunkIndex++, + start: currentStart, + end: currentStart + currentChunk.length, + metadata: { + section: `Параграф ${chunkIndex}`, + isComplete: true + } + }); + } + + // Начинаем новый чанк с текущего параграфа + currentChunk = paragraph; + currentStart = text.indexOf(paragraph); + } + } + + // Сохраняем последний чанк + if (currentChunk.length > 0) { + chunks.push({ + text: currentChunk.trim(), + index: chunkIndex, + start: currentStart, + end: currentStart + currentChunk.length, + metadata: { + section: `Параграф ${chunkIndex + 1}`, + isComplete: true + } + }); + } + + // Добавляем перекрытие (overlap) между чанками + if (overlap > 0 && chunks.length > 1) { + for (let i = 1; i < chunks.length; i++) { + const prevChunk = chunks[i - 1]; + const currentChunk = chunks[i]; + + // Берем последние N символов предыдущего чанка + const overlapText = prevChunk.text.slice(-overlap); + if (overlapText.trim().length > 0) { + // Добавляем в начало текущего чанка + currentChunk.text = overlapText + '\n\n' + currentChunk.text; + currentChunk.metadata.hasOverlap = true; + } + } + } + + return chunks; + } + + /** + * Разбить большой раздел на подразделы + */ + splitLargeSection(text, sectionTitle, maxChunkSize, overlap) { + const chunks = []; + + // Разбиваем по предложениям + const sentences = text.split(/(?<=[.!?])\s+/).filter(s => s.trim().length > 0); + + let currentChunk = ''; + let chunkIndex = 0; + + for (const sentence of sentences) { + const potentialChunk = currentChunk + (currentChunk ? ' ' : '') + sentence; + + if (potentialChunk.length <= maxChunkSize) { + currentChunk = potentialChunk; + } else { + // Сохраняем текущий чанк + if (currentChunk.length >= this.minChunkSize) { + chunks.push({ + text: currentChunk.trim(), + index: chunkIndex++, + metadata: { + section: `${sectionTitle} - Часть ${chunkIndex}`, + isComplete: false + } + }); + } + currentChunk = sentence; + } + } + + // Сохраняем последний чанк + if (currentChunk.length >= this.minChunkSize) { + chunks.push({ + text: currentChunk.trim(), + index: chunkIndex, + metadata: { + section: `${sectionTitle} - Часть ${chunkIndex + 1}`, + isComplete: false + } + }); + } + + return chunks; + } +} + +// Создаем singleton экземпляр +const semanticChunkingService = new SemanticChunkingService(); + +module.exports = semanticChunkingService; + diff --git a/backend/services/unifiedMessageProcessor.js b/backend/services/unifiedMessageProcessor.js index 5817e6a..79374e7 100644 --- a/backend/services/unifiedMessageProcessor.js +++ b/backend/services/unifiedMessageProcessor.js @@ -150,7 +150,7 @@ async function processMessage(messageData) { const messageType = determineMessageType(recipientId, userId, isAdmin); // 5. Определяем нужно ли генерировать AI ответ - const shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId); + let shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId); logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin }); @@ -227,27 +227,37 @@ async function processMessage(messageData) { // Автоматически подписываем согласие if (documentIds.length > 0 && consentTypes.length > 0) { - const consentRoutes = require('../routes/consent'); - // Вызываем логику подписания напрямую через сервис или API try { + // Используем проверку существования вместо ON CONFLICT (т.к. может не быть уникального ограничения) + for (let i = 0; i < documentIds.length; i++) { + const docId = documentIds[i]; + const docTitle = consentDocuments.find(d => d.id === docId)?.title || ''; + const consentType = consentTypes[i]; + + // Проверяем, есть ли уже согласие + const existing = await db.getQuery()( + `SELECT id FROM consent_logs + WHERE user_id = $1 AND consent_type = $2 AND document_id = $3 AND status = 'granted'`, + [userId, consentType, docId] + ); + + if (existing.rows.length > 0) { + // Обновляем существующее + await db.getQuery()( + `UPDATE consent_logs + SET signed_at = NOW(), revoked_at = NULL, updated_at = NOW() + WHERE id = $1`, + [existing.rows[0].id] + ); + } else { + // Создаем новое await db.getQuery()( - `INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at) - SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW() - ON CONFLICT (user_id, consent_type, document_id) - DO UPDATE SET - status = 'granted', - signed_at = NOW(), - revoked_at = NULL, - updated_at = NOW() - WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`, - [ - userId, - walletIdentity?.provider_id || null, - documentIds, - consentDocuments.map(doc => doc.title), - consentTypes - ] + `INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'granted', NOW(), 'web', NOW(), NOW())`, + [userId, walletIdentity?.provider_id || null, docId, docTitle, consentType] ); + } + } logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`); } catch (consentError) { logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError); @@ -330,6 +340,9 @@ async function processMessage(messageData) { // 8. Генерируем AI ответ (если нужно) let aiResponse = null; + // Инициализируем finalAiResponse для использования в результатах (должен быть доступен везде) + let finalAiResponse = null; + let aiResponseDisabled = false; if (shouldGenerateAi) { // Загружаем историю беседы @@ -377,7 +390,7 @@ async function processMessage(messageData) { }); // Формируем финальный ответ ИИ с системным сообщением, если нужно - let finalAiResponse = aiResponse.response; + finalAiResponse = aiResponse.response; if (consentSystemMessage && consentSystemMessage.consentRequired) { // Добавляем системное сообщение к ответу ИИ finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`; @@ -433,6 +446,9 @@ async function processMessage(messageData) { ); logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id); + } else if (aiResponse && aiResponse.disabled) { + aiResponseDisabled = true; + logger.info('[UnifiedMessageProcessor] AI ассистент отключен для текущего канала — ответ не генерируется.'); } else { logger.warn('[UnifiedMessageProcessor] AI не вернул ответ:', aiResponse?.reason); } @@ -456,10 +472,11 @@ async function processMessage(messageData) { userMessageId, conversationId, aiResponse: aiResponse && aiResponse.success ? { - response: finalAiResponse || aiResponse.response, + response: finalAiResponse || (aiResponse?.response || ''), ragData: aiResponse.ragData } : null, - noAiResponse: !shouldGenerateAi + noAiResponse: !shouldGenerateAi || aiResponseDisabled, + assistantDisabled: aiResponseDisabled }; // Если есть информация о согласиях, добавляем её в результат diff --git a/backend/services/userContextService.js b/backend/services/userContextService.js new file mode 100644 index 0000000..c684bce --- /dev/null +++ b/backend/services/userContextService.js @@ -0,0 +1,275 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +/** + * User Context Service + * Предоставляет информацию о пользователе: теги, имя, язык + * Кэширование (TTL: 10 минут) + */ + +const db = require('../db'); +const encryptedDb = require('./encryptedDatabaseService'); +const logger = require('../utils/logger'); +const encryptionUtils = require('../utils/encryptionUtils'); + +// Кэш для пользовательских данных +const userCache = new Map(); +const CACHE_TTL = 10 * 60 * 1000; // 10 минут + +/** + * Получить теги пользователя (tagIds) + * @param {number} userId - ID пользователя + * @returns {Promise>} Массив tagIds + */ +async function getUserTags(userId) { + try { + // Проверяем кэш + const cacheKey = `tags_${userId}`; + const cached = userCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { + return cached.data; + } + + // Гостевые пользователи не имеют тегов + if (typeof userId === 'string' && userId.startsWith('guest_')) { + return []; + } + + const query = db.getQuery(); + const result = await query( + 'SELECT tag_id FROM user_tag_links WHERE user_id = $1', + [userId] + ); + + const tagIds = result.rows.map(row => row.tag_id); + + // Сохраняем в кэш + userCache.set(cacheKey, { + data: tagIds, + timestamp: Date.now() + }); + + return tagIds; + } catch (error) { + logger.error('[UserContextService] Ошибка получения тегов пользователя:', error.message); + return []; + } +} + +/** + * Получить названия тегов по их ID + * @param {Array} tagIds - Массив tagIds + * @returns {Promise>} Массив названий тегов + */ +async function getTagNames(tagIds) { + if (!tagIds || tagIds.length === 0) { + return []; + } + + try { + // Проверяем кэш + const cacheKey = `tagNames_${tagIds.sort().join(',')}`; + const cached = userCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { + return cached.data; + } + + // Находим таблицу "Теги клиентов" + // encryptedDb.getData уже расшифровывает данные, поэтому используем поле name + const tables = await encryptedDb.getData('user_tables', {}); + const tagsTable = tables.find(table => table.name === 'Теги клиентов'); + + if (!tagsTable) { + logger.warn('[UserContextService] Таблица "Теги клиентов" не найдена'); + return []; + } + + // Получаем строки таблицы (теги) + const rows = await encryptedDb.getData('user_rows', { + table_id: tagsTable.id, + id: { $in: tagIds } + }); + + // Получаем столбец с названием тега + // encryptedDb.getData уже расшифровывает данные + const columns = await encryptedDb.getData('user_columns', { + table_id: tagsTable.id + }); + + // Ищем столбец "Список тегов", затем "Название", затем первый текстовый столбец + const nameColumn = columns.find(col => + col.name === 'Список тегов' && col.type === 'text' + ) || columns.find(col => + (col.name === 'Название' || col.name === 'Название тега') && col.type === 'text' + ) || columns.find(col => col.type === 'text'); + + if (!nameColumn) { + logger.warn('[UserContextService] Столбец с названием тега не найден'); + return []; + } + + // Получаем значения ячеек + const cellValues = await encryptedDb.getData('user_cell_values', { + row_id: { $in: rows.map(r => r.id) }, + column_id: nameColumn.id + }); + + // Создаем маппинг row_id -> название + const tagNamesMap = new Map(); + for (const cell of cellValues) { + if (cell.value) { + tagNamesMap.set(cell.row_id, cell.value); + } + } + + // Сортируем по порядку tagIds + const tagNames = tagIds + .map(tagId => tagNamesMap.get(tagId)) + .filter(Boolean); + + // Сохраняем в кэш + userCache.set(cacheKey, { + data: tagNames, + timestamp: Date.now() + }); + + return tagNames; + } catch (error) { + logger.error('[UserContextService] Ошибка получения названий тегов:', error.message); + return []; + } +} + +/** + * Получить полный контекст пользователя + * @param {number} userId - ID пользователя + * @returns {Promise} Контекст пользователя + */ +async function getUserContext(userId) { + try { + // Проверяем кэш + const cacheKey = `context_${userId}`; + const cached = userCache.get(cacheKey); + if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) { + return cached.data; + } + + // Гостевые пользователи + if (typeof userId === 'string' && userId.startsWith('guest_')) { + return { + id: userId, + name: null, + tags: [], + tagNames: [], + language: 'ru', + role: 'guest' + }; + } + + // Получаем данные пользователя + const query = db.getQuery(); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + const userResult = await query(` + SELECT + u.id, + decrypt_text(u.first_name_encrypted, $1) as first_name, + decrypt_text(u.last_name_encrypted, $1) as last_name, + u.preferred_language, + u.role + FROM users u + WHERE u.id = $2 + `, [encryptionKey, userId]); + + if (userResult.rows.length === 0) { + return null; + } + + const user = userResult.rows[0]; + + // Формируем имя + const firstName = user.first_name || ''; + const lastName = user.last_name || ''; + const name = [firstName, lastName].filter(Boolean).join(' ') || null; + + // Получаем теги + const tagIds = await getUserTags(userId); + const tagNames = await getTagNames(tagIds); + + const context = { + id: userId, + name, + tags: tagIds, + tagNames, + language: user.preferred_language || 'ru', + role: user.role || 'user' + }; + + // Сохраняем в кэш + userCache.set(cacheKey, { + data: context, + timestamp: Date.now() + }); + + return context; + } catch (error) { + logger.error('[UserContextService] Ошибка получения контекста пользователя:', error.message); + return null; + } +} + +/** + * Инвалидация кэша для пользователя + * @param {number} userId - ID пользователя + */ +function invalidateUserCache(userId) { + const keysToDelete = []; + for (const key of userCache.keys()) { + if (key.includes(`_${userId}`) || key.includes(`_${userId}_`)) { + keysToDelete.push(key); + } + } + + for (const key of keysToDelete) { + userCache.delete(key); + } + + logger.debug(`[UserContextService] Кэш инвалидирован для пользователя ${userId}`); +} + +/** + * Очистка всего кэша + */ +function clearCache() { + userCache.clear(); + logger.info('[UserContextService] Кэш очищен'); +} + +/** + * Получить статистику кэша + */ +function getCacheStats() { + return { + size: userCache.size, + ttl: CACHE_TTL + }; +} + +module.exports = { + getUserTags, + getTagNames, + getUserContext, + invalidateUserCache, + clearCache, + getCacheStats +}; + diff --git a/backend/services/vectorSearchClient.js b/backend/services/vectorSearchClient.js index bbe676c..847e528 100644 --- a/backend/services/vectorSearchClient.js +++ b/backend/services/vectorSearchClient.js @@ -13,11 +13,36 @@ const axios = require('axios'); const logger = require('../utils/logger'); const ollamaConfig = require('./ollamaConfig'); +const aiConfigService = require('./aiConfigService'); -const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001'; -const TIMEOUTS = ollamaConfig.getTimeouts(); +const MIN_VECTOR_UPSERT_TIMEOUT = 360000; // 6 минут — с запасом для больших документов + +// Загружаем настройки из aiConfigService (с fallback на ENV) +let VECTOR_SEARCH_URL = null; +let TIMEOUTS = null; + +// Инициализация настроек (асинхронная загрузка) +async function loadSettings() { + try { + const vectorConfig = await aiConfigService.getVectorSearchConfig(); + VECTOR_SEARCH_URL = vectorConfig.url || process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001'; + TIMEOUTS = ollamaConfig.getTimeouts(); + } catch (error) { + logger.warn('[VectorSearchClient] Ошибка загрузки настроек, используем дефолты:', error.message); + VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001'; + TIMEOUTS = ollamaConfig.getTimeouts(); + } +} + +// Инициализируем настройки при загрузке модуля +loadSettings().catch(err => logger.warn('[VectorSearchClient] Ошибка инициализации:', err.message)); async function upsert(tableId, rows) { + // Загружаем актуальные настройки + if (!VECTOR_SEARCH_URL || !TIMEOUTS) { + await loadSettings(); + } + logger.info(`[VectorSearch] upsert: tableId=${tableId}, rows=${rows.length}`); try { const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, { @@ -28,7 +53,7 @@ async function upsert(tableId, rows) { metadata: r.metadata || {} })) }, { - timeout: TIMEOUTS.vectorUpsert // Централизованный таймаут для индексации + timeout: Math.max(TIMEOUTS.vectorUpsert || 0, MIN_VECTOR_UPSERT_TIMEOUT) }); logger.info(`[VectorSearch] upsert result:`, res.data); return res.data; @@ -39,6 +64,11 @@ async function upsert(tableId, rows) { } async function search(tableId, query, topK = 3) { + // Загружаем актуальные настройки + if (!VECTOR_SEARCH_URL || !TIMEOUTS) { + await loadSettings(); + } + logger.info(`[VectorSearch] search: tableId=${tableId}, query="${query}", topK=${topK}`); try { const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, { @@ -91,6 +121,11 @@ async function rebuild(tableId, rows) { } async function health() { + // Загружаем актуальные настройки + if (!VECTOR_SEARCH_URL || !TIMEOUTS) { + await loadSettings(); + } + logger.info(`[VectorSearch] health check`); try { const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: TIMEOUTS.vectorHealth }); diff --git a/backend/tests/ragService.test.js b/backend/tests/ragService.test.js deleted file mode 100644 index 13f3708..0000000 --- a/backend/tests/ragService.test.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Copyright (c) 2024-2025 Тарабанов Александр Викторович - * All rights reserved. - * - * This software is proprietary and confidential. - * Unauthorized copying, modification, or distribution is prohibited. - * - * For licensing inquiries: info@hb3-accelerator.com - * Website: https://hb3-accelerator.com - * GitHub: https://github.com/VC-HB3-Accelerator - */ - -// Принудительно устанавливаем URL для Docker-сети -process.env.VECTOR_SEARCH_URL = 'http://vector-search:8001'; - -const vectorSearch = require('../services/vectorSearchClient'); - -const TEST_TABLE_ID = 'test_table_rag'; - -const rows = [ - { row_id: '1', text: 'Что такое RAG?', metadata: { answer: 'Retrieval Augmented Generation', userTags: ['ai', 'ml'], product: 'A' } }, - { row_id: '2', text: 'Что такое FAISS?', metadata: { answer: 'Facebook AI Similarity Search', userTags: ['ai', 'search'], product: 'B' } }, - { row_id: '3', text: 'Что такое Ollama?', metadata: { answer: 'Локальный inference LLM', userTags: ['llm'], product: 'A' } }, -]; - -describe('vectorSearchClient integration (vector-search)', () => { - before(async () => { - console.log('Загружаем тестовые данные...'); - console.log('VECTOR_SEARCH_URL:', process.env.VECTOR_SEARCH_URL); - await vectorSearch.rebuild(TEST_TABLE_ID, rows); - console.log('Тестовые данные загружены'); - }); - - after(async () => { - console.log('Очищаем тестовые данные...'); - await vectorSearch.remove(TEST_TABLE_ID, rows.map(r => r.row_id)); - console.log('Тестовые данные очищены'); - }); - - it('Поиск без фильтрации', async () => { - const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое RAG?', 1); - console.log('Результаты поиска:', results); - if (!results || results.length === 0) throw new Error('Нет результатов поиска'); - if (results[0].metadata.answer !== 'Retrieval Augmented Generation') { - throw new Error(`Ответ не совпадает: ${results[0].metadata.answer}`); - } - }); - - it('Поиск с фильтрацией по продукту (должен найти Ollama)', async () => { - const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3); - console.log('Результаты поиска Ollama:', results); - if (!results || results.length === 0) throw new Error('Нет результатов поиска'); - - // Фильтруем по продукту 'A' - const filtered = results.filter(r => r.metadata.product === 'A'); - if (filtered.length === 0) throw new Error('Нет результатов с продуктом A'); - if (filtered[0].metadata.answer !== 'Локальный inference LLM') { - throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`); - } - }); - - it('Проверка порога score', async () => { - const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3); - console.log('Результаты поиска с порогом:', results); - if (!results || results.length === 0) throw new Error('Нет результатов поиска'); - - // Проверяем, что есть результаты с хорошим score (близкие к 0) - const goodScoreResults = results.filter(r => Math.abs(r.score) < 10); - if (goodScoreResults.length === 0) throw new Error('Нет результатов с хорошим score'); - console.log('Результаты с хорошим score:', goodScoreResults.length); - }); -}); \ No newline at end of file diff --git a/backend/tests/ragServiceFull.test.js b/backend/tests/ragServiceFull.test.js deleted file mode 100644 index 543fd02..0000000 --- a/backend/tests/ragServiceFull.test.js +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Copyright (c) 2024-2025 Тарабанов Александр Викторович - * All rights reserved. - * - * This software is proprietary and confidential. - * Unauthorized copying, modification, or distribution is prohibited. - * - * For licensing inquiries: info@hb3-accelerator.com - * Website: https://hb3-accelerator.com - * GitHub: https://github.com/VC-HB3-Accelerator - */ - -// Временно устанавливаем URL для локального тестирования -process.env.VECTOR_SEARCH_URL = 'http://localhost:8001'; - -const ragService = require('../services/ragService'); -const db = require('../db'); - -const TEST_TABLE_ID = 999999; // Используем числовой ID - -describe('ragService full integration (DB + vector-search)', () => { - before(async () => { - console.log('Создаем тестовую таблицу и данные...'); - - // Создаем тестовую таблицу - await db.getQuery()(` - INSERT INTO user_tables (id, name, description) - VALUES ($1, 'Test RAG Table', 'Test table for RAG integration') - ON CONFLICT (id) DO NOTHING - `, [TEST_TABLE_ID]); - - // Создаем колонки - const columns = [ - { id: 'col_question', name: 'Question', type: 'text', purpose: 'question' }, - { id: 'col_answer', name: 'Answer', type: 'text', purpose: 'answer' }, - { id: 'col_product', name: 'Product', type: 'text', purpose: 'product' } - ]; - - for (const col of columns) { - await db.getQuery()(` - INSERT INTO user_columns (id, table_id, name, type, options) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO NOTHING - `, [col.id, TEST_TABLE_ID, col.name, col.type, JSON.stringify({ purpose: col.purpose })]); - } - - // Создаем строки - const rows = [ - { id: 'row_1', question: 'Что такое RAG?', answer: 'Retrieval Augmented Generation', product: 'A' }, - { id: 'row_2', question: 'Что такое FAISS?', answer: 'Facebook AI Similarity Search', product: 'B' }, - { id: 'row_3', question: 'Что такое Ollama?', answer: 'Локальный inference LLM', product: 'A' } - ]; - - for (const row of rows) { - // Создаем строку - await db.getQuery()(` - INSERT INTO user_rows (id, table_id, name) - VALUES ($1, $2, $3) - ON CONFLICT (id) DO NOTHING - `, [row.id, TEST_TABLE_ID, row.question]); - - // Создаем значения ячеек - await db.getQuery()(` - INSERT INTO user_cell_values (row_id, column_id, value) - VALUES ($1, $2, $3), ($1, $4, $5), ($1, $6, $7), ($1, $8, $9) - ON CONFLICT (row_id, column_id) DO UPDATE SET value = EXCLUDED.value - `, [ - row.id, 'col_question', row.question, - row.id, 'col_answer', row.answer, - row.id, 'col_product', row.product - ]); - } - - console.log('Тестовые данные созданы'); - }); - - after(async () => { - console.log('Очищаем тестовые данные...'); - - // Удаляем значения ячеек - await db.getQuery()(` - DELETE FROM user_cell_values - WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1) - `, [TEST_TABLE_ID]); - - // Удаляем строки - await db.getQuery()(` - DELETE FROM user_rows WHERE table_id = $1 - `, [TEST_TABLE_ID]); - - // Удаляем колонки - await db.getQuery()(` - DELETE FROM user_columns WHERE table_id = $1 - `, [TEST_TABLE_ID]); - - // Удаляем таблицу - await db.getQuery()(` - DELETE FROM user_tables WHERE id = $1 - `, [TEST_TABLE_ID]); - - console.log('Тестовые данные очищены'); - }); - - it('Полная интеграция: поиск без фильтрации', async () => { - const result = await ragService.ragAnswer({ - tableId: TEST_TABLE_ID, - userQuestion: 'Что такое RAG?' - }); - - console.log('Результат RAG:', result); - if (!result) throw new Error('Нет результата'); - if (result.answer !== 'Retrieval Augmented Generation') { - throw new Error(`Ответ не совпадает: ${result.answer}`); - } - }); - - it('Полная интеграция: фильтрация по продукту', async () => { - const result = await ragService.ragAnswer({ - tableId: TEST_TABLE_ID, - userQuestion: 'Что такое Ollama?', - product: 'A' - }); - - console.log('Результат с фильтром по продукту:', result); - if (!result) throw new Error('Нет результата'); - if (result.answer !== 'Локальный inference LLM') { - throw new Error(`Ответ не совпадает: ${result.answer}`); - } - }); - - it('Полная интеграция: комбинированная фильтрация', async () => { - const result = await ragService.ragAnswer({ - tableId: TEST_TABLE_ID, - userQuestion: 'Что такое RAG?', - product: 'A' - }); - - console.log('Результат с комбинированной фильтрацией:', result); - if (!result) throw new Error('Нет результата'); - if (result.answer !== 'Retrieval Augmented Generation') { - throw new Error(`Ответ не совпадает: ${result.answer}`); - } - }); - - it('Полная интеграция: проверка порога score', async () => { - const result = await ragService.ragAnswer({ - tableId: TEST_TABLE_ID, - userQuestion: 'Что такое Ollama?', - threshold: 0.95 - }); - - console.log('Результат с высоким порогом:', result); - // С высоким порогом может не быть результата, это нормально - if (result && result.score < 0.95) { - throw new Error(`Score слишком низкий: ${result.score}`); - } - }); -}); \ No newline at end of file diff --git a/backend/tests/vectorSearchClient.test.js b/backend/tests/vectorSearchClient.test.js deleted file mode 100644 index 53387b7..0000000 --- a/backend/tests/vectorSearchClient.test.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) 2024-2025 Тарабанов Александр Викторович - * All rights reserved. - * - * This software is proprietary and confidential. - * Unauthorized copying, modification, or distribution is prohibited. - * - * For licensing inquiries: info@hb3-accelerator.com - * Website: https://hb3-accelerator.com - * GitHub: https://github.com/VC-HB3-Accelerator - */ - -const vectorSearch = require('../services/vectorSearchClient'); - -const TEST_TABLE_ID = 'test_table_1'; - -const rows = [ - { row_id: '1', text: 'Как тебя зовут?', metadata: { answer: 'Алексей' } }, - { row_id: '2', text: 'Где ты живёшь?', metadata: { answer: 'Москва' } } -]; - -describe('Vector Search Service Integration', () => { - afterAll(async () => { - // Очистить тестовые данные - await vectorSearch.remove(TEST_TABLE_ID, rows.map(r => r.row_id)); - }); - - test('Upsert and search', async () => { - await vectorSearch.upsert(TEST_TABLE_ID, rows); - const results = await vectorSearch.search(TEST_TABLE_ID, 'Как зовут?', 1); - expect(results.length).toBeGreaterThan(0); - expect(results[0].metadata.answer).toBe('Алексей'); - }); - - test('Delete', async () => { - await vectorSearch.remove(TEST_TABLE_ID, ['1']); - const results = await vectorSearch.search(TEST_TABLE_ID, 'Как зовут?', 1); - expect(results.length === 0 || results[0].metadata.answer !== 'Алексей').toBe(true); - }); - - test('Rebuild', async () => { - await vectorSearch.rebuild(TEST_TABLE_ID, [rows[1]]); - const results = await vectorSearch.search(TEST_TABLE_ID, 'Где ты живёшь?', 1); - expect(results.length).toBeGreaterThan(0); - expect(results[0].metadata.answer).toBe('Москва'); - }); -}); \ No newline at end of file diff --git a/backend/utils/ollamaRequestBuilder.js b/backend/utils/ollamaRequestBuilder.js new file mode 100644 index 0000000..006a199 --- /dev/null +++ b/backend/utils/ollamaRequestBuilder.js @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +/** + * Ollama Request Builder + * Утилита для формирования тела запроса к Ollama API + * Устраняет дублирование кода между ragService.js и ai-queue.js + */ + +/** + * Построить тело запроса для Ollama API + * @param {Object} options - Опции запроса + * @param {Array} options.messages - Массив сообщений для LLM + * @param {string} options.model - Название модели (опционально, будет использован дефолтный) + * @param {Object} options.llmParameters - Параметры LLM (temperature, maxTokens, top_p, top_k, repeat_penalty) + * @param {Object} options.qwenParameters - Специфичные параметры qwen (format) + * @param {string} options.defaultModel - Дефолтная модель (если model не указан) + * @param {Array} options.tools - Массив определений функций для function calling (опционально) + * @param {string} options.tool_choice - Выбор функции ("auto", "none", или конкретная функция) (опционально) + * @param {boolean} options.stream - Потоковая передача (по умолчанию false) + * @returns {Object} Тело запроса для Ollama API + */ +function buildOllamaRequest(options = {}) { + const { + messages, + model, + llmParameters, + qwenParameters, + defaultModel, + tools = null, + tool_choice = null, + stream = false + } = options; + + if (!messages || !Array.isArray(messages)) { + throw new Error('messages обязателен и должен быть массивом'); + } + + if (!llmParameters) { + throw new Error('llmParameters обязателен'); + } + + // Формируем базовое тело запроса + const requestBody = { + model: model || defaultModel, + messages: messages, + stream: stream, + // Общие параметры LLM + temperature: llmParameters.temperature, + num_predict: llmParameters.maxTokens, // Ollama использует num_predict вместо maxTokens + top_p: llmParameters.top_p, + top_k: llmParameters.top_k, + repeat_penalty: llmParameters.repeat_penalty + }; + + // Добавляем специфичные параметры qwen (если они заданы) + if (qwenParameters && qwenParameters.format !== null && qwenParameters.format !== undefined) { + requestBody.format = qwenParameters.format; + } + + // Добавляем tools для function calling (если переданы) + if (tools && Array.isArray(tools) && tools.length > 0) { + requestBody.tools = tools; + requestBody.tool_choice = tool_choice || "auto"; + } + + return requestBody; +} + +module.exports = { + buildOllamaRequest +}; + diff --git a/frontend/src/assets/styles/home.css.bak b/frontend/src/assets/styles/home.css.bak deleted file mode 100644 index 65d9de4..0000000 --- a/frontend/src/assets/styles/home.css.bak +++ /dev/null @@ -1,1422 +0,0 @@ -/* Переменные CSS для цветов, размеров и т.д. */ -:root { - /* Цвета */ - --color-primary: #4CAF50; - --color-primary-dark: #45a049; - --color-secondary: #2196F3; - --color-danger: #F44336; - --color-warning: #FF9800; - --color-light: #f5f5f5; - --color-dark: #333333; - --color-grey: #777777; - --color-grey-light: #e0e0e0; - --color-white: #ffffff; - --color-black: #000000; - --color-telegram: #0088cc; - --color-error: #e74c3c; - - /* Цвета сообщений */ - --color-user-message: #EFFAFF; - --color-ai-message: #F8F8F8; - --color-system-message: #FFF3E0; - --color-system-text: #FF5722; - - /* Тени */ - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.1); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); - - /* Отступы */ - --spacing-xs: 5px; - --spacing-sm: 10px; - --spacing-md: 15px; - --spacing-lg: 20px; - --spacing-xl: 30px; - - /* Размеры шрифтов */ - --font-size-xs: 12px; - --font-size-sm: 13px; - --font-size-md: 14px; - --font-size-lg: 16px; - --font-size-xl: 18px; - --font-size-xxl: 24px; - - /* Радиусы скругления */ - --radius-sm: 4px; - --radius-md: 6px; - --radius-lg: 8px; - - /* Переходы */ - --transition-fast: 0.2s ease; - --transition-normal: 0.3s ease; - - /* Размеры компонентов */ - --sidebar-expanded-width: 325px; - --nav-btn-size: 40px; - --chat-input-min-height: 100px; - --chat-input-max-height: 200px; - --chat-input-focus-min-height: 170px; - --chat-input-focus-max-height: 300px; - - /* Унифицированные размеры для кнопок и форм */ - --button-height: 48px; - --button-height-mobile: 42px; - --button-padding: 0 var(--spacing-lg); - --button-gap: var(--spacing-md); - --form-gap: var(--spacing-md); - --block-padding: 24px; - --block-padding-mobile: 16px; - --block-margin: 24px; - --block-margin-mobile: 16px; - --input-height: 48px; - --input-height-mobile: 42px; - --input-padding: 0 var(--spacing-lg); - - /* Общие стили */ - --button-radius: var(--radius-lg); - --input-radius: var(--radius-lg); - --block-radius: var(--radius-lg); -} - -/* Общие стили для всех элементов */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - font-family: 'Roboto', 'Helvetica Neue', Arial, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - background-color: var(--color-white); -} - -/* Стили для монопространственных шрифтов (код, верификация) */ -code, .verification-code code, .address { - font-family: 'Courier New', Courier, monospace; -} - -/* Унификация размеров шрифтов */ -h1, h2, h3, h4, h5, h6 { - font-weight: 500; -} - -h3 { - font-size: var(--font-size-xl); - margin-bottom: var(--spacing-md); -} - -p { - font-size: var(--font-size-md); - line-height: 1.5; -} - -input, textarea { - font-size: var(--font-size-md); -} - -/* Контейнеры */ -.app-container { - display: flex; - flex-direction: column; - min-height: 100vh; - background-color: var(--color-white); -} - -.main-content { - flex: 1; - display: flex; - flex-direction: column; - max-width: 1200px; - margin: 0; - padding: 0 20px; - width: 100%; - background-color: var(--color-white); -} - -/* Адаптация контента при боковой панели */ -.main-content.no-right-sidebar { - margin-right: 190px; -} - -.main-content:not(.no-right-sidebar) { - margin-right: 190px; -} - -/* Стили для контейнера чата */ -.chat-container { - flex: 1; - display: flex; - flex-direction: column; - margin: var(--spacing-lg) auto; - min-height: 500px; - max-width: 1150px; - width: 100%; - position: relative; -} - -.chat-messages { - display: flex; - flex-direction: column; - overflow-y: auto; - padding: var(--spacing-lg); - background: var(--color-white); - border-radius: var(--radius-lg); - border: 1px solid var(--color-grey-light); - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: calc(var(--chat-input-height, 80px) + 15px); /* Добавляем 15px отступа между сообщениями и полем ввода */ - transition: bottom var(--transition-normal); -} - -/* Стили для сообщений */ -.message { - margin-bottom: var(--spacing-md); - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius-lg); - max-width: 75%; - word-wrap: break-word; - position: relative; - box-shadow: var(--shadow-sm); -} - -.user-message { - background-color: var(--color-user-message); - align-self: flex-end; - margin-left: auto; - margin-right: var(--spacing-sm); - border-bottom-right-radius: 2px; -} - -.ai-message { - background-color: var(--color-ai-message); - align-self: flex-start; - margin-right: auto; - margin-left: var(--spacing-sm); - word-break: break-word; - max-width: 70%; - border-bottom-left-radius: 2px; -} - -.system-message { - background-color: var(--color-system-message); - align-self: center; - margin-left: auto; - margin-right: auto; - font-style: italic; - color: var(--color-system-text); - text-align: center; - max-width: 90%; -} - -.message-content { - margin-bottom: var(--spacing-xs); - white-space: pre-wrap; - word-break: break-word; - font-size: var(--font-size-md); - line-height: 1.5; -} - -.message-meta { - display: flex; - justify-content: space-between; - align-items: center; -} - -.message-time { - font-size: var(--font-size-xs); - color: var(--color-grey); - text-align: right; -} - -.message-status { - font-size: var(--font-size-xs); - color: var(--color-grey); -} - -.sending-indicator { - color: var(--color-secondary); - font-style: italic; -} - -.error-indicator { - color: var(--color-danger); - font-weight: bold; -} - -.is-local { - opacity: 0.7; -} - -.has-error { - border: 1px solid var(--color-danger); -} - -/* Стили для ввода сообщений */ -.chat-input { - display: flex; - flex-direction: column; - padding: var(--spacing-sm) var(--spacing-md); - background: var(--color-white); - border-radius: var(--radius-lg); - border: 1px solid var(--color-grey-light); - position: absolute; - left: 0; - right: 0; - bottom: 0; - transition: all var(--transition-normal); - z-index: 10; - box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05); -} - -.input-area { - display: flex; - align-items: flex-end; - gap: var(--spacing-sm); -} - -.chat-input textarea { - flex: 1; - border: none; - resize: none; - font-size: var(--font-size-md); - line-height: 1.5; - min-height: 24px; - max-height: 120px; /* Ограничение высоты textarea */ - padding: 8px 0; /* Уменьшаем вертикальные отступы */ - outline: none; - overflow-y: hidden; /* Убираем скролл, так как высота меняется динамически */ - height: auto; /* Позволяем высоте изменяться */ -} - -.chat-input textarea:focus { - outline: none; -} - -/* Контейнер для иконок */ -.chat-icons { - display: flex; - gap: 6px; - flex-wrap: nowrap; - align-items: center; -} - -/* Стили для кнопок-иконок */ -.chat-icon-btn { - width: 36px; - height: 36px; - border-radius: 50%; - background: transparent; - border: none; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all var(--transition-fast); - color: var(--color-grey); - padding: 0; - position: relative; -} - -.chat-icon-btn:hover { - color: var(--color-primary); - background-color: rgba(0, 0, 0, 0.05); -} - -.chat-icon-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* Стили для кнопки отправки */ -.chat-icon-btn.send-button { - background-color: var(--color-primary); - color: white; - width: 36px; - height: 36px; -} - -.chat-icon-btn.send-button:hover:not(:disabled) { - background-color: var(--color-primary-dark); - color: white; - transform: scale(1.05); -} - -.chat-icon-btn.send-button:disabled { - background-color: #ccc; - opacity: 0.7; -} - -/* Стили для состояния записи */ -.chat-icon-btn.recording { - color: var(--color-danger); - animation: pulse 1.5s infinite; -} - -.chat-icon-btn.recording::after { - content: ''; - position: absolute; - width: 8px; - height: 8px; - background-color: var(--color-danger); - border-radius: 50%; - top: 2px; - right: 2px; -} - -@keyframes pulse { - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.1); - } - 100% { - transform: scale(1); - } -} - -/* Стили для области предпросмотра */ -.attachment-preview { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid var(--color-grey-light); - max-height: 100px; /* Ограничение высоты области превью */ - overflow-y: auto; /* Скролл для превью */ -} - -.preview-item { - position: relative; - display: flex; - align-items: center; - background-color: var(--color-light); - border-radius: var(--radius-md); - padding: 4px 8px; - font-size: var(--font-size-sm); -} - -.image-preview { - width: 40px; - height: 40px; - object-fit: cover; - border-radius: var(--radius-sm); - margin-right: 8px; -} - -.audio-preview, -.video-preview, -.file-preview { - display: flex; - align-items: center; - gap: 5px; -} - -.remove-attachment-btn { - position: absolute; - top: -5px; - right: -5px; - width: 18px; - height: 18px; - background-color: rgba(0, 0, 0, 0.6); - color: white; - border: none; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - line-height: 1; - padding: 0; -} - -/* Новый контейнер для действий чата */ -.chat-actions { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; -} - -/* Стили для кнопок в чате */ -.chat-buttons { - display: flex; - gap: var(--spacing-sm); - margin-top: var(--spacing-xs); - padding-bottom: 0; - justify-content: flex-end; - flex-wrap: nowrap; - box-sizing: border-box; - align-items: center; -} - -.chat-buttons button { - padding: 8px 16px; - border-radius: var(--radius-sm); - border: none; - cursor: pointer; - font-size: var(--font-size-md); - transition: background-color var(--transition-normal); - white-space: nowrap; - flex-shrink: 0; - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - } - -.chat-buttons .clear-btn { - background-color: var(--color-danger); - color: var(--color-white); -} - -.chat-buttons .clear-btn:hover:not(:disabled) { - background-color: #da190b; - } - -.chat-buttons button:disabled { - background-color: #cccccc; - cursor: not-allowed; -} - -/* Стили для правой панели */ -.wallet-sidebar { - position: fixed; - top: 0; - right: 0; - width: 100%; - height: 100%; - background-color: var(--color-white); - z-index: 1000; - overflow-y: auto; - padding: var(--spacing-lg); - box-sizing: border-box; - display: flex; - flex-direction: column; - transition: transform var(--transition-normal), opacity var(--transition-normal); - box-shadow: -5px 0 15px rgba(0, 0, 0, 0.1); -} - -.wallet-sidebar-content { - max-width: 600px; - width: 100%; - margin: 0 auto; - padding: 0 var(--spacing-md); - box-sizing: border-box; - display: flex; - flex-direction: column; - gap: var(--spacing-lg); -} - -/* Стили для заголовка */ -.header { - background: var(--color-white); - padding: 15px 20px; - position: sticky; - top: 0; - z-index: 100; -} - -.header-content { - max-width: 1200px; - margin: 0 auto; - padding: 0 var(--spacing-lg); - display: flex; - justify-content: space-between; - align-items: center; -} - -.header-text { - flex: 1; -} - -.title { - font-size: var(--font-size-xxl); - font-weight: 500; - color: var(--color-dark); - margin-bottom: var(--spacing-xs); -} - -.subtitle { - font-size: var(--font-size-lg); - color: #666; -} - -/* Анимация появления и исчезновения правой панели */ -.sidebar-slide-enter-active, -.sidebar-slide-leave-active { - transition: all var(--transition-normal); -} - -.sidebar-slide-enter-from, -.sidebar-slide-leave-to { - transform: translateX(100%); - opacity: 0; - } - -.sidebar-slide-enter-to, -.sidebar-slide-leave-from { - transform: translateX(0); - opacity: 1; -} - -/* Стили для блока кнопок авторизации */ -.auth-buttons-container { - width: 100%; - max-width: 450px; - margin-bottom: var(--spacing-lg); - background-color: var(--color-white); - border-radius: var(--radius-lg); - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); - padding: var(--spacing-lg); - box-sizing: border-box; - position: relative; -} - -.auth-btn { - width: 100%; - height: var(--nav-btn-size); - border-radius: var(--radius-lg); - background-color: var(--color-light); - border: 1px solid rgba(0, 0, 0, 0.1); - color: var(--color-dark); - font-size: var(--font-size-md); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - padding: 0 var(--spacing-md); - box-sizing: border-box; - transition: all var(--transition-normal); - margin: 0; -} - -.auth-btn:hover { - background-color: var(--color-grey-light); -} - -/* Медиа-запросы для адаптивности */ -@media screen and (min-width: 1200px) { - .wallet-sidebar { - width: 30%; - max-width: 550px; - } -} - -@media screen and (min-width: 769px) and (max-width: 1199px) { - .wallet-sidebar { - width: 40%; - max-width: 500px; - } -} - -@media screen and (max-width: 768px) { - .wallet-sidebar { - padding: var(--spacing-md); - } - - .wallet-sidebar-content { - padding: 0; - gap: var(--spacing-md); - } - - .disconnect-block { - margin-bottom: var(--spacing-md); - } - - .disconnect-btn, - .close-wallet-sidebar { - height: 42px; - } - - .close-wallet-sidebar { - width: 42px; - min-width: 42px; - font-size: 18px; - } - - .identifiers-block { - padding: var(--spacing-md); - } - - .identifier-item { - font-size: var(--font-size-sm); - margin-bottom: var(--spacing-xs); - } - - .identifier-label { - min-width: 80px; - } -} - -@media screen and (max-width: 480px) { - .wallet-sidebar { - padding: var(--spacing-sm); - } - - .wallet-sidebar-content { - gap: var(--spacing-sm); - } - - .disconnect-block { - margin-bottom: var(--spacing-sm); - } - - .disconnect-btn, - .close-wallet-sidebar { - height: 36px; - font-size: var(--font-size-sm); - } - - .close-wallet-sidebar { - width: 36px; - min-width: 36px; - } - - .identifiers-block { - padding: var(--spacing-sm); - } -} - -/* Анимации */ -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -@keyframes slideInRight { - from { - transform: translateX(100%); - } - to { - transform: translateX(0); - } -} - -@keyframes slideOutRight { - from { - transform: translateX(0); - } - to { - transform: translateX(100%); - } -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes slideIn { - from { - transform: translateY(-50px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -/* Стили для форм email и telegram */ -.auth-buttons-container .email-form, -.auth-buttons-container .telegram-form, -.auth-buttons-container .telegram-verification { - width: 100%; - display: flex; - flex-direction: column; - gap: var(--spacing-md); - margin: var(--spacing-md) 0; -} - -/* Стили для инпутов */ -.auth-buttons-container input[type="email"], -.auth-buttons-container input[type="text"] { - width: 100%; - height: 48px; - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-grey-light); - border-radius: var(--radius-lg); - font-size: var(--font-size-md); - margin: 0 0 var(--spacing-sm) 0; - box-sizing: border-box; - background-color: var(--color-white); -} - -/* Стили для кнопок в формах */ -.auth-buttons-container .email-form button, -.auth-buttons-container .telegram-form button, -.auth-buttons-container .telegram-verification button, -.auth-buttons-container .telegram-verification a { - width: 100%; - height: 48px; - margin: 0; - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius-lg); - font-size: var(--font-size-lg); - font-weight: 500; - cursor: pointer; - transition: all var(--transition-normal); - border: 1px solid rgba(0, 0, 0, 0.1); - text-align: center; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - text-decoration: none; -} - -/* Стили для основных кнопок */ -.auth-buttons-container button[type="submit"], -.auth-buttons-container a[href*="telegram"] { - background-color: var(--color-primary); - color: var(--color-white); -} - -.auth-buttons-container button[type="submit"]:hover, -.auth-buttons-container a[href*="telegram"]:hover { - background-color: var(--color-primary-dark); -} - -/* Стили для кнопок отмены */ -.auth-buttons-container button:not([type="submit"]) { - background-color: var(--color-grey-light); - color: var(--color-dark); -} - -.auth-buttons-container button:not([type="submit"]):hover { - background-color: #d9d9d9; -} - -/* Стили для телеграм-ссылки */ -.auth-buttons-container a[href*="telegram"] { - background-color: var(--color-telegram); -} - -.auth-buttons-container a[href*="telegram"]:hover { - background-color: #0077b3; -} - -@media screen and (max-width: 480px) { - .auth-buttons-container .email-form button, - .auth-buttons-container .telegram-form button, - .auth-buttons-container .telegram-verification button, - .auth-buttons-container .telegram-verification a, - .auth-buttons-container input[type="email"], - .auth-buttons-container input[type="text"] { - height: 42px; - font-size: var(--font-size-sm); - } -} - -/* Общие стили для форм */ -.auth-buttons-container .email-form, -.auth-buttons-container .verification-block { - width: 100%; - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - margin: var(--spacing-sm) 0; -} - -.auth-buttons-container .email-form-container { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - width: 100%; -} - -/* Стили для инпутов */ -.auth-buttons-container input[type="email"], -.auth-buttons-container input[type="text"] { - width: 100%; - height: 48px; - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-grey-light); - border-radius: var(--radius-lg); - font-size: var(--font-size-md); - margin: 0 0 var(--spacing-sm) 0; - box-sizing: border-box; - background-color: var(--color-white); -} - -/* Общие стили для всех кнопок в формах */ -.auth-buttons-container .email-form button, -.auth-buttons-container .verification-block button, -.auth-buttons-container .verification-block a.bot-link { - width: 100%; - height: 48px; - margin: 0; - padding: var(--spacing-sm) var(--spacing-md); - border-radius: var(--radius-lg); - font-size: var(--font-size-lg); - font-weight: 500; - cursor: pointer; - transition: all var(--transition-normal); - border: none; - text-align: center; - display: flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - text-decoration: none; -} - -/* Стили для кнопок отправки/подтверждения */ -.auth-buttons-container button[type="submit"], -.auth-buttons-container .send-email-btn { - background-color: var(--color-primary); - color: var(--color-white); -} - -/* Стили для кнопок отмены */ -.auth-buttons-container button:not([type="submit"]):not(.send-email-btn):not(.bot-link) { - background-color: #E8E8E8; - color: var(--color-dark); -} - -/* Стили для ссылки Telegram */ -.auth-buttons-container .verification-block a.bot-link { - background-color: #0088cc; - color: var(--color-white); -} - -/* Стили для блока с кодом верификации */ -.auth-buttons-container .verification-code { - display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-bottom: var(--spacing-sm); - font-size: var(--font-size-md); - width: 100%; - height: 48px; - border-radius: var(--radius-lg); - background-color: var(--color-white); - border: 1px solid var(--color-grey-light); - padding: 0 var(--spacing-md); - box-sizing: border-box; -} - -/* Стили для текста в формах */ -.auth-buttons-container p { - margin: 0 0 var(--spacing-sm) 0; - font-size: var(--font-size-md); - color: var(--color-dark); -} - -/* Эффекты при наведении */ -.auth-buttons-container button[type="submit"]:hover, -.auth-buttons-container .send-email-btn:hover { - background-color: var(--color-primary-dark); -} - -.auth-buttons-container button:not([type="submit"]):not(.send-email-btn):hover { - background-color: #DADADA; -} - -.auth-buttons-container .verification-block a.bot-link:hover { - background-color: #0077b3; -} - -@media screen and (max-width: 480px) { - .auth-buttons-container .email-form button, - .auth-buttons-container .verification-block button, - .auth-buttons-container .verification-block a.bot-link, - .auth-buttons-container input[type="email"], - .auth-buttons-container input[type="text"] { - height: 42px; - font-size: var(--font-size-sm); - } -} - -/* Общие стили для контейнера */ -.auth-buttons-container, -.wallet-info-container { - width: 100%; - max-width: 450px; - margin-bottom: var(--spacing-lg); - background-color: var(--color-white); - border-radius: var(--radius-lg); - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05); - padding: var(--spacing-lg); - box-sizing: border-box; - position: relative; -} - -/* Стили для заголовка */ -.header-with-close { - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - height: var(--nav-btn-size); - margin-bottom: var(--spacing-lg); - position: relative; -} - -/* Стили для кнопок в заголовке */ -.header-button { - height: var(--nav-btn-size); - border-radius: var(--radius-lg); - font-size: var(--font-size-md); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - box-sizing: border-box; - transition: all var(--transition-normal); -} - -/* Кнопка отключения */ -.disconnect-btn { - background-color: var(--color-white); - border: 1px solid var(--color-error); - color: var(--color-error); - padding: 0 var(--spacing-md); - flex: 1; - margin-right: var(--spacing-sm); -} - -.disconnect-btn:hover { - background-color: #ffebee; -} - -/* Кнопка закрытия */ -.close-wallet-sidebar { - width: var(--nav-btn-size); - height: var(--nav-btn-size); - min-width: var(--nav-btn-size); - background-color: var(--color-white); - color: var(--color-dark); - border: 1px solid var(--color-grey); - font-size: 20px; - padding: 0; - line-height: 1; -} - -.close-wallet-sidebar:hover { - background-color: var(--color-grey-light); - border-color: var(--color-dark); -} - -/* Стили для кнопок авторизации */ -.auth-buttons-wrapper { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - width: 100%; -} - -.auth-btn { - width: 100%; - height: var(--nav-btn-size); - border-radius: var(--radius-lg); - background-color: var(--color-light); - border: 1px solid rgba(0, 0, 0, 0.1); - color: var(--color-dark); - font-size: var(--font-size-md); - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - padding: 0 var(--spacing-md); - box-sizing: border-box; - transition: all var(--transition-normal); - margin: 0; -} - -.auth-btn:hover { - background-color: var(--color-grey-light); -} - -/* Стили для блока идентификаторов */ -.identifiers-block { - margin-top: var(--spacing-lg); - border-top: 1px solid var(--color-grey-light); - padding-top: var(--spacing-lg); -} - -.identifiers-block h3 { - margin: 0 0 var(--spacing-md) 0; - font-size: var(--font-size-xl); - color: var(--color-dark); -} - -.identifier-item { - display: flex; - align-items: center; - margin-bottom: var(--spacing-sm); - font-size: var(--font-size-md); -} - -.identifier-item:last-child { - margin-bottom: 0; -} - -.identifier-label { - min-width: 100px; - color: var(--color-grey); - font-weight: 500; -} - -.identifier-value { - flex: 1; - font-family: monospace; - color: var(--color-dark); - word-break: break-all; -} - -@media screen and (max-width: 480px) { - .auth-buttons-container, - .wallet-info-container { - padding: var(--spacing-md); - } - - .header-with-close { - height: 32px; - margin-bottom: var(--spacing-md); - } - - .header-button { - height: 32px; - font-size: var(--font-size-sm); - } - - .disconnect-btn { - padding: 0 12px; - } - - .close-wallet-sidebar { - width: 32px; - height: 32px; - min-width: 32px; - font-size: 18px; - } - - .auth-btn { - height: 32px; - font-size: var(--font-size-sm); - } - - .identifiers-block { - margin-top: var(--spacing-md); - padding-top: var(--spacing-md); - } - - .identifier-item { - font-size: var(--font-size-sm); - margin-bottom: var(--spacing-xs); - } - - .identifier-label { - min-width: 80px; - } -} - -@media screen and (max-width: 360px) { - .auth-buttons-container, - .wallet-info-container { - padding: var(--spacing-sm); - } - - .header-button { - font-size: var(--font-size-xs); - } - - .disconnect-btn { - padding: 0 8px; - } - - .close-wallet-sidebar { - font-size: 16px; - } - - .identifiers-block { - margin-top: var(--spacing-sm); - padding-top: var(--spacing-sm); - } -} - -/* Общие стили для кнопок */ -.auth-btn, -.disconnect-btn, -.close-wallet-sidebar, -.send-email-btn, -.chat-buttons button, -.header-button, -.connect-btn, -.cancel-btn, -.bot-link { - height: var(--button-height); - padding: 0 var(--spacing-lg); - border-radius: var(--radius-lg); - font-size: var(--font-size-md); - font-weight: 500; - border: none; - cursor: pointer; - transition: var(--transition-fast); - display: flex; - align-items: center; - justify-content: center; - gap: var(--spacing-sm); - background: var(--color-primary); - color: var(--color-white); - width: 100%; - margin: 0; - text-decoration: none; -} - -/* Стили для квадратных кнопок (close) */ -.close-wallet-sidebar { - width: var(--button-height); - padding: 0; - position: absolute; - top: var(--block-padding); - right: var(--block-padding); - background: var(--color-grey-light); - color: var(--color-dark); - font-size: var(--font-size-xl); -} - -/* Общие стили для форм */ -.email-form, -.verification-block, -.auth-buttons-wrapper, -.email-verification-form { - display: flex; - flex-direction: column; - gap: var(--spacing-md); - width: 100%; - margin-bottom: var(--block-margin); -} - -/* Контейнер формы email */ -.email-form-container { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - width: 100%; -} - -/* Общие стили для инпутов */ -input[type="email"], -input[type="text"], -.email-input { - height: var(--input-height); - padding: 0 var(--spacing-lg); - border-radius: var(--radius-lg); - border: 1px solid var(--color-grey-light); - font-size: var(--font-size-md); - width: 100%; - background: var(--color-white); -} - -/* Общие стили для контейнеров */ -.auth-container, -.wallet-info-container, -.identifiers-block, -.token-balances, -.user-info { - width: 100%; - max-width: 450px; - padding: var(--block-padding); - margin-bottom: var(--block-margin); - background: var(--color-white); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); -} - -/* Заголовки в блоках */ -.identifiers-block h3, -.token-balances h3, -.user-info h3 { - margin: 0 0 var(--spacing-md) 0; - font-size: var(--font-size-xl); - color: var(--color-dark); - border-bottom: 1px solid var(--color-grey-light); - padding-bottom: var(--spacing-sm); -} - -/* Элементы списков */ -.identifier-item, -.token-balance, -.user-info-item { - display: flex; - align-items: center; - margin-bottom: var(--spacing-sm); - font-size: var(--font-size-md); -} - -.identifier-label, -.token-name, -.user-info-label { - min-width: 100px; - color: var(--color-grey); - font-weight: 500; -} - -.identifier-value, -.token-amount, -.user-info-value { - flex: 1; - font-family: monospace; - color: var(--color-dark); - word-break: break-all; -} - -/* Код верификации */ -.verification-code { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); - background: var(--color-light); - border-radius: var(--radius-lg); - font-size: var(--font-size-md); - cursor: pointer; -} - -.verification-code code { - font-family: monospace; - color: var(--color-dark); - font-weight: bold; -} - -.copied-message { - color: var(--color-primary); - font-size: var(--font-size-sm); -} - -/* Сообщения об ошибках */ -.error-message { - color: var(--color-error); - padding: var(--spacing-sm); - margin-top: var(--spacing-sm); - background: #ffebee; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: space-between; -} - -.close-error { - background: none; - border: none; - color: var(--color-error); - cursor: pointer; - font-size: var(--font-size-xl); - padding: 0 var(--spacing-xs); -} - -/* Медиа-запросы */ -@media screen and (max-width: 480px) { - :root { - --button-height: var(--button-height-mobile); - --input-height: var(--input-height-mobile); - --block-padding: var(--block-padding-mobile); - --block-margin: var(--block-margin-mobile); - } - - /* Общие стили для кнопок на мобильных */ - .auth-btn, - .disconnect-btn, - .close-wallet-sidebar, - .send-email-btn, - .chat-buttons button, - .header-button, - .connect-btn, - .cancel-btn, - .bot-link { - font-size: var(--font-size-sm); - } - - .close-wallet-sidebar { - width: var(--button-height); - font-size: 18px; - } - - /* Адаптация размеров текста */ - .verification-code, - .identifier-item, - .token-balance, - .user-info-item { - font-size: var(--font-size-sm); - } - - .identifier-label, - .token-name, - .user-info-label { - min-width: 80px; - } -} - -@media screen and (max-width: 360px) { - :root { - --block-padding: var(--spacing-sm); - --block-margin: var(--spacing-sm); - } - - .close-wallet-sidebar { - font-size: 16px; - } - - .auth-btn, - .disconnect-btn, - .send-email-btn, - .chat-buttons button, - .header-button, - .connect-btn, - .cancel-btn, - .bot-link { - font-size: var(--font-size-xs); - padding: 0 var(--spacing-sm); - } - - .verification-code { - font-size: var(--font-size-xs); - } -} - -/* Анимации */ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slideIn { - from { - transform: translateY(-50px); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -/* Анимации для боковой панели */ -.sidebar-slide-enter-active, -.sidebar-slide-leave-active { - transition: all var(--transition-normal); -} - -.sidebar-slide-enter-from, -.sidebar-slide-leave-to { - transform: translateX(100%); - opacity: 0; -} - -.sidebar-slide-enter-to, -.sidebar-slide-leave-from { - transform: translateX(0); - opacity: 1; -} diff --git a/frontend/src/components/ai-assistant/RuleEditor.vue b/frontend/src/components/ai-assistant/RuleEditor.vue index e74d6b8..87c4c38 100644 --- a/frontend/src/components/ai-assistant/RuleEditor.vue +++ b/frontend/src/components/ai-assistant/RuleEditor.vue @@ -14,68 +14,231 @@