feat: новая функция
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"server": "nodemon server.js --signal SIGUSR2",
|
||||
"migrate": "node scripts/run-migrations.js",
|
||||
"prod": "NODE_ENV=production node server.js",
|
||||
"test": "mocha test/**/*.test.js",
|
||||
"test": "mocha tests/**/*.test.js",
|
||||
"check-ollama": "node scripts/check-ollama-models.js",
|
||||
"check-ethers": "node scripts/check-ethers-v6-compatibility.js",
|
||||
"lint": "eslint .",
|
||||
|
||||
@@ -194,7 +194,7 @@ router.post('/verify', async (req, res) => {
|
||||
|
||||
// Добавляем ссылки на документы в resources
|
||||
documents.forEach(doc => {
|
||||
resources.push(`${origin}/public/page/${doc.id}`);
|
||||
resources.push(`${origin}/content/published/${doc.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -145,32 +145,8 @@ router.post('/', upload.single('file'), async (req, res) => {
|
||||
const { rows } = await db.getQuery()(sql, values);
|
||||
const created = rows[0];
|
||||
|
||||
// Индексация в vector-search (только для HTML, если есть текст)
|
||||
try {
|
||||
if (created && (created.format === 'html' || pageData.format === 'html')) {
|
||||
const text = stripHtml(created.content || pageData.content || '');
|
||||
if (text && text.length > 0) {
|
||||
const url = created.visibility === 'public' && created.status === 'published'
|
||||
? `/public/page/${created.id}`
|
||||
: `/content/page/${created.id}`;
|
||||
await vectorSearchClient.upsert('legal_docs', [{
|
||||
row_id: created.id,
|
||||
text,
|
||||
metadata: {
|
||||
doc_id: created.id,
|
||||
title: created.title,
|
||||
url,
|
||||
visibility: created.visibility || pageData.visibility,
|
||||
required_permission: created.required_permission || pageData.required_permission,
|
||||
format: created.format || pageData.format,
|
||||
updated_at: created.updated_at || null
|
||||
}
|
||||
}]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[pages] vector upsert error:', e.message);
|
||||
}
|
||||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||
// Автоматическая индексация при создании отключена
|
||||
|
||||
res.json(created);
|
||||
});
|
||||
@@ -280,6 +256,58 @@ router.post('/:id/reindex', async (req, res) => {
|
||||
const url = page.visibility === 'public' && page.status === 'published'
|
||||
? `/public/page/${page.id}`
|
||||
: `/content/page/${page.id}`;
|
||||
|
||||
// Удаляем старые чанки документа перед реиндексацией
|
||||
// Удаляем возможные чанки (doc_id_chunk_0, doc_id_chunk_1, ...) и сам документ (doc_id)
|
||||
const oldRowIds = [String(page.id)]; // Удаляем основной документ
|
||||
// Также удаляем возможные чанки (до 100 чанков на документ)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
oldRowIds.push(`${page.id}_chunk_${i}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await vectorSearchClient.remove('legal_docs', oldRowIds);
|
||||
console.log(`[pages] Удалены старые чанки документа ${page.id} перед реиндексацией`);
|
||||
} catch (removeError) {
|
||||
console.warn(`[pages] Ошибка удаления старых чанков (продолжаем индексацию):`, removeError.message);
|
||||
// Продолжаем индексацию даже если удаление не удалось
|
||||
}
|
||||
|
||||
// Используем Semantic Chunking для разбивки документа
|
||||
const semanticChunkingService = require('../services/semanticChunkingService');
|
||||
const docLength = text.length;
|
||||
const useLLM = docLength <= 8000;
|
||||
|
||||
const chunks = await semanticChunkingService.chunkDocument(text, {
|
||||
maxChunkSize: 1500,
|
||||
overlap: 200,
|
||||
useLLM
|
||||
});
|
||||
|
||||
// Индексируем каждый чанк отдельно
|
||||
const rowsToUpsert = chunks.map((chunk, index) => ({
|
||||
row_id: `${page.id}_chunk_${index}`,
|
||||
text: chunk.text,
|
||||
metadata: {
|
||||
doc_id: page.id,
|
||||
chunk_index: index,
|
||||
section: chunk.metadata?.section || 'Документ',
|
||||
parent_doc_id: page.id,
|
||||
title: page.title,
|
||||
url: `${url}#chunk_${index}`,
|
||||
visibility: page.visibility,
|
||||
required_permission: page.required_permission,
|
||||
format: page.format,
|
||||
updated_at: page.updated_at || null,
|
||||
isComplete: chunk.metadata?.isComplete || false
|
||||
}
|
||||
}));
|
||||
|
||||
if (chunks.length > 1) {
|
||||
console.log(`[pages] Документ ${page.id} разбит на ${chunks.length} чанков при реиндексации`);
|
||||
await vectorSearchClient.upsert('legal_docs', rowsToUpsert);
|
||||
} else {
|
||||
// Если чанк один, индексируем как раньше
|
||||
await vectorSearchClient.upsert('legal_docs', [{
|
||||
row_id: page.id,
|
||||
text,
|
||||
@@ -293,7 +321,9 @@ router.post('/:id/reindex', async (req, res) => {
|
||||
updated_at: page.updated_at || null
|
||||
}
|
||||
}]);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
res.json({ success: true, chunksCount: chunks.length });
|
||||
} catch (e) {
|
||||
console.error('[pages] manual reindex error:', e.message);
|
||||
res.status(500).json({ error: 'Ошибка индексации' });
|
||||
@@ -346,32 +376,8 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
|
||||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||||
const updated = rows[0];
|
||||
|
||||
// Индексация для HTML
|
||||
try {
|
||||
if (updated && (updated.format === 'html')) {
|
||||
const text = stripHtml(updated.content || '');
|
||||
if (text) {
|
||||
const url = updated.visibility === 'public' && updated.status === 'published'
|
||||
? `/public/page/${updated.id}`
|
||||
: `/content/page/${updated.id}`;
|
||||
await vectorSearchClient.upsert('legal_docs', [{
|
||||
row_id: updated.id,
|
||||
text,
|
||||
metadata: {
|
||||
doc_id: updated.id,
|
||||
title: updated.title,
|
||||
url,
|
||||
visibility: updated.visibility,
|
||||
required_permission: updated.required_permission,
|
||||
format: updated.format,
|
||||
updated_at: updated.updated_at || null
|
||||
}
|
||||
}]);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[pages] vector upsert (update) error:', e.message);
|
||||
}
|
||||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||
// Автоматическая индексация при обновлении отключена
|
||||
|
||||
res.json(updated);
|
||||
});
|
||||
@@ -406,7 +412,14 @@ router.delete('/:id', async (req, res) => {
|
||||
const deleted = rows[0];
|
||||
try {
|
||||
if (deleted && deleted.format === 'html') {
|
||||
await vectorSearchClient.remove('legal_docs', [deleted.id]);
|
||||
// Удаляем документ и все его чанки
|
||||
const rowIdsToDelete = [String(deleted.id)]; // Основной документ
|
||||
// Удаляем возможные чанки (до 100 чанков на документ)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
rowIdsToDelete.push(`${deleted.id}_chunk_${i}`);
|
||||
}
|
||||
await vectorSearchClient.remove('legal_docs', rowIdsToDelete);
|
||||
console.log(`[pages] Удалены документ ${deleted.id} и все его чанки из векторного поиска`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[pages] vector remove error:', e.message);
|
||||
|
||||
@@ -482,6 +482,35 @@ router.put('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// AI CONFIG (централизованные настройки)
|
||||
// ============================================
|
||||
|
||||
// Получить все настройки AI Config
|
||||
router.get('/ai-config', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const aiConfigService = require('../services/aiConfigService');
|
||||
const config = await aiConfigService.getConfig();
|
||||
res.json({ success: true, config });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при получении AI Config:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки AI Config
|
||||
router.put('/ai-config', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const aiConfigService = require('../services/aiConfigService');
|
||||
const userId = req.session.userId || null;
|
||||
const updated = await aiConfigService.updateConfig(req.body, userId);
|
||||
res.json({ success: true, config: updated });
|
||||
} catch (error) {
|
||||
logger.error('Ошибка при обновлении AI Config:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить набор правил
|
||||
router.delete('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* Отладочный скрипт для мониторинга файлов в процессе деплоя
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
console.log('🔍 ОТЛАДОЧНЫЙ МОНИТОР: Отслеживание файлов current-params.json');
|
||||
console.log('=' .repeat(70));
|
||||
|
||||
class FileMonitor {
|
||||
constructor() {
|
||||
this.watchedFiles = new Map();
|
||||
this.isMonitoring = false;
|
||||
}
|
||||
|
||||
startMonitoring() {
|
||||
console.log('🚀 Запуск мониторинга файлов...');
|
||||
this.isMonitoring = true;
|
||||
|
||||
const deployDir = path.join(__dirname, './deploy');
|
||||
const tempDir = path.join(__dirname, '../temp');
|
||||
|
||||
// Мониторим директории
|
||||
this.watchDirectory(deployDir, 'deploy');
|
||||
this.watchDirectory(tempDir, 'temp');
|
||||
|
||||
// Проверяем существующие файлы
|
||||
this.checkExistingFiles();
|
||||
|
||||
console.log('✅ Мониторинг запущен. Нажмите Ctrl+C для остановки.');
|
||||
}
|
||||
|
||||
watchDirectory(dirPath, label) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
console.log(`📁 Директория ${label} не существует: ${dirPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📁 Мониторим директорию ${label}: ${dirPath}`);
|
||||
|
||||
try {
|
||||
const watcher = fs.watch(dirPath, (eventType, filename) => {
|
||||
if (filename && filename.includes('current-params')) {
|
||||
const filePath = path.join(dirPath, filename);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
console.log(`\n🔔 ${timestamp} - ${label.toUpperCase()}:`);
|
||||
console.log(` Событие: ${eventType}`);
|
||||
console.log(` Файл: ${filename}`);
|
||||
console.log(` Путь: ${filePath}`);
|
||||
|
||||
if (eventType === 'rename' && filename) {
|
||||
// Файл создан или удален
|
||||
setTimeout(() => {
|
||||
const exists = fs.existsSync(filePath);
|
||||
console.log(` Статус: ${exists ? 'СУЩЕСТВУЕТ' : 'УДАЛЕН'}`);
|
||||
|
||||
if (exists) {
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
console.log(` Размер: ${stats.size} байт`);
|
||||
console.log(` Изменен: ${stats.mtime}`);
|
||||
} catch (statError) {
|
||||
console.log(` Ошибка получения статистики: ${statError.message}`);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.watchedFiles.set(dirPath, watcher);
|
||||
console.log(`✅ Мониторинг ${label} запущен`);
|
||||
|
||||
} catch (watchError) {
|
||||
console.log(`❌ Ошибка мониторинга ${label}: ${watchError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
checkExistingFiles() {
|
||||
console.log('\n🔍 Проверка существующих файлов...');
|
||||
|
||||
const pathsToCheck = [
|
||||
path.join(__dirname, './deploy/current-params.json'),
|
||||
path.join(__dirname, '../temp'),
|
||||
path.join(__dirname, './deploy')
|
||||
];
|
||||
|
||||
pathsToCheck.forEach(checkPath => {
|
||||
try {
|
||||
if (fs.existsSync(checkPath)) {
|
||||
const stats = fs.statSync(checkPath);
|
||||
if (stats.isFile()) {
|
||||
console.log(`📄 Файл найден: ${checkPath}`);
|
||||
console.log(` Размер: ${stats.size} байт`);
|
||||
console.log(` Создан: ${stats.birthtime}`);
|
||||
console.log(` Изменен: ${stats.mtime}`);
|
||||
} else if (stats.isDirectory()) {
|
||||
console.log(`📁 Директория найдена: ${checkPath}`);
|
||||
const files = fs.readdirSync(checkPath);
|
||||
const currentParamsFiles = files.filter(f => f.includes('current-params'));
|
||||
if (currentParamsFiles.length > 0) {
|
||||
console.log(` Файлы current-params: ${currentParamsFiles.join(', ')}`);
|
||||
} else {
|
||||
console.log(` Файлы current-params: не найдены`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`❌ Не найден: ${checkPath}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Ошибка проверки ${checkPath}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stopMonitoring() {
|
||||
console.log('\n🛑 Остановка мониторинга...');
|
||||
this.isMonitoring = false;
|
||||
|
||||
this.watchedFiles.forEach((watcher, path) => {
|
||||
try {
|
||||
watcher.close();
|
||||
console.log(`✅ Мониторинг остановлен: ${path}`);
|
||||
} catch (error) {
|
||||
console.log(`❌ Ошибка остановки мониторинга ${path}: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.watchedFiles.clear();
|
||||
console.log('✅ Мониторинг полностью остановлен');
|
||||
}
|
||||
|
||||
// Метод для периодической проверки
|
||||
startPeriodicCheck(intervalMs = 5000) {
|
||||
console.log(`⏰ Запуск периодической проверки (каждые ${intervalMs}ms)...`);
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!this.isMonitoring) {
|
||||
clearInterval(checkInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
this.performPeriodicCheck();
|
||||
}, intervalMs);
|
||||
|
||||
return checkInterval;
|
||||
}
|
||||
|
||||
performPeriodicCheck() {
|
||||
const timestamp = new Date().toISOString();
|
||||
console.log(`\n⏰ ${timestamp} - Периодическая проверка:`);
|
||||
|
||||
const filesToCheck = [
|
||||
path.join(__dirname, './deploy/current-params.json'),
|
||||
path.join(__dirname, './deploy'),
|
||||
path.join(__dirname, '../temp')
|
||||
];
|
||||
|
||||
filesToCheck.forEach(filePath => {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.isFile()) {
|
||||
console.log(` 📄 ${path.basename(filePath)}: ${stats.size} байт`);
|
||||
} else if (stats.isDirectory()) {
|
||||
const files = fs.readdirSync(filePath);
|
||||
const currentParamsFiles = files.filter(f => f.includes('current-params'));
|
||||
console.log(` 📁 ${path.basename(filePath)}: ${files.length} файлов, current-params: ${currentParamsFiles.length}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ❌ ${path.basename(filePath)}: не существует`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ⚠️ ${path.basename(filePath)}: ошибка ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем экземпляр монитора
|
||||
const monitor = new FileMonitor();
|
||||
|
||||
// Обработка сигналов завершения
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n🛑 Получен сигнал SIGINT...');
|
||||
monitor.stopMonitoring();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\n🛑 Получен сигнал SIGTERM...');
|
||||
monitor.stopMonitoring();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Запускаем мониторинг
|
||||
monitor.startMonitoring();
|
||||
monitor.startPeriodicCheck(3000); // Проверка каждые 3 секунды
|
||||
|
||||
console.log('\n💡 Инструкции:');
|
||||
console.log(' - Запустите этот скрипт в отдельном терминале');
|
||||
console.log(' - Затем запустите деплой DLE в другом терминале');
|
||||
console.log(' - Наблюдайте за изменениями файлов в реальном времени');
|
||||
console.log(' - Нажмите Ctrl+C для остановки мониторинга');
|
||||
@@ -424,6 +424,16 @@ class UniversalGuestService {
|
||||
}
|
||||
});
|
||||
|
||||
if (aiResponse && aiResponse.disabled) {
|
||||
logger.info(`[UniversalGuestService] AI ассистент отключен для канала ${channel}. Ответ не формируется.`);
|
||||
return {
|
||||
success: true,
|
||||
identifier,
|
||||
aiResponse: null,
|
||||
assistantDisabled: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!aiResponse || !aiResponse.success) {
|
||||
logger.warn(`[UniversalGuestService] AI не вернул ответ для ${identifier}`);
|
||||
return {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,38 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Кэширование AI ответов для ускорения работы
|
||||
* Использует настройки из aiConfigService
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
const ollamaConfig = require('./ollamaConfig');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
|
||||
class AICache {
|
||||
constructor() {
|
||||
const timeouts = ollamaConfig.getTimeouts();
|
||||
|
||||
// Загружаем настройки из aiConfigService
|
||||
this.cache = new Map();
|
||||
this.maxSize = timeouts.cacheMax; // Из централизованных настроек
|
||||
this.ttl = timeouts.cacheLLM; // 24 часа (для LLM)
|
||||
this.ragTtl = timeouts.cacheRAG; // 5 минут (для RAG результатов)
|
||||
this._loadSettings();
|
||||
}
|
||||
|
||||
// Генерация ключа кэша на основе запроса
|
||||
generateKey(messages, options = {}) {
|
||||
/**
|
||||
* Загружает настройки кэша из aiConfigService
|
||||
* @private
|
||||
*/
|
||||
async _loadSettings() {
|
||||
try {
|
||||
const cacheConfig = await aiConfigService.getCacheConfig();
|
||||
this.maxSize = cacheConfig.maxSize || 1000;
|
||||
this.ttl = cacheConfig.llmTTL || 86400000; // 24 часа
|
||||
this.ragTtl = cacheConfig.ragTTL || 300000; // 5 минут
|
||||
} catch (error) {
|
||||
logger.warn('[AICache] Ошибка загрузки настроек, используем дефолты:', error.message);
|
||||
// Дефолтные значения
|
||||
const timeouts = ollamaConfig.getTimeouts();
|
||||
this.maxSize = timeouts.cacheMax || 1000;
|
||||
this.ttl = timeouts.cacheLLM || 86400000;
|
||||
this.ragTtl = timeouts.cacheRAG || 300000;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает актуальные настройки (перезагружает из БД)
|
||||
*/
|
||||
async _getSettings() {
|
||||
await this._loadSettings();
|
||||
return {
|
||||
maxSize: this.maxSize,
|
||||
ttl: this.ttl,
|
||||
ragTtl: this.ragTtl
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Генерация ключа кэша на основе запроса
|
||||
* Использует параметры LLM из настроек для генерации ключа
|
||||
*/
|
||||
async generateKey(messages, options = {}) {
|
||||
// Загружаем актуальные параметры LLM для ключа
|
||||
const llmParams = await aiConfigService.getLLMParameters();
|
||||
|
||||
const content = JSON.stringify({
|
||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||
temperature: options.temperature || 0.3,
|
||||
maxTokens: options.num_predict || 150
|
||||
temperature: options.temperature || llmParams.temperature,
|
||||
maxTokens: options.num_predict || llmParams.maxTokens
|
||||
});
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
|
||||
// ✨ НОВОЕ: Генерация ключа для RAG результатов
|
||||
generateKeyForRAG(tableId, userQuestion, product = null) {
|
||||
const content = JSON.stringify({ tableId, userQuestion, product });
|
||||
/**
|
||||
* Генерация ключа для RAG результатов
|
||||
* Включает tagIds для учета фильтрации по тегам
|
||||
*/
|
||||
generateKeyForRAG(tableId, userQuestion, product = null, userId = null, tagIds = null) {
|
||||
// Сортируем tagIds для стабильности ключа (одинаковый порядок = одинаковый ключ)
|
||||
const sortedTagIds = tagIds ? [...tagIds].sort((a, b) => a - b) : null;
|
||||
const content = JSON.stringify({ tableId, userQuestion, product, userId, tagIds: sortedTagIds });
|
||||
return crypto.createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
|
||||
// Получение ответа из кэша
|
||||
/**
|
||||
* Получение ответа из кэша (LLM)
|
||||
*/
|
||||
get(key) {
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) return null;
|
||||
@@ -47,14 +103,15 @@ class AICache {
|
||||
return cached.response;
|
||||
}
|
||||
|
||||
// ✨ НОВОЕ: Получение с учетом типа кэша (RAG или LLM)
|
||||
/**
|
||||
* Получение с учетом типа кэша (RAG или LLM)
|
||||
*/
|
||||
getWithTTL(key, type = 'llm') {
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
// Выбираем TTL в зависимости от типа
|
||||
const ttl = type === 'rag' ? this.ragTtl : this.ttl;
|
||||
|
||||
|
||||
// Проверяем TTL
|
||||
if (Date.now() - cached.timestamp > ttl) {
|
||||
this.cache.delete(key);
|
||||
@@ -65,101 +122,110 @@ class AICache {
|
||||
return cached.response;
|
||||
}
|
||||
|
||||
// Сохранение ответа в кэш
|
||||
set(key, response) {
|
||||
// Очищаем старые записи если кэш переполнен
|
||||
/**
|
||||
* Сохранение в кэш
|
||||
*/
|
||||
set(key, value, type = 'llm') {
|
||||
// Проверяем размер кэша
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
// Удаляем самую старую запись
|
||||
const oldestKey = Array.from(this.cache.keys())[0];
|
||||
this.cache.delete(oldestKey);
|
||||
logger.warn(`[AICache] Кэш переполнен, удалена старая запись: ${oldestKey.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
this.cache.set(key, {
|
||||
response,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
logger.info(`[AICache] Cached response for key: ${key.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
// ✨ НОВОЕ: Сохранение с указанием типа (rag или llm)
|
||||
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,
|
||||
response: value,
|
||||
timestamp: Date.now(),
|
||||
type: type // Сохраняем тип для статистики
|
||||
type
|
||||
});
|
||||
|
||||
logger.info(`[AICache] Cached ${type} response for key: ${key.substring(0, 8)}...`);
|
||||
logger.debug(`[AICache] Сохранено в кэш (${type}): ${key.substring(0, 8)}...`);
|
||||
}
|
||||
|
||||
// Очистка кэша
|
||||
/**
|
||||
* Сохранение с указанием типа
|
||||
*/
|
||||
setWithType(key, value, type = 'llm') {
|
||||
this.set(key, value, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка кэша
|
||||
*/
|
||||
clear() {
|
||||
const size = this.cache.size;
|
||||
this.cache.clear();
|
||||
logger.info('[AICache] Cache cleared');
|
||||
logger.info(`[AICache] Кэш очищен. Удалено записей: ${size}`);
|
||||
return size;
|
||||
}
|
||||
|
||||
// Очистка старых записей по времени
|
||||
cleanup(maxAge = 3600000) { // По умолчанию 1 час
|
||||
const now = Date.now();
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
if (now - value.timestamp > maxAge) {
|
||||
this.cache.delete(key);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`[AICache] Cleaned up ${deletedCount} old entries`);
|
||||
}
|
||||
}
|
||||
|
||||
// Статистика кэша
|
||||
/**
|
||||
* Получение статистики
|
||||
*/
|
||||
getStats() {
|
||||
return {
|
||||
const stats = {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
hitRate: this.calculateHitRate()
|
||||
ttl: this.ttl,
|
||||
ragTtl: this.ragTtl
|
||||
};
|
||||
}
|
||||
|
||||
calculateHitRate() {
|
||||
// Простая реализация - в реальности нужно отслеживать hits/misses
|
||||
if (this.maxSize === 0) return 0;
|
||||
return this.cache.size / this.maxSize;
|
||||
}
|
||||
|
||||
// ✨ НОВОЕ: Статистика по типу кэша
|
||||
getStatsByType() {
|
||||
const stats = { rag: 0, llm: 0, other: 0 };
|
||||
// Подсчитываем по типам
|
||||
let llmCount = 0;
|
||||
let ragCount = 0;
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
const type = value.type || 'other';
|
||||
stats[type] = (stats[type] || 0) + 1;
|
||||
if (value.type === 'rag') {
|
||||
ragCount++;
|
||||
} else {
|
||||
llmCount++;
|
||||
}
|
||||
}
|
||||
|
||||
stats.llmCount = llmCount;
|
||||
stats.ragCount = ragCount;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
// ✨ НОВОЕ: Инвалидация по префиксу (для очистки RAG кэша при обновлении таблиц)
|
||||
invalidateByPrefix(prefix) {
|
||||
let deletedCount = 0;
|
||||
/**
|
||||
* Получение статистики по типам
|
||||
*/
|
||||
getStatsByType() {
|
||||
const stats = {
|
||||
llm: { count: 0, size: 0 },
|
||||
rag: { count: 0, size: 0 }
|
||||
};
|
||||
|
||||
for (const [key, value] of this.cache.entries()) {
|
||||
const type = value.type || 'llm';
|
||||
stats[type].count++;
|
||||
stats[type].size += JSON.stringify(value.response).length;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидация кэша по префиксу
|
||||
*/
|
||||
invalidateByPrefix(prefix) {
|
||||
let count = 0;
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
this.cache.delete(key);
|
||||
deletedCount++;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (deletedCount > 0) {
|
||||
logger.info(`[AICache] Инвалидировано ${deletedCount} записей с префиксом: ${prefix}`);
|
||||
if (count > 0) {
|
||||
logger.info(`[AICache] Инвалидировано записей с префиксом ${prefix}: ${count}`);
|
||||
}
|
||||
return deletedCount;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AICache();
|
||||
// Экспортируем singleton экземпляр
|
||||
const aiCache = new AICache();
|
||||
|
||||
module.exports = aiCache;
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const logger = require('../utils/logger');
|
||||
const axios = require('axios');
|
||||
const ollamaConfig = require('./ollamaConfig');
|
||||
const aiCache = require('./ai-cache');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
const { buildOllamaRequest } = require('../utils/ollamaRequestBuilder');
|
||||
|
||||
class AIQueue extends EventEmitter {
|
||||
constructor() {
|
||||
@@ -237,25 +239,56 @@ class AIQueue extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Вызываем Ollama API
|
||||
// 2. Загружаем параметры LLM и qwen из настроек
|
||||
const llmParameters = task.request.llmParameters || await aiConfigService.getLLMParameters();
|
||||
const qwenParameters = task.request.qwenParameters || await aiConfigService.getQwenSpecificParameters();
|
||||
const ollamaConfig_data = await ollamaConfig.getConfigAsync();
|
||||
|
||||
// 3. Формируем тело запроса (используем утилиту)
|
||||
const requestBody = buildOllamaRequest({
|
||||
messages: task.request.messages,
|
||||
model: task.request.model,
|
||||
llmParameters: llmParameters,
|
||||
qwenParameters: qwenParameters,
|
||||
defaultModel: ollamaConfig_data.defaultModel,
|
||||
tools: task.request.tools || null,
|
||||
tool_choice: task.request.tool_choice || null,
|
||||
stream: false
|
||||
});
|
||||
|
||||
// 4. Вызываем 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
|
||||
}, {
|
||||
logger.info(`[AIQueue] Отправка запроса в Ollama с параметрами:`, {
|
||||
model: requestBody.model,
|
||||
temperature: requestBody.temperature,
|
||||
num_predict: requestBody.num_predict,
|
||||
format: requestBody.format || 'не задан',
|
||||
hasTools: !!requestBody.tools
|
||||
});
|
||||
|
||||
const response = await axios.post(`${ollamaUrl}/api/chat`, requestBody, {
|
||||
timeout: timeouts.ollamaChat
|
||||
});
|
||||
|
||||
const result = response.data.message.content;
|
||||
// Обработка function calls (если есть)
|
||||
// ВАЖНО: Function calling в очереди не поддерживается, т.к. нужен userId
|
||||
// Если ИИ запросил функции - возвращаем ответ без их выполнения
|
||||
let result;
|
||||
if (response.data.message.tool_calls && response.data.message.tool_calls.length > 0) {
|
||||
logger.warn(`[AIQueue] ИИ запросил выполнение ${response.data.message.tool_calls.length} функций, но function calling в очереди не поддерживается`);
|
||||
result = response.data.message.content || 'Функции не выполнены (не поддерживается в очереди)';
|
||||
} else {
|
||||
result = response.data.message.content;
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
// 3. Сохраняем в кэш
|
||||
// 4. Сохраняем в кэш
|
||||
aiCache.set(cacheKey, result);
|
||||
|
||||
// 4. Обновляем статус
|
||||
// 5. Обновляем статус
|
||||
this.updateRequestStatus(task.id, 'completed', result, null, responseTime);
|
||||
this.emit(`task_${task.id}_completed`, { response: result, fromCache: false });
|
||||
|
||||
@@ -273,4 +306,5 @@ class AIQueue extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AIQueue;
|
||||
module.exports = AIQueue;
|
||||
|
||||
|
||||
@@ -28,10 +28,6 @@ async function getSettings() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Получаем ключ шифрования через унифицированную утилиту
|
||||
const encryptionUtils = require('../utils/encryptionUtils');
|
||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||
|
||||
// Обрабатываем selected_rag_tables
|
||||
if (setting.selected_rag_tables) {
|
||||
try {
|
||||
@@ -64,13 +60,37 @@ async function getSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const defaultChannelState = { web: true, telegram: true, email: true };
|
||||
let enabledChannels = setting.enabled_channels;
|
||||
if (typeof enabledChannels === 'string') {
|
||||
try {
|
||||
enabledChannels = JSON.parse(enabledChannels);
|
||||
} catch (parseError) {
|
||||
logger.error('[aiAssistantSettingsService] Error parsing enabled_channels:', parseError);
|
||||
enabledChannels = null;
|
||||
}
|
||||
}
|
||||
if (!enabledChannels || typeof enabledChannels !== 'object') {
|
||||
enabledChannels = { ...defaultChannelState };
|
||||
} else {
|
||||
enabledChannels = {
|
||||
...defaultChannelState,
|
||||
...Object.keys(enabledChannels).reduce((acc, key) => {
|
||||
acc[key] = Boolean(enabledChannels[key]);
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
setting.enabled_channels = enabledChannels;
|
||||
|
||||
logger.info(`[aiAssistantSettingsService] Final settings result:`, {
|
||||
id: setting.id,
|
||||
selected_rag_tables: setting.selected_rag_tables,
|
||||
rules_id: setting.rules_id,
|
||||
hasSupportEmail: setting.hasSupportEmail,
|
||||
hasTelegramBot: setting.hasTelegramBot,
|
||||
timestamp: setting.timestamp
|
||||
timestamp: setting.timestamp,
|
||||
enabled_channels: setting.enabled_channels
|
||||
});
|
||||
|
||||
return setting;
|
||||
@@ -80,12 +100,37 @@ async function getSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertSettings({ system_prompt, selected_rag_tables, model, embedding_model, rules, updated_by, telegram_settings_id, email_settings_id, system_message }) {
|
||||
async function upsertSettings({
|
||||
system_prompt,
|
||||
selected_rag_tables,
|
||||
model,
|
||||
embedding_model,
|
||||
rules,
|
||||
updated_by,
|
||||
telegram_settings_id,
|
||||
email_settings_id,
|
||||
system_message,
|
||||
enabled_channels
|
||||
}) {
|
||||
const defaultChannelState = { web: true, telegram: true, email: true };
|
||||
let channelsPayload = enabled_channels;
|
||||
if (!channelsPayload || typeof channelsPayload !== 'object') {
|
||||
channelsPayload = { ...defaultChannelState };
|
||||
} else {
|
||||
channelsPayload = {
|
||||
...defaultChannelState,
|
||||
...Object.keys(channelsPayload).reduce((acc, key) => {
|
||||
acc[key] = Boolean(channelsPayload[key]);
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: 1,
|
||||
system_prompt,
|
||||
selected_rag_tables,
|
||||
languages: ['ru'], // Устанавливаем русский язык по умолчанию
|
||||
languages: ['ru'],
|
||||
model,
|
||||
embedding_model,
|
||||
rules,
|
||||
@@ -93,17 +138,15 @@ async function upsertSettings({ system_prompt, selected_rag_tables, model, embed
|
||||
updated_by,
|
||||
telegram_settings_id,
|
||||
email_settings_id,
|
||||
system_message
|
||||
system_message,
|
||||
enabled_channels: channelsPayload
|
||||
};
|
||||
|
||||
// Проверяем, существует ли запись
|
||||
const existing = await encryptedDb.getData(TABLE, { id: 1 }, 1);
|
||||
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Обновляем существующую запись
|
||||
return await encryptedDb.saveData(TABLE, data, { id: 1 });
|
||||
} else {
|
||||
// Создаем новую запись
|
||||
return await encryptedDb.saveData(TABLE, data);
|
||||
}
|
||||
}
|
||||
|
||||
399
backend/services/aiConfigService.js
Normal file
399
backend/services/aiConfigService.js
Normal file
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Централизованный сервис для управления всеми настройками AI
|
||||
*
|
||||
* Принципы:
|
||||
* - Единый источник истины (таблица ai_config)
|
||||
* - Кэширование в памяти (TTL: 1 минута)
|
||||
* - Автоматическая инвалидация при изменении
|
||||
* - Приоритет источников: БД > ENV > хардкод
|
||||
*/
|
||||
|
||||
const db = require('../db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class AIConfigService {
|
||||
constructor() {
|
||||
// Кэш для настроек
|
||||
this.cache = null;
|
||||
this.cacheTimestamp = 0;
|
||||
this.CACHE_TTL = 60000; // 1 минута
|
||||
|
||||
// Дефолтные значения (fallback)
|
||||
this.defaults = {
|
||||
ollama_base_url: process.env.OLLAMA_BASE_URL || 'http://ollama:11434',
|
||||
ollama_llm_model: process.env.OLLAMA_MODEL || 'qwen2.5:7b',
|
||||
ollama_embedding_model: process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large:latest',
|
||||
vector_search_url: process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001',
|
||||
embedding_parameters: {
|
||||
batch_size: 32,
|
||||
normalize: true,
|
||||
dimension: null,
|
||||
pooling: 'mean'
|
||||
},
|
||||
llm_parameters: {
|
||||
temperature: 0.3,
|
||||
maxTokens: 150,
|
||||
top_p: 0.9,
|
||||
top_k: 40,
|
||||
repeat_penalty: 1.1
|
||||
},
|
||||
qwen_specific_parameters: {
|
||||
format: null
|
||||
},
|
||||
rag_settings: {
|
||||
threshold: 300,
|
||||
maxResults: 3,
|
||||
searchMethod: 'hybrid',
|
||||
relevanceThreshold: 0.1,
|
||||
keywordExtraction: {
|
||||
enabled: true,
|
||||
minWordLength: 3,
|
||||
maxKeywords: 10,
|
||||
removeStopWords: true,
|
||||
language: 'ru'
|
||||
},
|
||||
searchWeights: {
|
||||
semantic: 70,
|
||||
keyword: 30
|
||||
},
|
||||
advanced: {
|
||||
enableFuzzySearch: true,
|
||||
enableStemming: true,
|
||||
enableSynonyms: false
|
||||
}
|
||||
},
|
||||
cache_settings: {
|
||||
enabled: true,
|
||||
llmTTL: 86400000,
|
||||
ragTTL: 300000,
|
||||
maxSize: 1000
|
||||
},
|
||||
queue_settings: {
|
||||
enabled: true,
|
||||
timeout: 180000,
|
||||
maxSize: 100,
|
||||
interval: 100
|
||||
},
|
||||
timeouts: {
|
||||
ollamaChat: 600000,
|
||||
ollamaEmbedding: 90000,
|
||||
vectorSearch: 90000,
|
||||
vectorUpsert: 600000,
|
||||
vectorHealth: 5000,
|
||||
ollamaHealth: 5000,
|
||||
ollamaTags: 10000
|
||||
},
|
||||
rag_behavior: {
|
||||
upsertOnQuery: false,
|
||||
autoIndexOnTableChange: true
|
||||
},
|
||||
deduplication_settings: {
|
||||
enabled: true,
|
||||
ttl: 300000
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка актуальности кэша
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isCacheValid() {
|
||||
if (!this.cache) return false;
|
||||
const now = Date.now();
|
||||
return (now - this.cacheTimestamp) < this.CACHE_TTL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить все настройки из БД
|
||||
* @returns {Promise<Object>} Полный объект настроек
|
||||
*/
|
||||
async loadConfig() {
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
const result = await query(
|
||||
'SELECT * FROM ai_config WHERE id = 1 LIMIT 1'
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
logger.warn('[aiConfigService] Таблица ai_config пуста, используем дефолтные значения');
|
||||
// Создаем дефолтную запись
|
||||
await this._createDefaultConfig();
|
||||
return this.defaults;
|
||||
}
|
||||
|
||||
const config = result.rows[0];
|
||||
|
||||
// Парсим JSONB поля
|
||||
const parsedConfig = {
|
||||
...config,
|
||||
embedding_parameters: config.embedding_parameters || this.defaults.embedding_parameters,
|
||||
llm_parameters: config.llm_parameters || this.defaults.llm_parameters,
|
||||
qwen_specific_parameters: config.qwen_specific_parameters || this.defaults.qwen_specific_parameters,
|
||||
rag_settings: config.rag_settings || this.defaults.rag_settings,
|
||||
cache_settings: config.cache_settings || this.defaults.cache_settings,
|
||||
queue_settings: config.queue_settings || this.defaults.queue_settings,
|
||||
timeouts: config.timeouts || this.defaults.timeouts,
|
||||
rag_behavior: config.rag_behavior || this.defaults.rag_behavior,
|
||||
deduplication_settings: config.deduplication_settings || this.defaults.deduplication_settings
|
||||
};
|
||||
|
||||
// Объединяем таймауты с дефолтами и при необходимости обновляем БД
|
||||
const existingTimeouts = parsedConfig.timeouts || {};
|
||||
const mergedTimeouts = { ...existingTimeouts };
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(this.defaults.timeouts)) {
|
||||
const rawValue = existingTimeouts[key];
|
||||
const numericValue = Number(rawValue);
|
||||
|
||||
if (!Number.isFinite(numericValue) || numericValue < defaultValue) {
|
||||
mergedTimeouts[key] = defaultValue;
|
||||
} else {
|
||||
mergedTimeouts[key] = numericValue;
|
||||
}
|
||||
}
|
||||
|
||||
const shouldPersistTimeouts = JSON.stringify(existingTimeouts) !== JSON.stringify(mergedTimeouts);
|
||||
parsedConfig.timeouts = mergedTimeouts;
|
||||
|
||||
// Обновляем кэш
|
||||
this.cache = parsedConfig;
|
||||
this.cacheTimestamp = Date.now();
|
||||
|
||||
if (shouldPersistTimeouts) {
|
||||
try {
|
||||
await query(
|
||||
'UPDATE ai_config SET timeouts = $1::jsonb, updated_at = NOW() WHERE id = 1',
|
||||
[JSON.stringify(mergedTimeouts)]
|
||||
);
|
||||
logger.info('[aiConfigService] Таймауты обновлены до актуальных значений по умолчанию');
|
||||
} catch (updateError) {
|
||||
logger.warn('[aiConfigService] Не удалось обновить таймауты в БД:', updateError.message);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('[aiConfigService] Настройки загружены из БД');
|
||||
return parsedConfig;
|
||||
} catch (error) {
|
||||
logger.error('[aiConfigService] Ошибка загрузки настроек из БД:', error.message);
|
||||
// Возвращаем дефолтные значения в случае ошибки
|
||||
return this.defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать дефолтную запись в БД
|
||||
* @private
|
||||
*/
|
||||
async _createDefaultConfig() {
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
await query(
|
||||
`INSERT INTO ai_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING`
|
||||
);
|
||||
logger.info('[aiConfigService] Создана дефолтная запись в ai_config');
|
||||
} catch (error) {
|
||||
logger.error('[aiConfigService] Ошибка создания дефолтной записи:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить все настройки (с кэшированием)
|
||||
* @returns {Promise<Object>} Настройки
|
||||
*/
|
||||
async getConfig() {
|
||||
if (this._isCacheValid()) {
|
||||
return this.cache;
|
||||
}
|
||||
return await this.loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить настройки
|
||||
* @param {Object} updates - Обновления
|
||||
* @param {number} userId - ID пользователя (опционально)
|
||||
* @returns {Promise<Object>} Обновленные настройки
|
||||
*/
|
||||
async updateConfig(updates, userId = null) {
|
||||
try {
|
||||
const query = db.getQuery();
|
||||
const fields = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// Строим SET часть запроса
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key === 'id' || key === 'updated_at' || key === 'updated_by') continue;
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
// JSONB поля
|
||||
fields.push(`${key} = $${paramIndex}::jsonb`);
|
||||
values.push(JSON.stringify(value));
|
||||
} else {
|
||||
fields.push(`${key} = $${paramIndex}`);
|
||||
values.push(value);
|
||||
}
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Добавляем updated_at и updated_by
|
||||
if (fields.length > 0) {
|
||||
fields.push(`updated_at = NOW()`);
|
||||
if (userId) {
|
||||
fields.push(`updated_by = $${paramIndex}`);
|
||||
values.push(userId);
|
||||
}
|
||||
|
||||
const sql = `UPDATE ai_config SET ${fields.join(', ')} WHERE id = 1`;
|
||||
await query(sql, values);
|
||||
|
||||
// Инвалидируем кэш
|
||||
this.invalidateCache();
|
||||
|
||||
logger.info('[aiConfigService] Настройки обновлены');
|
||||
return await this.loadConfig();
|
||||
}
|
||||
|
||||
return await this.getConfig();
|
||||
} catch (error) {
|
||||
logger.error('[aiConfigService] Ошибка обновления настроек:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидация кэша (принудительная перезагрузка)
|
||||
*/
|
||||
invalidateCache() {
|
||||
this.cache = null;
|
||||
this.cacheTimestamp = 0;
|
||||
logger.debug('[aiConfigService] Кэш инвалидирован');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// МЕТОДЫ ДЛЯ КОНКРЕТНЫХ КАТЕГОРИЙ
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Получить настройки Ollama
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getOllamaConfig() {
|
||||
const config = await this.getConfig();
|
||||
return {
|
||||
baseUrl: config.ollama_base_url || this.defaults.ollama_base_url,
|
||||
llmModel: config.ollama_llm_model || this.defaults.ollama_llm_model,
|
||||
embeddingModel: config.ollama_embedding_model || this.defaults.ollama_embedding_model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить RAG настройки
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getRAGConfig() {
|
||||
const config = await this.getConfig();
|
||||
return config.rag_settings || this.defaults.rag_settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить LLM параметры (общие)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getLLMParameters() {
|
||||
const config = await this.getConfig();
|
||||
return config.llm_parameters || this.defaults.llm_parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить специфичные параметры qwen
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getQwenSpecificParameters() {
|
||||
const config = await this.getConfig();
|
||||
return config.qwen_specific_parameters || this.defaults.qwen_specific_parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить настройки кэша
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getCacheConfig() {
|
||||
const config = await this.getConfig();
|
||||
return config.cache_settings || this.defaults.cache_settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить настройки очереди
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getQueueConfig() {
|
||||
const config = await this.getConfig();
|
||||
return config.queue_settings || this.defaults.queue_settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить таймауты
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getTimeouts() {
|
||||
const config = await this.getConfig();
|
||||
return config.timeouts || this.defaults.timeouts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить настройки дедупликации
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getDeduplicationConfig() {
|
||||
const config = await this.getConfig();
|
||||
return config.deduplication_settings || this.defaults.deduplication_settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить настройки embedding модели
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getEmbeddingParameters() {
|
||||
const config = await this.getConfig();
|
||||
return config.embedding_parameters || this.defaults.embedding_parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить настройки Vector Search
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getVectorSearchConfig() {
|
||||
const config = await this.getConfig();
|
||||
return {
|
||||
url: config.vector_search_url || this.defaults.vector_search_url
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить настройки RAG поведения
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getRAGBehavior() {
|
||||
const config = await this.getConfig();
|
||||
return config.rag_behavior || this.defaults.rag_behavior;
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем singleton экземпляр
|
||||
const aiConfigService = new AIConfigService();
|
||||
|
||||
module.exports = aiConfigService;
|
||||
|
||||
@@ -118,7 +118,7 @@ async function getConsentDocuments(missingConsents = []) {
|
||||
title: doc.title,
|
||||
summary: doc.summary,
|
||||
consentType: DOCUMENT_CONSENT_MAP[doc.title],
|
||||
url: `/public/page/${doc.id}`
|
||||
url: `/content/published/${doc.id}`
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('[ConsentService] Ошибка получения документов:', error);
|
||||
|
||||
@@ -236,16 +236,16 @@ class EncryptedDataService {
|
||||
console.log(`🔐 Будем шифровать ${key} -> ${key}_encrypted`);
|
||||
} else if (unencryptedColumn) {
|
||||
// Если есть незашифрованная колонка, сохраняем как есть
|
||||
// Проверяем, что значение не пустое перед сохранением (кроме role и sender_type)
|
||||
// Проверяем, что значение не пустое перед сохранением (кроме role, sender_type и user_id)
|
||||
if ((value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) &&
|
||||
key !== 'role' && key !== 'sender_type') {
|
||||
// Пропускаем пустые значения, кроме role и sender_type
|
||||
key !== 'role' && key !== 'sender_type' && key !== 'user_id') {
|
||||
// Пропускаем пустые значения, кроме role, sender_type и user_id
|
||||
// console.log(`⚠️ Пропускаем пустое незашифрованное поле ${key}`);
|
||||
continue;
|
||||
}
|
||||
filteredData[key] = value; // Добавляем в отфильтрованные данные
|
||||
unencryptedData[key] = `$${paramIndex++}`;
|
||||
// console.log(`✅ Добавили незашифрованное поле ${key} в filteredData и unencryptedData`);
|
||||
console.log(`✅ Добавили незашифрованное поле ${key} в filteredData и unencryptedData`);
|
||||
} else {
|
||||
// Если колонка не найдена, пропускаем
|
||||
// console.warn(`⚠️ Колонка ${key} не найдена в таблице ${tableName}`);
|
||||
@@ -254,6 +254,11 @@ class EncryptedDataService {
|
||||
|
||||
const allData = { ...unencryptedData, ...encryptedData };
|
||||
|
||||
console.log(`🔍 allData:`, JSON.stringify(allData, null, 2));
|
||||
console.log(`🔍 filteredData:`, JSON.stringify(filteredData, null, 2));
|
||||
console.log(`🔍 unencryptedData:`, JSON.stringify(unencryptedData, null, 2));
|
||||
console.log(`🔍 encryptedData:`, JSON.stringify(encryptedData, null, 2));
|
||||
|
||||
// Проверяем, есть ли данные для сохранения
|
||||
if (Object.keys(allData).length === 0) {
|
||||
// console.warn(`⚠️ Нет данных для сохранения в таблице ${tableName} - все значения пустые`);
|
||||
@@ -310,29 +315,36 @@ class EncryptedDataService {
|
||||
// Проходим по колонкам в порядке allData и добавляем соответствующие значения
|
||||
for (const key of Object.keys(allData)) {
|
||||
const placeholder = allData[key].toString();
|
||||
console.log(`🔍 Обрабатываем ключ: ${key}, placeholder: ${placeholder}`);
|
||||
// Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text)
|
||||
const paramMatches = placeholder.match(/\$(\d+)/g);
|
||||
console.log(`🔍 paramMatches для ${key}:`, paramMatches);
|
||||
if (paramMatches) {
|
||||
// Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.)
|
||||
// Для незашифрованных - первый параметр ($2, $3 и т.д.)
|
||||
if (encryptedData[key]) {
|
||||
// Это зашифрованная колонка - берем второй параметр (первый это $1 - ключ шифрования)
|
||||
// Это зашифрованная колонка - берем первый параметр (это значение для шифрования)
|
||||
const originalKey = key.replace('_encrypted', '');
|
||||
console.log(`🔍 Это зашифрованная колонка, originalKey: ${originalKey}, filteredData[originalKey]:`, filteredData[originalKey]);
|
||||
if (filteredData[originalKey] !== undefined && paramMatches.length > 0) {
|
||||
// Последний параметр это значение для шифрования
|
||||
const valueParam = paramMatches[paramMatches.length - 1];
|
||||
// Первый параметр это значение для шифрования
|
||||
const valueParam = paramMatches[0];
|
||||
const paramNum = parseInt(valueParam.substring(1));
|
||||
console.log(`🔍 Устанавливаем paramMap[${paramNum}] =`, filteredData[originalKey]);
|
||||
paramMap.set(paramNum, filteredData[originalKey]);
|
||||
}
|
||||
} else if (unencryptedData[key]) {
|
||||
// Это незашифрованная колонка - берем параметр из плейсхолдера
|
||||
const valueParam = paramMatches[0];
|
||||
const paramNum = parseInt(valueParam.substring(1));
|
||||
console.log(`🔍 Это незашифрованная колонка, устанавливаем paramMap[${paramNum}] =`, filteredData[key]);
|
||||
paramMap.set(paramNum, filteredData[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔍 paramMap после цикла:`, Array.from(paramMap.entries()));
|
||||
|
||||
// Создаем массив параметров в правильном порядке (от $1 до максимального номера)
|
||||
const maxParamNum = Math.max(...Array.from(paramMap.keys()));
|
||||
const params = [];
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
|
||||
/**
|
||||
* Сервис дедупликации сообщений
|
||||
@@ -21,8 +22,22 @@ const logger = require('../utils/logger');
|
||||
// Хранилище хешей обработанных сообщений (в памяти)
|
||||
const processedMessages = new Map();
|
||||
|
||||
// Время жизни записи о сообщении (5 минут)
|
||||
const MESSAGE_TTL = 5 * 60 * 1000;
|
||||
// Время жизни записи о сообщении (загружается из aiConfigService)
|
||||
let MESSAGE_TTL = null;
|
||||
|
||||
// Инициализация настроек (асинхронная загрузка)
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const dedupConfig = await aiConfigService.getDeduplicationConfig();
|
||||
MESSAGE_TTL = dedupConfig.ttl || 5 * 60 * 1000; // Дефолт 5 минут
|
||||
} catch (error) {
|
||||
logger.warn('[MessageDeduplication] Ошибка загрузки настроек, используем дефолт:', error.message);
|
||||
MESSAGE_TTL = 5 * 60 * 1000; // Дефолт 5 минут
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализируем настройки при загрузке модуля
|
||||
loadSettings().catch(err => logger.warn('[MessageDeduplication] Ошибка инициализации:', err.message));
|
||||
|
||||
/**
|
||||
* Создать хеш сообщения
|
||||
@@ -48,7 +63,12 @@ function createMessageHash(messageData) {
|
||||
* @param {Object} messageData - Данные сообщения
|
||||
* @returns {boolean} true если сообщение уже обрабатывалось
|
||||
*/
|
||||
function isDuplicate(messageData) {
|
||||
async function isDuplicate(messageData) {
|
||||
// Загружаем актуальные настройки, если они не загружены
|
||||
if (MESSAGE_TTL === null) {
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
const hash = createMessageHash(messageData);
|
||||
|
||||
if (processedMessages.has(hash)) {
|
||||
@@ -72,7 +92,12 @@ function isDuplicate(messageData) {
|
||||
* Пометить сообщение как обработанное
|
||||
* @param {Object} messageData - Данные сообщения
|
||||
*/
|
||||
function markAsProcessed(messageData) {
|
||||
async function markAsProcessed(messageData) {
|
||||
// Загружаем актуальные настройки, если они не загружены
|
||||
if (MESSAGE_TTL === null) {
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
const hash = createMessageHash(messageData);
|
||||
|
||||
processedMessages.set(hash, {
|
||||
@@ -91,11 +116,14 @@ function markAsProcessed(messageData) {
|
||||
* Очистить старые записи из хранилища
|
||||
*/
|
||||
function cleanupOldEntries() {
|
||||
// Если настройки не загружены, используем дефолт
|
||||
const ttl = MESSAGE_TTL || 5 * 60 * 1000;
|
||||
|
||||
const now = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [hash, entry] of processedMessages.entries()) {
|
||||
if (now - entry.timestamp > MESSAGE_TTL) {
|
||||
if (now - entry.timestamp > ttl) {
|
||||
processedMessages.delete(hash);
|
||||
cleanedCount++;
|
||||
}
|
||||
@@ -113,7 +141,7 @@ function cleanupOldEntries() {
|
||||
function getStats() {
|
||||
return {
|
||||
totalTracked: processedMessages.size,
|
||||
ttl: MESSAGE_TTL
|
||||
ttl: MESSAGE_TTL || 5 * 60 * 1000
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
828
backend/services/multiSourceSearchService.js
Normal file
828
backend/services/multiSourceSearchService.js
Normal file
@@ -0,0 +1,828 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
const vectorSearch = require('./vectorSearchClient');
|
||||
const ragService = require('./ragService');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
const userContextService = require('./userContextService');
|
||||
const encryptedDb = require('./encryptedDatabaseService');
|
||||
const db = require('../db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const DOCUMENT_SNIPPET_LENGTH = 350;
|
||||
|
||||
function resolveDocumentIdFromResult(result) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = result.metadata || {};
|
||||
const candidates = [metadata.doc_id, metadata.parent_doc_id];
|
||||
|
||||
for (const value of candidates) {
|
||||
const parsed = parseInt(value, 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result.row_id === 'string') {
|
||||
const match = result.row_id.match(/^(\d+)(?:_chunk_\d+)?$/);
|
||||
if (match) {
|
||||
const parsed = parseInt(match[1], 10);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof result.row_id === 'number' && Number.isFinite(result.row_id)) {
|
||||
return result.row_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractPlainText(content, format = 'text') {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
let plain = content;
|
||||
|
||||
if (format === 'html' || /<[^>]+>/.test(content)) {
|
||||
plain = plain
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ' ')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ');
|
||||
}
|
||||
|
||||
plain = plain
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
return plain;
|
||||
}
|
||||
|
||||
function buildSnippet(text, maxLength = DOCUMENT_SNIPPET_LENGTH) {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, maxLength)}...`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сервис для параллельного поиска в таблицах и документах
|
||||
* с комбинацией нескольких методов анализа
|
||||
*/
|
||||
class MultiSourceSearchService {
|
||||
constructor() {
|
||||
this.searchMethods = {
|
||||
semantic: 'semantic',
|
||||
keyword: 'keyword',
|
||||
hybrid: 'hybrid'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Основной метод поиска в нескольких источниках параллельно
|
||||
* @param {Object} options - Опции поиска
|
||||
* @param {string} options.query - Поисковый запрос
|
||||
* @param {Array<number>} options.tableIds - ID таблиц для поиска
|
||||
* @param {boolean} options.searchInDocuments - Поиск в документах (legal_docs)
|
||||
* @param {string} options.searchMethod - Метод поиска: 'semantic', 'keyword', 'hybrid'
|
||||
* @param {number} options.userId - ID пользователя (для фильтрации по тегам)
|
||||
* @param {number} options.maxResultsPerSource - Максимум результатов из каждого источника
|
||||
* @param {number} options.totalMaxResults - Максимум результатов всего
|
||||
* @returns {Promise<Object>} Объединенные результаты поиска
|
||||
*/
|
||||
async search({
|
||||
query,
|
||||
tableIds = [],
|
||||
searchInDocuments = true,
|
||||
searchMethod = 'hybrid',
|
||||
userId = null,
|
||||
maxResultsPerSource = 10,
|
||||
totalMaxResults = 20
|
||||
}) {
|
||||
logger.info(`[MultiSourceSearch] Поиск: query="${query}", tableIds=${tableIds.join(',')}, searchInDocuments=${searchInDocuments}, method=${searchMethod}`);
|
||||
|
||||
try {
|
||||
// Загружаем настройки RAG
|
||||
const ragConfig = await aiConfigService.getRAGConfig();
|
||||
const finalMaxResults = maxResultsPerSource || ragConfig.maxResults || 10;
|
||||
|
||||
// Параллельно запускаем поиск в разных источниках
|
||||
const searchPromises = [];
|
||||
|
||||
// 1. Поиск в таблицах (user_tables)
|
||||
if (tableIds && tableIds.length > 0) {
|
||||
for (const tableId of tableIds) {
|
||||
searchPromises.push(
|
||||
this.searchInTable({
|
||||
tableId,
|
||||
query,
|
||||
searchMethod,
|
||||
userId,
|
||||
maxResults: finalMaxResults
|
||||
}).catch(err => {
|
||||
logger.error(`[MultiSourceSearch] Ошибка поиска в таблице ${tableId}:`, err.message);
|
||||
return { source: 'table', tableId, results: [], error: err.message };
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Поиск в документах (legal_docs)
|
||||
if (searchInDocuments) {
|
||||
searchPromises.push(
|
||||
this.searchInDocuments({
|
||||
query,
|
||||
searchMethod,
|
||||
maxResults: finalMaxResults
|
||||
}).catch(err => {
|
||||
logger.error(`[MultiSourceSearch] Ошибка поиска в документах:`, err.message);
|
||||
return { source: 'documents', results: [], error: err.message };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Ждем результаты из всех источников
|
||||
logger.info(`[MultiSourceSearch] Ожидание результатов из ${searchPromises.length} источников...`);
|
||||
const promiseStartTime = Date.now();
|
||||
const searchResults = await Promise.all(searchPromises);
|
||||
const promiseDuration = Date.now() - promiseStartTime;
|
||||
logger.info(`[MultiSourceSearch] Получены результаты из всех источников за ${promiseDuration}ms, всего: ${searchResults.length}`);
|
||||
|
||||
// Объединяем результаты
|
||||
logger.info(`[MultiSourceSearch] Объединение результатов...`);
|
||||
const mergedResults = this.mergeResults(searchResults, {
|
||||
totalMaxResults,
|
||||
searchMethod
|
||||
});
|
||||
|
||||
logger.info(`[MultiSourceSearch] Найдено результатов: ${mergedResults.results.length} из ${searchResults.length} источников`);
|
||||
|
||||
return mergedResults;
|
||||
} catch (error) {
|
||||
logger.error(`[MultiSourceSearch] Ошибка поиска:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск в конкретной таблице
|
||||
* @param {Object} options - Опции поиска
|
||||
* @returns {Promise<Object>} Результаты поиска
|
||||
*/
|
||||
async searchInTable({
|
||||
tableId,
|
||||
query,
|
||||
searchMethod,
|
||||
userId,
|
||||
maxResults
|
||||
}) {
|
||||
logger.info(`[MultiSourceSearch] Поиск в таблице ${tableId}, метод: ${searchMethod}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
let result;
|
||||
switch (searchMethod) {
|
||||
case this.searchMethods.semantic:
|
||||
result = await this.semanticSearchInTable(tableId, query, userId, maxResults);
|
||||
break;
|
||||
|
||||
case this.searchMethods.keyword:
|
||||
result = await this.keywordSearchInTable(tableId, query, userId, maxResults);
|
||||
break;
|
||||
|
||||
case this.searchMethods.hybrid:
|
||||
default:
|
||||
result = await this.hybridSearchInTable(tableId, query, userId, maxResults);
|
||||
break;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[MultiSourceSearch] Поиск в таблице ${tableId} завершен за ${duration}ms, найдено: ${result?.results?.length || 0} результатов`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Семантический поиск в таблице (векторный)
|
||||
*/
|
||||
async semanticSearchInTable(tableId, query, userId, maxResults) {
|
||||
try {
|
||||
// Используем векторный поиск напрямую для получения нескольких результатов
|
||||
const vectorResults = await vectorSearch.search(tableId, query, maxResults);
|
||||
|
||||
// Получаем данные таблицы для формирования полных результатов
|
||||
const encryptionUtils = require('../utils/encryptionUtils');
|
||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||
const db = require('../db');
|
||||
|
||||
// Фильтруем по тегам пользователя, если указан
|
||||
let filteredRowIds = null;
|
||||
if (userId) {
|
||||
// Используем логику из ragService для фильтрации
|
||||
const userTagIds = await userContextService.getUserTags(userId);
|
||||
if (userTagIds && userTagIds.length > 0) {
|
||||
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
|
||||
const tagsColumn = columns.find(col =>
|
||||
col.options?.purpose === 'userTags' &&
|
||||
(col.type === 'multiselect-relation' || col.type === 'relation')
|
||||
);
|
||||
|
||||
if (tagsColumn) {
|
||||
const result = await db.getQuery()(`
|
||||
SELECT DISTINCT from_row_id
|
||||
FROM user_table_relations
|
||||
WHERE column_id = $1
|
||||
AND to_row_id = ANY($2)
|
||||
`, [tagsColumn.id, userTagIds]);
|
||||
filteredRowIds = result.rows.map(row => row.from_row_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Формируем результаты
|
||||
const results = [];
|
||||
for (const vectorResult of vectorResults) {
|
||||
const rowId = parseInt(vectorResult.row_id);
|
||||
|
||||
// Пропускаем, если фильтрация по тегам и строка не подходит
|
||||
if (filteredRowIds !== null && filteredRowIds.length > 0 && !filteredRowIds.includes(rowId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
source: 'table',
|
||||
sourceId: tableId,
|
||||
rowId: rowId,
|
||||
text: vectorResult.metadata?.answer || vectorResult.metadata?.text || '',
|
||||
context: vectorResult.metadata?.context || '',
|
||||
score: vectorResult.score || 0,
|
||||
metadata: {
|
||||
answer: vectorResult.metadata?.answer,
|
||||
context: vectorResult.metadata?.context,
|
||||
product: vectorResult.metadata?.product,
|
||||
priority: vectorResult.metadata?.priority,
|
||||
date: vectorResult.metadata?.date,
|
||||
userTags: vectorResult.metadata?.userTags
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'table',
|
||||
tableId,
|
||||
method: 'semantic',
|
||||
results,
|
||||
count: results.length
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[MultiSourceSearch] Ошибка семантического поиска в таблице ${tableId}:`, error);
|
||||
return {
|
||||
source: 'table',
|
||||
tableId,
|
||||
method: 'semantic',
|
||||
results: [],
|
||||
count: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по ключевым словам в таблице
|
||||
*/
|
||||
async keywordSearchInTable(tableId, query, userId, maxResults) {
|
||||
// Извлекаем ключевые слова из запроса
|
||||
const keywords = this.extractKeywords(query);
|
||||
|
||||
if (keywords.length === 0) {
|
||||
return {
|
||||
source: 'table',
|
||||
tableId,
|
||||
method: 'keyword',
|
||||
results: [],
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Используем RAG сервис для получения данных таблицы
|
||||
const encryptedDb = require('./encryptedDatabaseService');
|
||||
const encryptionUtils = require('../utils/encryptionUtils');
|
||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||
const db = require('../db');
|
||||
|
||||
// Получаем строки таблицы
|
||||
const rowsResult = await db.getQuery()(
|
||||
'SELECT id FROM user_rows WHERE table_id = $1',
|
||||
[tableId]
|
||||
);
|
||||
const rows = rowsResult.rows;
|
||||
|
||||
// Фильтруем по тегам пользователя, если указан
|
||||
let filteredRowIds = rows.map(r => r.id);
|
||||
if (userId) {
|
||||
const userTagIds = await userContextService.getUserTags(userId);
|
||||
if (userTagIds && userTagIds.length > 0) {
|
||||
// Получаем строки с тегами пользователя через user_table_relations
|
||||
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
|
||||
const tagsColumn = columns.find(col =>
|
||||
col.options?.purpose === 'userTags' &&
|
||||
(col.type === 'multiselect-relation' || col.type === 'relation')
|
||||
);
|
||||
|
||||
if (tagsColumn) {
|
||||
const filteredRowsResult = await db.getQuery()(
|
||||
`SELECT DISTINCT from_row_id as id
|
||||
FROM user_table_relations
|
||||
WHERE column_id = $1
|
||||
AND to_row_id = ANY($2)`,
|
||||
[tagsColumn.id, userTagIds]
|
||||
);
|
||||
filteredRowIds = filteredRowsResult.rows.map(r => r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем данные ячеек для каждой строки
|
||||
const results = [];
|
||||
for (const rowId of filteredRowIds) {
|
||||
const cellsResult = await db.getQuery()(
|
||||
`SELECT
|
||||
uc.id as column_id,
|
||||
decrypt_text(uc.name_encrypted, $1) as column_name,
|
||||
decrypt_text(ucv.value_encrypted, $1) as value
|
||||
FROM user_cell_values ucv
|
||||
JOIN user_columns uc ON uc.id = ucv.column_id
|
||||
WHERE ucv.row_id = $2 AND uc.table_id = $3`,
|
||||
[encryptionKey, rowId, tableId]
|
||||
);
|
||||
const cells = cellsResult.rows;
|
||||
|
||||
// Формируем текст строки из всех ячеек
|
||||
const rowText = cells
|
||||
.map(cell => `${cell.column_name}: ${cell.value}`)
|
||||
.join(' ');
|
||||
|
||||
// Вычисляем совпадение по ключевым словам
|
||||
const matchScore = this.calculateKeywordMatch(rowText, keywords);
|
||||
|
||||
if (matchScore > 0) {
|
||||
// Ищем столбец с ответом (purpose: 'answer')
|
||||
// Получаем столбцы для проверки purpose
|
||||
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
|
||||
const answerColumn = columns.find(col => col.options?.purpose === 'answer');
|
||||
const answerColumnId = answerColumn ? answerColumn.id : null;
|
||||
|
||||
const answerCell = answerColumnId
|
||||
? cells.find(c => c.column_id === answerColumnId)
|
||||
: cells.find(c => {
|
||||
// Fallback: ищем по названию
|
||||
return c.column_name && (
|
||||
c.column_name.toLowerCase().includes('ответ') ||
|
||||
c.column_name.toLowerCase().includes('answer')
|
||||
);
|
||||
});
|
||||
|
||||
const questionColumn = columns.find(col => col.options?.purpose === 'question');
|
||||
const questionColumnId = questionColumn ? questionColumn.id : null;
|
||||
const questionCell = questionColumnId
|
||||
? cells.find(c => c.column_id === questionColumnId)
|
||||
: null;
|
||||
|
||||
results.push({
|
||||
source: 'table',
|
||||
sourceId: tableId,
|
||||
rowId: rowId,
|
||||
text: answerCell ? answerCell.value : rowText,
|
||||
context: questionCell ? questionCell.value : rowText,
|
||||
score: matchScore,
|
||||
metadata: {
|
||||
method: 'keyword',
|
||||
keywords: keywords,
|
||||
rowData: cells.reduce((acc, cell) => {
|
||||
acc[cell.column_name] = cell.value;
|
||||
return acc;
|
||||
}, {})
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по релевантности и берем топ-N
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
const topResults = results.slice(0, maxResults);
|
||||
|
||||
return {
|
||||
source: 'table',
|
||||
tableId,
|
||||
method: 'keyword',
|
||||
results: topResults,
|
||||
count: topResults.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Гибридный поиск в таблице (семантический + ключевые слова)
|
||||
*/
|
||||
async hybridSearchInTable(tableId, query, userId, maxResults) {
|
||||
// Параллельно выполняем оба типа поиска
|
||||
const [semanticResults, keywordResults] = await Promise.all([
|
||||
this.semanticSearchInTable(tableId, query, userId, maxResults * 2),
|
||||
this.keywordSearchInTable(tableId, query, userId, maxResults * 2)
|
||||
]);
|
||||
|
||||
// Объединяем результаты с весами
|
||||
const semanticWeight = 0.7;
|
||||
const keywordWeight = 0.3;
|
||||
|
||||
const combined = this.combineSearchResults(
|
||||
semanticResults.results,
|
||||
keywordResults.results,
|
||||
semanticWeight,
|
||||
keywordWeight
|
||||
);
|
||||
|
||||
// Сортируем и берем топ-N
|
||||
combined.sort((a, b) => b.combinedScore - a.combinedScore);
|
||||
const topResults = combined.slice(0, maxResults);
|
||||
|
||||
return {
|
||||
source: 'table',
|
||||
tableId,
|
||||
method: 'hybrid',
|
||||
results: topResults.map(r => ({
|
||||
...r,
|
||||
score: r.combinedScore
|
||||
})),
|
||||
count: topResults.length,
|
||||
semanticCount: semanticResults.count,
|
||||
keywordCount: keywordResults.count
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск в документах (legal_docs)
|
||||
*/
|
||||
async searchInDocuments({
|
||||
query,
|
||||
searchMethod,
|
||||
maxResults
|
||||
}) {
|
||||
logger.info(`[MultiSourceSearch] Поиск в документах, метод: ${searchMethod}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
const tableId = 'legal_docs';
|
||||
|
||||
try {
|
||||
// Векторный поиск в документах
|
||||
const vectorResults = await vectorSearch.search(tableId, query, maxResults * 2);
|
||||
|
||||
const documentIds = new Set();
|
||||
for (const result of vectorResults) {
|
||||
const docId = resolveDocumentIdFromResult(result);
|
||||
if (docId !== null) {
|
||||
documentIds.add(docId);
|
||||
}
|
||||
}
|
||||
|
||||
const documentSnippets = new Map();
|
||||
if (documentIds.size > 0) {
|
||||
const idsArray = Array.from(documentIds);
|
||||
try {
|
||||
const queryFn = db.getQuery();
|
||||
const { rows } = await queryFn(
|
||||
`SELECT id, content, format FROM admin_pages_simple WHERE id = ANY($1::int[])`,
|
||||
[idsArray]
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const snippet = buildSnippet(extractPlainText(row.content, row.format));
|
||||
documentSnippets.set(String(row.id), snippet);
|
||||
}
|
||||
} catch (dbError) {
|
||||
logger.warn(`[MultiSourceSearch] Не удалось загрузить содержимое документов: ${dbError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Формируем результаты
|
||||
const results = vectorResults.map(result => {
|
||||
const metadata = result.metadata || {};
|
||||
const docId = resolveDocumentIdFromResult(result);
|
||||
const docKey = docId !== null ? String(docId) : null;
|
||||
|
||||
const chunkText = buildSnippet(result.text || metadata.content || metadata.text || '');
|
||||
const fallbackText = docKey ? documentSnippets.get(docKey) : '';
|
||||
const finalText = chunkText || fallbackText || '';
|
||||
|
||||
const contextValue = metadata.title || metadata.section || '';
|
||||
|
||||
return {
|
||||
source: 'document',
|
||||
sourceId: tableId,
|
||||
rowId: result.row_id,
|
||||
text: finalText,
|
||||
context: contextValue,
|
||||
score: result.score || 0,
|
||||
metadata: {
|
||||
doc_id: metadata.doc_id || docId,
|
||||
title: metadata.title,
|
||||
url: metadata.url,
|
||||
format: metadata.format,
|
||||
visibility: metadata.visibility,
|
||||
section: metadata.section,
|
||||
chunk_index: metadata.chunk_index,
|
||||
snippetSource: chunkText ? 'chunk' : (fallbackText ? 'document' : 'unknown')
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Если гибридный поиск, добавляем поиск по ключевым словам
|
||||
if (searchMethod === this.searchMethods.hybrid) {
|
||||
const keywordResults = await this.keywordSearchInDocuments(query, maxResults);
|
||||
|
||||
// Объединяем результаты
|
||||
const combined = this.combineSearchResults(
|
||||
results,
|
||||
keywordResults,
|
||||
0.7, // вес для семантического
|
||||
0.3 // вес для ключевых слов
|
||||
);
|
||||
|
||||
combined.sort((a, b) => b.combinedScore - a.combinedScore);
|
||||
const topResults = combined.slice(0, maxResults);
|
||||
|
||||
return {
|
||||
source: 'documents',
|
||||
method: 'hybrid',
|
||||
results: topResults.map(r => ({
|
||||
...r,
|
||||
score: r.combinedScore
|
||||
})),
|
||||
count: topResults.length
|
||||
};
|
||||
}
|
||||
|
||||
// Сортируем по релевантности
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
const topResults = results.slice(0, maxResults);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.info(`[MultiSourceSearch] Поиск в документах завершен за ${duration}ms, найдено: ${topResults.length} результатов`);
|
||||
|
||||
return {
|
||||
source: 'documents',
|
||||
method: searchMethod,
|
||||
results: topResults,
|
||||
count: topResults.length
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error(`[MultiSourceSearch] Ошибка поиска в документах за ${duration}ms:`, error);
|
||||
return {
|
||||
source: 'documents',
|
||||
method: searchMethod,
|
||||
results: [],
|
||||
count: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Поиск по ключевым словам в документах
|
||||
*/
|
||||
async keywordSearchInDocuments(query, maxResults) {
|
||||
// Для поиска по ключевым словам в документах нужно получить все документы
|
||||
// и фильтровать по ключевым словам. Это может быть медленно для больших объемов.
|
||||
// В будущем можно добавить индекс для ключевых слов.
|
||||
|
||||
const keywords = this.extractKeywords(query);
|
||||
|
||||
// Пока возвращаем пустой результат, т.к. для документов векторный поиск обычно достаточно
|
||||
// Для полноценной реализации нужно добавить индекс ключевых слов
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Объединение результатов из разных источников
|
||||
*/
|
||||
mergeResults(searchResults, options = {}) {
|
||||
const { totalMaxResults = 20, searchMethod = 'hybrid' } = options;
|
||||
|
||||
const allResults = [];
|
||||
|
||||
// Собираем все результаты из всех источников
|
||||
for (const searchResult of searchResults) {
|
||||
if (searchResult.results && searchResult.results.length > 0) {
|
||||
allResults.push(...searchResult.results.map(result => ({
|
||||
...result,
|
||||
sourceType: searchResult.source,
|
||||
sourceId: searchResult.tableId || searchResult.sourceId
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем дубликаты (по rowId и sourceType)
|
||||
const uniqueResults = this.removeDuplicates(allResults);
|
||||
|
||||
// Сортируем по релевантности
|
||||
uniqueResults.sort((a, b) => {
|
||||
// Приоритет: таблицы > документы (можно настроить)
|
||||
const sourcePriority = {
|
||||
table: 1.0,
|
||||
document: 0.9
|
||||
};
|
||||
|
||||
const priorityA = sourcePriority[a.sourceType] || 0.8;
|
||||
const priorityB = sourcePriority[b.sourceType] || 0.8;
|
||||
|
||||
// Комбинируем релевантность и приоритет источника
|
||||
const scoreA = (a.score || 0) * priorityA;
|
||||
const scoreB = (b.score || 0) * priorityB;
|
||||
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
// Берем топ-N результатов
|
||||
const topResults = uniqueResults.slice(0, totalMaxResults);
|
||||
|
||||
// Группируем по источникам для статистики
|
||||
const sourcesStats = {};
|
||||
for (const result of topResults) {
|
||||
const sourceKey = `${result.sourceType}_${result.sourceId || 'unknown'}`;
|
||||
if (!sourcesStats[sourceKey]) {
|
||||
sourcesStats[sourceKey] = {
|
||||
source: result.sourceType,
|
||||
sourceId: result.sourceId,
|
||||
count: 0
|
||||
};
|
||||
}
|
||||
sourcesStats[sourceKey].count++;
|
||||
}
|
||||
|
||||
return {
|
||||
results: topResults,
|
||||
totalCount: topResults.length,
|
||||
sourcesCount: searchResults.length,
|
||||
sourcesStats: Object.values(sourcesStats),
|
||||
searchMethod
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Объединение результатов семантического и ключевого поиска
|
||||
*/
|
||||
combineSearchResults(semanticResults, keywordResults, semanticWeight, keywordWeight) {
|
||||
const combined = new Map();
|
||||
|
||||
// Нормализуем скоры для семантического поиска
|
||||
const normalizedSemantic = this.normalizeScores(semanticResults);
|
||||
|
||||
// Нормализуем скоры для поиска по ключевым словам
|
||||
const normalizedKeyword = this.normalizeScores(keywordResults);
|
||||
|
||||
// Добавляем результаты семантического поиска
|
||||
normalizedSemantic.forEach(result => {
|
||||
const key = `${result.source}_${result.sourceId}_${result.rowId || 'unknown'}`;
|
||||
combined.set(key, {
|
||||
...result,
|
||||
semanticScore: result.score,
|
||||
keywordScore: 0,
|
||||
combinedScore: result.score * semanticWeight
|
||||
});
|
||||
});
|
||||
|
||||
// Добавляем результаты поиска по ключевым словам
|
||||
normalizedKeyword.forEach(result => {
|
||||
const key = `${result.source}_${result.sourceId}_${result.rowId || 'unknown'}`;
|
||||
const existing = combined.get(key);
|
||||
|
||||
if (existing) {
|
||||
// Объединяем скоры
|
||||
existing.keywordScore = result.score;
|
||||
existing.combinedScore = (existing.semanticScore * semanticWeight) + (result.score * keywordWeight);
|
||||
} else {
|
||||
// Новый результат
|
||||
combined.set(key, {
|
||||
...result,
|
||||
semanticScore: 0,
|
||||
keywordScore: result.score,
|
||||
combinedScore: result.score * keywordWeight
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(combined.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализация скоров (0-1)
|
||||
*/
|
||||
normalizeScores(results) {
|
||||
if (results.length === 0) return [];
|
||||
|
||||
const scores = results.map(r => Math.abs(r.score || 0));
|
||||
const maxScore = Math.max(...scores);
|
||||
const minScore = Math.min(...scores);
|
||||
const range = maxScore - minScore || 1;
|
||||
|
||||
return results.map(result => ({
|
||||
...result,
|
||||
score: range > 0 ? (Math.abs(result.score || 0) - minScore) / range : 0.5
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечение ключевых слов из запроса
|
||||
*/
|
||||
extractKeywords(query) {
|
||||
if (!query || typeof query !== 'string') return [];
|
||||
|
||||
// Удаляем стоп-слова
|
||||
const stopWords = new Set([
|
||||
'как', 'что', 'где', 'когда', 'почему', 'кто', 'куда', 'откуда',
|
||||
'для', 'при', 'над', 'под', 'перед', 'после', 'через',
|
||||
'и', 'или', 'но', 'а', 'да', 'нет', 'не',
|
||||
'в', 'на', 'с', 'со', 'из', 'к', 'от', 'до', 'по', 'о', 'об', 'обо',
|
||||
'это', 'этот', 'эта', 'эти', 'этот', 'тот', 'та', 'те', 'то',
|
||||
'быть', 'есть', 'был', 'была', 'было', 'были'
|
||||
]);
|
||||
|
||||
// Разбиваем на слова (сохраняем кириллицу и латиницу)
|
||||
const words = query
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s\u0400-\u04FF]/g, ' ') // \u0400-\u04FF - диапазон кириллицы
|
||||
.split(/\s+/)
|
||||
.filter(word => word.length > 2 && !stopWords.has(word));
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Расчет совпадения по ключевым словам
|
||||
*/
|
||||
calculateKeywordMatch(text, keywords) {
|
||||
if (!text || !keywords || keywords.length === 0) return 0;
|
||||
|
||||
const textLower = text.toLowerCase();
|
||||
let matchCount = 0;
|
||||
|
||||
for (const keyword of keywords) {
|
||||
if (textLower.includes(keyword.toLowerCase())) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаем процент совпадения
|
||||
return keywords.length > 0 ? matchCount / keywords.length : 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Удаление дубликатов из результатов
|
||||
*/
|
||||
removeDuplicates(results) {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
|
||||
for (const result of results) {
|
||||
const key = `${result.sourceType}_${result.sourceId}_${result.rowId || 'unknown'}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
unique.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем singleton экземпляр
|
||||
const multiSourceSearchService = new MultiSourceSearchService();
|
||||
|
||||
module.exports = multiSourceSearchService;
|
||||
|
||||
@@ -12,57 +12,67 @@
|
||||
|
||||
/**
|
||||
* Конфигурационный сервис для Ollama и AI инфраструктуры
|
||||
* Централизует все настройки, URL и таймауты для:
|
||||
* - Ollama API
|
||||
* - Vector Search
|
||||
* - AI Cache
|
||||
* - AI Queue
|
||||
* Обёртка над aiConfigService для обратной совместимости
|
||||
*
|
||||
* ВАЖНО: Настройки берутся из таблицы ai_providers_settings (через aiProviderSettingsService)
|
||||
* ВАЖНО: Все настройки теперь берутся из ai_config через aiConfigService
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
|
||||
// Кэш для настроек из БД
|
||||
let settingsCache = null;
|
||||
// Кэш для синхронных методов (для обратной совместимости)
|
||||
let syncCache = null;
|
||||
let syncCacheTimestamp = 0;
|
||||
const SYNC_CACHE_TTL = 60000; // 1 минута
|
||||
|
||||
/**
|
||||
* Загружает настройки Ollama из базы данных
|
||||
* @returns {Promise<Object>} Настройки Ollama провайдера
|
||||
* Обновляет синхронный кэш из aiConfigService
|
||||
* @private
|
||||
*/
|
||||
async function loadSettingsFromDb() {
|
||||
async function _updateSyncCache() {
|
||||
try {
|
||||
const aiProviderSettingsService = require('./aiProviderSettingsService');
|
||||
const settings = await aiProviderSettingsService.getProviderSettings('ollama');
|
||||
|
||||
if (settings) {
|
||||
settingsCache = settings;
|
||||
logger.info(`[ollamaConfig] Loaded settings from DB: model=${settings.selected_model}, base_url=${settings.base_url}`);
|
||||
}
|
||||
|
||||
return settings;
|
||||
const ollamaConfig = await aiConfigService.getOllamaConfig();
|
||||
syncCache = {
|
||||
baseUrl: ollamaConfig.baseUrl,
|
||||
defaultModel: ollamaConfig.llmModel,
|
||||
embeddingModel: ollamaConfig.embeddingModel
|
||||
};
|
||||
syncCacheTimestamp = Date.now();
|
||||
} catch (error) {
|
||||
logger.error('[ollamaConfig] Ошибка загрузки настроек Ollama из БД:', error.message);
|
||||
return null;
|
||||
logger.warn('[ollamaConfig] Failed to update sync cache:', error.message);
|
||||
// Используем дефолты
|
||||
syncCache = {
|
||||
baseUrl: process.env.OLLAMA_BASE_URL || 'http://ollama:11434',
|
||||
defaultModel: process.env.OLLAMA_MODEL || 'qwen2.5:7b',
|
||||
embeddingModel: process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large:latest'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренняя функция: определяет base URL из доступных источников
|
||||
* Приоритет: кэш из БД > переменная окружения > Docker дефолт
|
||||
* @returns {string} Базовый URL Ollama
|
||||
* Получает значение из синхронного кэша или обновляет его
|
||||
* @private
|
||||
*/
|
||||
function _getBaseUrlFromSources() {
|
||||
// Приоритет 1: кэш из БД
|
||||
if (settingsCache && settingsCache.base_url) {
|
||||
return settingsCache.base_url;
|
||||
function _getFromSyncCache(key) {
|
||||
const now = Date.now();
|
||||
if (!syncCache || (now - syncCacheTimestamp) > SYNC_CACHE_TTL) {
|
||||
// Обновляем кэш асинхронно (не блокируя)
|
||||
_updateSyncCache().catch(err => logger.warn('[ollamaConfig] Sync cache update failed:', err.message));
|
||||
}
|
||||
// Приоритет 2: переменная окружения
|
||||
if (process.env.OLLAMA_BASE_URL) {
|
||||
return process.env.OLLAMA_BASE_URL;
|
||||
|
||||
// Если кэш есть - используем его
|
||||
if (syncCache && syncCache[key]) {
|
||||
return syncCache[key];
|
||||
}
|
||||
// Приоритет 3: Docker дефолт
|
||||
return 'http://ollama:11434';
|
||||
|
||||
// Иначе используем дефолты
|
||||
const defaults = {
|
||||
baseUrl: process.env.OLLAMA_BASE_URL || 'http://ollama:11434',
|
||||
defaultModel: process.env.OLLAMA_MODEL || 'qwen2.5:7b',
|
||||
embeddingModel: process.env.OLLAMA_EMBED_MODEL || 'mxbai-embed-large:latest'
|
||||
};
|
||||
|
||||
return defaults[key] || defaults.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +80,7 @@ function _getBaseUrlFromSources() {
|
||||
* @returns {string} Базовый URL Ollama
|
||||
*/
|
||||
function getBaseUrl() {
|
||||
return _getBaseUrlFromSources();
|
||||
return _getFromSyncCache('baseUrl');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,15 +88,8 @@ function getBaseUrl() {
|
||||
* @returns {Promise<string>} Базовый URL Ollama
|
||||
*/
|
||||
async function getBaseUrlAsync() {
|
||||
try {
|
||||
if (!settingsCache) {
|
||||
await loadSettingsFromDb();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[ollamaConfig] Failed to load base_url from DB, using default');
|
||||
}
|
||||
|
||||
return _getBaseUrlFromSources();
|
||||
const config = await aiConfigService.getOllamaConfig();
|
||||
return config.baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,12 +107,7 @@ function getApiUrl(endpoint) {
|
||||
* @returns {string} Название модели
|
||||
*/
|
||||
function getDefaultModel() {
|
||||
// Приоритет: кэш из БД > дефолт
|
||||
if (settingsCache && settingsCache.selected_model) {
|
||||
return settingsCache.selected_model;
|
||||
}
|
||||
// Дефолтное значение если БД недоступна
|
||||
return 'qwen2.5:7b';
|
||||
return _getFromSyncCache('defaultModel');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,19 +115,8 @@ function getDefaultModel() {
|
||||
* @returns {Promise<string>} Название модели из БД
|
||||
*/
|
||||
async function getDefaultModelAsync() {
|
||||
try {
|
||||
if (!settingsCache) {
|
||||
await loadSettingsFromDb();
|
||||
}
|
||||
|
||||
if (settingsCache && settingsCache.selected_model) {
|
||||
logger.info(`[ollamaConfig] Using model from DB: ${settingsCache.selected_model}`);
|
||||
return settingsCache.selected_model;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('[ollamaConfig] Failed to load model from DB, using default');
|
||||
}
|
||||
return 'qwen2.5:7b';
|
||||
const config = await aiConfigService.getOllamaConfig();
|
||||
return config.llmModel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,59 +124,116 @@ async function getDefaultModelAsync() {
|
||||
* @returns {Promise<string>} Название embedding модели из БД
|
||||
*/
|
||||
async function getEmbeddingModel() {
|
||||
const config = await aiConfigService.getOllamaConfig();
|
||||
return config.embeddingModel;
|
||||
}
|
||||
|
||||
// Кэш для таймаутов (синхронный доступ)
|
||||
let timeoutsCache = null;
|
||||
let timeoutsCacheTimestamp = 0;
|
||||
|
||||
/**
|
||||
* Обновляет кэш таймаутов из aiConfigService
|
||||
* @private
|
||||
*/
|
||||
async function _updateTimeoutsCache() {
|
||||
try {
|
||||
if (!settingsCache) {
|
||||
await loadSettingsFromDb();
|
||||
}
|
||||
const timeouts = await aiConfigService.getTimeouts();
|
||||
const cacheConfig = await aiConfigService.getCacheConfig();
|
||||
const queueConfig = await aiConfigService.getQueueConfig();
|
||||
|
||||
if (settingsCache && settingsCache.embedding_model) {
|
||||
logger.info(`[ollamaConfig] Using embedding model from DB: ${settingsCache.embedding_model}`);
|
||||
return settingsCache.embedding_model;
|
||||
}
|
||||
timeoutsCache = {
|
||||
// Ollama API - таймауты запросов
|
||||
ollamaChat: timeouts.ollamaChat,
|
||||
ollamaEmbedding: timeouts.ollamaEmbedding,
|
||||
ollamaHealth: timeouts.ollamaHealth,
|
||||
ollamaTags: timeouts.ollamaTags,
|
||||
|
||||
// Vector Search - таймауты запросов
|
||||
vectorSearch: timeouts.vectorSearch,
|
||||
vectorUpsert: timeouts.vectorUpsert,
|
||||
vectorHealth: timeouts.vectorHealth,
|
||||
|
||||
// AI Cache - TTL (Time To Live) для кэширования
|
||||
cacheLLM: cacheConfig.llmTTL,
|
||||
cacheRAG: cacheConfig.ragTTL,
|
||||
cacheMax: cacheConfig.maxSize,
|
||||
|
||||
// AI Queue - параметры очереди
|
||||
queueTimeout: queueConfig.timeout,
|
||||
queueMaxSize: queueConfig.maxSize,
|
||||
queueInterval: queueConfig.interval,
|
||||
|
||||
// Default для совместимости
|
||||
default: timeouts.ollamaChat
|
||||
};
|
||||
timeoutsCacheTimestamp = Date.now();
|
||||
} catch (error) {
|
||||
logger.warn('[ollamaConfig] Failed to load embedding model from DB, using default');
|
||||
logger.warn('[ollamaConfig] Failed to update timeouts cache:', error.message);
|
||||
// Используем дефолты
|
||||
timeoutsCache = {
|
||||
ollamaChat: 600000,
|
||||
ollamaEmbedding: 90000,
|
||||
ollamaHealth: 5000,
|
||||
ollamaTags: 10000,
|
||||
vectorSearch: 90000,
|
||||
vectorUpsert: 600000,
|
||||
vectorHealth: 5000,
|
||||
cacheLLM: 86400000,
|
||||
cacheRAG: 300000,
|
||||
cacheMax: 1000,
|
||||
queueTimeout: 180000,
|
||||
queueMaxSize: 100,
|
||||
queueInterval: 100,
|
||||
default: 180000
|
||||
};
|
||||
}
|
||||
return 'mxbai-embed-large:latest';
|
||||
}
|
||||
|
||||
/**
|
||||
* Централизованные таймауты для Ollama и AI сервисов
|
||||
* Синхронная версия с кэшированием (для обратной совместимости)
|
||||
* @returns {Object} Объект с различными таймаутами
|
||||
*/
|
||||
function getTimeouts() {
|
||||
const now = Date.now();
|
||||
if (!timeoutsCache || (now - timeoutsCacheTimestamp) > SYNC_CACHE_TTL) {
|
||||
// Обновляем кэш асинхронно (не блокируя)
|
||||
_updateTimeoutsCache().catch(err => logger.warn('[ollamaConfig] Timeouts cache update failed:', err.message));
|
||||
}
|
||||
|
||||
// Если кэш есть - используем его
|
||||
if (timeoutsCache) {
|
||||
return timeoutsCache;
|
||||
}
|
||||
|
||||
// Иначе используем дефолты
|
||||
return {
|
||||
// Ollama API - таймауты запросов
|
||||
ollamaChat: 180000, // 180 сек (3 мин) - генерация ответов LLM (увеличено для сложных запросов)
|
||||
ollamaEmbedding: 90000, // 90 сек (1.5 мин) - генерация embeddings (увеличено)
|
||||
ollamaHealth: 5000, // 5 сек - health check
|
||||
ollamaTags: 10000, // 10 сек - список моделей
|
||||
|
||||
// Vector Search - таймауты запросов
|
||||
vectorSearch: 90000, // 90 сек - поиск по векторам (увеличено для больших баз)
|
||||
vectorUpsert: 90000, // 90 сек - индексация данных (увеличено)
|
||||
vectorHealth: 5000, // 5 сек - health check
|
||||
|
||||
// AI Cache - TTL (Time To Live) для кэширования
|
||||
cacheLLM: 24 * 60 * 60 * 1000, // 24 часа - LLM ответы
|
||||
cacheRAG: 5 * 60 * 1000, // 5 минут - RAG результаты
|
||||
cacheMax: 1000, // Максимум записей в кэше
|
||||
|
||||
// AI Queue - параметры очереди
|
||||
queueTimeout: 180000, // 180 сек - таймаут задачи в очереди (увеличено)
|
||||
queueMaxSize: 100, // Максимум задач в очереди
|
||||
queueInterval: 100, // 100 мс - интервал проверки очереди
|
||||
|
||||
// Default для совместимости
|
||||
default: 180000 // 180 сек (увеличено с 120)
|
||||
ollamaChat: 600000,
|
||||
ollamaEmbedding: 90000,
|
||||
ollamaHealth: 5000,
|
||||
ollamaTags: 10000,
|
||||
vectorSearch: 90000,
|
||||
vectorUpsert: 600000,
|
||||
vectorHealth: 5000,
|
||||
cacheLLM: 86400000,
|
||||
cacheRAG: 300000,
|
||||
cacheMax: 1000,
|
||||
queueTimeout: 180000,
|
||||
queueMaxSize: 100,
|
||||
queueInterval: 100,
|
||||
default: 180000
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает timeout для запросов к Ollama (обратная совместимость)
|
||||
* Синхронная версия (для обратной совместимости)
|
||||
* @returns {number} Timeout в миллисекундах
|
||||
*/
|
||||
function getTimeout() {
|
||||
return getTimeouts().ollamaChat; // 120 секунд (2 минуты) - для генерации длинных ответов
|
||||
const timeouts = getTimeouts();
|
||||
return timeouts.ollamaChat;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,36 +241,13 @@ function getTimeout() {
|
||||
* @returns {Object} Объект с конфигурацией
|
||||
*/
|
||||
function getConfig() {
|
||||
return {
|
||||
baseUrl: getBaseUrl(),
|
||||
defaultModel: getDefaultModel(),
|
||||
timeout: getTimeout(),
|
||||
apiUrl: {
|
||||
tags: getApiUrl('tags'),
|
||||
generate: getApiUrl('generate'),
|
||||
chat: getApiUrl('chat'),
|
||||
models: getApiUrl('models'),
|
||||
show: getApiUrl('show'),
|
||||
pull: getApiUrl('pull'),
|
||||
push: getApiUrl('push')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все конфигурационные параметры Ollama (асинхронная версия)
|
||||
* @returns {Promise<Object>} Объект с конфигурацией
|
||||
*/
|
||||
async function getConfigAsync() {
|
||||
const baseUrl = await getBaseUrlAsync();
|
||||
const defaultModel = await getDefaultModelAsync();
|
||||
const embeddingModel = await getEmbeddingModel();
|
||||
const baseUrl = getBaseUrl();
|
||||
const defaultModel = getDefaultModel();
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
defaultModel,
|
||||
embeddingModel,
|
||||
timeout: getTimeout(),
|
||||
timeout: null, // Теперь асинхронный
|
||||
apiUrl: {
|
||||
tags: `${baseUrl}/api/tags`,
|
||||
generate: `${baseUrl}/api/generate`,
|
||||
@@ -239,11 +260,60 @@ async function getConfigAsync() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает все конфигурационные параметры Ollama (асинхронная версия)
|
||||
* @returns {Promise<Object>} Объект с конфигурацией
|
||||
*/
|
||||
async function getConfigAsync() {
|
||||
const ollamaConfig = await aiConfigService.getOllamaConfig();
|
||||
const timeout = await getTimeout();
|
||||
|
||||
return {
|
||||
baseUrl: ollamaConfig.baseUrl,
|
||||
defaultModel: ollamaConfig.llmModel,
|
||||
embeddingModel: ollamaConfig.embeddingModel,
|
||||
timeout,
|
||||
apiUrl: {
|
||||
tags: `${ollamaConfig.baseUrl}/api/tags`,
|
||||
generate: `${ollamaConfig.baseUrl}/api/generate`,
|
||||
chat: `${ollamaConfig.baseUrl}/api/chat`,
|
||||
models: `${ollamaConfig.baseUrl}/api/models`,
|
||||
show: `${ollamaConfig.baseUrl}/api/show`,
|
||||
pull: `${ollamaConfig.baseUrl}/api/pull`,
|
||||
push: `${ollamaConfig.baseUrl}/api/push`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает настройки Ollama из базы данных (для обратной совместимости)
|
||||
* @returns {Promise<Object>} Настройки Ollama провайдера
|
||||
*/
|
||||
async function loadSettingsFromDb() {
|
||||
try {
|
||||
const config = await aiConfigService.getOllamaConfig();
|
||||
// Обновляем синхронный кэш
|
||||
await _updateSyncCache();
|
||||
return {
|
||||
base_url: config.baseUrl,
|
||||
selected_model: config.llmModel,
|
||||
embedding_model: config.embeddingModel
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[ollamaConfig] Ошибка загрузки настроек Ollama из БД:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает кэш настроек (для перезагрузки)
|
||||
*/
|
||||
function clearCache() {
|
||||
settingsCache = null;
|
||||
syncCache = null;
|
||||
syncCacheTimestamp = 0;
|
||||
timeoutsCache = null;
|
||||
timeoutsCacheTimestamp = 0;
|
||||
aiConfigService.invalidateCache();
|
||||
logger.info('[ollamaConfig] Settings cache cleared');
|
||||
}
|
||||
|
||||
@@ -253,7 +323,7 @@ function clearCache() {
|
||||
*/
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const baseUrl = getBaseUrl();
|
||||
const baseUrl = await getBaseUrlAsync();
|
||||
const response = await fetch(`${baseUrl}/api/tags`);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -265,10 +335,12 @@ async function checkHealth() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const defaultModel = await getDefaultModelAsync();
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
baseUrl,
|
||||
model: getDefaultModel(),
|
||||
model: defaultModel,
|
||||
availableModels: data.models?.length || 0
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -280,6 +352,14 @@ async function checkHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация синхронного кэша при загрузке модуля
|
||||
_updateSyncCache().catch(err => {
|
||||
logger.warn('[ollamaConfig] Initial sync cache update failed:', err.message);
|
||||
});
|
||||
_updateTimeoutsCache().catch(err => {
|
||||
logger.warn('[ollamaConfig] Initial timeouts cache update failed:', err.message);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getBaseUrl,
|
||||
getBaseUrlAsync,
|
||||
@@ -287,11 +367,12 @@ module.exports = {
|
||||
getDefaultModel,
|
||||
getDefaultModelAsync,
|
||||
getEmbeddingModel,
|
||||
getTimeout, // Обратная совместимость (возвращает ollamaChat timeout)
|
||||
getTimeouts, // ✨ НОВОЕ: Централизованные таймауты для всех сервисов
|
||||
getTimeout, // Синхронная версия (для обратной совместимости)
|
||||
getTimeouts, // Синхронная версия с кэшированием (для обратной совместимости)
|
||||
getConfig,
|
||||
getConfigAsync,
|
||||
loadSettingsFromDb,
|
||||
clearCache,
|
||||
checkHealth
|
||||
};
|
||||
|
||||
|
||||
610
backend/services/profileAnalysisService.js
Normal file
610
backend/services/profileAnalysisService.js
Normal file
@@ -0,0 +1,610 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Profile Analysis Service
|
||||
* Анализирует сообщения пользователя и автоматически обновляет профиль
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
const ollamaConfig = require('./ollamaConfig');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
const userContextService = require('./userContextService');
|
||||
const encryptedDb = require('./encryptedDatabaseService');
|
||||
const db = require('../db');
|
||||
|
||||
/**
|
||||
* Извлечь имя пользователя из сообщения через LLM (JSON mode)
|
||||
* @param {string} message - Сообщение пользователя
|
||||
* @returns {Promise<Object>} { name: string|null, should_update_name: boolean }
|
||||
*/
|
||||
async function extractName(message) {
|
||||
try {
|
||||
logger.info(`[ProfileAnalysis] Начало извлечения имени из сообщения: "${message.substring(0, 50)}${message.length > 50 ? '...' : ''}"`);
|
||||
|
||||
const ollamaUrl = await ollamaConfig.getBaseUrlAsync();
|
||||
const llmModel = await ollamaConfig.getDefaultModelAsync();
|
||||
const llmParameters = await aiConfigService.getLLMParameters();
|
||||
const timeouts = await aiConfigService.getTimeouts();
|
||||
|
||||
const nameExtractionTimeout = Math.min(timeouts.ollamaChat || 60000, 60000);
|
||||
logger.info(`[ProfileAnalysis] Параметры для извлечения имени: model=${llmModel}, timeout=${nameExtractionTimeout/1000}с`);
|
||||
|
||||
const prompt = `Определи, указал ли пользователь своё имя в сообщении.
|
||||
|
||||
Сообщение: "${message}"
|
||||
|
||||
Требования:
|
||||
- Пользователь пишет по-русски, возможны формулировки вроде "я Алекс", "меня зовут Анна", "это Иван".
|
||||
- Если явно видишь имя, нормализуй его (первая буква прописная, без лишних слов) и установи "should_update_name": true.
|
||||
- Если имя не указано или есть сомнения, верни name=null и should_update_name=false.
|
||||
- Если уверенность ниже 0.7, не обновляй имя.
|
||||
- Верни строго JSON-объект одной строкой: {"name":string|null,"should_update_name":boolean,"confidence":number}.`;
|
||||
|
||||
const extractStartTime = Date.now();
|
||||
logger.info(`[ProfileAnalysis] Отправка запроса к Ollama для извлечения имени...`);
|
||||
|
||||
const response = await axios.post(`${ollamaUrl}/api/chat`, {
|
||||
model: llmModel,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: llmParameters.temperature,
|
||||
num_predict: llmParameters.maxTokens,
|
||||
top_p: llmParameters.top_p,
|
||||
top_k: llmParameters.top_k,
|
||||
repeat_penalty: llmParameters.repeat_penalty,
|
||||
format: 'json', // Используем JSON mode для structured output
|
||||
stream: false
|
||||
}, {
|
||||
timeout: nameExtractionTimeout
|
||||
});
|
||||
|
||||
const extractDuration = Date.now() - extractStartTime;
|
||||
logger.info(`[ProfileAnalysis] Получен ответ от Ollama для извлечения имени за ${extractDuration}ms, статус: ${response.status}`);
|
||||
|
||||
// Приводим ответ к объекту (учитываем возможную строку при потоковом ответе)
|
||||
let payload = response.data;
|
||||
if (!payload) {
|
||||
logger.error('[ProfileAnalysis] Ответ от Ollama не содержит data:', response);
|
||||
return { name: null, should_update_name: false, confidence: 0 };
|
||||
}
|
||||
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
const lines = payload.trim().split('\n').filter(Boolean);
|
||||
const lastLine = lines.length > 0 ? lines[lines.length - 1] : payload;
|
||||
payload = JSON.parse(lastLine);
|
||||
} catch (parseError) {
|
||||
logger.error('[ProfileAnalysis] Не удалось распарсить строковый ответ Ollama:', {
|
||||
error: parseError.message,
|
||||
preview: payload.substring(0, 200)
|
||||
});
|
||||
return { name: null, should_update_name: false, confidence: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
payload = payload[payload.length - 1] || {};
|
||||
}
|
||||
|
||||
const messagePayload = payload.message || payload.response || null;
|
||||
if (!messagePayload || !messagePayload.content) {
|
||||
logger.error('[ProfileAnalysis] Ответ от Ollama не содержит message.content:', payload);
|
||||
return { name: null, should_update_name: false, confidence: 0 };
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(messagePayload.content);
|
||||
} catch (parseError) {
|
||||
logger.error('[ProfileAnalysis] Ошибка парсинга JSON из ответа Ollama:', {
|
||||
content: messagePayload.content,
|
||||
error: parseError.message
|
||||
});
|
||||
return { name: null, should_update_name: false, confidence: 0 };
|
||||
}
|
||||
|
||||
logger.info(`[ProfileAnalysis] Результат извлечения имени: name=${result.name || 'null'}, should_update=${result.should_update_name}, confidence=${result.confidence || 'N/A'}`);
|
||||
|
||||
let normalizedName = typeof result.name === 'string' ? result.name.trim() : '';
|
||||
if (normalizedName) {
|
||||
normalizedName = normalizedName.replace(/\s+/g, ' ');
|
||||
normalizedName = normalizedName.replace(/^["'«»]+|["'«»]+$/g, '');
|
||||
if (normalizedName.length === 1) {
|
||||
normalizedName = normalizedName.toUpperCase();
|
||||
} else {
|
||||
normalizedName = normalizedName.charAt(0).toUpperCase() + normalizedName.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
const rawConfidence = Number(result.confidence);
|
||||
const confidence = Number.isFinite(rawConfidence)
|
||||
? Math.max(0, Math.min(1, rawConfidence))
|
||||
: (normalizedName ? 1 : 0);
|
||||
|
||||
if (confidence < 0.7 || !normalizedName) {
|
||||
if (normalizedName && confidence < 0.7) {
|
||||
logger.debug(`[ProfileAnalysis] Низкая уверенность в имени (${confidence}), не обновляем`);
|
||||
}
|
||||
return { name: null, should_update_name: false, confidence };
|
||||
}
|
||||
|
||||
const shouldUpdate = typeof result.should_update_name === 'boolean'
|
||||
? result.should_update_name
|
||||
: !!normalizedName;
|
||||
|
||||
const finalResult = {
|
||||
name: normalizedName || null,
|
||||
should_update_name: shouldUpdate,
|
||||
confidence
|
||||
};
|
||||
|
||||
logger.debug(`[ProfileAnalysis] Финальный результат извлечения имени:`, finalResult);
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
logger.error('[ProfileAnalysis] Ошибка извлечения имени:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
response: error.response?.data ? JSON.stringify(error.response.data).substring(0, 200) : undefined
|
||||
});
|
||||
return { name: null, should_update_name: false, confidence: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти таблицу по названию
|
||||
* @param {string} tableName - Название таблицы
|
||||
* @returns {Promise<Object|null>} Таблица или null
|
||||
*/
|
||||
async function findTableByName(tableName) {
|
||||
try {
|
||||
const tables = await encryptedDb.getData('user_tables', {});
|
||||
return tables.find(table => table.name === tableName) || null;
|
||||
} catch (error) {
|
||||
logger.error(`[ProfileAnalysis] Ошибка поиска таблицы "${tableName}":`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить столбец по названию
|
||||
* @param {number} tableId - ID таблицы
|
||||
* @param {string} columnName - Название столбца
|
||||
* @returns {Promise<Object|null>} Столбец или null
|
||||
*/
|
||||
async function getColumnByName(tableId, columnName) {
|
||||
try {
|
||||
const columns = await encryptedDb.getData('user_columns', { table_id: tableId });
|
||||
return columns.find(col => col.name === columnName) || null;
|
||||
} catch (error) {
|
||||
logger.error(`[ProfileAnalysis] Ошибка поиска столбца "${columnName}":`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить значение ячейки
|
||||
* @param {number} rowId - ID строки
|
||||
* @param {number} columnId - ID столбца
|
||||
* @returns {Promise<string|null>} Значение ячейки или null
|
||||
*/
|
||||
async function getCellValue(rowId, columnId) {
|
||||
try {
|
||||
const cellValues = await encryptedDb.getData('user_cell_values', {
|
||||
row_id: rowId,
|
||||
column_id: columnId
|
||||
}, 1);
|
||||
return cellValues && cellValues.length > 0 ? cellValues[0].value : null;
|
||||
} catch (error) {
|
||||
logger.error(`[ProfileAnalysis] Ошибка получения значения ячейки:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить строки таблицы
|
||||
* @param {number} tableId - ID таблицы
|
||||
* @returns {Promise<Array>} Массив строк
|
||||
*/
|
||||
async function getTableRows(tableId) {
|
||||
try {
|
||||
return await encryptedDb.getData('user_rows', { table_id: tableId });
|
||||
} catch (error) {
|
||||
logger.error(`[ProfileAnalysis] Ошибка получения строк таблицы:`, error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсить теги из ячейки (multiselect-relation или строка)
|
||||
* @param {*} tagsCell - Значение ячейки с тегами
|
||||
* @returns {Array<string>} Массив названий тегов
|
||||
*/
|
||||
function parseTagsFromCell(tagsCell) {
|
||||
if (!tagsCell) return [];
|
||||
|
||||
// Если это массив
|
||||
if (Array.isArray(tagsCell)) {
|
||||
return tagsCell.map(String);
|
||||
}
|
||||
|
||||
// Если это строка JSON
|
||||
if (typeof tagsCell === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(tagsCell);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(String);
|
||||
}
|
||||
} catch (e) {
|
||||
// Не JSON, возможно просто строка с названиями через запятую
|
||||
return tagsCell.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти ключевые слова в сообщении
|
||||
* @param {string} message - Сообщение пользователя
|
||||
* @param {number} keywordsTableId - ID таблицы "Ключевые слова и теги"
|
||||
* @returns {Promise<Array>} Массив найденных строк с ключевыми словами
|
||||
*/
|
||||
async function findKeywordsInMessage(message, keywordsTableId) {
|
||||
try {
|
||||
const rows = await getTableRows(keywordsTableId);
|
||||
|
||||
// Получаем столбец "Ключевое слово"
|
||||
const keywordColumn = await getColumnByName(keywordsTableId, 'Ключевое слово');
|
||||
if (!keywordColumn) {
|
||||
logger.warn('[ProfileAnalysis] Столбец "Ключевое слово" не найден');
|
||||
return [];
|
||||
}
|
||||
|
||||
const messageLower = message.toLowerCase();
|
||||
const foundKeywords = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const keywordsCell = await getCellValue(row.id, keywordColumn.id);
|
||||
if (!keywordsCell) continue;
|
||||
|
||||
// Парсим ключевые слова (разделены запятой)
|
||||
const keywords = keywordsCell.split(',').map(k => k.trim().toLowerCase());
|
||||
|
||||
// Проверяем, есть ли хотя бы одно ключевое слово в сообщении
|
||||
if (keywords.some(kw => messageLower.includes(kw))) {
|
||||
foundKeywords.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return foundKeywords;
|
||||
} catch (error) {
|
||||
logger.error('[ProfileAnalysis] Ошибка поиска ключевых слов:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить теги по ключевым словам из таблицы
|
||||
* @param {Array} foundKeywordRows - Массив строк с найденными ключевыми словами
|
||||
* @param {number} keywordsTableId - ID таблицы "Ключевые слова и теги"
|
||||
* @returns {Promise<Array>} Массив объектов { tagNames: Array, action: string }
|
||||
*/
|
||||
async function getTagsByKeywords(foundKeywordRows, keywordsTableId) {
|
||||
try {
|
||||
const tagsColumn = await getColumnByName(keywordsTableId, 'Теги');
|
||||
const actionColumn = await getColumnByName(keywordsTableId, 'Действие');
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const row of foundKeywordRows) {
|
||||
const tagsCell = tagsColumn ? await getCellValue(row.id, tagsColumn.id) : null;
|
||||
const action = actionColumn ? await getCellValue(row.id, actionColumn.id) : null;
|
||||
|
||||
const tagNames = parseTagsFromCell(tagsCell);
|
||||
|
||||
if (tagNames.length > 0) {
|
||||
results.push({
|
||||
tagNames,
|
||||
action: action || 'добавить'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('[ProfileAnalysis] Ошибка получения тегов по ключевым словам:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти tagIds по названиям тегов
|
||||
* @param {Array<string>} tagNames - Массив названий тегов
|
||||
* @returns {Promise<Array<number>>} Массив tagIds
|
||||
*/
|
||||
async function getTagIdsByNames(tagNames) {
|
||||
if (!tagNames || tagNames.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Находим таблицу "Теги клиентов"
|
||||
const tagsTable = await findTableByName('Теги клиентов');
|
||||
if (!tagsTable) {
|
||||
logger.warn('[ProfileAnalysis] Таблица "Теги клиентов" не найдена');
|
||||
return [];
|
||||
}
|
||||
|
||||
const allColumns = await encryptedDb.getData('user_columns', { table_id: tagsTable.id });
|
||||
|
||||
// Подбираем колонку с названием тега по приоритету
|
||||
const nameColumn =
|
||||
allColumns.find(col => col.options && col.options.purpose === 'userTags') ||
|
||||
allColumns.find(col => col.name === 'Название') ||
|
||||
allColumns.find(col => col.name === 'Список тегов') ||
|
||||
allColumns.find(col => col.type === 'text');
|
||||
|
||||
if (!nameColumn) {
|
||||
logger.warn('[ProfileAnalysis] Столбец с названием тега не найден');
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await getTableRows(tagsTable.id);
|
||||
|
||||
const tagIds = [];
|
||||
for (const row of rows) {
|
||||
const cellValue = await getCellValue(row.id, nameColumn.id);
|
||||
if (!cellValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedNames = parseTagsFromCell(cellValue);
|
||||
if (normalizedNames.some(name => tagNames.includes(name))) {
|
||||
tagIds.push(row.id);
|
||||
}
|
||||
}
|
||||
|
||||
return tagIds;
|
||||
} catch (error) {
|
||||
logger.error('[ProfileAnalysis] Ошибка поиска tagIds по названиям:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать действие (заменить/добавить теги)
|
||||
* @param {string} action - Действие (например, "заменить клиент" или "добавить")
|
||||
* @param {Array<string>} currentTagNames - Текущие названия тегов
|
||||
* @param {Array<string>} newTagNames - Новые названия тегов
|
||||
* @returns {Array<string>} Результирующие названия тегов
|
||||
*/
|
||||
function processAction(action, currentTagNames, newTagNames) {
|
||||
if (!action || action === 'добавить') {
|
||||
// Добавляем новые теги к существующим
|
||||
return [...new Set([...currentTagNames, ...newTagNames])];
|
||||
}
|
||||
|
||||
// Обработка действия "заменить <тег>"
|
||||
if (action.startsWith('заменить')) {
|
||||
const tagToReplace = action.replace('заменить', '').trim();
|
||||
|
||||
// Удаляем тег для замены
|
||||
const filtered = currentTagNames.filter(tag => tag !== tagToReplace);
|
||||
|
||||
// Добавляем новые теги
|
||||
return [...new Set([...filtered, ...newTagNames])];
|
||||
}
|
||||
|
||||
// По умолчанию - добавляем
|
||||
return [...new Set([...currentTagNames, ...newTagNames])];
|
||||
}
|
||||
|
||||
/**
|
||||
* Анализировать сообщение пользователя и обновить профиль
|
||||
* @param {number} userId - ID пользователя
|
||||
* @param {string} message - Сообщение пользователя
|
||||
* @returns {Promise<Object>} { name: string|null, suggestedTags: Array<string> }
|
||||
*/
|
||||
async function analyzeUserMessage(userId, message) {
|
||||
try {
|
||||
logger.info(`[ProfileAnalysis] Анализ сообщения пользователя ${userId}`);
|
||||
logger.info(`[ProfileAnalysis] Сообщение пользователя ${userId}: "${message.substring(0, 100)}${message.length > 100 ? '...' : ''}"`);
|
||||
|
||||
const DEFAULT_TAG_NAME = 'Без лицензии';
|
||||
const isGuest = typeof userId === 'string' && userId.startsWith('guest_');
|
||||
|
||||
let currentContext = null;
|
||||
let currentName = null;
|
||||
let currentTagIds = [];
|
||||
let currentTagNames = [];
|
||||
let suggestedTags = [];
|
||||
let nameMissing = false;
|
||||
|
||||
if (!isGuest) {
|
||||
currentContext = await userContextService.getUserContext(userId);
|
||||
currentName = currentContext?.name || null;
|
||||
currentTagIds = Array.isArray(currentContext?.tags) ? [...currentContext.tags] : await userContextService.getUserTags(userId);
|
||||
currentTagNames = Array.isArray(currentContext?.tagNames) && currentContext.tagNames.length > 0
|
||||
? [...currentContext.tagNames]
|
||||
: await userContextService.getTagNames(currentTagIds);
|
||||
|
||||
if (!currentTagIds || currentTagIds.length === 0) {
|
||||
const defaultTagIds = await getTagIdsByNames([DEFAULT_TAG_NAME]);
|
||||
if (defaultTagIds.length > 0) {
|
||||
await updateUserTagsInternal(userId, defaultTagIds);
|
||||
userContextService.invalidateUserCache(userId);
|
||||
currentTagIds = [...defaultTagIds];
|
||||
currentTagNames = [DEFAULT_TAG_NAME];
|
||||
logger.info(`[ProfileAnalysis] Пользователю ${userId} автоматически назначен тег по умолчанию: ${DEFAULT_TAG_NAME}`);
|
||||
} else {
|
||||
logger.warn(`[ProfileAnalysis] Тег по умолчанию "${DEFAULT_TAG_NAME}" не найден в базе`);
|
||||
}
|
||||
}
|
||||
|
||||
suggestedTags = [...currentTagNames];
|
||||
nameMissing = !currentName;
|
||||
}
|
||||
|
||||
// 1. Извлечение имени из сообщения
|
||||
logger.info(`[ProfileAnalysis] Начало извлечения имени для пользователя ${userId}...`);
|
||||
const nameResult = await extractName(message);
|
||||
logger.info(`[ProfileAnalysis] Извлечение имени завершено: name=${nameResult.name || 'null'}, should_update=${nameResult.should_update_name}`);
|
||||
|
||||
// 2. Обновление имени пользователя (если нужно)
|
||||
if (!isGuest && nameResult.name) {
|
||||
const shouldUpdateName = nameResult.should_update_name || !currentName;
|
||||
if (shouldUpdateName && (currentName || '') !== nameResult.name) {
|
||||
logger.info(`[ProfileAnalysis] Обновление имени пользователя ${userId}: "${currentName || ''}" → "${nameResult.name}"`);
|
||||
await updateUserNameInternal(userId, nameResult.name);
|
||||
userContextService.invalidateUserCache(userId);
|
||||
currentName = nameResult.name;
|
||||
nameMissing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Поиск ключевых слов в таблице "Ключевые слова и теги"
|
||||
const keywordsTable = await findTableByName('Ключевые слова и теги');
|
||||
|
||||
if (!isGuest && keywordsTable) {
|
||||
const foundKeywords = await findKeywordsInMessage(message, keywordsTable.id);
|
||||
|
||||
if (foundKeywords.length > 0) {
|
||||
// Получаем теги по ключевым словам
|
||||
const tagsByKeywords = await getTagsByKeywords(foundKeywords, keywordsTable.id);
|
||||
|
||||
// Обрабатываем действия и собираем новые теги
|
||||
let allTagNames = [...currentTagNames];
|
||||
|
||||
for (const { tagNames, action } of tagsByKeywords) {
|
||||
allTagNames = processAction(action, allTagNames, tagNames);
|
||||
}
|
||||
|
||||
// Находим tagIds по названиям
|
||||
const tagIds = await getTagIdsByNames(allTagNames);
|
||||
|
||||
// Обновляем теги пользователя (если есть изменения)
|
||||
const hasChanges = tagIds.length !== currentTagIds.length || !tagIds.every(id => currentTagIds.includes(id));
|
||||
if (hasChanges) {
|
||||
logger.info(`[ProfileAnalysis] Обновление тегов пользователя ${userId}: ${currentTagNames.join(', ') || '—'} → ${allTagNames.join(', ')}`);
|
||||
await updateUserTagsInternal(userId, tagIds);
|
||||
userContextService.invalidateUserCache(userId);
|
||||
currentTagIds = [...tagIds];
|
||||
currentTagNames = [...allTagNames];
|
||||
}
|
||||
|
||||
suggestedTags = [...currentTagNames];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: nameResult.name,
|
||||
should_update_name: !isGuest && nameResult.name ? (nameResult.should_update_name || !currentName) : false,
|
||||
suggestedTags,
|
||||
currentName,
|
||||
currentTagNames,
|
||||
nameMissing: !isGuest ? nameMissing : false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[ProfileAnalysis] Ошибка анализа сообщения:', error.message);
|
||||
return { name: null, should_update_name: false, suggestedTags: [], currentName: null, currentTagNames: [], nameMissing: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренняя функция для обновления имени пользователя (без проверки прав)
|
||||
* @param {number} userId - ID пользователя
|
||||
* @param {string} name - Новое имя
|
||||
*/
|
||||
async function updateUserNameInternal(userId, name) {
|
||||
try {
|
||||
const encryptionUtils = require('../utils/encryptionUtils');
|
||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||
|
||||
const nameParts = name.trim().split(' ');
|
||||
const firstName = nameParts[0] || '';
|
||||
const lastName = nameParts.slice(1).join(' ') || '';
|
||||
|
||||
await db.getQuery()(
|
||||
`UPDATE users
|
||||
SET first_name_encrypted = encrypt_text($1, $3),
|
||||
last_name_encrypted = encrypt_text($2, $3)
|
||||
WHERE id = $4`,
|
||||
[firstName, lastName, encryptionKey, userId]
|
||||
);
|
||||
|
||||
// Инвалидируем кэш пользователя
|
||||
userContextService.invalidateUserCache(userId);
|
||||
|
||||
// Отправляем WebSocket уведомление об обновлении контакта
|
||||
const { broadcastContactsUpdate } = require('../wsHub');
|
||||
broadcastContactsUpdate();
|
||||
|
||||
logger.info(`[ProfileAnalysis] Имя пользователя ${userId} обновлено: "${name}"`);
|
||||
} catch (error) {
|
||||
logger.error(`[ProfileAnalysis] Ошибка обновления имени пользователя ${userId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Внутренняя функция для обновления тегов пользователя (без проверки прав)
|
||||
* @param {number} userId - ID пользователя
|
||||
* @param {Array<number>} tagIds - Массив tagIds
|
||||
*/
|
||||
async function updateUserTagsInternal(userId, tagIds) {
|
||||
try {
|
||||
// Удаляем старые связи
|
||||
await db.getQuery()('DELETE FROM user_tag_links WHERE user_id = $1', [userId]);
|
||||
|
||||
// Добавляем новые связи
|
||||
for (const tagId of tagIds) {
|
||||
await db.getQuery()(
|
||||
'INSERT INTO user_tag_links (user_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[userId, tagId]
|
||||
);
|
||||
}
|
||||
|
||||
// Отправляем WebSocket уведомление
|
||||
const { broadcastTagsUpdate } = require('../wsHub');
|
||||
broadcastTagsUpdate(null, userId);
|
||||
|
||||
// Инвалидируем кэш пользователя
|
||||
userContextService.invalidateUserCache(userId);
|
||||
|
||||
logger.info(`[ProfileAnalysis] Теги пользователя ${userId} обновлены: ${tagIds.join(', ')}`);
|
||||
} catch (error) {
|
||||
logger.error(`[ProfileAnalysis] Ошибка обновления тегов пользователя ${userId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
analyzeUserMessage,
|
||||
extractName,
|
||||
findKeywordsInMessage,
|
||||
getTagsByKeywords,
|
||||
getTagIdsByNames,
|
||||
processAction,
|
||||
updateUserNameInternal,
|
||||
updateUserTagsInternal,
|
||||
findTableByName,
|
||||
getColumnByName,
|
||||
getCellValue,
|
||||
getTableRows
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
361
backend/services/semanticChunkingService.js
Normal file
361
backend/services/semanticChunkingService.js
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
const ollamaConfig = require('./ollamaConfig');
|
||||
const logger = require('../utils/logger');
|
||||
const axios = require('axios');
|
||||
|
||||
/**
|
||||
* Сервис для интеллектуальной разбивки документов на смысловые части (Semantic Chunking)
|
||||
*/
|
||||
class SemanticChunkingService {
|
||||
constructor() {
|
||||
this.defaultMaxChunkSize = 1000; // Максимальный размер чанка в символах
|
||||
this.defaultOverlap = 200; // Перекрытие между чанками в символах
|
||||
this.minChunkSize = 100; // Минимальный размер чанка
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбить документ на смысловые чанки
|
||||
* @param {string} text - Текст документа
|
||||
* @param {Object} options - Опции разбивки
|
||||
* @param {number} options.maxChunkSize - Максимальный размер чанка
|
||||
* @param {number} options.overlap - Перекрытие между чанками
|
||||
* @param {boolean} options.useLLM - Использовать LLM для определения структуры
|
||||
* @returns {Promise<Array>} Массив чанков с метаданными
|
||||
*/
|
||||
async chunkDocument(text, options = {}) {
|
||||
if (!text || typeof text !== 'string' || text.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const {
|
||||
maxChunkSize = this.defaultMaxChunkSize,
|
||||
overlap = this.defaultOverlap,
|
||||
useLLM = true
|
||||
} = options;
|
||||
|
||||
logger.info(`[SemanticChunking] Разбивка документа: длина=${text.length}, maxChunkSize=${maxChunkSize}, useLLM=${useLLM}`);
|
||||
|
||||
try {
|
||||
// Если документ небольшой, возвращаем как один чанк
|
||||
if (text.length <= maxChunkSize) {
|
||||
return [{
|
||||
text: text.trim(),
|
||||
index: 0,
|
||||
start: 0,
|
||||
end: text.length,
|
||||
metadata: {
|
||||
section: 'Документ',
|
||||
isComplete: true
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
let chunks = [];
|
||||
|
||||
if (useLLM) {
|
||||
// Используем LLM для определения структуры
|
||||
chunks = await this.chunkWithLLM(text, maxChunkSize, overlap);
|
||||
} else {
|
||||
// Простая разбивка по параграфам и предложениям
|
||||
chunks = await this.chunkByStructure(text, maxChunkSize, overlap);
|
||||
}
|
||||
|
||||
logger.info(`[SemanticChunking] Получено чанков: ${chunks.length}`);
|
||||
return chunks;
|
||||
} catch (error) {
|
||||
logger.error(`[SemanticChunking] Ошибка разбивки документа:`, error);
|
||||
// Fallback на простую разбивку
|
||||
return this.chunkByStructure(text, maxChunkSize, overlap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбивка с использованием LLM для определения структуры
|
||||
*/
|
||||
async chunkWithLLM(text, maxChunkSize, overlap) {
|
||||
try {
|
||||
// Получаем конфигурацию Ollama
|
||||
const ollamaConfig_data = await ollamaConfig.getConfigAsync();
|
||||
const baseUrl = ollamaConfig_data.baseUrl || 'http://ollama:11434';
|
||||
const model = ollamaConfig_data.defaultModel || 'qwen2.5:7b';
|
||||
|
||||
// Если текст очень большой, анализируем первые 10000 символов для определения структуры
|
||||
const analysisText = text.length > 10000 ? text.substring(0, 10000) : text;
|
||||
|
||||
const prompt = `Проанализируй структуру следующего документа и определи логические разделы.
|
||||
|
||||
Документ:
|
||||
${analysisText}${text.length > 10000 ? '\n\n[Документ продолжается...]' : ''}
|
||||
|
||||
Верни JSON с разделами в следующем формате:
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"title": "Название раздела (например, 'Общие условия', 'Стоимость')",
|
||||
"start": позиция_начала_раздела_в_тексте,
|
||||
"end": позиция_конца_раздела_в_тексте,
|
||||
"text": "полный_текст_раздела"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Важно:
|
||||
- Разделы должны быть логически завершенными (законченная мысль)
|
||||
- Если раздел слишком большой (больше ${maxChunkSize} символов), раздели его на подразделы
|
||||
- Не обрезай предложения посередине
|
||||
- Если не можешь определить структуру, верни один раздел с title: "Документ"`;
|
||||
|
||||
const response = await axios.post(`${baseUrl}/api/chat`, {
|
||||
model: model,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
format: 'json',
|
||||
options: {
|
||||
temperature: 0.1, // Низкая температура для точности
|
||||
num_predict: 2000
|
||||
}
|
||||
}, {
|
||||
timeout: 30000
|
||||
});
|
||||
|
||||
const content = response.data.message?.content || response.data.response || '';
|
||||
let structure;
|
||||
|
||||
try {
|
||||
// Парсим JSON из ответа (может быть обернут в markdown)
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
structure = JSON.parse(jsonMatch[0]);
|
||||
} else {
|
||||
structure = JSON.parse(content);
|
||||
}
|
||||
} catch (parseError) {
|
||||
logger.warn(`[SemanticChunking] Ошибка парсинга JSON от LLM, используем простую разбивку:`, parseError);
|
||||
return this.chunkByStructure(text, maxChunkSize, overlap);
|
||||
}
|
||||
|
||||
// Формируем чанки из разделов
|
||||
const chunks = [];
|
||||
|
||||
if (structure.sections && Array.isArray(structure.sections)) {
|
||||
for (let i = 0; i < structure.sections.length; i++) {
|
||||
const section = structure.sections[i];
|
||||
const sectionText = section.text || text.substring(section.start || 0, section.end || text.length);
|
||||
|
||||
// Если раздел большой, разбиваем его на подразделы
|
||||
if (sectionText.length > maxChunkSize) {
|
||||
const subChunks = this.splitLargeSection(sectionText, section.title || 'Раздел', maxChunkSize, overlap);
|
||||
chunks.push(...subChunks.map((chunk, idx) => ({
|
||||
...chunk,
|
||||
index: chunks.length + idx,
|
||||
metadata: {
|
||||
...chunk.metadata,
|
||||
parentSection: section.title
|
||||
}
|
||||
})));
|
||||
} else {
|
||||
chunks.push({
|
||||
text: sectionText.trim(),
|
||||
index: chunks.length,
|
||||
start: section.start || 0,
|
||||
end: section.end || sectionText.length,
|
||||
metadata: {
|
||||
section: section.title || `Раздел ${i + 1}`,
|
||||
isComplete: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Если LLM не определил структуру, используем простую разбивку
|
||||
if (chunks.length === 0) {
|
||||
return this.chunkByStructure(text, maxChunkSize, overlap);
|
||||
}
|
||||
|
||||
return chunks;
|
||||
} catch (error) {
|
||||
logger.error(`[SemanticChunking] Ошибка LLM разбивки:`, error);
|
||||
// Fallback на простую разбивку
|
||||
return this.chunkByStructure(text, maxChunkSize, overlap);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбивка по структуре (параграфы, предложения)
|
||||
*/
|
||||
async chunkByStructure(text, maxChunkSize, overlap) {
|
||||
const chunks = [];
|
||||
|
||||
// Разбиваем на параграфы (двойной перенос строки)
|
||||
const paragraphs = text.split(/\n\s*\n/).filter(p => p.trim().length > 0);
|
||||
|
||||
let currentChunk = '';
|
||||
let currentStart = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
for (let i = 0; i < paragraphs.length; i++) {
|
||||
const paragraph = paragraphs[i].trim();
|
||||
|
||||
// Если параграф сам по себе больше maxChunkSize, разбиваем его
|
||||
if (paragraph.length > maxChunkSize) {
|
||||
// Сохраняем текущий чанк, если есть
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push({
|
||||
text: currentChunk.trim(),
|
||||
index: chunkIndex++,
|
||||
start: currentStart,
|
||||
end: currentStart + currentChunk.length,
|
||||
metadata: {
|
||||
section: `Параграф ${chunkIndex}`,
|
||||
isComplete: true
|
||||
}
|
||||
});
|
||||
currentChunk = '';
|
||||
}
|
||||
|
||||
// Разбиваем большой параграф
|
||||
const subChunks = this.splitLargeSection(paragraph, `Параграф ${i + 1}`, maxChunkSize, overlap);
|
||||
chunks.push(...subChunks.map((chunk, idx) => ({
|
||||
...chunk,
|
||||
index: chunkIndex++,
|
||||
metadata: {
|
||||
...chunk.metadata,
|
||||
parentParagraph: i + 1
|
||||
}
|
||||
})));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем, поместится ли параграф в текущий чанк
|
||||
const potentialChunk = currentChunk + (currentChunk ? '\n\n' : '') + paragraph;
|
||||
|
||||
if (potentialChunk.length <= maxChunkSize) {
|
||||
// Добавляем параграф к текущему чанку
|
||||
if (currentChunk.length === 0) {
|
||||
currentStart = text.indexOf(paragraph);
|
||||
}
|
||||
currentChunk = potentialChunk;
|
||||
} else {
|
||||
// Сохраняем текущий чанк и начинаем новый
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push({
|
||||
text: currentChunk.trim(),
|
||||
index: chunkIndex++,
|
||||
start: currentStart,
|
||||
end: currentStart + currentChunk.length,
|
||||
metadata: {
|
||||
section: `Параграф ${chunkIndex}`,
|
||||
isComplete: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Начинаем новый чанк с текущего параграфа
|
||||
currentChunk = paragraph;
|
||||
currentStart = text.indexOf(paragraph);
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем последний чанк
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push({
|
||||
text: currentChunk.trim(),
|
||||
index: chunkIndex,
|
||||
start: currentStart,
|
||||
end: currentStart + currentChunk.length,
|
||||
metadata: {
|
||||
section: `Параграф ${chunkIndex + 1}`,
|
||||
isComplete: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Добавляем перекрытие (overlap) между чанками
|
||||
if (overlap > 0 && chunks.length > 1) {
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const prevChunk = chunks[i - 1];
|
||||
const currentChunk = chunks[i];
|
||||
|
||||
// Берем последние N символов предыдущего чанка
|
||||
const overlapText = prevChunk.text.slice(-overlap);
|
||||
if (overlapText.trim().length > 0) {
|
||||
// Добавляем в начало текущего чанка
|
||||
currentChunk.text = overlapText + '\n\n' + currentChunk.text;
|
||||
currentChunk.metadata.hasOverlap = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разбить большой раздел на подразделы
|
||||
*/
|
||||
splitLargeSection(text, sectionTitle, maxChunkSize, overlap) {
|
||||
const chunks = [];
|
||||
|
||||
// Разбиваем по предложениям
|
||||
const sentences = text.split(/(?<=[.!?])\s+/).filter(s => s.trim().length > 0);
|
||||
|
||||
let currentChunk = '';
|
||||
let chunkIndex = 0;
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const potentialChunk = currentChunk + (currentChunk ? ' ' : '') + sentence;
|
||||
|
||||
if (potentialChunk.length <= maxChunkSize) {
|
||||
currentChunk = potentialChunk;
|
||||
} else {
|
||||
// Сохраняем текущий чанк
|
||||
if (currentChunk.length >= this.minChunkSize) {
|
||||
chunks.push({
|
||||
text: currentChunk.trim(),
|
||||
index: chunkIndex++,
|
||||
metadata: {
|
||||
section: `${sectionTitle} - Часть ${chunkIndex}`,
|
||||
isComplete: false
|
||||
}
|
||||
});
|
||||
}
|
||||
currentChunk = sentence;
|
||||
}
|
||||
}
|
||||
|
||||
// Сохраняем последний чанк
|
||||
if (currentChunk.length >= this.minChunkSize) {
|
||||
chunks.push({
|
||||
text: currentChunk.trim(),
|
||||
index: chunkIndex,
|
||||
metadata: {
|
||||
section: `${sectionTitle} - Часть ${chunkIndex + 1}`,
|
||||
isComplete: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем singleton экземпляр
|
||||
const semanticChunkingService = new SemanticChunkingService();
|
||||
|
||||
module.exports = semanticChunkingService;
|
||||
|
||||
@@ -150,7 +150,7 @@ async function processMessage(messageData) {
|
||||
const messageType = determineMessageType(recipientId, userId, isAdmin);
|
||||
|
||||
// 5. Определяем нужно ли генерировать AI ответ
|
||||
const shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId);
|
||||
let shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId);
|
||||
|
||||
logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin });
|
||||
|
||||
@@ -227,27 +227,37 @@ async function processMessage(messageData) {
|
||||
|
||||
// Автоматически подписываем согласие
|
||||
if (documentIds.length > 0 && consentTypes.length > 0) {
|
||||
const consentRoutes = require('../routes/consent');
|
||||
// Вызываем логику подписания напрямую через сервис или API
|
||||
try {
|
||||
// Используем проверку существования вместо ON CONFLICT (т.к. может не быть уникального ограничения)
|
||||
for (let i = 0; i < documentIds.length; i++) {
|
||||
const docId = documentIds[i];
|
||||
const docTitle = consentDocuments.find(d => d.id === docId)?.title || '';
|
||||
const consentType = consentTypes[i];
|
||||
|
||||
// Проверяем, есть ли уже согласие
|
||||
const existing = await db.getQuery()(
|
||||
`SELECT id FROM consent_logs
|
||||
WHERE user_id = $1 AND consent_type = $2 AND document_id = $3 AND status = 'granted'`,
|
||||
[userId, consentType, docId]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Обновляем существующее
|
||||
await db.getQuery()(
|
||||
`UPDATE consent_logs
|
||||
SET signed_at = NOW(), revoked_at = NULL, updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[existing.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// Создаем новое
|
||||
await db.getQuery()(
|
||||
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at)
|
||||
SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW()
|
||||
ON CONFLICT (user_id, consent_type, document_id)
|
||||
DO UPDATE SET
|
||||
status = 'granted',
|
||||
signed_at = NOW(),
|
||||
revoked_at = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
|
||||
[
|
||||
userId,
|
||||
walletIdentity?.provider_id || null,
|
||||
documentIds,
|
||||
consentDocuments.map(doc => doc.title),
|
||||
consentTypes
|
||||
]
|
||||
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 'granted', NOW(), 'web', NOW(), NOW())`,
|
||||
[userId, walletIdentity?.provider_id || null, docId, docTitle, consentType]
|
||||
);
|
||||
}
|
||||
}
|
||||
logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`);
|
||||
} catch (consentError) {
|
||||
logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError);
|
||||
@@ -330,6 +340,9 @@ async function processMessage(messageData) {
|
||||
|
||||
// 8. Генерируем AI ответ (если нужно)
|
||||
let aiResponse = null;
|
||||
// Инициализируем finalAiResponse для использования в результатах (должен быть доступен везде)
|
||||
let finalAiResponse = null;
|
||||
let aiResponseDisabled = false;
|
||||
|
||||
if (shouldGenerateAi) {
|
||||
// Загружаем историю беседы
|
||||
@@ -377,7 +390,7 @@ async function processMessage(messageData) {
|
||||
});
|
||||
|
||||
// Формируем финальный ответ ИИ с системным сообщением, если нужно
|
||||
let finalAiResponse = aiResponse.response;
|
||||
finalAiResponse = aiResponse.response;
|
||||
if (consentSystemMessage && consentSystemMessage.consentRequired) {
|
||||
// Добавляем системное сообщение к ответу ИИ
|
||||
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
|
||||
@@ -433,6 +446,9 @@ async function processMessage(messageData) {
|
||||
);
|
||||
|
||||
logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id);
|
||||
} else if (aiResponse && aiResponse.disabled) {
|
||||
aiResponseDisabled = true;
|
||||
logger.info('[UnifiedMessageProcessor] AI ассистент отключен для текущего канала — ответ не генерируется.');
|
||||
} else {
|
||||
logger.warn('[UnifiedMessageProcessor] AI не вернул ответ:', aiResponse?.reason);
|
||||
}
|
||||
@@ -456,10 +472,11 @@ async function processMessage(messageData) {
|
||||
userMessageId,
|
||||
conversationId,
|
||||
aiResponse: aiResponse && aiResponse.success ? {
|
||||
response: finalAiResponse || aiResponse.response,
|
||||
response: finalAiResponse || (aiResponse?.response || ''),
|
||||
ragData: aiResponse.ragData
|
||||
} : null,
|
||||
noAiResponse: !shouldGenerateAi
|
||||
noAiResponse: !shouldGenerateAi || aiResponseDisabled,
|
||||
assistantDisabled: aiResponseDisabled
|
||||
};
|
||||
|
||||
// Если есть информация о согласиях, добавляем её в результат
|
||||
|
||||
275
backend/services/userContextService.js
Normal file
275
backend/services/userContextService.js
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
/**
|
||||
* User Context Service
|
||||
* Предоставляет информацию о пользователе: теги, имя, язык
|
||||
* Кэширование (TTL: 10 минут)
|
||||
*/
|
||||
|
||||
const db = require('../db');
|
||||
const encryptedDb = require('./encryptedDatabaseService');
|
||||
const logger = require('../utils/logger');
|
||||
const encryptionUtils = require('../utils/encryptionUtils');
|
||||
|
||||
// Кэш для пользовательских данных
|
||||
const userCache = new Map();
|
||||
const CACHE_TTL = 10 * 60 * 1000; // 10 минут
|
||||
|
||||
/**
|
||||
* Получить теги пользователя (tagIds)
|
||||
* @param {number} userId - ID пользователя
|
||||
* @returns {Promise<Array<number>>} Массив tagIds
|
||||
*/
|
||||
async function getUserTags(userId) {
|
||||
try {
|
||||
// Проверяем кэш
|
||||
const cacheKey = `tags_${userId}`;
|
||||
const cached = userCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Гостевые пользователи не имеют тегов
|
||||
if (typeof userId === 'string' && userId.startsWith('guest_')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const query = db.getQuery();
|
||||
const result = await query(
|
||||
'SELECT tag_id FROM user_tag_links WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
const tagIds = result.rows.map(row => row.tag_id);
|
||||
|
||||
// Сохраняем в кэш
|
||||
userCache.set(cacheKey, {
|
||||
data: tagIds,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return tagIds;
|
||||
} catch (error) {
|
||||
logger.error('[UserContextService] Ошибка получения тегов пользователя:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить названия тегов по их ID
|
||||
* @param {Array<number>} tagIds - Массив tagIds
|
||||
* @returns {Promise<Array<string>>} Массив названий тегов
|
||||
*/
|
||||
async function getTagNames(tagIds) {
|
||||
if (!tagIds || tagIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Проверяем кэш
|
||||
const cacheKey = `tagNames_${tagIds.sort().join(',')}`;
|
||||
const cached = userCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Находим таблицу "Теги клиентов"
|
||||
// encryptedDb.getData уже расшифровывает данные, поэтому используем поле name
|
||||
const tables = await encryptedDb.getData('user_tables', {});
|
||||
const tagsTable = tables.find(table => table.name === 'Теги клиентов');
|
||||
|
||||
if (!tagsTable) {
|
||||
logger.warn('[UserContextService] Таблица "Теги клиентов" не найдена');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Получаем строки таблицы (теги)
|
||||
const rows = await encryptedDb.getData('user_rows', {
|
||||
table_id: tagsTable.id,
|
||||
id: { $in: tagIds }
|
||||
});
|
||||
|
||||
// Получаем столбец с названием тега
|
||||
// encryptedDb.getData уже расшифровывает данные
|
||||
const columns = await encryptedDb.getData('user_columns', {
|
||||
table_id: tagsTable.id
|
||||
});
|
||||
|
||||
// Ищем столбец "Список тегов", затем "Название", затем первый текстовый столбец
|
||||
const nameColumn = columns.find(col =>
|
||||
col.name === 'Список тегов' && col.type === 'text'
|
||||
) || columns.find(col =>
|
||||
(col.name === 'Название' || col.name === 'Название тега') && col.type === 'text'
|
||||
) || columns.find(col => col.type === 'text');
|
||||
|
||||
if (!nameColumn) {
|
||||
logger.warn('[UserContextService] Столбец с названием тега не найден');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Получаем значения ячеек
|
||||
const cellValues = await encryptedDb.getData('user_cell_values', {
|
||||
row_id: { $in: rows.map(r => r.id) },
|
||||
column_id: nameColumn.id
|
||||
});
|
||||
|
||||
// Создаем маппинг row_id -> название
|
||||
const tagNamesMap = new Map();
|
||||
for (const cell of cellValues) {
|
||||
if (cell.value) {
|
||||
tagNamesMap.set(cell.row_id, cell.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по порядку tagIds
|
||||
const tagNames = tagIds
|
||||
.map(tagId => tagNamesMap.get(tagId))
|
||||
.filter(Boolean);
|
||||
|
||||
// Сохраняем в кэш
|
||||
userCache.set(cacheKey, {
|
||||
data: tagNames,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return tagNames;
|
||||
} catch (error) {
|
||||
logger.error('[UserContextService] Ошибка получения названий тегов:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить полный контекст пользователя
|
||||
* @param {number} userId - ID пользователя
|
||||
* @returns {Promise<Object>} Контекст пользователя
|
||||
*/
|
||||
async function getUserContext(userId) {
|
||||
try {
|
||||
// Проверяем кэш
|
||||
const cacheKey = `context_${userId}`;
|
||||
const cached = userCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < CACHE_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
// Гостевые пользователи
|
||||
if (typeof userId === 'string' && userId.startsWith('guest_')) {
|
||||
return {
|
||||
id: userId,
|
||||
name: null,
|
||||
tags: [],
|
||||
tagNames: [],
|
||||
language: 'ru',
|
||||
role: 'guest'
|
||||
};
|
||||
}
|
||||
|
||||
// Получаем данные пользователя
|
||||
const query = db.getQuery();
|
||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||
|
||||
const userResult = await query(`
|
||||
SELECT
|
||||
u.id,
|
||||
decrypt_text(u.first_name_encrypted, $1) as first_name,
|
||||
decrypt_text(u.last_name_encrypted, $1) as last_name,
|
||||
u.preferred_language,
|
||||
u.role
|
||||
FROM users u
|
||||
WHERE u.id = $2
|
||||
`, [encryptionKey, userId]);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Формируем имя
|
||||
const firstName = user.first_name || '';
|
||||
const lastName = user.last_name || '';
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ') || null;
|
||||
|
||||
// Получаем теги
|
||||
const tagIds = await getUserTags(userId);
|
||||
const tagNames = await getTagNames(tagIds);
|
||||
|
||||
const context = {
|
||||
id: userId,
|
||||
name,
|
||||
tags: tagIds,
|
||||
tagNames,
|
||||
language: user.preferred_language || 'ru',
|
||||
role: user.role || 'user'
|
||||
};
|
||||
|
||||
// Сохраняем в кэш
|
||||
userCache.set(cacheKey, {
|
||||
data: context,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
logger.error('[UserContextService] Ошибка получения контекста пользователя:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Инвалидация кэша для пользователя
|
||||
* @param {number} userId - ID пользователя
|
||||
*/
|
||||
function invalidateUserCache(userId) {
|
||||
const keysToDelete = [];
|
||||
for (const key of userCache.keys()) {
|
||||
if (key.includes(`_${userId}`) || key.includes(`_${userId}_`)) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToDelete) {
|
||||
userCache.delete(key);
|
||||
}
|
||||
|
||||
logger.debug(`[UserContextService] Кэш инвалидирован для пользователя ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистка всего кэша
|
||||
*/
|
||||
function clearCache() {
|
||||
userCache.clear();
|
||||
logger.info('[UserContextService] Кэш очищен');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить статистику кэша
|
||||
*/
|
||||
function getCacheStats() {
|
||||
return {
|
||||
size: userCache.size,
|
||||
ttl: CACHE_TTL
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getUserTags,
|
||||
getTagNames,
|
||||
getUserContext,
|
||||
invalidateUserCache,
|
||||
clearCache,
|
||||
getCacheStats
|
||||
};
|
||||
|
||||
@@ -13,11 +13,36 @@
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
const ollamaConfig = require('./ollamaConfig');
|
||||
const aiConfigService = require('./aiConfigService');
|
||||
|
||||
const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
|
||||
const TIMEOUTS = ollamaConfig.getTimeouts();
|
||||
const MIN_VECTOR_UPSERT_TIMEOUT = 360000; // 6 минут — с запасом для больших документов
|
||||
|
||||
// Загружаем настройки из aiConfigService (с fallback на ENV)
|
||||
let VECTOR_SEARCH_URL = null;
|
||||
let TIMEOUTS = null;
|
||||
|
||||
// Инициализация настроек (асинхронная загрузка)
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const vectorConfig = await aiConfigService.getVectorSearchConfig();
|
||||
VECTOR_SEARCH_URL = vectorConfig.url || process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
|
||||
TIMEOUTS = ollamaConfig.getTimeouts();
|
||||
} catch (error) {
|
||||
logger.warn('[VectorSearchClient] Ошибка загрузки настроек, используем дефолты:', error.message);
|
||||
VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
|
||||
TIMEOUTS = ollamaConfig.getTimeouts();
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализируем настройки при загрузке модуля
|
||||
loadSettings().catch(err => logger.warn('[VectorSearchClient] Ошибка инициализации:', err.message));
|
||||
|
||||
async function upsert(tableId, rows) {
|
||||
// Загружаем актуальные настройки
|
||||
if (!VECTOR_SEARCH_URL || !TIMEOUTS) {
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
logger.info(`[VectorSearch] upsert: tableId=${tableId}, rows=${rows.length}`);
|
||||
try {
|
||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, {
|
||||
@@ -28,7 +53,7 @@ async function upsert(tableId, rows) {
|
||||
metadata: r.metadata || {}
|
||||
}))
|
||||
}, {
|
||||
timeout: TIMEOUTS.vectorUpsert // Централизованный таймаут для индексации
|
||||
timeout: Math.max(TIMEOUTS.vectorUpsert || 0, MIN_VECTOR_UPSERT_TIMEOUT)
|
||||
});
|
||||
logger.info(`[VectorSearch] upsert result:`, res.data);
|
||||
return res.data;
|
||||
@@ -39,6 +64,11 @@ async function upsert(tableId, rows) {
|
||||
}
|
||||
|
||||
async function search(tableId, query, topK = 3) {
|
||||
// Загружаем актуальные настройки
|
||||
if (!VECTOR_SEARCH_URL || !TIMEOUTS) {
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
logger.info(`[VectorSearch] search: tableId=${tableId}, query="${query}", topK=${topK}`);
|
||||
try {
|
||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, {
|
||||
@@ -91,6 +121,11 @@ async function rebuild(tableId, rows) {
|
||||
}
|
||||
|
||||
async function health() {
|
||||
// Загружаем актуальные настройки
|
||||
if (!VECTOR_SEARCH_URL || !TIMEOUTS) {
|
||||
await loadSettings();
|
||||
}
|
||||
|
||||
logger.info(`[VectorSearch] health check`);
|
||||
try {
|
||||
const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: TIMEOUTS.vectorHealth });
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
// Принудительно устанавливаем URL для Docker-сети
|
||||
process.env.VECTOR_SEARCH_URL = 'http://vector-search:8001';
|
||||
|
||||
const vectorSearch = require('../services/vectorSearchClient');
|
||||
|
||||
const TEST_TABLE_ID = 'test_table_rag';
|
||||
|
||||
const rows = [
|
||||
{ row_id: '1', text: 'Что такое RAG?', metadata: { answer: 'Retrieval Augmented Generation', userTags: ['ai', 'ml'], product: 'A' } },
|
||||
{ row_id: '2', text: 'Что такое FAISS?', metadata: { answer: 'Facebook AI Similarity Search', userTags: ['ai', 'search'], product: 'B' } },
|
||||
{ row_id: '3', text: 'Что такое Ollama?', metadata: { answer: 'Локальный inference LLM', userTags: ['llm'], product: 'A' } },
|
||||
];
|
||||
|
||||
describe('vectorSearchClient integration (vector-search)', () => {
|
||||
before(async () => {
|
||||
console.log('Загружаем тестовые данные...');
|
||||
console.log('VECTOR_SEARCH_URL:', process.env.VECTOR_SEARCH_URL);
|
||||
await vectorSearch.rebuild(TEST_TABLE_ID, rows);
|
||||
console.log('Тестовые данные загружены');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
console.log('Очищаем тестовые данные...');
|
||||
await vectorSearch.remove(TEST_TABLE_ID, rows.map(r => r.row_id));
|
||||
console.log('Тестовые данные очищены');
|
||||
});
|
||||
|
||||
it('Поиск без фильтрации', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое RAG?', 1);
|
||||
console.log('Результаты поиска:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
if (results[0].metadata.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${results[0].metadata.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Поиск с фильтрацией по продукту (должен найти Ollama)', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3);
|
||||
console.log('Результаты поиска Ollama:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
|
||||
// Фильтруем по продукту 'A'
|
||||
const filtered = results.filter(r => r.metadata.product === 'A');
|
||||
if (filtered.length === 0) throw new Error('Нет результатов с продуктом A');
|
||||
if (filtered[0].metadata.answer !== 'Локальный inference LLM') {
|
||||
throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Проверка порога score', async () => {
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3);
|
||||
console.log('Результаты поиска с порогом:', results);
|
||||
if (!results || results.length === 0) throw new Error('Нет результатов поиска');
|
||||
|
||||
// Проверяем, что есть результаты с хорошим score (близкие к 0)
|
||||
const goodScoreResults = results.filter(r => Math.abs(r.score) < 10);
|
||||
if (goodScoreResults.length === 0) throw new Error('Нет результатов с хорошим score');
|
||||
console.log('Результаты с хорошим score:', goodScoreResults.length);
|
||||
});
|
||||
});
|
||||
@@ -1,158 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
// Временно устанавливаем URL для локального тестирования
|
||||
process.env.VECTOR_SEARCH_URL = 'http://localhost:8001';
|
||||
|
||||
const ragService = require('../services/ragService');
|
||||
const db = require('../db');
|
||||
|
||||
const TEST_TABLE_ID = 999999; // Используем числовой ID
|
||||
|
||||
describe('ragService full integration (DB + vector-search)', () => {
|
||||
before(async () => {
|
||||
console.log('Создаем тестовую таблицу и данные...');
|
||||
|
||||
// Создаем тестовую таблицу
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_tables (id, name, description)
|
||||
VALUES ($1, 'Test RAG Table', 'Test table for RAG integration')
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Создаем колонки
|
||||
const columns = [
|
||||
{ id: 'col_question', name: 'Question', type: 'text', purpose: 'question' },
|
||||
{ id: 'col_answer', name: 'Answer', type: 'text', purpose: 'answer' },
|
||||
{ id: 'col_product', name: 'Product', type: 'text', purpose: 'product' }
|
||||
];
|
||||
|
||||
for (const col of columns) {
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_columns (id, table_id, name, type, options)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, [col.id, TEST_TABLE_ID, col.name, col.type, JSON.stringify({ purpose: col.purpose })]);
|
||||
}
|
||||
|
||||
// Создаем строки
|
||||
const rows = [
|
||||
{ id: 'row_1', question: 'Что такое RAG?', answer: 'Retrieval Augmented Generation', product: 'A' },
|
||||
{ id: 'row_2', question: 'Что такое FAISS?', answer: 'Facebook AI Similarity Search', product: 'B' },
|
||||
{ id: 'row_3', question: 'Что такое Ollama?', answer: 'Локальный inference LLM', product: 'A' }
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
// Создаем строку
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_rows (id, table_id, name)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, [row.id, TEST_TABLE_ID, row.question]);
|
||||
|
||||
// Создаем значения ячеек
|
||||
await db.getQuery()(`
|
||||
INSERT INTO user_cell_values (row_id, column_id, value)
|
||||
VALUES ($1, $2, $3), ($1, $4, $5), ($1, $6, $7), ($1, $8, $9)
|
||||
ON CONFLICT (row_id, column_id) DO UPDATE SET value = EXCLUDED.value
|
||||
`, [
|
||||
row.id, 'col_question', row.question,
|
||||
row.id, 'col_answer', row.answer,
|
||||
row.id, 'col_product', row.product
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('Тестовые данные созданы');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
console.log('Очищаем тестовые данные...');
|
||||
|
||||
// Удаляем значения ячеек
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_cell_values
|
||||
WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Удаляем строки
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_rows WHERE table_id = $1
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Удаляем колонки
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_columns WHERE table_id = $1
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
// Удаляем таблицу
|
||||
await db.getQuery()(`
|
||||
DELETE FROM user_tables WHERE id = $1
|
||||
`, [TEST_TABLE_ID]);
|
||||
|
||||
console.log('Тестовые данные очищены');
|
||||
});
|
||||
|
||||
it('Полная интеграция: поиск без фильтрации', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое RAG?'
|
||||
});
|
||||
|
||||
console.log('Результат RAG:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: фильтрация по продукту', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое Ollama?',
|
||||
product: 'A'
|
||||
});
|
||||
|
||||
console.log('Результат с фильтром по продукту:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Локальный inference LLM') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: комбинированная фильтрация', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое RAG?',
|
||||
product: 'A'
|
||||
});
|
||||
|
||||
console.log('Результат с комбинированной фильтрацией:', result);
|
||||
if (!result) throw new Error('Нет результата');
|
||||
if (result.answer !== 'Retrieval Augmented Generation') {
|
||||
throw new Error(`Ответ не совпадает: ${result.answer}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('Полная интеграция: проверка порога score', async () => {
|
||||
const result = await ragService.ragAnswer({
|
||||
tableId: TEST_TABLE_ID,
|
||||
userQuestion: 'Что такое Ollama?',
|
||||
threshold: 0.95
|
||||
});
|
||||
|
||||
console.log('Результат с высоким порогом:', result);
|
||||
// С высоким порогом может не быть результата, это нормально
|
||||
if (result && result.score < 0.95) {
|
||||
throw new Error(`Score слишком низкий: ${result.score}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
const vectorSearch = require('../services/vectorSearchClient');
|
||||
|
||||
const TEST_TABLE_ID = 'test_table_1';
|
||||
|
||||
const rows = [
|
||||
{ row_id: '1', text: 'Как тебя зовут?', metadata: { answer: 'Алексей' } },
|
||||
{ row_id: '2', text: 'Где ты живёшь?', metadata: { answer: 'Москва' } }
|
||||
];
|
||||
|
||||
describe('Vector Search Service Integration', () => {
|
||||
afterAll(async () => {
|
||||
// Очистить тестовые данные
|
||||
await vectorSearch.remove(TEST_TABLE_ID, rows.map(r => r.row_id));
|
||||
});
|
||||
|
||||
test('Upsert and search', async () => {
|
||||
await vectorSearch.upsert(TEST_TABLE_ID, rows);
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Как зовут?', 1);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].metadata.answer).toBe('Алексей');
|
||||
});
|
||||
|
||||
test('Delete', async () => {
|
||||
await vectorSearch.remove(TEST_TABLE_ID, ['1']);
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Как зовут?', 1);
|
||||
expect(results.length === 0 || results[0].metadata.answer !== 'Алексей').toBe(true);
|
||||
});
|
||||
|
||||
test('Rebuild', async () => {
|
||||
await vectorSearch.rebuild(TEST_TABLE_ID, [rows[1]]);
|
||||
const results = await vectorSearch.search(TEST_TABLE_ID, 'Где ты живёшь?', 1);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].metadata.answer).toBe('Москва');
|
||||
});
|
||||
});
|
||||
82
backend/utils/ollamaRequestBuilder.js
Normal file
82
backend/utils/ollamaRequestBuilder.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||||
* All rights reserved.
|
||||
*
|
||||
* This software is proprietary and confidential.
|
||||
* Unauthorized copying, modification, or distribution is prohibited.
|
||||
*
|
||||
* For licensing inquiries: info@hb3-accelerator.com
|
||||
* Website: https://hb3-accelerator.com
|
||||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ollama Request Builder
|
||||
* Утилита для формирования тела запроса к Ollama API
|
||||
* Устраняет дублирование кода между ragService.js и ai-queue.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* Построить тело запроса для Ollama API
|
||||
* @param {Object} options - Опции запроса
|
||||
* @param {Array} options.messages - Массив сообщений для LLM
|
||||
* @param {string} options.model - Название модели (опционально, будет использован дефолтный)
|
||||
* @param {Object} options.llmParameters - Параметры LLM (temperature, maxTokens, top_p, top_k, repeat_penalty)
|
||||
* @param {Object} options.qwenParameters - Специфичные параметры qwen (format)
|
||||
* @param {string} options.defaultModel - Дефолтная модель (если model не указан)
|
||||
* @param {Array} options.tools - Массив определений функций для function calling (опционально)
|
||||
* @param {string} options.tool_choice - Выбор функции ("auto", "none", или конкретная функция) (опционально)
|
||||
* @param {boolean} options.stream - Потоковая передача (по умолчанию false)
|
||||
* @returns {Object} Тело запроса для Ollama API
|
||||
*/
|
||||
function buildOllamaRequest(options = {}) {
|
||||
const {
|
||||
messages,
|
||||
model,
|
||||
llmParameters,
|
||||
qwenParameters,
|
||||
defaultModel,
|
||||
tools = null,
|
||||
tool_choice = null,
|
||||
stream = false
|
||||
} = options;
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
throw new Error('messages обязателен и должен быть массивом');
|
||||
}
|
||||
|
||||
if (!llmParameters) {
|
||||
throw new Error('llmParameters обязателен');
|
||||
}
|
||||
|
||||
// Формируем базовое тело запроса
|
||||
const requestBody = {
|
||||
model: model || defaultModel,
|
||||
messages: messages,
|
||||
stream: stream,
|
||||
// Общие параметры LLM
|
||||
temperature: llmParameters.temperature,
|
||||
num_predict: llmParameters.maxTokens, // Ollama использует num_predict вместо maxTokens
|
||||
top_p: llmParameters.top_p,
|
||||
top_k: llmParameters.top_k,
|
||||
repeat_penalty: llmParameters.repeat_penalty
|
||||
};
|
||||
|
||||
// Добавляем специфичные параметры qwen (если они заданы)
|
||||
if (qwenParameters && qwenParameters.format !== null && qwenParameters.format !== undefined) {
|
||||
requestBody.format = qwenParameters.format;
|
||||
}
|
||||
|
||||
// Добавляем tools для function calling (если переданы)
|
||||
if (tools && Array.isArray(tools) && tools.length > 0) {
|
||||
requestBody.tools = tools;
|
||||
requestBody.tool_choice = tool_choice || "auto";
|
||||
}
|
||||
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildOllamaRequest
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user