380 lines
17 KiB
JavaScript
380 lines
17 KiB
JavaScript
/**
|
||
* 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;
|