# 🔧 ЗАДАЧА: Рефакторинг AI сервисов (устранение дублей + интеграция Queue/Cache) **Дата:** 2025-10-09 **Приоритет:** ВЫСОКИЙ **Статус:** 📋 В РАЗРАБОТКЕ --- ## 🎯 **ЦЕЛЬ** Устранить дублирование кода и интегрировать существующие сервисы `ai-queue.js` и `ai-cache.js` в основной поток обработки. --- ## ❌ **НАЙДЕННЫЕ ДУБЛИ** ### **ДУБЛЬ #1: Кэширование** ⭐⭐⭐ КРИТИЧЕСКИЙ #### **`ragService.js` (строки 20-22, 78-84, 182-185):** ```javascript 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 строк):** ```javascript 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):** ```javascript 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`:** ```javascript const cacheKey = `${tableId}:${userQuestion}:${product}`; // ❌ Простая строка ``` #### **`ai-cache.js`:** ```javascript generateKey(messages, options = {}) { return crypto.createHash('md5').update(content).digest('hex'); // ✅ MD5 хеш } ``` **Вывод:** Использовать единый метод из `ai-cache.js` --- ### **ДУБЛЬ #4: Import внутри функций** ⭐⭐ **`ragService.js` (строки 359-361):** ```javascript async function generateLLMResponse({...}) { const axios = require('axios'); // ❌ Каждый раз! const ollamaConfig = require('./ollamaConfig'); // ❌ Каждый раз! } ``` **Вывод:** Вынести импорты наверх файла --- ### **ДУБЛЬ #5: Fallback на несуществующую очередь** ⭐ **`ragService.js` (строки 375-379):** ```javascript if (error.message.includes('очередь перегружена') && answer) { // ❌ Очередь не используется! return answer; } ``` **Вывод:** Удалить или исправить после интеграции очереди --- ## 🔧 **ПЛАН ИСПРАВЛЕНИЙ** ### **ЭТАП 1: Доработать `ai-cache.js`** ⭐⭐⭐ **Файл:** `backend/services/ai-cache.js` **Добавить методы:** ```javascript 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` **Добавить методы для обработки:** ```javascript 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)** **Было:** ```javascript 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; // ❌ УДАЛИТЬ! ``` **Стало:** ```javascript 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)** **Было:** ```javascript const cacheKey = `${tableId}:${userQuestion}:${product}`; const cached = ragCache.get(cacheKey); if (cached && (Date.now() - cached.timestamp) < RAG_CACHE_TTL) { return cached.result; } ``` **Стало:** ```javascript // Используем 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)** **Было:** ```javascript ragCache.set(cacheKey, { result, timestamp: Date.now() }); ``` **Стало:** ```javascript // Сохраняем в ai-cache с типом 'rag' aiCache.setWithType(cacheKey, result, 'rag'); ``` #### **3.4. Заменить прямой вызов Ollama на очередь (строки 358-383)** **Было:** ```javascript 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) { // ... } } ``` **Стало:** ```javascript 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:** ```javascript // Запускаем 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`:** ```javascript 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:** ```javascript 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:** ```javascript 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` **Добавить статистику:** ```javascript // 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)` - возвращает Promise - [ ] `startWorker()` - запуск обработки - [ ] `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 часов --- ## 🚀 **ПОРЯДОК РАБОТЫ** 1. ✅ Доработать `ai-cache.js` (добавить методы) 2. ✅ Доработать `ai-queue.js` (добавить worker) 3. ✅ Рефакторить `ragService.js` (убрать дубли) 4. ✅ Интегрировать в основной поток 5. ✅ Протестировать --- **Статус:** ✅ ВЫПОЛНЕНО ## ✅ **ВЫПОЛНЕННЫЕ ЗАДАЧИ:** 1. ✅ Доработан `ai-cache.js` (+5 методов, TTL из ollamaConfig) 2. ✅ Доработан `ai-queue.js` (+worker, FIFO без приоритетов) 3. ✅ Рефакторинг `ragService.js` (удален ragCache, интеграция Cache + Queue) 4. ✅ Обновлен `adminLogicService.js` (editor/readonly, удалены неиспользуемые методы) 5. ✅ Добавлена валидация прав (chat.js, messages.js, auth.js) 6. ✅ Удалены legacy сервисы (guestService, guestMessageService, index.js) 7. ✅ Интегрирован WebBot (botManager использует класс) 8. ✅ Централизованы все AI таймауты в ollamaConfig.js **Всего изменено:** 13 файлов **Удалено:** 3 файла **Создано:** 0 файлов (только доработка существующих!)