/** * 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 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 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'); // Кэш для плейсхолдеров таблиц 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 всех строк на каждый запрос поиска // Теперь из настроек ai_config let RAG_BEHAVIOR = null; // Флаги для включения/выключения 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(); // Загружаем RAG поведение из настроек async function getRAGBehavior() { if (!RAG_BEHAVIOR) { RAG_BEHAVIOR = await aiConfigService.getRAGBehavior(); } return RAG_BEHAVIOR; } async function getTableData(tableId) { const columns = await encryptedDb.getData('user_columns', { table_id: tableId }); const rows = await encryptedDb.getData('user_rows', { table_id: tableId }); const cellValues = rows.length > 0 ? await encryptedDb.getData('user_cell_values', { row_id: { $in: rows.map(row => row.id) } }) : []; 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'); 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, }; return result; }); return data; } /** * Получить строки таблицы с фильтрацией по тегам пользователя * @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, userId, userTagIds); const cached = aiCache.getWithTTL(cacheKey, 'rag'); if (cached) { console.log(`[RAG] Возврат RAG результата из кэша (userId=${userId}, tagIds=${userTagIds ? userTagIds.join(',') : 'null'})`); return cached; } } // Фильтрация по тегам пользователя ДО получения данных const filteredRowIds = await getFilteredRowIdsByTags(tableId, userId); const data = await getTableData(tableId); // Применяем фильтрацию по тегам, если есть отфильтрованные строки 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 = filteredData.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question); // Фильтруем только строки с непустым вопросом (text) const rowsForUpsert = filteredData .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 } })); // Выполняем 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, 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, score: result.score, metadata: result.metadata }); }); } else { console.log(`[RAG] No data in table, skipping search`); } // Фильтрация по тегам пользователя (если была применена) // Если мы уже отфильтровали данные по тегам, нужно отфильтровать результаты векторного поиска let filtered = 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) { filtered = filtered.filter(row => Array.isArray(row.metadata.product) ? row.metadata.product.includes(product) : row.metadata.product === product); } // Берём ближайший результат с учётом порога (по модулю) console.log(`[RAG] Looking for best result with abs(threshold): ${finalThreshold}`); const best = filtered.reduce((acc, row) => { 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); 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) // Используем те же tagIds, что и для проверки кэша if (USE_AI_CACHE) { 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, ... } * @param {Array} selectedRagTables - Массив ID выбранных RAG таблиц для фильтрации */ async function getAllPlaceholdersWithValues(selectedRagTables = []) { try { console.log('[RAG] Начинаем загрузку плейсхолдеров...'); // Получаем колонки с плейсхолдерами let columns = await encryptedDb.getData('user_columns', {}); // Фильтруем по выбранным RAG таблицам, если они указаны if (selectedRagTables && selectedRagTables.length > 0) { columns = columns.filter(col => selectedRagTables.includes(col.table_id)); console.log(`[RAG] Фильтруем по RAG таблицам: ${selectedRagTables.join(', ')}`); } 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] : []); } /** * Выполнить 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, clarifyingAnswer, objectionAnswer, answer, systemPrompt, userTags, product, priority, date, rules, history, model, selectedRagTables, userId = null, // Добавляем userId для function calling multiSourceResults = null, // Результаты мульти-источникового поиска userProfile = null }) { 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, userProfile: userProfile ? { name: userProfile.name, tags: userProfile.tags, nameMissing: userProfile.nameMissing } : null }); try { const aiAssistant = require('./ai-assistant'); // Создаем контекст беседы с RAG данными const conversationContext = createConversationContext({ userQuestion, ragAnswer: answer, ragContext: context, history, product, priority, date }, 'generateLLMResponse'); const conversationSummary = buildConversationSummary(history, { maxMessages: 12, maxChars: 700, snippetLength: 160 }); const summaryPrefix = conversationSummary ? `Краткая сводка предыдущего диалога:\n${conversationSummary}\n\n` : ''; // Формируем улучшенный промпт для LLM с учетом найденной информации let prompt = ''; // Если есть результаты мульти-источникового поиска, используем их 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 = `${summaryPrefix}База знаний содержит ответ:\n"${answer}"\n\nВопрос пользователя: ${userQuestion}\n\nДай пользователю этот ответ из базы знаний.`; } if (!prompt) { prompt = `${summaryPrefix}Вопрос пользователя: ${userQuestion}`; } if (context && !multiSourceResults) { prompt += `\n\nДополнительный контекст: ${context}`; } if (product) { prompt += `\n\nПродукт: ${product}`; } if (priority) { prompt += `\n\nПриоритет: ${priority}`; } if (date) { 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 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 выше console.log(`[RAG] Сформированный промпт:`, prompt.substring(0, 200) + '...'); // Получаем ответ от AI с учетом истории беседы let llmResponse; // Формируем сообщения для LLM const messages = []; if (finalSystemPrompt) { messages.push({ role: 'system', content: finalSystemPrompt }); } 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 }); } } 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 { // ✨ НОВОЕ: Используем очередь (если включена) // ВАЖНО: Function calling не поддерживается в очереди, поэтому если tools нужны - используем прямой вызов if (USE_AI_QUEUE && !userId) { try { llmResponse = await aiQueue.addTask({ messages, model: requestBody.model, // Передаем параметры для очереди llmParameters, qwenParameters }); 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 promptSize = JSON.stringify(messages).length; 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) { logger.warn(`[RAG] ⚠️ Большой промпт (${promptSize} символов). Возможны проблемы с производительностью.`); } if (promptSize > 50000) { logger.error(`[RAG] ⚠️⚠️ ОЧЕНЬ БОЛЬШОЙ промпт (${promptSize} символов). Модель может не справиться.`); } // Логируем информацию о 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 }); // Предупреждение если запрос занял слишком много времени 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 && ( error.message.includes('timeout') || error.message.includes('ETIMEDOUT') || error.message.includes('ECONNABORTED') ); logger.error(`[RAG] Ошибка при вызове Ollama:`, { message: error.message, code: error.code, isTimeout, stack: error.stack }); if (isTimeout) { logger.warn(`[RAG] Ollama timeout после ${timeouts.ollamaChat/1000}с. Возможно, модель перегружена или контекст слишком большой.`); } else { logger.error(`[RAG] Error in Ollama call:`, error.message, error.stack); } // Финальный fallback - возврат ответа из RAG if (answer) { logger.info('[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 'Извините, произошла ошибка при генерации ответа.'; } } 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 данными */ 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 = null, history = [], conversationId = null, forceReindex = false }) { // Загружаем настройки 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 результат (с фильтрацией по тегам, если userId передан) const ragResult = await ragAnswer({ tableId, userQuestion, product, threshold: finalThreshold, forceReindex, userId }); // Анализируем контекст беседы 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) <= finalThreshold) { 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: finalThreshold, forceReindex, userId }); // Объединяем результаты 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, getAllPlaceholdersWithValues, // Плейсхолдеры столбцов (значения из первой строки) getTablePlaceholders, // Плейсхолдеры таблиц (генерируются на лету) invalidateTablePlaceholdersCache, // Инвалидация кэша плейсхолдеров таблиц replacePlaceholders, // Функция подстановки плейсхолдеров generatePlaceholder // Функция генерации плейсхолдера из названия };