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

This commit is contained in:
2025-10-08 18:01:14 +03:00
parent 2c53bce32a
commit 725e7fd5a2
60 changed files with 5427 additions and 3921 deletions

View File

@@ -10,439 +10,186 @@
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const { ChatOllama } = require('@langchain/ollama');
const aiCache = require('./ai-cache');
const AIQueue = require('./ai-queue');
const logger = require('../utils/logger');
const ollamaConfig = require('./ollamaConfig');
// Константы для AI параметров
const AI_CONFIG = {
temperature: 0.3,
maxTokens: 512,
timeout: 120000, // Уменьшаем до 120 секунд, чтобы соответствовать EmailBot
numCtx: 2048,
numGpu: 1,
numThread: 4,
repeatPenalty: 1.1,
topK: 40,
topP: 0.9,
// tfsZ не поддерживается в текущем Ollama — удаляем
mirostat: 2,
mirostatTau: 5,
mirostatEta: 0.1,
seed: -1,
// Ограничим количество генерируемых токенов для CPU, чтобы избежать таймаутов
numPredict: 256,
stop: []
};
/**
* AI Assistant - тонкая обёртка для работы с Ollama и RAG
* Основная логика вынесена в отдельные сервисы:
* - ragService.js - генерация ответов через RAG
* - aiAssistantSettingsService.js - настройки ИИ
* - aiAssistantRulesService.js - правила ИИ
* - messageDeduplicationService.js - дедупликация сообщений
* - ai-queue.js - управление очередью (отдельный сервис)
*/
class AIAssistant {
constructor() {
this.baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
this.defaultModel = process.env.OLLAMA_MODEL || 'qwen2.5:7b';
this.lastHealthCheck = 0;
this.healthCheckInterval = 300000; // 5 минут (увеличено с 30 секунд для уменьшения логов)
// Создаем экземпляр AIQueue
this.aiQueue = new AIQueue();
this.isProcessingQueue = false;
// Запускаем обработку очереди
this.startQueueProcessing();
this.baseUrl = null;
this.defaultModel = null;
this.isInitialized = false;
}
// Запуск обработки очереди
async startQueueProcessing() {
if (this.isProcessingQueue) return;
this.isProcessingQueue = true;
logger.info('[AIAssistant] Запущена обработка очереди AIQueue');
while (this.isProcessingQueue) {
try {
// Получаем следующий запрос из очереди
const requestItem = this.aiQueue.getNextRequest();
if (!requestItem) {
// Если очередь пуста, ждем немного
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
}
logger.info(`[AIAssistant] Обрабатываем запрос ${requestItem.id} из очереди`);
// Обновляем статус на "processing"
this.aiQueue.updateRequestStatus(requestItem.id, 'processing');
const startTime = Date.now();
try {
// Обрабатываем запрос
const result = await this.processQueueRequest(requestItem.request);
const responseTime = Date.now() - startTime;
// Обновляем статус на "completed"
this.aiQueue.updateRequestStatus(requestItem.id, 'completed', result, null, responseTime);
logger.info(`[AIAssistant] Запрос ${requestItem.id} завершен за ${responseTime}ms`);
} catch (error) {
const responseTime = Date.now() - startTime;
// Обновляем статус на "failed"
this.aiQueue.updateRequestStatus(requestItem.id, 'failed', null, error.message, responseTime);
logger.error(`[AIAssistant] Запрос ${requestItem.id} завершился с ошибкой:`, error.message);
logger.error(`[AIAssistant] Детали ошибки:`, error.stack || error);
}
} catch (error) {
logger.error('[AIAssistant] Ошибка в обработке очереди:', error);
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}
// Остановка обработки очереди
stopQueueProcessing() {
this.isProcessingQueue = false;
logger.info('[AIAssistant] Остановлена обработка очереди AIQueue');
}
// Обработка запроса из очереди
async processQueueRequest(request) {
/**
* Инициализация из БД
*/
async initialize() {
try {
const { message, history, systemPrompt, rules } = request;
await ollamaConfig.loadSettingsFromDb();
// Используем прямой запрос к API, а не getResponse (чтобы избежать цикла)
const result = await this.directRequest(
[{ role: 'user', content: message }],
systemPrompt,
{ temperature: 0.3, maxTokens: 150 }
);
this.baseUrl = ollamaConfig.getBaseUrl();
this.defaultModel = ollamaConfig.getDefaultModel();
return result;
if (!this.baseUrl || !this.defaultModel) {
throw new Error('Настройки Ollama не найдены в БД');
}
this.isInitialized = true;
logger.info(`[AIAssistant] ✅ Инициализирован из БД: model=${this.defaultModel}`);
} catch (error) {
logger.error(`[AIAssistant] Ошибка в processQueueRequest:`, error.message);
logger.error(`[AIAssistant] Stack trace:`, error.stack);
throw error; // Перебрасываем ошибку дальше
}
}
// Добавление запроса в очередь
async addToQueue(request, priority = 0) {
return await this.aiQueue.addRequest(request, priority);
}
// Получение статистики очереди
getQueueStats() {
return this.aiQueue.getStats();
}
// Получение размера очереди
getQueueSize() {
return this.aiQueue.getQueueSize();
}
// Проверка здоровья модели
async checkModelHealth() {
const now = Date.now();
if (now - this.lastHealthCheck < this.healthCheckInterval) {
return true; // Используем кэшированный результат
}
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
if (!response.ok) {
throw new Error(`Ollama API returned ${response.status}`);
}
const data = await response.json();
const modelExists = data.models?.some(model => model.name === this.defaultModel);
this.lastHealthCheck = now;
return modelExists;
} catch (error) {
logger.error('Model health check failed:', error);
return false;
}
}
// Очистка старого кэша
cleanupCache() {
const now = Date.now();
const maxAge = 3600000; // 1 час
aiCache.cleanup(maxAge);
}
// Создание чата с кастомным системным промптом
createChat(customSystemPrompt = '') {
let systemPrompt = customSystemPrompt;
if (!systemPrompt) {
systemPrompt = 'Вы - полезный ассистент. Отвечайте на русском языке кратко и по делу.';
}
return new ChatOllama({
baseUrl: this.baseUrl,
model: this.defaultModel,
system: systemPrompt,
...AI_CONFIG,
options: AI_CONFIG
});
}
// Определение приоритета запроса
getRequestPriority(message, history, rules) {
let priority = 0;
// Высокий приоритет для коротких запросов
if (message.length < 50) {
priority += 10;
}
// Приоритет по типу запроса
const urgentKeywords = ['срочно', 'важно', 'помоги'];
if (urgentKeywords.some(keyword => message.toLowerCase().includes(keyword))) {
priority += 20;
}
// Приоритет для администраторов
if (rules && rules.isAdmin) {
priority += 15;
}
// Приоритет по времени ожидания (если есть история)
if (history && history.length > 0) {
const lastMessage = history[history.length - 1];
const timeDiff = Date.now() - (lastMessage.timestamp || Date.now());
if (timeDiff > 30000) { // Более 30 секунд ожидания
priority += 5;
}
}
return priority;
}
// Основной метод для получения ответа
async getResponse(message, history = null, systemPrompt = '', rules = null) {
try {
// Очищаем старый кэш
this.cleanupCache();
// Проверяем здоровье модели
const isHealthy = await this.checkModelHealth();
if (!isHealthy) {
return 'Извините, модель временно недоступна. Пожалуйста, попробуйте позже.';
}
// Проверяем кэш
const cacheKey = aiCache.generateKey([{ role: 'user', content: message }], {
temperature: 0.3,
maxTokens: 150
});
const cachedResponse = aiCache.get(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
// Определяем приоритет запроса
const priority = this.getRequestPriority(message, history, rules);
// Добавляем запрос в очередь
const requestId = await this.addToQueue({
message,
history,
systemPrompt,
rules
}, priority);
// Ждем результат из очереди
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Request timeout - очередь перегружена'));
}, 180000); // 180 секунд таймаут для очереди
const onCompleted = (item) => {
if (item.id === requestId) {
clearTimeout(timeout);
this.aiQueue.off('requestCompleted', onCompleted);
this.aiQueue.off('requestFailed', onFailed);
try {
aiCache.set(cacheKey, item.result);
} catch {}
resolve(item.result);
}
};
const onFailed = (item) => {
if (item.id === requestId) {
clearTimeout(timeout);
this.aiQueue.off('requestCompleted', onCompleted);
this.aiQueue.off('requestFailed', onFailed);
reject(new Error(item.error));
}
};
this.aiQueue.on('requestCompleted', onCompleted);
this.aiQueue.on('requestFailed', onFailed);
});
} catch (error) {
logger.error('Error in getResponse:', error);
return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.';
}
}
// Алиас для getResponse (для совместимости)
async processMessage(message, history = null, systemPrompt = '', rules = null) {
return this.getResponse(message, history, systemPrompt, rules);
}
// Прямой запрос к API (для очереди)
async directRequest(messages, systemPrompt = '', optionsOverride = {}) {
try {
const model = this.defaultModel;
logger.info(`[AIAssistant] directRequest: модель=${model}, сообщений=${messages?.length || 0}, systemPrompt="${systemPrompt?.substring(0, 50)}..."`);
// Создаем AbortController для таймаута
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), AI_CONFIG.timeout);
// Маппинг camelCase → snake_case для опций Ollama
const mapOptionsToOllama = (opts) => ({
temperature: opts.temperature,
// Используем только num_predict; не мапим maxTokens, чтобы не завышать лимит генерации
num_predict: typeof opts.numPredict === 'number' && opts.numPredict > 0 ? opts.numPredict : undefined,
num_ctx: opts.numCtx,
num_gpu: opts.numGpu,
num_thread: opts.numThread,
repeat_penalty: opts.repeatPenalty,
top_k: opts.topK,
top_p: opts.topP,
tfs_z: opts.tfsZ,
mirostat: opts.mirostat,
mirostat_tau: opts.mirostatTau,
mirostat_eta: opts.mirostatEta,
seed: opts.seed,
stop: Array.isArray(opts.stop) ? opts.stop : []
});
const mergedConfig = { ...AI_CONFIG, ...optionsOverride };
const ollamaOptions = mapOptionsToOllama(mergedConfig);
// Вставляем системный промпт в начало, если задан
const finalMessages = Array.isArray(messages) ? [...messages] : [];
// Нормализация: только 'user' | 'assistant' | 'system'
for (const m of finalMessages) {
if (m && m.role) {
if (m.role !== 'assistant' && m.role !== 'system') m.role = 'user';
}
}
if (systemPrompt && !finalMessages.find(m => m.role === 'system')) {
finalMessages.unshift({ role: 'system', content: systemPrompt });
}
let response;
try {
logger.info(`[AIAssistant] Вызываю Ollama API: ${this.baseUrl}/api/chat`);
response = await fetch(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
model,
messages: finalMessages,
stream: false,
options: ollamaOptions
})
});
logger.info(`[AIAssistant] Ollama API ответил: status=${response.status}`);
} finally {
clearTimeout(timeoutId);
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Ollama /api/chat возвращает ответ в data.message.content
if (data.message && typeof data.message.content === 'string') {
const content = data.message.content;
try {
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
aiCache.set(cacheKey, content);
} catch {}
return content;
}
// OpenAI-совместимый /v1/chat/completions
if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) {
const content = data.choices[0].message.content;
try {
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
aiCache.set(cacheKey, content);
} catch {}
return content;
}
const content = data.response || '';
try {
const cacheKey = aiCache.generateKey(messages, { num_predict: ollamaOptions.num_predict, temperature: ollamaOptions.temperature });
aiCache.set(cacheKey, content);
} catch {}
return content;
} catch (error) {
logger.error('Error in directRequest:', error);
if (error.name === 'AbortError') {
throw new Error('Request timeout - модель не ответила в течение 120 секунд');
}
logger.error('[AIAssistant] ❌ КРИТИЧЕСКАЯ ОШИБКА загрузки настроек из БД:', error.message);
throw error;
}
}
// Получение списка доступных моделей
async getAvailableModels() {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
const data = await response.json();
return data.models || [];
} catch (error) {
logger.error('Error getting available models:', error);
return [];
}
}
/**
* Генерация ответа для всех каналов (web, telegram, email)
* Используется ботами (telegramBot, emailBot)
*/
async generateResponse(options) {
const {
channel,
messageId,
userId,
userQuestion,
conversationHistory = [],
conversationId,
ragTableId = null,
metadata = {}
} = options;
// Проверка здоровья AI сервиса
async checkHealth() {
try {
const response = await fetch(`${this.baseUrl}/api/tags`);
if (!response.ok) {
throw new Error(`Ollama API returned ${response.status}`);
logger.info(`[AIAssistant] Генерация ответа для канала ${channel}, пользователь ${userId}`);
const messageDeduplicationService = require('./messageDeduplicationService');
const aiAssistantSettingsService = require('./aiAssistantSettingsService');
const aiAssistantRulesService = require('./aiAssistantRulesService');
const { ragAnswer } = require('./ragService');
// 1. Проверяем дедупликацию
const cleanMessageId = messageDeduplicationService.cleanMessageId(messageId, channel);
const isAlreadyProcessed = await messageDeduplicationService.isMessageAlreadyProcessed(
channel,
cleanMessageId,
userId,
'user'
);
if (isAlreadyProcessed) {
logger.info(`[AIAssistant] Сообщение ${cleanMessageId} уже обработано - пропускаем`);
return { success: false, reason: 'duplicate' };
}
const data = await response.json();
// 2. Получаем настройки AI ассистента
const aiSettings = await aiAssistantSettingsService.getSettings();
let rules = null;
if (aiSettings && aiSettings.rules_id) {
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
}
// 3. Генерируем AI ответ через RAG
const aiResponse = await ragAnswer({
userQuestion,
conversationHistory,
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
rules: rules ? rules.rules : null,
ragTableId
});
if (!aiResponse) {
logger.warn(`[AIAssistant] Пустой ответ от AI для пользователя ${userId}`);
return { success: false, reason: 'empty_response' };
}
// 4. Сохраняем ответ с дедупликацией
const aiResponseId = `ai_response_${cleanMessageId}_${Date.now()}`;
const saveResult = await messageDeduplicationService.saveMessageWithDeduplication(
{
user_id: userId,
conversation_id: conversationId,
sender_type: 'assistant',
content: aiResponse,
channel: channel,
role: 'assistant',
direction: 'out',
created_at: new Date(),
...metadata
},
channel,
aiResponseId,
userId,
'assistant',
'messages'
);
if (!saveResult.success) {
logger.error(`[AIAssistant] Ошибка сохранения AI ответа:`, saveResult.error);
return { success: false, reason: 'save_error' };
}
logger.info(`[AIAssistant] AI ответ успешно сгенерирован и сохранен для пользователя ${userId}`);
return {
status: 'ok',
models: data.models?.length || 0,
baseUrl: this.baseUrl
success: true,
response: aiResponse,
messageId: aiResponseId,
conversationId: conversationId
};
} catch (error) {
logger.error('AI health check failed:', error);
return {
status: 'error',
error: error.message,
baseUrl: this.baseUrl
};
logger.error(`[AIAssistant] Ошибка генерации ответа:`, error);
return { success: false, reason: 'error', error: error.message };
}
}
// Добавляем методы из vectorStore.js
async initVectorStore() {
// ... код инициализации ...
/**
* Простая генерация ответа (для гостевых сообщений)
* Используется в guestMessageService
*/
async getResponse(message, history = null, systemPrompt = '', rules = null) {
try {
const { ragAnswer } = require('./ragService');
const result = await ragAnswer({
userQuestion: message,
conversationHistory: history || [],
systemPrompt: systemPrompt || '',
rules: rules || null,
ragTableId: null
});
return result;
} catch (error) {
logger.error('[AIAssistant] Ошибка в getResponse:', error);
return 'Извините, я не смог обработать ваш запрос. Пожалуйста, попробуйте позже.';
}
}
async findSimilarDocuments(query, k = 3) {
// ... код поиска документов ...
/**
* Проверка здоровья AI сервиса
* Использует централизованный метод из ollamaConfig
*/
async checkHealth() {
if (!this.isInitialized) {
return { status: 'error', error: 'AI Assistant не инициализирован' };
}
// Используем метод проверки из ollamaConfig
return await ollamaConfig.checkHealth();
}
}
module.exports = new AIAssistant();
const aiAssistantInstance = new AIAssistant();
const initPromise = aiAssistantInstance.initialize();
module.exports = aiAssistantInstance;
module.exports.initPromise = initPromise;