/** * 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} { 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} Таблица или 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} Столбец или 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} Значение ячейки или 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} Массив строк */ 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} Массив названий тегов */ 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} Массив найденных строк с ключевыми словами */ 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} Массив объектов { 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} tagNames - Массив названий тегов * @returns {Promise>} Массив 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} currentTagNames - Текущие названия тегов * @param {Array} newTagNames - Новые названия тегов * @returns {Array} Результирующие названия тегов */ 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} { name: string|null, suggestedTags: Array } */ 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} 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 };