/** * 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/HB3-ACCELERATOR */ const encryptedDb = require('./encryptedDatabaseService'); const vectorSearch = require('./vectorSearchClient'); const { getProviderSettings } = require('./aiProviderSettingsService'); const axios = require('axios'); const ollamaConfig = require('./ollamaConfig'); const aiCache = require('./ai-cache'); const AIQueue = require('./ai-queue'); const logger = require('../utils/logger'); // console.log('[RAG] ragService.js loaded'); // Управляет поведением: выполнять ли upsert всех строк на каждый запрос поиска const UPSERT_ON_QUERY = process.env.RAG_UPSERT_ON_QUERY === 'true'; // Флаги для включения/выключения Queue и Cache const USE_AI_CACHE = process.env.USE_AI_CACHE !== 'false'; // default: true const USE_AI_QUEUE = process.env.USE_AI_QUEUE !== 'false'; // default: true // Создаем экземпляр очереди const aiQueue = new AIQueue(); 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'); const answerColId = getColId('answer'); const contextColId = getColId('context'); const productColId = getColId('product'); 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 = { id: row.id, question: cells.find(c => c.column_id === questionColId)?.value, answer: cells.find(c => c.column_id === answerColId)?.value, context: cells.find(c => c.column_id === contextColId)?.value, product: parseIfArray(cells.find(c => c.column_id === productColId)?.value), userTags: parseIfArray(cells.find(c => c.column_id === getColId('userTags'))?.value), 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}"`); // Проверяем кэш (используем ai-cache вместо ragCache) if (USE_AI_CACHE) { const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product); const cached = aiCache.getWithTTL(cacheKey, 'rag'); if (cached) { console.log(`[RAG] Возврат RAG результата из кэша`); return cached; } } 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 // }); }); const questions = data.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question); // Фильтруем только строки с непустым вопросом (text) const rowsForUpsert = data .filter(row => row.id && row.question && String(row.question).trim().length > 0) .map(row => ({ row_id: row.id, text: row.question, metadata: { answer: row.answer || null, context: row.context || null, product: row.product || [], userTags: row.userTags || [], priority: row.priority || null, date: row.date || null } })); // 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) { await vectorSearch.upsert(tableId, rowsForUpsert); } // Поиск let results = []; if (rowsForUpsert.length > 0 && userQuestion && userQuestion.trim()) { results = await vectorSearch.search(tableId, userQuestion, 3); // Увеличиваем до 3 результатов для лучшего поиска console.log(`[RAG] Search completed, got ${results.length} results`); // Подробное логирование результатов поиска results.forEach((result, index) => { console.log(`[RAG] Search result ${index}:`, { row_id: result.row_id, score: result.score, metadata: result.metadata }); }); } else { console.log(`[RAG] No data in table, skipping search`); } // Фильтрация по тегам/продукту let filtered = results; // console.log(`[RAG] Before filtering: ${filtered.length} results`); 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}`); const best = filtered.reduce((acc, row) => { if (Math.abs(row.score) <= threshold && (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, product: best?.metadata?.product, priority: best?.metadata?.priority, date: best?.metadata?.date, score: best?.score !== undefined && best?.score !== null ? Number(best.score) : null, }; console.log(`[RAG] Final result:`, result); // Кэшируем результат (используем ai-cache вместо ragCache) if (USE_AI_CACHE) { const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product); aiCache.setWithType(cacheKey, result, 'rag'); } return result; } /** * Загрузка всех плейсхолдеров и их значений из пользовательских таблиц * Возвращает объект: { placeholder1: value1, placeholder2: value2, ... } */ async function getAllPlaceholdersWithValues() { try { console.log('[RAG] Начинаем загрузку плейсхолдеров...'); // Получаем все колонки с плейсхолдерами const columns = await encryptedDb.getData('user_columns', {}); console.log(`[RAG] Получено колонок: ${columns.length}`); const columnsWithPlaceholders = columns.filter(col => col.placeholder && col.placeholder.trim() !== ''); console.log(`[RAG] Колонок с плейсхолдерами: ${columnsWithPlaceholders.length}`); if (columnsWithPlaceholders.length === 0) { console.log('[RAG] Нет колонок с плейсхолдерами'); return {}; } // Получаем значения для каждой колонки с плейсхолдером const map = {}; for (const column of columnsWithPlaceholders) { try { console.log(`[RAG] Получаем значение для плейсхолдера: ${column.placeholder} (column_id: ${column.id})`); // Получаем первое значение для этой колонки const values = await encryptedDb.getData('user_cell_values', { column_id: column.id }, 1); console.log(`[RAG] Найдено значений для ${column.placeholder}: ${values ? values.length : 0}`); if (values && values.length > 0 && values[0].value) { map[column.placeholder] = values[0].value; console.log(`[RAG] Установлено значение для ${column.placeholder}: ${values[0].value.substring(0, 50)}...`); } else { console.log(`[RAG] Нет значений для плейсхолдера ${column.placeholder}`); } } catch (error) { console.error(`[RAG] Ошибка получения значения для плейсхолдера ${column.placeholder}:`, error); } } console.log(`[RAG] Итоговый объект плейсхолдеров:`, Object.keys(map)); return map; } catch (error) { console.error('[RAG] Ошибка получения плейсхолдеров:', error); return {}; } } /** * Подставляет значения плейсхолдеров в строку (например, systemPrompt) * Пример: "Добро пожаловать, {client_name}!" => "Добро пожаловать, ООО Ромашка!" */ function replacePlaceholders(str, placeholders) { if (!str || typeof str !== 'string') return str; return str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { return key in placeholders ? placeholders[key] : match; }); } function parseIfArray(val) { if (typeof val === 'string') { try { const arr = JSON.parse(val); if (Array.isArray(arr)) return arr; } catch {} } return Array.isArray(val) ? val : (val ? [val] : []); } async function generateLLMResponse({ userQuestion, context, clarifyingAnswer, objectionAnswer, answer, systemPrompt, userTags, product, priority, date, rules, history, model }) { console.log(`[RAG] generateLLMResponse called with:`, { userQuestion, context, answer, systemPrompt: systemPrompt ? systemPrompt.substring(0, 100) + '...' : 'null', userTags, product, priority, date, model, historyLength: history ? history.length : 0 }); try { const aiAssistant = require('./ai-assistant'); // Создаем контекст беседы с RAG данными const conversationContext = createConversationContext({ userQuestion, ragAnswer: answer, ragContext: context, history, product, priority, date }, 'generateLLMResponse'); // Формируем улучшенный промпт для LLM с учетом найденной информации let prompt = `Вопрос пользователя: ${userQuestion}`; // Добавляем найденную информацию из RAG if (answer) { // Формат: делаем RAG ответ главным, вопрос - контекстом prompt = `База знаний содержит ответ:\n"${answer}"\n\nВопрос пользователя: ${userQuestion}\n\nДай пользователю этот ответ из базы знаний.`; } if (context) { prompt += `\n\nДополнительный контекст: ${context}`; } if (product) { prompt += `\n\nПродукт: ${product}`; } if (priority) { prompt += `\n\nПриоритет: ${priority}`; } if (date) { prompt += `\n\nДата: ${date}`; } // --- ДОБАВЛЕНО: подстановка плейсхолдеров --- let finalSystemPrompt = systemPrompt; if (systemPrompt && systemPrompt.includes('{')) { const placeholders = await getAllPlaceholdersWithValues(); finalSystemPrompt = replacePlaceholders(systemPrompt, placeholders); console.log(`[RAG] Подставлены плейсхолдеры в системный промпт`); } // --- КОНЕЦ ДОБАВЛЕНИЯ --- // Системный промпт полностью настраивается пользователем в /settings/ai/assistant // RAG ответ уже добавлен в prompt выше console.log(`[RAG] Сформированный промпт:`, prompt.substring(0, 200) + '...'); // Получаем ответ от AI с учетом истории беседы let llmResponse; // Формируем сообщения для LLM const messages = []; if (finalSystemPrompt) { messages.push({ role: 'system', content: finalSystemPrompt }); } for (const h of (history || [])) { if (h && h.content) { const role = h.role === 'assistant' ? 'assistant' : 'user'; messages.push({ role, content: h.content }); } } messages.push({ role: 'user', content: prompt }); try { // ✨ НОВОЕ: Используем очередь (если включена) if (USE_AI_QUEUE) { try { llmResponse = await aiQueue.addTask({ messages, model // Приоритет не используется - все запросы обрабатываются FIFO }); console.log('[RAG] LLM response from queue:', llmResponse ? llmResponse.substring(0, 100) + '...' : 'null'); return llmResponse; } catch (queueError) { console.warn('[RAG] Queue error, fallback to direct call:', queueError.message); // Fallback: если очередь переполнена и есть ответ из RAG - возвращаем его if (queueError.message.includes('переполнена') && answer) { console.log('[RAG] Возврат прямого ответа из RAG (очередь переполнена)'); return answer; } // Продолжаем к прямому вызову } } // Прямой вызов Ollama (если очередь отключена или ошибка очереди) const ollamaUrl = ollamaConfig.getBaseUrl(); const timeouts = ollamaConfig.getTimeouts(); // Логируем размер промпта для отладки const promptSize = JSON.stringify(messages).length; console.log(`[RAG] Отправка запроса в Ollama. Размер промпта: ${promptSize} символов, таймаут: ${timeouts.ollamaChat/1000}с`); // Проверяем размер промпта и предупреждаем, если он большой if (promptSize > 10000) { console.warn(`[RAG] ⚠️ Большой промпт (${promptSize} символов). Возможны проблемы с производительностью.`); } const response = await axios.post(`${ollamaUrl}/api/chat`, { model: model || ollamaConfig.getDefaultModel(), messages: messages, stream: false }, { timeout: timeouts.ollamaChat }); llmResponse = response.data.message.content; } catch (error) { const isTimeout = error.message && ( error.message.includes('timeout') || error.message.includes('ETIMEDOUT') || error.message.includes('ECONNABORTED') ); if (isTimeout) { console.warn(`[RAG] Ollama timeout после ${timeouts.ollamaChat/1000}с. Возможно, модель перегружена или контекст слишком большой.`); } else { console.error(`[RAG] Error in Ollama call:`, error.message); } // Финальный fallback - возврат ответа из RAG if (answer) { console.log('[RAG] Возврат прямого ответа из RAG (ошибка Ollama)'); return answer; } // Если был таймаут и нет ответа из RAG - возвращаем более информативное сообщение if (isTimeout) { return 'Извините, обработка запроса заняла слишком много времени. Пожалуйста, попробуйте упростить ваш вопрос или повторите попытку позже.'; } return 'Извините, произошла ошибка при генерации ответа.'; } console.log(`[RAG] LLM response generated:`, llmResponse ? (typeof llmResponse === 'string' ? llmResponse.substring(0, 100) + '...' : JSON.stringify(llmResponse).substring(0, 100) + '...') : 'null'); return llmResponse; } catch (error) { console.error(`[RAG] Error generating LLM response:`, error); return 'Извините, произошла ошибка при генерации ответа.'; } } /** * Создает контекст беседы с RAG данными */ function createConversationContext({ userQuestion, ragAnswer, ragContext, history, product, priority, date }, source = 'generic') { const context = { currentQuestion: userQuestion, ragData: { answer: ragAnswer, context: ragContext, product, priority, date }, conversationHistory: history || [], hasRagData: !!(ragAnswer || ragContext), isFollowUpQuestion: history && history.length > 0 }; console.log(`[RAG] Создан контекст беседы (${source}):`, { hasRagData: context.hasRagData, historyLength: context.conversationHistory.length, isFollowUp: context.isFollowUpQuestion }); return context; } /** * Улучшенная функция RAG с поддержкой беседы */ async function ragAnswerWithConversation({ tableId, userQuestion, product = null, threshold = 300, history = [], conversationId = null, forceReindex = false }) { console.log(`[RAG] ragAnswerWithConversation: tableId=${tableId}, question="${userQuestion}", historyLength=${history.length}`); // Получаем базовый RAG результат const ragResult = await ragAnswer({ tableId, userQuestion, product, threshold, forceReindex }); // Анализируем контекст беседы const conversationContext = createConversationContext({ userQuestion, ragAnswer: ragResult.answer, ragContext: ragResult.context, history, product: ragResult.product, priority: ragResult.priority, date: ragResult.date }, 'ragAnswerWithConversation'); // Если это уточняющий вопрос и есть история if (conversationContext.isFollowUpQuestion && conversationContext.hasRagData) { console.log(`[RAG] Обнаружен уточняющий вопрос с RAG данными`); // Проверяем, есть ли точный ответ в первом поиске if (ragResult.answer && typeof ragResult.score === 'number' && Math.abs(ragResult.score) <= threshold) { console.log(`[RAG] Найден точный ответ (score=${ragResult.score}), возвращаем ответ из базы без модификаций`); return { ...ragResult, // Возвращаем чистый ответ answer: ragResult.answer, conversationContext, isFollowUp: true }; } // Модифицируем вопрос с учетом контекста (only if no confident match) const contextualQuestion = `${userQuestion}\n\nКонтекст предыдущих ответов: ${history.map(msg => msg.content).join('\n')}`; // Повторяем поиск с контекстуализированным вопросом const contextualRagResult = await ragAnswer({ tableId, userQuestion: contextualQuestion, product, threshold, forceReindex }); // Объединяем результаты return { ...contextualRagResult, conversationContext, isFollowUp: true }; } return { ...ragResult, conversationContext, isFollowUp: false }; } // ✨ НОВОЕ: Функция для запуска AI Queue Worker function startQueueWorker() { if (USE_AI_QUEUE) { aiQueue.startWorker(); logger.info('[RAG] ✅ AI Queue Worker запущен из ragService'); } else { logger.info('[RAG] AI Queue отключена (USE_AI_QUEUE=false)'); } } // ✨ НОВОЕ: Функция для остановки AI Queue Worker function stopQueueWorker() { if (aiQueue && aiQueue.workerInterval) { aiQueue.stopWorker(); logger.info('[RAG] ⏹️ AI Queue Worker остановлен'); } } // ✨ НОВОЕ: Получение статистики function getQueueStats() { return aiQueue.getStats(); } function getCacheStats() { return { ...aiCache.getStats(), byType: aiCache.getStatsByType() }; } module.exports = { ragAnswer, getTableData, generateLLMResponse, ragAnswerWithConversation, startQueueWorker, // ✨ НОВОЕ stopQueueWorker, // ✨ НОВОЕ getQueueStats, // ✨ НОВОЕ getCacheStats // ✨ НОВОЕ };