Files
DLE/aidocs/TASK_REFACTOR_AI_SERVICES.md
2025-10-09 16:48:20 +03:00

760 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🔧 ЗАДАЧА: Рефакторинг 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 файлов (только доработка существующих!)