feat: новая функция

This commit is contained in:
2025-11-06 16:24:50 +03:00
parent b3620b264b
commit 714a3f55c7
34 changed files with 5436 additions and 2433 deletions

View File

@@ -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,