feat: новая функция

This commit is contained in:
2025-11-06 16:24:50 +03:00
parent b3620b264b
commit 714a3f55c7
34 changed files with 5436 additions and 2433 deletions

View File

@@ -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 .",

View File

@@ -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}`);
}); });
} }

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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 для остановки мониторинга');

View File

@@ -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 {

View File

@@ -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] : []);
}
logger.info(`[AIAssistant] Определены tableIds для RAG: ${JSON.stringify(tableIds)}`);
// 4. Выполняем RAG поиск если есть tableId // 4. Выполняем мульти-источниковый поиск (таблицы + документы)
let ragResult = null; logger.info(`[AIAssistant] Начало мульти-источникового поиска...`);
if (tableId) { const multiSourceSearchService = require('./multiSourceSearchService');
const ragConfig = await (require('./aiConfigService')).getRAGConfig();
logger.info(`[AIAssistant] RAG конфигурация получена, метод поиска: ${ragConfig.searchMethod || 'hybrid'}`);
let searchResults = null;
let ragResult = null; // Для обратной совместимости
if (tableIds.length > 0 || true) { // Всегда ищем в документах, если включено
try {
logger.info(`[AIAssistant] Вызов multiSourceSearchService.search для запроса: "${userQuestion.substring(0, 50)}..."`);
const searchStartTime = Date.now();
searchResults = await multiSourceSearchService.search({
query: userQuestion,
tableIds: tableIds,
searchInDocuments: true, // Поиск в документах включен
searchMethod: ragConfig.searchMethod || 'hybrid', // 'semantic', 'keyword', 'hybrid'
userId: userId,
maxResultsPerSource: ragConfig.maxResults || 10,
totalMaxResults: (ragConfig.maxResults || 10) * 2 // Увеличиваем для объединения
});
const searchDuration = Date.now() - searchStartTime;
logger.info(`[AIAssistant] Мульти-источниковый поиск завершен за ${searchDuration}ms, найдено результатов: ${searchResults?.results?.length || 0}`);
// Формируем объединенный результат для обратной совместимости
if (searchResults.results && searchResults.results.length > 0) {
// Берем лучший результат
const bestResult = searchResults.results[0];
ragResult = {
answer: bestResult.text,
context: bestResult.context || '',
product: bestResult.metadata?.product || null,
priority: bestResult.metadata?.priority || null,
date: bestResult.metadata?.date || null,
score: bestResult.score || 0
};
// Формируем контекст из всех результатов для LLM
const allResultsContext = searchResults.results
.slice(0, 3) // Берем топ-3 результатов
.map((r, idx) => {
const sourceLabel = r.sourceType === 'table' ? 'Таблица' : 'Документ';
const fallbackText = (r.metadata?.answer && String(r.metadata.answer).trim())
|| (r.metadata?.title && String(r.metadata.title).trim())
|| '(текст отсутствует)';
const text = (r.text && r.text.trim()) || fallbackText;
const snippetLimit = 300;
const truncatedText = text.length > snippetLimit
? `${text.slice(0, snippetLimit)}...`
: text;
return `[${idx + 1}] ${sourceLabel}: ${truncatedText}`;
})
.join('\n\n');
ragResult.context = allResultsContext;
}
} catch (error) {
logger.error(`[AIAssistant] Ошибка мульти-источникового поиска:`, error);
// Fallback на старый метод, если новый не работает
if (tableIds.length > 0) {
const { ragAnswer } = require('./ragService');
ragResult = await ragAnswer({ ragResult = await ragAnswer({
tableId, tableId: tableIds[0],
userQuestion userQuestion,
// threshold использует дефолтное значение 300 из ragService 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,

View File

@@ -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,14 +103,15 @@ 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
if (Date.now() - cached.timestamp > ttl) { if (Date.now() - cached.timestamp > ttl) {
this.cache.delete(key); this.cache.delete(key);
@@ -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;

View File

@@ -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 });
@@ -273,4 +306,5 @@ class AIQueue extends EventEmitter {
} }
} }
module.exports = AIQueue; module.exports = AIQueue;

View File

@@ -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);
} }
} }

View 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;

View File

@@ -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);

View File

@@ -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 = [];

View File

@@ -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
}; };
} }

View 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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&lt;/gi, '<')
.replace(/&gt;/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;

View File

@@ -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() {
const config = await aiConfigService.getOllamaConfig();
return config.embeddingModel;
}
// Кэш для таймаутов (синхронный доступ)
let timeoutsCache = null;
let timeoutsCacheTimestamp = 0;
/**
* Обновляет кэш таймаутов из aiConfigService
* @private
*/
async function _updateTimeoutsCache() {
try { try {
if (!settingsCache) { const timeouts = await aiConfigService.getTimeouts();
await loadSettingsFromDb(); const cacheConfig = await aiConfigService.getCacheConfig();
} const queueConfig = await aiConfigService.getQueueConfig();
if (settingsCache && settingsCache.embedding_model) { timeoutsCache = {
logger.info(`[ollamaConfig] Using embedding model from DB: ${settingsCache.embedding_model}`); // Ollama API - таймауты запросов
return settingsCache.embedding_model; 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
}; };

View 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

View 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;

View File

@@ -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 {
// Используем проверку существования вместо ON CONFLICT (т.к. может не быть уникального ограничения)
for (let i = 0; i < documentIds.length; i++) {
const docId = documentIds[i];
const docTitle = consentDocuments.find(d => d.id === docId)?.title || '';
const consentType = consentTypes[i];
// Проверяем, есть ли уже согласие
const existing = await db.getQuery()(
`SELECT id FROM consent_logs
WHERE user_id = $1 AND consent_type = $2 AND document_id = $3 AND status = 'granted'`,
[userId, consentType, docId]
);
if (existing.rows.length > 0) {
// Обновляем существующее
await db.getQuery()(
`UPDATE consent_logs
SET signed_at = NOW(), revoked_at = NULL, updated_at = NOW()
WHERE id = $1`,
[existing.rows[0].id]
);
} else {
// Создаем новое
await db.getQuery()( await db.getQuery()(
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at) `INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, created_at, updated_at)
SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW() VALUES ($1, $2, $3, $4, $5, 'granted', NOW(), 'web', NOW(), NOW())`,
ON CONFLICT (user_id, consent_type, document_id) [userId, walletIdentity?.provider_id || null, docId, docTitle, consentType]
DO UPDATE SET
status = 'granted',
signed_at = NOW(),
revoked_at = NULL,
updated_at = NOW()
WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
[
userId,
walletIdentity?.provider_id || null,
documentIds,
consentDocuments.map(doc => doc.title),
consentTypes
]
); );
}
}
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
}; };
// Если есть информация о согласиях, добавляем её в результат // Если есть информация о согласиях, добавляем её в результат

View 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
};

View File

@@ -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 });

View File

@@ -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);
});
});

View File

@@ -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}`);
}
});
});

View File

@@ -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('Москва');
});
});

View 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

View File

@@ -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>

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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

View File

@@ -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);
}); });
} }
}, },