Files
DLE/backend/services/profileAnalysisService.js
2026-03-01 22:03:48 +03:00

611 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

/**
* Copyright (c) 2024-2026 Тарабанов Александр Викторович
* 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 = userContextService.isGuestId(userId);
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
};