feat: новая функция
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
"server": "nodemon server.js --signal SIGUSR2",
|
"server": "nodemon server.js --signal SIGUSR2",
|
||||||
"migrate": "node scripts/run-migrations.js",
|
"migrate": "node scripts/run-migrations.js",
|
||||||
"prod": "NODE_ENV=production node server.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-ollama": "node scripts/check-ollama-models.js",
|
||||||
"check-ethers": "node scripts/check-ethers-v6-compatibility.js",
|
"check-ethers": "node scripts/check-ethers-v6-compatibility.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ router.post('/verify', async (req, res) => {
|
|||||||
|
|
||||||
// Добавляем ссылки на документы в resources
|
// Добавляем ссылки на документы в resources
|
||||||
documents.forEach(doc => {
|
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 { rows } = await db.getQuery()(sql, values);
|
||||||
const created = rows[0];
|
const created = rows[0];
|
||||||
|
|
||||||
// Индексация в vector-search (только для HTML, если есть текст)
|
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(created);
|
res.json(created);
|
||||||
});
|
});
|
||||||
@@ -280,6 +256,58 @@ router.post('/:id/reindex', async (req, res) => {
|
|||||||
const url = page.visibility === 'public' && page.status === 'published'
|
const url = page.visibility === 'public' && page.status === 'published'
|
||||||
? `/public/page/${page.id}`
|
? `/public/page/${page.id}`
|
||||||
: `/content/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', [{
|
await vectorSearchClient.upsert('legal_docs', [{
|
||||||
row_id: page.id,
|
row_id: page.id,
|
||||||
text,
|
text,
|
||||||
@@ -293,7 +321,9 @@ router.post('/:id/reindex', async (req, res) => {
|
|||||||
updated_at: page.updated_at || null
|
updated_at: page.updated_at || null
|
||||||
}
|
}
|
||||||
}]);
|
}]);
|
||||||
res.json({ success: true });
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, chunksCount: chunks.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[pages] manual reindex error:', e.message);
|
console.error('[pages] manual reindex error:', e.message);
|
||||||
res.status(500).json({ error: 'Ошибка индексации' });
|
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' });
|
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||||||
const updated = rows[0];
|
const updated = rows[0];
|
||||||
|
|
||||||
// Индексация для HTML
|
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
});
|
});
|
||||||
@@ -406,7 +412,14 @@ router.delete('/:id', async (req, res) => {
|
|||||||
const deleted = rows[0];
|
const deleted = rows[0];
|
||||||
try {
|
try {
|
||||||
if (deleted && deleted.format === 'html') {
|
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) {
|
} catch (e) {
|
||||||
console.error('[pages] vector remove error:', e.message);
|
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) => {
|
router.delete('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) => {
|
||||||
try {
|
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) {
|
if (!aiResponse || !aiResponse.success) {
|
||||||
logger.warn(`[UniversalGuestService] AI не вернул ответ для ${identifier}`);
|
logger.warn(`[UniversalGuestService] AI не вернул ответ для ${identifier}`);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const ollamaConfig = require('./ollamaConfig');
|
const ollamaConfig = require('./ollamaConfig');
|
||||||
const { shouldProcessWithAI } = require('../utils/languageFilter');
|
const { shouldProcessWithAI } = require('../utils/languageFilter');
|
||||||
|
const userContextService = require('./userContextService');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI Assistant - тонкая обёртка для работы с Ollama и RAG
|
* AI Assistant - тонкая обёртка для работы с Ollama и RAG
|
||||||
@@ -86,6 +87,7 @@ class AIAssistant {
|
|||||||
const messageDeduplicationService = require('./messageDeduplicationService');
|
const messageDeduplicationService = require('./messageDeduplicationService');
|
||||||
const aiAssistantSettingsService = require('./aiAssistantSettingsService');
|
const aiAssistantSettingsService = require('./aiAssistantSettingsService');
|
||||||
const aiAssistantRulesService = require('./aiAssistantRulesService');
|
const aiAssistantRulesService = require('./aiAssistantRulesService');
|
||||||
|
const profileAnalysisService = require('./profileAnalysisService');
|
||||||
const { ragAnswer } = require('./ragService');
|
const { ragAnswer } = require('./ragService');
|
||||||
|
|
||||||
// 1. Проверяем дедупликацию через хеш
|
// 1. Проверяем дедупликацию через хеш
|
||||||
@@ -95,7 +97,7 @@ class AIAssistant {
|
|||||||
channel
|
channel
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDuplicate = messageDeduplicationService.isDuplicate(messageForDedup);
|
const isDuplicate = await messageDeduplicationService.isDuplicate(messageForDedup);
|
||||||
|
|
||||||
if (isDuplicate) {
|
if (isDuplicate) {
|
||||||
logger.info(`[AIAssistant] Сообщение уже обработано - пропускаем`);
|
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 ассистента
|
// 2. Получаем настройки AI ассистента
|
||||||
|
logger.info(`[AIAssistant] Получение настроек AI ассистента...`);
|
||||||
const aiSettings = await aiAssistantSettingsService.getSettings();
|
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;
|
let rules = null;
|
||||||
if (aiSettings && aiSettings.rules_id) {
|
if (aiSettings && aiSettings.rules_id) {
|
||||||
|
logger.info(`[AIAssistant] Загрузка правил по ID: ${aiSettings.rules_id}`);
|
||||||
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
|
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Определяем tableId для RAG
|
// 3. Определяем tableIds для RAG (может быть несколько таблиц)
|
||||||
let tableId = ragTableId;
|
const tableIds = aiSettings && aiSettings.selected_rag_tables && aiSettings.selected_rag_tables.length > 0
|
||||||
if (!tableId && aiSettings && aiSettings.selected_rag_tables && aiSettings.selected_rag_tables.length > 0) {
|
? aiSettings.selected_rag_tables
|
||||||
tableId = aiSettings.selected_rag_tables[0];
|
: (ragTableId ? [ragTableId] : []);
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Выполняем RAG поиск если есть tableId
|
logger.info(`[AIAssistant] Определены tableIds для RAG: ${JSON.stringify(tableIds)}`);
|
||||||
let ragResult = null;
|
|
||||||
if (tableId) {
|
// 4. Выполняем мульти-источниковый поиск (таблицы + документы)
|
||||||
ragResult = await ragAnswer({
|
logger.info(`[AIAssistant] Начало мульти-источникового поиска...`);
|
||||||
tableId,
|
const multiSourceSearchService = require('./multiSourceSearchService');
|
||||||
userQuestion
|
const ragConfig = await (require('./aiConfigService')).getRAGConfig();
|
||||||
// threshold использует дефолтное значение 300 из ragService
|
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: tableIds[0],
|
||||||
|
userQuestion,
|
||||||
|
userId: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Генерируем LLM ответ
|
// 5. Генерируем LLM ответ
|
||||||
const { generateLLMResponse } = require('./ragService');
|
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({
|
const aiResponse = await generateLLMResponse({
|
||||||
userQuestion,
|
userQuestion,
|
||||||
context: ragResult?.context || '',
|
context: ragResult?.context || '',
|
||||||
@@ -138,15 +303,21 @@ class AIAssistant {
|
|||||||
history: conversationHistory,
|
history: conversationHistory,
|
||||||
model: aiSettings ? aiSettings.model : undefined,
|
model: aiSettings ? aiSettings.model : undefined,
|
||||||
rules: rules ? rules.rules : null,
|
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) {
|
if (!aiResponse) {
|
||||||
logger.warn(`[AIAssistant] Пустой ответ от AI для пользователя ${userId}`);
|
logger.warn(`[AIAssistant] Пустой ответ от AI для пользователя ${userId}`);
|
||||||
return { success: false, reason: 'empty_response' };
|
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 {
|
return {
|
||||||
success: true,
|
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 ответов для ускорения работы
|
* Кэширование AI ответов для ускорения работы
|
||||||
|
* Использует настройки из aiConfigService
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const ollamaConfig = require('./ollamaConfig');
|
const ollamaConfig = require('./ollamaConfig');
|
||||||
|
const aiConfigService = require('./aiConfigService');
|
||||||
|
|
||||||
class AICache {
|
class AICache {
|
||||||
constructor() {
|
constructor() {
|
||||||
const timeouts = ollamaConfig.getTimeouts();
|
// Загружаем настройки из aiConfigService
|
||||||
|
|
||||||
this.cache = new Map();
|
this.cache = new Map();
|
||||||
this.maxSize = timeouts.cacheMax; // Из централизованных настроек
|
this._loadSettings();
|
||||||
this.ttl = timeouts.cacheLLM; // 24 часа (для LLM)
|
|
||||||
this.ragTtl = timeouts.cacheRAG; // 5 минут (для RAG результатов)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерация ключа кэша на основе запроса
|
/**
|
||||||
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({
|
const content = JSON.stringify({
|
||||||
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
messages: messages.map(m => ({ role: m.role, content: m.content })),
|
||||||
temperature: options.temperature || 0.3,
|
temperature: options.temperature || llmParams.temperature,
|
||||||
maxTokens: options.num_predict || 150
|
maxTokens: options.num_predict || llmParams.maxTokens
|
||||||
});
|
});
|
||||||
return crypto.createHash('md5').update(content).digest('hex');
|
return crypto.createHash('md5').update(content).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✨ НОВОЕ: Генерация ключа для RAG результатов
|
/**
|
||||||
generateKeyForRAG(tableId, userQuestion, product = null) {
|
* Генерация ключа для RAG результатов
|
||||||
const content = JSON.stringify({ tableId, userQuestion, product });
|
* Включает 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');
|
return crypto.createHash('md5').update(content).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получение ответа из кэша
|
/**
|
||||||
|
* Получение ответа из кэша (LLM)
|
||||||
|
*/
|
||||||
get(key) {
|
get(key) {
|
||||||
const cached = this.cache.get(key);
|
const cached = this.cache.get(key);
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
@@ -47,12 +103,13 @@ class AICache {
|
|||||||
return cached.response;
|
return cached.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✨ НОВОЕ: Получение с учетом типа кэша (RAG или LLM)
|
/**
|
||||||
|
* Получение с учетом типа кэша (RAG или LLM)
|
||||||
|
*/
|
||||||
getWithTTL(key, type = 'llm') {
|
getWithTTL(key, type = 'llm') {
|
||||||
const cached = this.cache.get(key);
|
const cached = this.cache.get(key);
|
||||||
if (!cached) return null;
|
if (!cached) return null;
|
||||||
|
|
||||||
// Выбираем TTL в зависимости от типа
|
|
||||||
const ttl = type === 'rag' ? this.ragTtl : this.ttl;
|
const ttl = type === 'rag' ? this.ragTtl : this.ttl;
|
||||||
|
|
||||||
// Проверяем TTL
|
// Проверяем TTL
|
||||||
@@ -65,101 +122,110 @@ class AICache {
|
|||||||
return cached.response;
|
return cached.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сохранение ответа в кэш
|
/**
|
||||||
set(key, response) {
|
* Сохранение в кэш
|
||||||
// Очищаем старые записи если кэш переполнен
|
*/
|
||||||
|
set(key, value, type = 'llm') {
|
||||||
|
// Проверяем размер кэша
|
||||||
if (this.cache.size >= this.maxSize) {
|
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);
|
this.cache.delete(oldestKey);
|
||||||
|
logger.warn(`[AICache] Кэш переполнен, удалена старая запись: ${oldestKey.substring(0, 8)}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cache.set(key, {
|
this.cache.set(key, {
|
||||||
response,
|
response: value,
|
||||||
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,
|
|
||||||
timestamp: Date.now(),
|
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() {
|
clear() {
|
||||||
|
const size = this.cache.size;
|
||||||
this.cache.clear();
|
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() {
|
getStats() {
|
||||||
return {
|
const stats = {
|
||||||
size: this.cache.size,
|
size: this.cache.size,
|
||||||
maxSize: this.maxSize,
|
maxSize: this.maxSize,
|
||||||
hitRate: this.calculateHitRate()
|
ttl: this.ttl,
|
||||||
|
ragTtl: this.ragTtl
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
calculateHitRate() {
|
// Подсчитываем по типам
|
||||||
// Простая реализация - в реальности нужно отслеживать hits/misses
|
let llmCount = 0;
|
||||||
if (this.maxSize === 0) return 0;
|
let ragCount = 0;
|
||||||
return this.cache.size / this.maxSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✨ НОВОЕ: Статистика по типу кэша
|
|
||||||
getStatsByType() {
|
|
||||||
const stats = { rag: 0, llm: 0, other: 0 };
|
|
||||||
for (const [key, value] of this.cache.entries()) {
|
for (const [key, value] of this.cache.entries()) {
|
||||||
const type = value.type || 'other';
|
if (value.type === 'rag') {
|
||||||
stats[type] = (stats[type] || 0) + 1;
|
ragCount++;
|
||||||
|
} else {
|
||||||
|
llmCount++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.llmCount = llmCount;
|
||||||
|
stats.ragCount = ragCount;
|
||||||
|
|
||||||
return stats;
|
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()) {
|
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)) {
|
if (key.startsWith(prefix)) {
|
||||||
this.cache.delete(key);
|
this.cache.delete(key);
|
||||||
deletedCount++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deletedCount > 0) {
|
if (count > 0) {
|
||||||
logger.info(`[AICache] Инвалидировано ${deletedCount} записей с префиксом: ${prefix}`);
|
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 axios = require('axios');
|
||||||
const ollamaConfig = require('./ollamaConfig');
|
const ollamaConfig = require('./ollamaConfig');
|
||||||
const aiCache = require('./ai-cache');
|
const aiCache = require('./ai-cache');
|
||||||
|
const aiConfigService = require('./aiConfigService');
|
||||||
|
const { buildOllamaRequest } = require('../utils/ollamaRequestBuilder');
|
||||||
|
|
||||||
class AIQueue extends EventEmitter {
|
class AIQueue extends EventEmitter {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -237,25 +239,56 @@ class AIQueue extends EventEmitter {
|
|||||||
return;
|
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 ollamaUrl = ollamaConfig.getBaseUrl();
|
||||||
const timeouts = ollamaConfig.getTimeouts();
|
const timeouts = ollamaConfig.getTimeouts();
|
||||||
|
|
||||||
const response = await axios.post(`${ollamaUrl}/api/chat`, {
|
logger.info(`[AIQueue] Отправка запроса в Ollama с параметрами:`, {
|
||||||
model: task.request.model || ollamaConfig.getDefaultModel(),
|
model: requestBody.model,
|
||||||
messages: task.request.messages,
|
temperature: requestBody.temperature,
|
||||||
stream: false
|
num_predict: requestBody.num_predict,
|
||||||
}, {
|
format: requestBody.format || 'не задан',
|
||||||
|
hasTools: !!requestBody.tools
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post(`${ollamaUrl}/api/chat`, requestBody, {
|
||||||
timeout: timeouts.ollamaChat
|
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;
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
// 3. Сохраняем в кэш
|
// 4. Сохраняем в кэш
|
||||||
aiCache.set(cacheKey, result);
|
aiCache.set(cacheKey, result);
|
||||||
|
|
||||||
// 4. Обновляем статус
|
// 5. Обновляем статус
|
||||||
this.updateRequestStatus(task.id, 'completed', result, null, responseTime);
|
this.updateRequestStatus(task.id, 'completed', result, null, responseTime);
|
||||||
this.emit(`task_${task.id}_completed`, { response: result, fromCache: false });
|
this.emit(`task_${task.id}_completed`, { response: result, fromCache: false });
|
||||||
|
|
||||||
@@ -274,3 +307,4 @@ class AIQueue extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AIQueue;
|
module.exports = AIQueue;
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ async function getSettings() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем ключ шифрования через унифицированную утилиту
|
|
||||||
const encryptionUtils = require('../utils/encryptionUtils');
|
|
||||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
|
||||||
|
|
||||||
// Обрабатываем selected_rag_tables
|
// Обрабатываем selected_rag_tables
|
||||||
if (setting.selected_rag_tables) {
|
if (setting.selected_rag_tables) {
|
||||||
try {
|
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:`, {
|
logger.info(`[aiAssistantSettingsService] Final settings result:`, {
|
||||||
id: setting.id,
|
id: setting.id,
|
||||||
selected_rag_tables: setting.selected_rag_tables,
|
selected_rag_tables: setting.selected_rag_tables,
|
||||||
rules_id: setting.rules_id,
|
rules_id: setting.rules_id,
|
||||||
hasSupportEmail: setting.hasSupportEmail,
|
hasSupportEmail: setting.hasSupportEmail,
|
||||||
hasTelegramBot: setting.hasTelegramBot,
|
hasTelegramBot: setting.hasTelegramBot,
|
||||||
timestamp: setting.timestamp
|
timestamp: setting.timestamp,
|
||||||
|
enabled_channels: setting.enabled_channels
|
||||||
});
|
});
|
||||||
|
|
||||||
return setting;
|
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 = {
|
const data = {
|
||||||
id: 1,
|
id: 1,
|
||||||
system_prompt,
|
system_prompt,
|
||||||
selected_rag_tables,
|
selected_rag_tables,
|
||||||
languages: ['ru'], // Устанавливаем русский язык по умолчанию
|
languages: ['ru'],
|
||||||
model,
|
model,
|
||||||
embedding_model,
|
embedding_model,
|
||||||
rules,
|
rules,
|
||||||
@@ -93,17 +138,15 @@ async function upsertSettings({ system_prompt, selected_rag_tables, model, embed
|
|||||||
updated_by,
|
updated_by,
|
||||||
telegram_settings_id,
|
telegram_settings_id,
|
||||||
email_settings_id,
|
email_settings_id,
|
||||||
system_message
|
system_message,
|
||||||
|
enabled_channels: channelsPayload
|
||||||
};
|
};
|
||||||
|
|
||||||
// Проверяем, существует ли запись
|
|
||||||
const existing = await encryptedDb.getData(TABLE, { id: 1 }, 1);
|
const existing = await encryptedDb.getData(TABLE, { id: 1 }, 1);
|
||||||
|
|
||||||
if (existing.length > 0) {
|
if (existing.length > 0) {
|
||||||
// Обновляем существующую запись
|
|
||||||
return await encryptedDb.saveData(TABLE, data, { id: 1 });
|
return await encryptedDb.saveData(TABLE, data, { id: 1 });
|
||||||
} else {
|
} else {
|
||||||
// Создаем новую запись
|
|
||||||
return await encryptedDb.saveData(TABLE, data);
|
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,
|
title: doc.title,
|
||||||
summary: doc.summary,
|
summary: doc.summary,
|
||||||
consentType: DOCUMENT_CONSENT_MAP[doc.title],
|
consentType: DOCUMENT_CONSENT_MAP[doc.title],
|
||||||
url: `/public/page/${doc.id}`
|
url: `/content/published/${doc.id}`
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ConsentService] Ошибка получения документов:', error);
|
logger.error('[ConsentService] Ошибка получения документов:', error);
|
||||||
|
|||||||
@@ -236,16 +236,16 @@ class EncryptedDataService {
|
|||||||
console.log(`🔐 Будем шифровать ${key} -> ${key}_encrypted`);
|
console.log(`🔐 Будем шифровать ${key} -> ${key}_encrypted`);
|
||||||
} else if (unencryptedColumn) {
|
} else if (unencryptedColumn) {
|
||||||
// Если есть незашифрованная колонка, сохраняем как есть
|
// Если есть незашифрованная колонка, сохраняем как есть
|
||||||
// Проверяем, что значение не пустое перед сохранением (кроме role и sender_type)
|
// Проверяем, что значение не пустое перед сохранением (кроме role, sender_type и user_id)
|
||||||
if ((value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) &&
|
if ((value === null || value === undefined || (typeof value === 'string' && value.trim() === '')) &&
|
||||||
key !== 'role' && key !== 'sender_type') {
|
key !== 'role' && key !== 'sender_type' && key !== 'user_id') {
|
||||||
// Пропускаем пустые значения, кроме role и sender_type
|
// Пропускаем пустые значения, кроме role, sender_type и user_id
|
||||||
// console.log(`⚠️ Пропускаем пустое незашифрованное поле ${key}`);
|
// console.log(`⚠️ Пропускаем пустое незашифрованное поле ${key}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
filteredData[key] = value; // Добавляем в отфильтрованные данные
|
filteredData[key] = value; // Добавляем в отфильтрованные данные
|
||||||
unencryptedData[key] = `$${paramIndex++}`;
|
unencryptedData[key] = `$${paramIndex++}`;
|
||||||
// console.log(`✅ Добавили незашифрованное поле ${key} в filteredData и unencryptedData`);
|
console.log(`✅ Добавили незашифрованное поле ${key} в filteredData и unencryptedData`);
|
||||||
} else {
|
} else {
|
||||||
// Если колонка не найдена, пропускаем
|
// Если колонка не найдена, пропускаем
|
||||||
// console.warn(`⚠️ Колонка ${key} не найдена в таблице ${tableName}`);
|
// console.warn(`⚠️ Колонка ${key} не найдена в таблице ${tableName}`);
|
||||||
@@ -254,6 +254,11 @@ class EncryptedDataService {
|
|||||||
|
|
||||||
const allData = { ...unencryptedData, ...encryptedData };
|
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) {
|
if (Object.keys(allData).length === 0) {
|
||||||
// console.warn(`⚠️ Нет данных для сохранения в таблице ${tableName} - все значения пустые`);
|
// console.warn(`⚠️ Нет данных для сохранения в таблице ${tableName} - все значения пустые`);
|
||||||
@@ -310,29 +315,36 @@ class EncryptedDataService {
|
|||||||
// Проходим по колонкам в порядке allData и добавляем соответствующие значения
|
// Проходим по колонкам в порядке allData и добавляем соответствующие значения
|
||||||
for (const key of Object.keys(allData)) {
|
for (const key of Object.keys(allData)) {
|
||||||
const placeholder = allData[key].toString();
|
const placeholder = allData[key].toString();
|
||||||
|
console.log(`🔍 Обрабатываем ключ: ${key}, placeholder: ${placeholder}`);
|
||||||
// Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text)
|
// Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text)
|
||||||
const paramMatches = placeholder.match(/\$(\d+)/g);
|
const paramMatches = placeholder.match(/\$(\d+)/g);
|
||||||
|
console.log(`🔍 paramMatches для ${key}:`, paramMatches);
|
||||||
if (paramMatches) {
|
if (paramMatches) {
|
||||||
// Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.)
|
// Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.)
|
||||||
// Для незашифрованных - первый параметр ($2, $3 и т.д.)
|
// Для незашифрованных - первый параметр ($2, $3 и т.д.)
|
||||||
if (encryptedData[key]) {
|
if (encryptedData[key]) {
|
||||||
// Это зашифрованная колонка - берем второй параметр (первый это $1 - ключ шифрования)
|
// Это зашифрованная колонка - берем первый параметр (это значение для шифрования)
|
||||||
const originalKey = key.replace('_encrypted', '');
|
const originalKey = key.replace('_encrypted', '');
|
||||||
|
console.log(`🔍 Это зашифрованная колонка, originalKey: ${originalKey}, filteredData[originalKey]:`, filteredData[originalKey]);
|
||||||
if (filteredData[originalKey] !== undefined && paramMatches.length > 0) {
|
if (filteredData[originalKey] !== undefined && paramMatches.length > 0) {
|
||||||
// Последний параметр это значение для шифрования
|
// Первый параметр это значение для шифрования
|
||||||
const valueParam = paramMatches[paramMatches.length - 1];
|
const valueParam = paramMatches[0];
|
||||||
const paramNum = parseInt(valueParam.substring(1));
|
const paramNum = parseInt(valueParam.substring(1));
|
||||||
|
console.log(`🔍 Устанавливаем paramMap[${paramNum}] =`, filteredData[originalKey]);
|
||||||
paramMap.set(paramNum, filteredData[originalKey]);
|
paramMap.set(paramNum, filteredData[originalKey]);
|
||||||
}
|
}
|
||||||
} else if (unencryptedData[key]) {
|
} else if (unencryptedData[key]) {
|
||||||
// Это незашифрованная колонка - берем параметр из плейсхолдера
|
// Это незашифрованная колонка - берем параметр из плейсхолдера
|
||||||
const valueParam = paramMatches[0];
|
const valueParam = paramMatches[0];
|
||||||
const paramNum = parseInt(valueParam.substring(1));
|
const paramNum = parseInt(valueParam.substring(1));
|
||||||
|
console.log(`🔍 Это незашифрованная колонка, устанавливаем paramMap[${paramNum}] =`, filteredData[key]);
|
||||||
paramMap.set(paramNum, filteredData[key]);
|
paramMap.set(paramNum, filteredData[key]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 paramMap после цикла:`, Array.from(paramMap.entries()));
|
||||||
|
|
||||||
// Создаем массив параметров в правильном порядке (от $1 до максимального номера)
|
// Создаем массив параметров в правильном порядке (от $1 до максимального номера)
|
||||||
const maxParamNum = Math.max(...Array.from(paramMap.keys()));
|
const maxParamNum = Math.max(...Array.from(paramMap.keys()));
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const aiConfigService = require('./aiConfigService');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Сервис дедупликации сообщений
|
* Сервис дедупликации сообщений
|
||||||
@@ -21,8 +22,22 @@ const logger = require('../utils/logger');
|
|||||||
// Хранилище хешей обработанных сообщений (в памяти)
|
// Хранилище хешей обработанных сообщений (в памяти)
|
||||||
const processedMessages = new Map();
|
const processedMessages = new Map();
|
||||||
|
|
||||||
// Время жизни записи о сообщении (5 минут)
|
// Время жизни записи о сообщении (загружается из aiConfigService)
|
||||||
const MESSAGE_TTL = 5 * 60 * 1000;
|
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 - Данные сообщения
|
* @param {Object} messageData - Данные сообщения
|
||||||
* @returns {boolean} true если сообщение уже обрабатывалось
|
* @returns {boolean} true если сообщение уже обрабатывалось
|
||||||
*/
|
*/
|
||||||
function isDuplicate(messageData) {
|
async function isDuplicate(messageData) {
|
||||||
|
// Загружаем актуальные настройки, если они не загружены
|
||||||
|
if (MESSAGE_TTL === null) {
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
const hash = createMessageHash(messageData);
|
const hash = createMessageHash(messageData);
|
||||||
|
|
||||||
if (processedMessages.has(hash)) {
|
if (processedMessages.has(hash)) {
|
||||||
@@ -72,7 +92,12 @@ function isDuplicate(messageData) {
|
|||||||
* Пометить сообщение как обработанное
|
* Пометить сообщение как обработанное
|
||||||
* @param {Object} messageData - Данные сообщения
|
* @param {Object} messageData - Данные сообщения
|
||||||
*/
|
*/
|
||||||
function markAsProcessed(messageData) {
|
async function markAsProcessed(messageData) {
|
||||||
|
// Загружаем актуальные настройки, если они не загружены
|
||||||
|
if (MESSAGE_TTL === null) {
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
const hash = createMessageHash(messageData);
|
const hash = createMessageHash(messageData);
|
||||||
|
|
||||||
processedMessages.set(hash, {
|
processedMessages.set(hash, {
|
||||||
@@ -91,11 +116,14 @@ function markAsProcessed(messageData) {
|
|||||||
* Очистить старые записи из хранилища
|
* Очистить старые записи из хранилища
|
||||||
*/
|
*/
|
||||||
function cleanupOldEntries() {
|
function cleanupOldEntries() {
|
||||||
|
// Если настройки не загружены, используем дефолт
|
||||||
|
const ttl = MESSAGE_TTL || 5 * 60 * 1000;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let cleanedCount = 0;
|
let cleanedCount = 0;
|
||||||
|
|
||||||
for (const [hash, entry] of processedMessages.entries()) {
|
for (const [hash, entry] of processedMessages.entries()) {
|
||||||
if (now - entry.timestamp > MESSAGE_TTL) {
|
if (now - entry.timestamp > ttl) {
|
||||||
processedMessages.delete(hash);
|
processedMessages.delete(hash);
|
||||||
cleanedCount++;
|
cleanedCount++;
|
||||||
}
|
}
|
||||||
@@ -113,7 +141,7 @@ function cleanupOldEntries() {
|
|||||||
function getStats() {
|
function getStats() {
|
||||||
return {
|
return {
|
||||||
totalTracked: processedMessages.size,
|
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 инфраструктуры
|
* Конфигурационный сервис для Ollama и AI инфраструктуры
|
||||||
* Централизует все настройки, URL и таймауты для:
|
* Обёртка над aiConfigService для обратной совместимости
|
||||||
* - Ollama API
|
|
||||||
* - Vector Search
|
|
||||||
* - AI Cache
|
|
||||||
* - AI Queue
|
|
||||||
*
|
*
|
||||||
* ВАЖНО: Настройки берутся из таблицы ai_providers_settings (через aiProviderSettingsService)
|
* ВАЖНО: Все настройки теперь берутся из ai_config через aiConfigService
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const logger = require('../utils/logger');
|
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 из базы данных
|
* Обновляет синхронный кэш из aiConfigService
|
||||||
* @returns {Promise<Object>} Настройки Ollama провайдера
|
* @private
|
||||||
*/
|
*/
|
||||||
async function loadSettingsFromDb() {
|
async function _updateSyncCache() {
|
||||||
try {
|
try {
|
||||||
const aiProviderSettingsService = require('./aiProviderSettingsService');
|
const ollamaConfig = await aiConfigService.getOllamaConfig();
|
||||||
const settings = await aiProviderSettingsService.getProviderSettings('ollama');
|
syncCache = {
|
||||||
|
baseUrl: ollamaConfig.baseUrl,
|
||||||
if (settings) {
|
defaultModel: ollamaConfig.llmModel,
|
||||||
settingsCache = settings;
|
embeddingModel: ollamaConfig.embeddingModel
|
||||||
logger.info(`[ollamaConfig] Loaded settings from DB: model=${settings.selected_model}, base_url=${settings.base_url}`);
|
};
|
||||||
}
|
syncCacheTimestamp = Date.now();
|
||||||
|
|
||||||
return settings;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ollamaConfig] Ошибка загрузки настроек Ollama из БД:', error.message);
|
logger.warn('[ollamaConfig] Failed to update sync cache:', error.message);
|
||||||
return null;
|
// Используем дефолты
|
||||||
|
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 дефолт
|
* @private
|
||||||
* @returns {string} Базовый URL Ollama
|
|
||||||
*/
|
*/
|
||||||
function _getBaseUrlFromSources() {
|
function _getFromSyncCache(key) {
|
||||||
// Приоритет 1: кэш из БД
|
const now = Date.now();
|
||||||
if (settingsCache && settingsCache.base_url) {
|
if (!syncCache || (now - syncCacheTimestamp) > SYNC_CACHE_TTL) {
|
||||||
return settingsCache.base_url;
|
// Обновляем кэш асинхронно (не блокируя)
|
||||||
|
_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
|
* @returns {string} Базовый URL Ollama
|
||||||
*/
|
*/
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
return _getBaseUrlFromSources();
|
return _getFromSyncCache('baseUrl');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,15 +88,8 @@ function getBaseUrl() {
|
|||||||
* @returns {Promise<string>} Базовый URL Ollama
|
* @returns {Promise<string>} Базовый URL Ollama
|
||||||
*/
|
*/
|
||||||
async function getBaseUrlAsync() {
|
async function getBaseUrlAsync() {
|
||||||
try {
|
const config = await aiConfigService.getOllamaConfig();
|
||||||
if (!settingsCache) {
|
return config.baseUrl;
|
||||||
await loadSettingsFromDb();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn('[ollamaConfig] Failed to load base_url from DB, using default');
|
|
||||||
}
|
|
||||||
|
|
||||||
return _getBaseUrlFromSources();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,12 +107,7 @@ function getApiUrl(endpoint) {
|
|||||||
* @returns {string} Название модели
|
* @returns {string} Название модели
|
||||||
*/
|
*/
|
||||||
function getDefaultModel() {
|
function getDefaultModel() {
|
||||||
// Приоритет: кэш из БД > дефолт
|
return _getFromSyncCache('defaultModel');
|
||||||
if (settingsCache && settingsCache.selected_model) {
|
|
||||||
return settingsCache.selected_model;
|
|
||||||
}
|
|
||||||
// Дефолтное значение если БД недоступна
|
|
||||||
return 'qwen2.5:7b';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,19 +115,8 @@ function getDefaultModel() {
|
|||||||
* @returns {Promise<string>} Название модели из БД
|
* @returns {Promise<string>} Название модели из БД
|
||||||
*/
|
*/
|
||||||
async function getDefaultModelAsync() {
|
async function getDefaultModelAsync() {
|
||||||
try {
|
const config = await aiConfigService.getOllamaConfig();
|
||||||
if (!settingsCache) {
|
return config.llmModel;
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,59 +124,116 @@ async function getDefaultModelAsync() {
|
|||||||
* @returns {Promise<string>} Название embedding модели из БД
|
* @returns {Promise<string>} Название embedding модели из БД
|
||||||
*/
|
*/
|
||||||
async function getEmbeddingModel() {
|
async function getEmbeddingModel() {
|
||||||
try {
|
const config = await aiConfigService.getOllamaConfig();
|
||||||
if (!settingsCache) {
|
return config.embeddingModel;
|
||||||
await loadSettingsFromDb();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (settingsCache && settingsCache.embedding_model) {
|
// Кэш для таймаутов (синхронный доступ)
|
||||||
logger.info(`[ollamaConfig] Using embedding model from DB: ${settingsCache.embedding_model}`);
|
let timeoutsCache = null;
|
||||||
return settingsCache.embedding_model;
|
let timeoutsCacheTimestamp = 0;
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Обновляет кэш таймаутов из aiConfigService
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async function _updateTimeoutsCache() {
|
||||||
|
try {
|
||||||
|
const timeouts = await aiConfigService.getTimeouts();
|
||||||
|
const cacheConfig = await aiConfigService.getCacheConfig();
|
||||||
|
const queueConfig = await aiConfigService.getQueueConfig();
|
||||||
|
|
||||||
|
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) {
|
} 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 сервисов
|
* Централизованные таймауты для Ollama и AI сервисов
|
||||||
|
* Синхронная версия с кэшированием (для обратной совместимости)
|
||||||
* @returns {Object} Объект с различными таймаутами
|
* @returns {Object} Объект с различными таймаутами
|
||||||
*/
|
*/
|
||||||
function getTimeouts() {
|
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 {
|
return {
|
||||||
// Ollama API - таймауты запросов
|
ollamaChat: 600000,
|
||||||
ollamaChat: 180000, // 180 сек (3 мин) - генерация ответов LLM (увеличено для сложных запросов)
|
ollamaEmbedding: 90000,
|
||||||
ollamaEmbedding: 90000, // 90 сек (1.5 мин) - генерация embeddings (увеличено)
|
ollamaHealth: 5000,
|
||||||
ollamaHealth: 5000, // 5 сек - health check
|
ollamaTags: 10000,
|
||||||
ollamaTags: 10000, // 10 сек - список моделей
|
vectorSearch: 90000,
|
||||||
|
vectorUpsert: 600000,
|
||||||
// Vector Search - таймауты запросов
|
vectorHealth: 5000,
|
||||||
vectorSearch: 90000, // 90 сек - поиск по векторам (увеличено для больших баз)
|
cacheLLM: 86400000,
|
||||||
vectorUpsert: 90000, // 90 сек - индексация данных (увеличено)
|
cacheRAG: 300000,
|
||||||
vectorHealth: 5000, // 5 сек - health check
|
cacheMax: 1000,
|
||||||
|
queueTimeout: 180000,
|
||||||
// AI Cache - TTL (Time To Live) для кэширования
|
queueMaxSize: 100,
|
||||||
cacheLLM: 24 * 60 * 60 * 1000, // 24 часа - LLM ответы
|
queueInterval: 100,
|
||||||
cacheRAG: 5 * 60 * 1000, // 5 минут - RAG результаты
|
default: 180000
|
||||||
cacheMax: 1000, // Максимум записей в кэше
|
|
||||||
|
|
||||||
// AI Queue - параметры очереди
|
|
||||||
queueTimeout: 180000, // 180 сек - таймаут задачи в очереди (увеличено)
|
|
||||||
queueMaxSize: 100, // Максимум задач в очереди
|
|
||||||
queueInterval: 100, // 100 мс - интервал проверки очереди
|
|
||||||
|
|
||||||
// Default для совместимости
|
|
||||||
default: 180000 // 180 сек (увеличено с 120)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получает timeout для запросов к Ollama (обратная совместимость)
|
* Получает timeout для запросов к Ollama (обратная совместимость)
|
||||||
|
* Синхронная версия (для обратной совместимости)
|
||||||
* @returns {number} Timeout в миллисекундах
|
* @returns {number} Timeout в миллисекундах
|
||||||
*/
|
*/
|
||||||
function getTimeout() {
|
function getTimeout() {
|
||||||
return getTimeouts().ollamaChat; // 120 секунд (2 минуты) - для генерации длинных ответов
|
const timeouts = getTimeouts();
|
||||||
|
return timeouts.ollamaChat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,36 +241,13 @@ function getTimeout() {
|
|||||||
* @returns {Object} Объект с конфигурацией
|
* @returns {Object} Объект с конфигурацией
|
||||||
*/
|
*/
|
||||||
function getConfig() {
|
function getConfig() {
|
||||||
return {
|
const baseUrl = getBaseUrl();
|
||||||
baseUrl: getBaseUrl(),
|
const defaultModel = getDefaultModel();
|
||||||
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();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
embeddingModel,
|
timeout: null, // Теперь асинхронный
|
||||||
timeout: getTimeout(),
|
|
||||||
apiUrl: {
|
apiUrl: {
|
||||||
tags: `${baseUrl}/api/tags`,
|
tags: `${baseUrl}/api/tags`,
|
||||||
generate: `${baseUrl}/api/generate`,
|
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() {
|
function clearCache() {
|
||||||
settingsCache = null;
|
syncCache = null;
|
||||||
|
syncCacheTimestamp = 0;
|
||||||
|
timeoutsCache = null;
|
||||||
|
timeoutsCacheTimestamp = 0;
|
||||||
|
aiConfigService.invalidateCache();
|
||||||
logger.info('[ollamaConfig] Settings cache cleared');
|
logger.info('[ollamaConfig] Settings cache cleared');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +323,7 @@ function clearCache() {
|
|||||||
*/
|
*/
|
||||||
async function checkHealth() {
|
async function checkHealth() {
|
||||||
try {
|
try {
|
||||||
const baseUrl = getBaseUrl();
|
const baseUrl = await getBaseUrlAsync();
|
||||||
const response = await fetch(`${baseUrl}/api/tags`);
|
const response = await fetch(`${baseUrl}/api/tags`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -265,10 +335,12 @@ async function checkHealth() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
const defaultModel = await getDefaultModelAsync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
baseUrl,
|
baseUrl,
|
||||||
model: getDefaultModel(),
|
model: defaultModel,
|
||||||
availableModels: data.models?.length || 0
|
availableModels: data.models?.length || 0
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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 = {
|
module.exports = {
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
getBaseUrlAsync,
|
getBaseUrlAsync,
|
||||||
@@ -287,11 +367,12 @@ module.exports = {
|
|||||||
getDefaultModel,
|
getDefaultModel,
|
||||||
getDefaultModelAsync,
|
getDefaultModelAsync,
|
||||||
getEmbeddingModel,
|
getEmbeddingModel,
|
||||||
getTimeout, // Обратная совместимость (возвращает ollamaChat timeout)
|
getTimeout, // Синхронная версия (для обратной совместимости)
|
||||||
getTimeouts, // ✨ НОВОЕ: Централизованные таймауты для всех сервисов
|
getTimeouts, // Синхронная версия с кэшированием (для обратной совместимости)
|
||||||
getConfig,
|
getConfig,
|
||||||
getConfigAsync,
|
getConfigAsync,
|
||||||
loadSettingsFromDb,
|
loadSettingsFromDb,
|
||||||
clearCache,
|
clearCache,
|
||||||
checkHealth
|
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);
|
const messageType = determineMessageType(recipientId, userId, isAdmin);
|
||||||
|
|
||||||
// 5. Определяем нужно ли генерировать AI ответ
|
// 5. Определяем нужно ли генерировать AI ответ
|
||||||
const shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId);
|
let shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId);
|
||||||
|
|
||||||
logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin });
|
logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin });
|
||||||
|
|
||||||
@@ -227,27 +227,37 @@ async function processMessage(messageData) {
|
|||||||
|
|
||||||
// Автоматически подписываем согласие
|
// Автоматически подписываем согласие
|
||||||
if (documentIds.length > 0 && consentTypes.length > 0) {
|
if (documentIds.length > 0 && consentTypes.length > 0) {
|
||||||
const consentRoutes = require('../routes/consent');
|
|
||||||
// Вызываем логику подписания напрямую через сервис или API
|
|
||||||
try {
|
try {
|
||||||
await db.getQuery()(
|
// Используем проверку существования вместо ON CONFLICT (т.к. может не быть уникального ограничения)
|
||||||
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at)
|
for (let i = 0; i < documentIds.length; i++) {
|
||||||
SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW()
|
const docId = documentIds[i];
|
||||||
ON CONFLICT (user_id, consent_type, document_id)
|
const docTitle = consentDocuments.find(d => d.id === docId)?.title || '';
|
||||||
DO UPDATE SET
|
const consentType = consentTypes[i];
|
||||||
status = 'granted',
|
|
||||||
signed_at = NOW(),
|
// Проверяем, есть ли уже согласие
|
||||||
revoked_at = NULL,
|
const existing = await db.getQuery()(
|
||||||
updated_at = NOW()
|
`SELECT id FROM consent_logs
|
||||||
WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
|
WHERE user_id = $1 AND consent_type = $2 AND document_id = $3 AND status = 'granted'`,
|
||||||
[
|
[userId, consentType, docId]
|
||||||
userId,
|
|
||||||
walletIdentity?.provider_id || null,
|
|
||||||
documentIds,
|
|
||||||
consentDocuments.map(doc => doc.title),
|
|
||||||
consentTypes
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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, 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}`);
|
logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`);
|
||||||
} catch (consentError) {
|
} catch (consentError) {
|
||||||
logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError);
|
logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError);
|
||||||
@@ -330,6 +340,9 @@ async function processMessage(messageData) {
|
|||||||
|
|
||||||
// 8. Генерируем AI ответ (если нужно)
|
// 8. Генерируем AI ответ (если нужно)
|
||||||
let aiResponse = null;
|
let aiResponse = null;
|
||||||
|
// Инициализируем finalAiResponse для использования в результатах (должен быть доступен везде)
|
||||||
|
let finalAiResponse = null;
|
||||||
|
let aiResponseDisabled = false;
|
||||||
|
|
||||||
if (shouldGenerateAi) {
|
if (shouldGenerateAi) {
|
||||||
// Загружаем историю беседы
|
// Загружаем историю беседы
|
||||||
@@ -377,7 +390,7 @@ async function processMessage(messageData) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Формируем финальный ответ ИИ с системным сообщением, если нужно
|
// Формируем финальный ответ ИИ с системным сообщением, если нужно
|
||||||
let finalAiResponse = aiResponse.response;
|
finalAiResponse = aiResponse.response;
|
||||||
if (consentSystemMessage && consentSystemMessage.consentRequired) {
|
if (consentSystemMessage && consentSystemMessage.consentRequired) {
|
||||||
// Добавляем системное сообщение к ответу ИИ
|
// Добавляем системное сообщение к ответу ИИ
|
||||||
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
|
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
|
||||||
@@ -433,6 +446,9 @@ async function processMessage(messageData) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id);
|
logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id);
|
||||||
|
} else if (aiResponse && aiResponse.disabled) {
|
||||||
|
aiResponseDisabled = true;
|
||||||
|
logger.info('[UnifiedMessageProcessor] AI ассистент отключен для текущего канала — ответ не генерируется.');
|
||||||
} else {
|
} else {
|
||||||
logger.warn('[UnifiedMessageProcessor] AI не вернул ответ:', aiResponse?.reason);
|
logger.warn('[UnifiedMessageProcessor] AI не вернул ответ:', aiResponse?.reason);
|
||||||
}
|
}
|
||||||
@@ -456,10 +472,11 @@ async function processMessage(messageData) {
|
|||||||
userMessageId,
|
userMessageId,
|
||||||
conversationId,
|
conversationId,
|
||||||
aiResponse: aiResponse && aiResponse.success ? {
|
aiResponse: aiResponse && aiResponse.success ? {
|
||||||
response: finalAiResponse || aiResponse.response,
|
response: finalAiResponse || (aiResponse?.response || ''),
|
||||||
ragData: aiResponse.ragData
|
ragData: aiResponse.ragData
|
||||||
} : null,
|
} : 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 axios = require('axios');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const ollamaConfig = require('./ollamaConfig');
|
const ollamaConfig = require('./ollamaConfig');
|
||||||
|
const aiConfigService = require('./aiConfigService');
|
||||||
|
|
||||||
const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
|
const MIN_VECTOR_UPSERT_TIMEOUT = 360000; // 6 минут — с запасом для больших документов
|
||||||
const TIMEOUTS = ollamaConfig.getTimeouts();
|
|
||||||
|
// Загружаем настройки из 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) {
|
async function upsert(tableId, rows) {
|
||||||
|
// Загружаем актуальные настройки
|
||||||
|
if (!VECTOR_SEARCH_URL || !TIMEOUTS) {
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`[VectorSearch] upsert: tableId=${tableId}, rows=${rows.length}`);
|
logger.info(`[VectorSearch] upsert: tableId=${tableId}, rows=${rows.length}`);
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, {
|
const res = await axios.post(`${VECTOR_SEARCH_URL}/upsert`, {
|
||||||
@@ -28,7 +53,7 @@ async function upsert(tableId, rows) {
|
|||||||
metadata: r.metadata || {}
|
metadata: r.metadata || {}
|
||||||
}))
|
}))
|
||||||
}, {
|
}, {
|
||||||
timeout: TIMEOUTS.vectorUpsert // Централизованный таймаут для индексации
|
timeout: Math.max(TIMEOUTS.vectorUpsert || 0, MIN_VECTOR_UPSERT_TIMEOUT)
|
||||||
});
|
});
|
||||||
logger.info(`[VectorSearch] upsert result:`, res.data);
|
logger.info(`[VectorSearch] upsert result:`, res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -39,6 +64,11 @@ async function upsert(tableId, rows) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function search(tableId, query, topK = 3) {
|
async function search(tableId, query, topK = 3) {
|
||||||
|
// Загружаем актуальные настройки
|
||||||
|
if (!VECTOR_SEARCH_URL || !TIMEOUTS) {
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`[VectorSearch] search: tableId=${tableId}, query="${query}", topK=${topK}`);
|
logger.info(`[VectorSearch] search: tableId=${tableId}, query="${query}", topK=${topK}`);
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, {
|
const res = await axios.post(`${VECTOR_SEARCH_URL}/search`, {
|
||||||
@@ -91,6 +121,11 @@ async function rebuild(tableId, rows) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function health() {
|
async function health() {
|
||||||
|
// Загружаем актуальные настройки
|
||||||
|
if (!VECTOR_SEARCH_URL || !TIMEOUTS) {
|
||||||
|
await loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`[VectorSearch] health check`);
|
logger.info(`[VectorSearch] health check`);
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: TIMEOUTS.vectorHealth });
|
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
|
||||||
|
};
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,68 +14,231 @@
|
|||||||
<div class='modal-bg'>
|
<div class='modal-bg'>
|
||||||
<div class='modal'>
|
<div class='modal'>
|
||||||
<h3>{{ rule ? 'Редактировать' : 'Создать' }} набор правил</h3>
|
<h3>{{ rule ? 'Редактировать' : 'Создать' }} набор правил</h3>
|
||||||
<label>Название</label>
|
|
||||||
<input v-model="name" />
|
<label>Название *</label>
|
||||||
|
<input v-model="name" placeholder="Например: VIP Правило" />
|
||||||
|
|
||||||
<label>Описание</label>
|
<label>Описание</label>
|
||||||
<textarea v-model="description" rows="3" placeholder="Опишите правило в свободной форме" />
|
<textarea v-model="description" rows="2" placeholder="Опишите правило в свободной форме" />
|
||||||
<label>Правила (JSON)</label>
|
|
||||||
<textarea v-model="rulesJson" rows="6"></textarea>
|
<div class="rules-section">
|
||||||
|
<h4>Системный промпт</h4>
|
||||||
|
<textarea
|
||||||
|
v-model="ruleFields.system_prompt"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Дополнительный системный промпт для этого правила. Например: 'Ты работаешь с VIP клиентами. Будь вежливым и профессиональным.'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-section">
|
||||||
|
<h4>Параметры LLM</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Temperature (0.0-2.0)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="ruleFields.temperature"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
placeholder="0.7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Max Tokens</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="ruleFields.max_tokens"
|
||||||
|
min="1"
|
||||||
|
max="4000"
|
||||||
|
placeholder="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-section">
|
||||||
|
<h4>Правила поведения</h4>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Разрешенные темы (через запятую)</label>
|
||||||
|
<input
|
||||||
|
v-model="allowedTopicsText"
|
||||||
|
placeholder="продукт, поддержка, VIP услуги"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Запрещенные слова (через запятую)</label>
|
||||||
|
<input
|
||||||
|
v-model="forbiddenWordsText"
|
||||||
|
placeholder="ругательство, спам"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="ruleFields.checkUserTags" />
|
||||||
|
Учитывать теги пользователя при фильтрации RAG
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="ruleFields.searchRagFirst" />
|
||||||
|
Сначала искать в RAG базе знаний
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" v-model="ruleFields.generateIfNoRag" />
|
||||||
|
Генерировать ответ, если ничего не найдено в RAG
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-section" v-if="showJsonPreview">
|
||||||
|
<h4>Предпросмотр JSON</h4>
|
||||||
|
<pre class="json-preview">{{ generatedJson }}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button @click="save">Сохранить</button>
|
<button @click="save" :disabled="!name.trim()">Сохранить</button>
|
||||||
<button @click="close">Отмена</button>
|
<button @click="close">Отмена</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch, computed } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const props = defineProps({ rule: Object });
|
const props = defineProps({ rule: Object });
|
||||||
|
|
||||||
const name = ref(props.rule ? props.rule.name : '');
|
const name = ref(props.rule ? props.rule.name : '');
|
||||||
const description = ref(props.rule ? props.rule.description : '');
|
const description = ref(props.rule ? props.rule.description : '');
|
||||||
const rulesJson = ref(props.rule ? JSON.stringify(props.rule.rules, null, 2) : '{\n "checkUserTags": true\n}');
|
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
|
const showJsonPreview = ref(false);
|
||||||
|
|
||||||
watch(() => props.rule, (newRule) => {
|
// Поля правила
|
||||||
name.value = newRule ? newRule.name : '';
|
const ruleFields = ref({
|
||||||
description.value = newRule ? newRule.description : '';
|
system_prompt: props.rule?.rules?.system_prompt || '',
|
||||||
rulesJson.value = newRule ? JSON.stringify(newRule.rules, null, 2) : '{\n "checkUserTags": true\n}';
|
temperature: props.rule?.rules?.temperature ?? 0.7,
|
||||||
|
max_tokens: props.rule?.rules?.max_tokens ?? 500,
|
||||||
|
checkUserTags: props.rule?.rules?.rules?.checkUserTags ?? true,
|
||||||
|
searchRagFirst: props.rule?.rules?.rules?.searchRagFirst ?? true,
|
||||||
|
generateIfNoRag: props.rule?.rules?.rules?.generateIfNoRag ?? true,
|
||||||
|
allowed_topics: props.rule?.rules?.rules?.allowed_topics || [],
|
||||||
|
forbidden_words: props.rule?.rules?.rules?.forbidden_words || []
|
||||||
});
|
});
|
||||||
|
|
||||||
function convertToJson() {
|
// Текстовые поля для массивов
|
||||||
// Простейший пример: если в описании есть "теги", выставляем checkUserTags
|
const allowedTopicsText = ref(
|
||||||
// В реальном проекте здесь можно интегрировать LLM или шаблоны
|
props.rule?.rules?.rules?.allowed_topics?.join(', ') || ''
|
||||||
try {
|
);
|
||||||
if (/тег[а-я]* пользов/.test(description.value.toLowerCase())) {
|
const forbiddenWordsText = ref(
|
||||||
rulesJson.value = JSON.stringify({ checkUserTags: true }, null, 2);
|
props.rule?.rules?.rules?.forbidden_words?.join(', ') || ''
|
||||||
error.value = '';
|
);
|
||||||
} else {
|
|
||||||
rulesJson.value = JSON.stringify({ customRule: description.value }, null, 2);
|
// Генерация JSON из полей формы
|
||||||
error.value = '';
|
const generatedJson = computed(() => {
|
||||||
|
const rules = {
|
||||||
|
system_prompt: ruleFields.value.system_prompt || undefined,
|
||||||
|
temperature: ruleFields.value.temperature,
|
||||||
|
max_tokens: ruleFields.value.max_tokens,
|
||||||
|
rules: {
|
||||||
|
checkUserTags: ruleFields.value.checkUserTags,
|
||||||
|
searchRagFirst: ruleFields.value.searchRagFirst,
|
||||||
|
generateIfNoRag: ruleFields.value.generateIfNoRag,
|
||||||
|
allowed_topics: allowedTopicsText.value
|
||||||
|
.split(',')
|
||||||
|
.map(t => t.trim())
|
||||||
|
.filter(t => t.length > 0),
|
||||||
|
forbidden_words: forbiddenWordsText.value
|
||||||
|
.split(',')
|
||||||
|
.map(w => w.trim())
|
||||||
|
.filter(w => w.length > 0)
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Удаляем undefined поля
|
||||||
|
Object.keys(rules).forEach(key => {
|
||||||
|
if (rules[key] === undefined) delete rules[key];
|
||||||
|
});
|
||||||
|
|
||||||
|
return JSON.stringify(rules, null, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.rule, (newRule) => {
|
||||||
|
if (newRule) {
|
||||||
|
name.value = newRule.name || '';
|
||||||
|
description.value = newRule.description || '';
|
||||||
|
|
||||||
|
ruleFields.value = {
|
||||||
|
system_prompt: newRule.rules?.system_prompt || '',
|
||||||
|
temperature: newRule.rules?.temperature ?? 0.7,
|
||||||
|
max_tokens: newRule.rules?.max_tokens ?? 500,
|
||||||
|
checkUserTags: newRule.rules?.rules?.checkUserTags ?? true,
|
||||||
|
searchRagFirst: newRule.rules?.rules?.searchRagFirst ?? true,
|
||||||
|
generateIfNoRag: newRule.rules?.rules?.generateIfNoRag ?? true,
|
||||||
|
allowed_topics: newRule.rules?.rules?.allowed_topics || [],
|
||||||
|
forbidden_words: newRule.rules?.rules?.forbidden_words || []
|
||||||
|
};
|
||||||
|
|
||||||
|
allowedTopicsText.value = (newRule.rules?.rules?.allowed_topics || []).join(', ');
|
||||||
|
forbiddenWordsText.value = (newRule.rules?.rules?.forbidden_words || []).join(', ');
|
||||||
|
} else {
|
||||||
|
// Сброс для нового правила
|
||||||
|
name.value = '';
|
||||||
|
description.value = '';
|
||||||
|
ruleFields.value = {
|
||||||
|
system_prompt: '',
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 500,
|
||||||
|
checkUserTags: true,
|
||||||
|
searchRagFirst: true,
|
||||||
|
generateIfNoRag: true,
|
||||||
|
allowed_topics: [],
|
||||||
|
forbidden_words: []
|
||||||
|
};
|
||||||
|
allowedTopicsText.value = '';
|
||||||
|
forbiddenWordsText.value = '';
|
||||||
|
}
|
||||||
|
error.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
error.value = 'Название обязательно для заполнения';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Генерируем JSON из полей формы
|
||||||
|
const rules = JSON.parse(generatedJson.value);
|
||||||
|
|
||||||
|
if (props.rule && props.rule.id) {
|
||||||
|
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, {
|
||||||
|
name: name.value,
|
||||||
|
description: description.value,
|
||||||
|
rules
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await axios.post('/settings/ai-assistant-rules', {
|
||||||
|
name: name.value,
|
||||||
|
description: description.value,
|
||||||
|
rules
|
||||||
|
});
|
||||||
|
}
|
||||||
|
emit('close', true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = 'Не удалось преобразовать описание в JSON';
|
error.value = `Ошибка сохранения: ${e.message}`;
|
||||||
|
console.error('Ошибка сохранения правила:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function close() {
|
||||||
let rules;
|
emit('close', false);
|
||||||
try {
|
|
||||||
rules = JSON.parse(rulesJson.value);
|
|
||||||
} catch (e) {
|
|
||||||
error.value = 'Ошибка в формате JSON!';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (props.rule && props.rule.id) {
|
|
||||||
await axios.put(`/settings/ai-assistant-rules/${props.rule.id}`, { name: name.value, description: description.value, rules });
|
|
||||||
} else {
|
|
||||||
await axios.post('/settings/ai-assistant-rules', { name: name.value, description: description.value, rules });
|
|
||||||
}
|
|
||||||
emit('close', true);
|
|
||||||
}
|
}
|
||||||
function close() { emit('close', false); }
|
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.modal-bg {
|
.modal-bg {
|
||||||
@@ -86,48 +249,169 @@ function close() { emit('close', false); }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
|
box-shadow: 0 2px 16px rgba(0,0,0,0.12);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
min-width: 320px;
|
min-width: 500px;
|
||||||
max-width: 420px;
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #555;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
input, textarea {
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.25rem;
|
||||||
padding: 0.5rem;
|
padding: 0.625rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary, #007bff);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-section {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-section:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-checkbox input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-preview {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary, #007bff);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.5rem 1.5rem;
|
padding: 0.625rem 1.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: var(--color-primary-dark, #0056b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
button:last-child {
|
button:last-child {
|
||||||
background: #eee;
|
background: #eee;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:last-child:hover {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #c00;
|
color: #c00;
|
||||||
margin-top: 0.5rem;
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #ffe6e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ffcccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -132,7 +132,7 @@ import { usePermissions } from '@/composables/usePermissions';
|
|||||||
|
|
||||||
const props = defineProps(['rowId', 'column', 'cellValues']);
|
const props = defineProps(['rowId', 'column', 'cellValues']);
|
||||||
const emit = defineEmits(['update']);
|
const emit = defineEmits(['update']);
|
||||||
const { canEditDataData } = usePermissions();
|
const { canEditData } = usePermissions();
|
||||||
|
|
||||||
const localValue = ref('');
|
const localValue = ref('');
|
||||||
const editing = ref(false);
|
const editing = ref(false);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export async function connectWithWallet() {
|
|||||||
const docsResponse = await axios.get('/consent/documents');
|
const docsResponse = await axios.get('/consent/documents');
|
||||||
if (docsResponse.data && docsResponse.data.length > 0) {
|
if (docsResponse.data && docsResponse.data.length > 0) {
|
||||||
docsResponse.data.forEach(doc => {
|
docsResponse.data.forEach(doc => {
|
||||||
resources.push(`${window.location.origin}/public/page/${doc.id}`);
|
resources.push(`${window.location.origin}/content/published/${doc.id}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -169,9 +169,11 @@ import { useAuthContext } from '@/composables/useAuth';
|
|||||||
import { usePermissions } from '@/composables/usePermissions';
|
import { usePermissions } from '@/composables/usePermissions';
|
||||||
import { PERMISSIONS } from './permissions.js';
|
import { PERMISSIONS } from './permissions.js';
|
||||||
import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket';
|
import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket';
|
||||||
|
import websocketServiceModule from '@/services/websocketService';
|
||||||
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions();
|
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions();
|
||||||
const { address, userId: currentUserId } = useAuthContext();
|
const { address, userId: currentUserId } = useAuthContext();
|
||||||
const { markContactAsRead } = useContactsAndMessagesWebSocket();
|
const { markContactAsRead } = useContactsAndMessagesWebSocket();
|
||||||
|
const { websocketService } = websocketServiceModule;
|
||||||
|
|
||||||
// Подписываемся на централизованные события очистки и обновления данных
|
// Подписываемся на централизованные события очистки и обновления данных
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -220,6 +222,13 @@ const tagsTableId = ref(null);
|
|||||||
const { onTagsUpdate } = useTagsWebSocket();
|
const { onTagsUpdate } = useTagsWebSocket();
|
||||||
let unsubscribeFromTags = null;
|
let unsubscribeFromTags = null;
|
||||||
|
|
||||||
|
// Обработчик обновления контактов через WebSocket
|
||||||
|
const handleContactsUpdate = async () => {
|
||||||
|
console.log('[ContactDetailsView] Получено обновление контакта, перезагружаем данные');
|
||||||
|
await reloadContact();
|
||||||
|
await loadUserTags();
|
||||||
|
};
|
||||||
|
|
||||||
// Функция маскировки персональных данных для читателей
|
// Функция маскировки персональных данных для читателей
|
||||||
function maskPersonalData(data) {
|
function maskPersonalData(data) {
|
||||||
if (!data || data === '-') return '-';
|
if (!data || data === '-') return '-';
|
||||||
@@ -725,6 +734,9 @@ onMounted(async () => {
|
|||||||
await loadAllTags();
|
await loadAllTags();
|
||||||
await loadUserTags();
|
await loadUserTags();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Подписываемся на обновления контактов (для обновления имени)
|
||||||
|
websocketService.on('contacts-updated', handleContactsUpdate);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -732,6 +744,7 @@ onUnmounted(() => {
|
|||||||
if (unsubscribeFromTags) {
|
if (unsubscribeFromTags) {
|
||||||
unsubscribeFromTags();
|
unsubscribeFromTags();
|
||||||
}
|
}
|
||||||
|
websocketService.off('contacts-updated', handleContactsUpdate);
|
||||||
});
|
});
|
||||||
watch(userId, async () => {
|
watch(userId, async () => {
|
||||||
await reloadContact();
|
await reloadContact();
|
||||||
|
|||||||
@@ -43,10 +43,27 @@
|
|||||||
<span class="page-status"><i class="fas fa-file"></i>{{ p.format || 'html' }}</span>
|
<span class="page-status"><i class="fas fa-file"></i>{{ p.format || 'html' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="canManageLegalDocs && address" class="page-actions">
|
<div v-if="canManageLegalDocs && address" class="page-actions">
|
||||||
<button class="action-btn primary" title="Индексировать" @click.stop="reindex(p.id)"><i class="fas fa-sync"></i><span>Индекс</span></button>
|
<button
|
||||||
|
class="action-btn primary"
|
||||||
|
title="Индексировать"
|
||||||
|
:disabled="reindexStatus[p.id]?.state === 'loading'"
|
||||||
|
@click.stop="reindex(p.id)"
|
||||||
|
>
|
||||||
|
<i :class="['fas', reindexStatus[p.id]?.state === 'loading' ? 'fa-spinner fa-spin' : 'fa-sync']"></i>
|
||||||
|
<span>Индекс</span>
|
||||||
|
</button>
|
||||||
<button class="action-btn primary" title="Редактировать" @click.stop="goEdit(p.id)"><i class="fas fa-edit"></i><span>Ред.</span></button>
|
<button class="action-btn primary" title="Редактировать" @click.stop="goEdit(p.id)"><i class="fas fa-edit"></i><span>Ред.</span></button>
|
||||||
<button class="action-btn danger" title="Удалить" @click.stop="doDelete(p.id)"><i class="fas fa-trash"></i><span>Удалить</span></button>
|
<button class="action-btn danger" title="Удалить" @click.stop="doDelete(p.id)"><i class="fas fa-trash"></i><span>Удалить</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
<transition name="fade">
|
||||||
|
<div
|
||||||
|
v-if="reindexStatus[p.id]"
|
||||||
|
class="reindex-status"
|
||||||
|
:class="reindexStatus[p.id].state"
|
||||||
|
>
|
||||||
|
{{ reindexStatus[p.id].message }}
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,7 +77,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import BaseLayout from '../../components/BaseLayout.vue';
|
import BaseLayout from '../../components/BaseLayout.vue';
|
||||||
import pagesService from '../../services/pagesService';
|
import pagesService from '../../services/pagesService';
|
||||||
@@ -79,19 +96,48 @@ const props = defineProps({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const search = ref('');
|
const search = ref('');
|
||||||
const pages = ref([]);
|
const pages = ref([]);
|
||||||
|
const reindexStatus = ref({});
|
||||||
const { address } = useAuthContext();
|
const { address } = useAuthContext();
|
||||||
const { hasPermission } = usePermissions();
|
const { hasPermission } = usePermissions();
|
||||||
const canManageLegalDocs = computed(() => hasPermission(SHARED_PERMISSIONS.MANAGE_LEGAL_DOCS));
|
const canManageLegalDocs = computed(() => hasPermission(SHARED_PERMISSIONS.MANAGE_LEGAL_DOCS));
|
||||||
|
|
||||||
|
const reindexTimers = new Map();
|
||||||
|
|
||||||
function goBack() { router.push({ name: 'content-list' }); }
|
function goBack() { router.push({ name: 'content-list' }); }
|
||||||
function openPublic(id) { router.push({ name: 'public-page-view', params: { id } }); }
|
function openPublic(id) { router.push({ name: 'public-page-view', params: { id } }); }
|
||||||
function goEdit(id) { router.push({ name: 'content-create', query: { edit: id } }); }
|
function goEdit(id) { router.push({ name: 'content-create', query: { edit: id } }); }
|
||||||
|
function updateReindexStatus(id, state, message) {
|
||||||
|
reindexStatus.value = {
|
||||||
|
...reindexStatus.value,
|
||||||
|
[id]: { state, message }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReindexCleanup(id, delay = 4000) {
|
||||||
|
if (reindexTimers.has(id)) {
|
||||||
|
clearTimeout(reindexTimers.get(id));
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const next = { ...reindexStatus.value };
|
||||||
|
delete next[id];
|
||||||
|
reindexStatus.value = next;
|
||||||
|
reindexTimers.delete(id);
|
||||||
|
}, delay);
|
||||||
|
reindexTimers.set(id, timer);
|
||||||
|
}
|
||||||
async function reindex(id) {
|
async function reindex(id) {
|
||||||
try {
|
try {
|
||||||
|
if (reindexStatus.value[id]?.state === 'loading') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateReindexStatus(id, 'loading', 'Индексация запущена...');
|
||||||
await api.post(`/pages/${id}/reindex`);
|
await api.post(`/pages/${id}/reindex`);
|
||||||
alert('Индексация выполнена');
|
updateReindexStatus(id, 'success', 'Индексация выполняется. Проверьте логи.');
|
||||||
|
scheduleReindexCleanup(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка индексации: ' + (e?.response?.data?.error || e.message));
|
const errorMessage = e?.response?.data?.error || e.message;
|
||||||
|
updateReindexStatus(id, 'error', `Ошибка индексации: ${errorMessage}`);
|
||||||
|
scheduleReindexCleanup(id, 6000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function doDelete(id) {
|
async function doDelete(id) {
|
||||||
@@ -119,6 +165,11 @@ onMounted(async () => {
|
|||||||
pages.value = [];
|
pages.value = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
reindexTimers.forEach(timer => clearTimeout(timer));
|
||||||
|
reindexTimers.clear();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -151,6 +202,13 @@ onMounted(async () => {
|
|||||||
.action-btn.primary:hover { background: var(--color-primary-dark); }
|
.action-btn.primary:hover { background: var(--color-primary-dark); }
|
||||||
.action-btn.danger { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
.action-btn.danger { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
||||||
.action-btn.danger:hover { background: #fee2e2; }
|
.action-btn.danger:hover { background: #fee2e2; }
|
||||||
|
.action-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.reindex-status { margin-top: 10px; font-size: 0.9rem; font-weight: 500; }
|
||||||
|
.reindex-status.loading { color: #2563eb; }
|
||||||
|
.reindex-status.success { color: #16a34a; }
|
||||||
|
.reindex-status.error { color: #dc2626; }
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
.empty-state { text-align:center; padding: 60px 20px; }
|
.empty-state { text-align:center; padding: 60px 20px; }
|
||||||
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
|
.empty-icon { font-size: 3rem; color: var(--color-grey-dark); margin-bottom: 10px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -66,13 +66,21 @@ export default defineConfig({
|
|||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
configure: (proxy, options) => {
|
configure: (proxy, options) => {
|
||||||
proxy.on('error', (err, req, res) => {
|
proxy.on('error', (err, req, res) => {
|
||||||
|
// Игнорируем ошибки ECONNREFUSED при старте сервера - это нормально
|
||||||
|
if (err.code === 'ECONNREFUSED' || err.message.includes('ECONNREFUSED')) {
|
||||||
|
// Не логируем как ошибку - это нормальное поведение при рестарте сервера
|
||||||
|
// Фронтенд автоматически переподключится
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.log('WebSocket proxy error:', err.message);
|
console.log('WebSocket proxy error:', err.message);
|
||||||
});
|
});
|
||||||
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
|
proxy.on('proxyReqWs', (proxyReq, req, socket) => {
|
||||||
console.log('WebSocket proxy request to:', req.url);
|
// Убираем избыточное логирование - это происходит слишком часто
|
||||||
|
// console.log('WebSocket proxy request to:', req.url);
|
||||||
});
|
});
|
||||||
proxy.on('proxyResWs', (proxyRes, req, socket) => {
|
proxy.on('proxyResWs', (proxyRes, req, socket) => {
|
||||||
console.log('WebSocket proxy response:', proxyRes.statusCode);
|
// Убираем избыточное логирование
|
||||||
|
// console.log('WebSocket proxy response:', proxyRes.statusCode);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user