feat: новая функция
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user