/** * Copyright (c) 2024-2026 Тарабанов Александр Викторович * 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 logger = require('../utils/logger'); const ollamaConfig = require('./ollamaConfig'); const { shouldProcessWithAI } = require('../utils/languageFilter'); const userContextService = require('./userContextService'); /** * AI Assistant - тонкая обёртка для работы с Ollama и RAG * Основная логика вынесена в отдельные сервисы: * - ragService.js - генерация ответов через RAG * - aiAssistantSettingsService.js - настройки ИИ * - aiAssistantRulesService.js - правила ИИ * - messageDeduplicationService.js - дедупликация сообщений * - ai-queue.js - управление очередью (отдельный сервис) */ class AIAssistant { constructor() { this.baseUrl = null; this.defaultModel = null; this.isInitialized = false; } /** * Инициализация из БД */ async initialize() { try { await ollamaConfig.loadSettingsFromDb(); this.baseUrl = ollamaConfig.getBaseUrl(); this.defaultModel = ollamaConfig.getDefaultModel(); if (!this.baseUrl || !this.defaultModel) { throw new Error('Настройки Ollama не найдены в БД'); } this.isInitialized = true; logger.info(`[AIAssistant] ✅ Инициализирован из БД: model=${this.defaultModel}`); } catch (error) { logger.error('[AIAssistant] ❌ КРИТИЧЕСКАЯ ОШИБКА загрузки настроек из БД:', error.message); throw error; } } /** * Генерация ответа для всех каналов (web, telegram, email) * Используется ботами (telegramBot, emailBot) */ async generateResponse(options) { const { channel, messageId, userId, userQuestion, conversationHistory = [], conversationId, ragTableId = null, metadata = {} } = options; try { logger.info(`[AIAssistant] Генерация ответа для канала ${channel}, пользователь ${userId}`); // 0. Проверяем язык сообщения (только русский) const languageCheck = shouldProcessWithAI(userQuestion); if (!languageCheck.shouldProcess) { logger.info(`[AIAssistant] ⚠️ Пропуск обработки: ${languageCheck.reason} (user: ${userId}, channel: ${channel})`); return { success: false, reason: languageCheck.reason, skipped: true, message: 'AI обрабатывает только сообщения на русском языке' }; } const messageDeduplicationService = require('./messageDeduplicationService'); const aiAssistantSettingsService = require('./aiAssistantSettingsService'); const aiAssistantRulesService = require('./aiAssistantRulesService'); const profileAnalysisService = require('./profileAnalysisService'); const { ragAnswer } = require('./ragService'); // 1. Проверяем дедупликацию через хеш const messageForDedup = { userId, content: userQuestion, channel }; const isDuplicate = await messageDeduplicationService.isDuplicate(messageForDedup); if (isDuplicate) { logger.info(`[AIAssistant] Сообщение уже обработано - пропускаем`); return { success: false, reason: 'duplicate' }; } // Помечаем как обработанное await messageDeduplicationService.markAsProcessed(messageForDedup); // 1.5. Анализ профиля пользователя и автоматическое обновление (если не гость) let userTags = null; let userNameForProfile = null; let shouldAskForName = false; let profileAnalysis = null; if (userId && !userContextService.isGuestId(userId)) { 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. Определяем 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. Выполняем мульти-источниковый поиск (таблицы + документы) 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: tableIds[0], userQuestion, userId: userId }); } } } // 5. Генерируем LLM ответ const { generateLLMResponse } = require('./ragService'); // Получаем актуальную информацию о пользователе для LLM if (!userNameForProfile && userId && !userContextService.isGuestId(userId)) { 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 || '', answer: ragResult?.answer || '', systemPrompt: aiSettings ? aiSettings.system_prompt : '', history: conversationHistory, model: aiSettings ? aiSettings.model : undefined, rules: rules ? rules.rules : null, 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}, длина: ${typeof aiResponse === 'string' ? aiResponse.length : JSON.stringify(aiResponse).length} символов`); return { success: true, response: aiResponse, ragData: ragResult, messageId: messageId, conversationId: conversationId }; } catch (error) { logger.error(`[AIAssistant] Ошибка генерации ответа:`, error); return { success: false, reason: 'error', error: error.message }; } } /** * Простая генерация ответа (для гостевых сообщений) * Используется в UniversalGuestService */ async getResponse(message, history = null, systemPrompt = '', rules = null) { try { const { generateLLMResponse } = require('./ragService'); const result = await generateLLMResponse({ userQuestion: message, context: '', answer: '', systemPrompt: systemPrompt || '', history: history || [], model: undefined, rules: rules }); return result; } catch (error) { logger.error('[AIAssistant] Ошибка в getResponse:', error); return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.'; } } /** * Проверка здоровья AI сервиса * Использует централизованный метод из ollamaConfig */ async checkHealth() { if (!this.isInitialized) { return { status: 'error', error: 'AI Assistant не инициализирован' }; } // Используем метод проверки из ollamaConfig return await ollamaConfig.checkHealth(); } } const aiAssistantInstance = new AIAssistant(); const initPromise = aiAssistantInstance.initialize(); module.exports = aiAssistantInstance; module.exports.initPromise = initPromise;