23 KiB
🔧 ЗАДАЧА: Рефакторинг AI сервисов (устранение дублей + интеграция Queue/Cache)
Дата: 2025-10-09
Приоритет: ВЫСОКИЙ
Статус: 📋 В РАЗРАБОТКЕ
🎯 ЦЕЛЬ
Устранить дублирование кода и интегрировать существующие сервисы ai-queue.js и ai-cache.js в основной поток обработки.
❌ НАЙДЕННЫЕ ДУБЛИ
ДУБЛЬ #1: Кэширование ⭐⭐⭐ КРИТИЧЕСКИЙ
ragService.js (строки 20-22, 78-84, 182-185):
const ragCache = new Map(); // ❌ Примитивный дубль!
const RAG_CACHE_TTL = 5 * 60 * 1000;
// Использование:
const cached = ragCache.get(cacheKey);
ragCache.set(cacheKey, { result, timestamp: Date.now() });
ai-cache.js (весь файл, 95 строк):
class AICache {
this.cache = new Map(); // ✅ Полноценный сервис!
this.maxSize = 1000;
this.ttl = 24 * 60 * 60 * 1000;
// + управление размером
// + автоочистка
// + статистика
}
Вывод: Удалить ragCache → использовать ai-cache.js
ДУБЛЬ #2: Вызовы Ollama API ⭐⭐⭐ КРИТИЧЕСКИЙ
ragService.js (строки 358-371):
const axios = require('axios'); // ❌ Внутри функции!
const ollamaConfig = require('./ollamaConfig');
const response = await axios.post(`${ollamaUrl}/api/chat`, {
model: model || ollamaConfig.getDefaultModel(),
messages: messages,
stream: false
}, {
timeout: ollamaConfig.getTimeout()
});
Проблема: Прямой вызов → пропускается ai-queue.js
Вывод: Использовать ai-queue.addTask()
ДУБЛЬ #3: Генерация ключа кэша ⭐⭐
ragService.js:
const cacheKey = `${tableId}:${userQuestion}:${product}`; // ❌ Простая строка
ai-cache.js:
generateKey(messages, options = {}) {
return crypto.createHash('md5').update(content).digest('hex'); // ✅ MD5 хеш
}
Вывод: Использовать единый метод из ai-cache.js
ДУБЛЬ #4: Import внутри функций ⭐⭐
ragService.js (строки 359-361):
async function generateLLMResponse({...}) {
const axios = require('axios'); // ❌ Каждый раз!
const ollamaConfig = require('./ollamaConfig'); // ❌ Каждый раз!
}
Вывод: Вынести импорты наверх файла
ДУБЛЬ #5: Fallback на несуществующую очередь ⭐
ragService.js (строки 375-379):
if (error.message.includes('очередь перегружена') && answer) { // ❌ Очередь не используется!
return answer;
}
Вывод: Удалить или исправить после интеграции очереди
🔧 ПЛАН ИСПРАВЛЕНИЙ
ЭТАП 1: Доработать ai-cache.js ⭐⭐⭐
Файл: backend/services/ai-cache.js
Добавить методы:
class AICache {
constructor() {
this.cache = new Map();
this.maxSize = 1000;
this.ttl = 24 * 60 * 60 * 1000; // Default: 24 часа
this.ragTtl = 5 * 60 * 1000; // ✨ НОВОЕ: 5 минут для RAG
}
// ✨ НОВОЕ: Генерация ключа для RAG результатов
generateKeyForRAG(tableId, userQuestion, product = null) {
const content = JSON.stringify({ tableId, userQuestion, product });
return crypto.createHash('md5').update(content).digest('hex');
}
// ✨ НОВОЕ: Получение с учетом типа (RAG или LLM)
getWithTTL(key, type = 'llm') {
const cached = this.cache.get(key);
if (!cached) return null;
const ttl = type === 'rag' ? this.ragTtl : this.ttl;
if (Date.now() - cached.timestamp > ttl) {
this.cache.delete(key);
return null;
}
return cached.response;
}
// ✨ НОВОЕ: Сохранение с типом
setWithType(key, response, type = 'llm') {
// Очищаем старые записи если кэш переполнен
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, {
response,
timestamp: Date.now(),
type: type // ✨ Сохраняем тип
});
logger.info(`[AICache] Cached ${type} response for key: ${key.substring(0, 8)}...`);
}
// ✨ НОВОЕ: Инвалидация по префиксу (для RAG при обновлении таблиц)
invalidateByPrefix(prefix) {
let deletedCount = 0;
for (const [key, value] of this.cache.entries()) {
if (key.startsWith(prefix)) {
this.cache.delete(key);
deletedCount++;
}
}
if (deletedCount > 0) {
logger.info(`[AICache] Invalidated ${deletedCount} entries with prefix: ${prefix}`);
}
return deletedCount;
}
// ✨ НОВОЕ: Статистика по типу
getStatsByType() {
const stats = { rag: 0, llm: 0, other: 0 };
for (const [key, value] of this.cache.entries()) {
const type = value.type || 'other';
stats[type] = (stats[type] || 0) + 1;
}
return stats;
}
}
ЭТАП 2: Доработать ai-queue.js ⭐⭐⭐
Файл: backend/services/ai-queue.js
Добавить методы для обработки:
const axios = require('axios');
const ollamaConfig = require('./ollamaConfig');
const aiCache = require('./ai-cache');
class AIQueue extends EventEmitter {
constructor() {
super();
this.queue = [];
this.isProcessing = false; // ✨ НОВОЕ
this.maxQueueSize = 100; // ✨ НОВОЕ
this.workerInterval = null; // ✨ НОВОЕ
this.stats = {
totalAdded: 0,
totalProcessed: 0,
totalFailed: 0,
avgResponseTime: 0,
lastProcessedAt: null,
initializedAt: Date.now()
};
}
// ✨ НОВОЕ: Добавление задачи с Promise
async addTask(taskData) {
// Проверяем лимит очереди
if (this.queue.length >= this.maxQueueSize) {
throw new Error('Очередь переполнена');
}
const taskId = Date.now() + Math.random();
const priority = taskData.priority || 5;
const queueItem = {
id: taskId,
request: taskData,
priority,
status: 'queued',
timestamp: Date.now()
};
this.queue.push(queueItem);
this.queue.sort((a, b) => b.priority - a.priority);
this.stats.totalAdded++;
logger.info(`[AIQueue] Задача ${taskId} добавлена (priority: ${priority}). Очередь: ${this.queue.length}`);
this.emit('requestAdded', queueItem);
// Возвращаем Promise для ожидания результата
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Queue timeout'));
}, 120000); // 2 минуты
this.once(`task_${taskId}_completed`, (result) => {
clearTimeout(timeout);
resolve(result.response);
});
this.once(`task_${taskId}_failed`, (error) => {
clearTimeout(timeout);
reject(new Error(error.message));
});
});
}
// ✨ НОВОЕ: Запуск автоматического worker
startWorker() {
if (this.workerInterval) {
logger.warn('[AIQueue] Worker уже запущен');
return;
}
logger.info('[AIQueue] 🚀 Запуск worker для обработки очереди...');
this.workerInterval = setInterval(() => {
this.processNextTask();
}, 100); // Проверяем очередь каждые 100ms
}
// ✨ НОВОЕ: Остановка worker
stopWorker() {
if (this.workerInterval) {
clearInterval(this.workerInterval);
this.workerInterval = null;
logger.info('[AIQueue] ⏹️ Worker остановлен');
}
}
// ✨ НОВОЕ: Обработка следующей задачи
async processNextTask() {
if (this.isProcessing) return;
const task = this.getNextRequest();
if (!task) return;
this.isProcessing = true;
const startTime = Date.now();
try {
logger.info(`[AIQueue] Обработка задачи ${task.id}`);
// 1. Проверяем кэш
const cacheKey = aiCache.generateKey(task.request.messages);
const cached = aiCache.get(cacheKey);
if (cached) {
logger.info(`[AIQueue] Cache HIT для задачи ${task.id}`);
const responseTime = Date.now() - startTime;
this.updateRequestStatus(task.id, 'completed', cached, null, responseTime);
this.emit(`task_${task.id}_completed`, { response: cached, fromCache: true });
return;
}
// 2. Вызываем Ollama API
const ollamaUrl = ollamaConfig.getBaseUrl();
const timeouts = ollamaConfig.getTimeouts();
const response = await axios.post(`${ollamaUrl}/api/chat`, {
model: task.request.model || ollamaConfig.getDefaultModel(),
messages: task.request.messages,
stream: false
}, {
timeout: timeouts.ollamaChat
});
const result = response.data.message.content;
const responseTime = Date.now() - startTime;
// 3. Сохраняем в кэш
aiCache.set(cacheKey, result);
// 4. Обновляем статус
this.updateRequestStatus(task.id, 'completed', result, null, responseTime);
this.emit(`task_${task.id}_completed`, { response: result, fromCache: false });
logger.info(`[AIQueue] ✅ Задача ${task.id} выполнена за ${responseTime}ms`);
} catch (error) {
logger.error(`[AIQueue] ❌ Ошибка задачи ${task.id}:`, error.message);
this.updateRequestStatus(task.id, 'failed', null, error.message);
this.emit(`task_${task.id}_failed`, { message: error.message });
} finally {
this.isProcessing = false;
}
}
}
ЭТАП 3: Рефакторинг ragService.js ⭐⭐⭐
Файл: backend/services/ragService.js
Изменения:
3.1. Вынести импорты наверх (строки 13-23)
Было:
const encryptedDb = require('./encryptedDatabaseService');
const vectorSearch = require('./vectorSearchClient');
const logger = require('../utils/logger');
// Простой кэш для RAG результатов
const ragCache = new Map(); // ❌ УДАЛИТЬ!
const RAG_CACHE_TTL = 5 * 60 * 1000; // ❌ УДАЛИТЬ!
Стало:
const encryptedDb = require('./encryptedDatabaseService');
const vectorSearch = require('./vectorSearchClient');
const logger = require('../utils/logger');
const axios = require('axios'); // ✨ НОВОЕ
const ollamaConfig = require('./ollamaConfig'); // ✨ НОВОЕ
const aiCache = require('./ai-cache'); // ✨ НОВОЕ
const aiQueue = require('./ai-queue'); // ✨ НОВОЕ
// Флаги для включения/выключения
const USE_AI_CACHE = process.env.USE_AI_CACHE !== 'false'; // default: true
const USE_AI_QUEUE = process.env.USE_AI_QUEUE !== 'false'; // default: true
3.2. Заменить ragCache на ai-cache (строки 78-84)
Было:
const cacheKey = `${tableId}:${userQuestion}:${product}`;
const cached = ragCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < RAG_CACHE_TTL) {
return cached.result;
}
Стало:
// Используем ai-cache с коротким TTL для RAG
const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product);
const cached = aiCache.getWithTTL(cacheKey, 'rag');
if (cached) {
console.log('[RAG] Возврат из кэша');
return cached;
}
3.3. Заменить ragCache.set() (строки 182-185)
Было:
ragCache.set(cacheKey, {
result,
timestamp: Date.now()
});
Стало:
// Сохраняем в ai-cache с типом 'rag'
aiCache.setWithType(cacheKey, result, 'rag');
3.4. Заменить прямой вызов Ollama на очередь (строки 358-383)
Было:
async function generateLLMResponse({...}) {
// ...
try {
const axios = require('axios'); // ❌ Внутри!
const ollamaConfig = require('./ollamaConfig'); // ❌ Внутри!
const response = await axios.post(`${ollamaUrl}/api/chat`, {...});
llmResponse = response.data.message.content;
} catch (error) {
// ...
}
}
Стало:
async function generateLLMResponse({
userQuestion,
context,
answer,
systemPrompt,
history,
model,
metadata = {}
}) {
try {
// Формируем сообщения для LLM
const messages = [];
const finalSystemPrompt = systemPrompt || 'Ты — ИИ-ассистент для бизнеса. Отвечай кратко и по делу';
if (finalSystemPrompt) {
messages.push({ role: 'system', content: finalSystemPrompt });
}
for (const h of (history || [])) {
if (h && h.content) {
const role = h.role === 'assistant' ? 'assistant' : 'user';
messages.push({ role, content: h.content });
}
}
// Формируем финальный промпт
let prompt = `Вопрос пользователя: ${userQuestion}`;
if (answer) prompt += `\n\nНайденный ответ из базы знаний: ${answer}`;
if (context) prompt += `\n\nДополнительный контекст: ${context}`;
messages.push({ role: 'user', content: prompt });
// ✨ НОВОЕ: Определяем приоритет
const priority = metadata.isAdmin ? 10 : metadata.isGuest ? 1 : 5;
let llmResponse;
// ✨ НОВОЕ: Используем очередь (если включена)
if (USE_AI_QUEUE) {
try {
llmResponse = await aiQueue.addTask({
messages,
model,
priority,
metadata
});
console.log('[RAG] LLM response from queue:', llmResponse?.substring(0, 100) + '...');
return llmResponse;
} catch (queueError) {
logger.warn('[RAG] Queue error, fallback to direct call:', queueError.message);
// Fallback: если очередь перегружена и есть ответ из RAG - возвращаем его
if (queueError.message.includes('переполнена') && answer) {
logger.info('[RAG] Возврат прямого ответа из RAG (очередь переполнена)');
return answer;
}
// Иначе пробуем прямой вызов (без очереди)
// Продолжаем к прямому вызову ниже
}
}
// Прямой вызов (если очередь отключена или ошибка)
try {
const ollamaUrl = ollamaConfig.getBaseUrl();
const timeouts = ollamaConfig.getTimeouts();
const response = await axios.post(`${ollamaUrl}/api/chat`, {
model: model || ollamaConfig.getDefaultModel(),
messages,
stream: false
}, {
timeout: timeouts.ollamaChat
});
llmResponse = response.data.message.content;
console.log('[RAG] LLM response (direct):', llmResponse?.substring(0, 100) + '...');
return llmResponse;
} catch (error) {
console.error('[RAG] Error in direct Ollama call:', error.message);
// Финальный fallback - возврат ответа из RAG
if (answer) {
logger.info('[RAG] Возврат прямого ответа из RAG (ошибка Ollama)');
return answer;
}
return 'Извините, произошла ошибка при генерации ответа.';
}
} catch (error) {
console.error('[RAG] Critical error in generateLLMResponse:', error);
return 'Извините, произошла ошибка при генерации ответа.';
}
}
ЭТАП 4: Запустить Worker в server.js
Файл: backend/server.js
Добавить после инициализации BotManager:
// Запускаем AI Queue Worker
const aiQueue = require('./services/ai-queue');
const aiQueueInstance = new aiQueue();
aiQueueInstance.startWorker();
logger.info('[Server] ✅ AI Queue Worker запущен');
// Graceful shutdown
process.on('SIGTERM', () => {
aiQueueInstance.stopWorker();
process.exit(0);
});
ЭТАП 5: Передать метаданные из основного потока
5.1. ai-assistant.js (строка ~120)
Добавить в вызов generateLLMResponse:
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,
metadata: { // ✨ НОВОЕ
isAdmin: metadata?.isAdmin || false,
isGuest: metadata?.isGuest || false,
channel: channel
}
});
5.2. UniversalGuestService.js (строка ~350)
Передать metadata:
const aiResponse = await aiAssistant.generateResponse({
channel: channel,
messageId: `guest_${identifier}_${Date.now()}`,
userId: identifier,
userQuestion: fullMessageContent,
conversationHistory: conversationHistory,
metadata: {
isGuest: true, // ✅ Уже есть
priority: 1, // ✨ НОВОЕ - низкий приоритет для гостей
hasMedia: !!processedContent,
mediaSummary: processedContent?.summary
}
});
5.3. unifiedMessageProcessor.js (строка ~203)
Передать metadata:
aiResponse = await aiAssistant.generateResponse({
channel,
messageId: userMessageId,
userId: userId,
userQuestion: content,
conversationHistory,
conversationId,
metadata: {
hasAttachments: attachments.length > 0,
channel,
isAdmin, // ✅ Уже есть
priority: isAdmin ? 10 : 5 // ✨ НОВОЕ - высокий приоритет для админов
}
});
ЭТАП 6: Обновить мониторинг
Файл: backend/routes/monitoring.js
Добавить статистику:
// AI Cache статистика
const aiCache = require('../services/ai-cache');
const cacheStats = aiCache.getStats();
const cacheByType = aiCache.getStatsByType();
results.aiCache = {
status: 'ok',
size: cacheStats.size,
maxSize: cacheStats.maxSize,
hitRate: `${(cacheStats.hitRate * 100).toFixed(2)}%`,
byType: cacheByType
};
// AI Queue статистика
const AIQueue = require('../services/ai-queue');
const queueStats = aiQueueInstance.getStats();
results.aiQueue = {
status: 'ok',
currentSize: queueStats.currentQueueSize,
totalProcessed: queueStats.totalProcessed,
totalFailed: queueStats.totalFailed,
avgResponseTime: `${Math.round(queueStats.averageProcessingTime)}ms`
};
📋 ЧЕКЛИСТ ИСПРАВЛЕНИЙ
Доработка существующих файлов:
-
1.
ai-cache.js- добавить методы:generateKeyForRAG(tableId, question, product)getWithTTL(key, type)setWithType(key, response, type)invalidateByPrefix(prefix)getStatsByType()
-
2.
ai-queue.js- добавить методы:addTask(taskData)- возвращает PromisestartWorker()- запуск обработкиstopWorker()- остановкаprocessNextTask()- обработка с Ollama + Cache- Свойство
maxQueueSize = 100
-
3.
ragService.js- исправить:- Удалить
ragCacheиRAG_CACHE_TTL - Добавить импорты наверху:
axios,ollamaConfig,aiCache,aiQueue - Заменить
ragCache.get()→aiCache.getWithTTL(key, 'rag') - Заменить
ragCache.set()→aiCache.setWithType(key, result, 'rag') - В
generateLLMResponse():- Удалить
require()внутри функции - Добавить вызов
aiQueue.addTask() - Оставить fallback на прямой вызов
- Удалить
- Удалить
-
4.
ai-assistant.js- передать metadata:- Добавить
metadataв вызовgenerateLLMResponse()
- Добавить
-
5.
UniversalGuestService.js- передать priority:- Добавить
priority: 1в metadata
- Добавить
-
6.
unifiedMessageProcessor.js- передать priority:- Добавить
priority: isAdmin ? 10 : 5в metadata
- Добавить
-
7.
server.js- запустить worker:- Создать экземпляр
AIQueue - Вызвать
aiQueueInstance.startWorker() - Добавить graceful shutdown
- Создать экземпляр
-
8.
routes/monitoring.js- добавить статистику:- Статистика
aiCache - Статистика
aiQueue
- Статистика
⏱️ ОЦЕНКА ВРЕМЕНИ
| Файл | Изменения | Время |
|---|---|---|
ai-cache.js |
+5 методов | 1-2 часа |
ai-queue.js |
+3 метода + worker | 2-3 часа |
ragService.js |
Удаление дублей, интеграция | 2-3 часа |
| Остальные | Передача metadata | 1 час |
| Тестирование | Полное | 2-3 часа |
ИТОГО: 8-12 часов
🚀 ПОРЯДОК РАБОТЫ
- ✅ Доработать
ai-cache.js(добавить методы) - ✅ Доработать
ai-queue.js(добавить worker) - ✅ Рефакторить
ragService.js(убрать дубли) - ✅ Интегрировать в основной поток
- ✅ Протестировать
Статус: ✅ ВЫПОЛНЕНО
✅ ВЫПОЛНЕННЫЕ ЗАДАЧИ:
- ✅ Доработан
ai-cache.js(+5 методов, TTL из ollamaConfig) - ✅ Доработан
ai-queue.js(+worker, FIFO без приоритетов) - ✅ Рефакторинг
ragService.js(удален ragCache, интеграция Cache + Queue) - ✅ Обновлен
adminLogicService.js(editor/readonly, удалены неиспользуемые методы) - ✅ Добавлена валидация прав (chat.js, messages.js, auth.js)
- ✅ Удалены legacy сервисы (guestService, guestMessageService, index.js)
- ✅ Интегрирован WebBot (botManager использует класс)
- ✅ Централизованы все AI таймауты в ollamaConfig.js
Всего изменено: 13 файлов
Удалено: 3 файла
Создано: 0 файлов (только доработка существующих!)