/** * Сервис ИИ-чата: системный промпт, загрузка истории, вызов ai.iieasy.ru (OpenAI-совместимый API), цикл tool_calls. */ const axios = require('axios'); const { getToolsSchema, runTool } = require('./aiToolsRegistry'); const AI_CHAT_URL = process.env.AI_CHAT_URL || 'https://ai.iieasy.ru/v1/chat/completions'; const AI_API_KEY = process.env.AI_API_KEY || ''; const AI_MODEL = process.env.AI_MODEL || 'gpt-4o-mini'; const MAX_TOOL_ITERATIONS = 5; const HISTORY_MESSAGES_LIMIT = 25; const REQUEST_TIMEOUT_MS = 60000; function buildSystemPrompt(userContext) { const { userName = 'Пользователь', role = '', allowedSections = [] } = userContext; const sections = Array.isArray(allowedSections) && allowedSections.length ? allowedSections.join(', ') : 'не заданы'; return `Ты помощник в системе управления МКД (многоквартирными домами). Пользователь: ${userName}, роль: ${role}. Разрешённые разделы: ${sections}. Задавай уточняющие вопросы, если данных недостаточно для действия. Для выполнения действий в программе (список домов, создание счёта, заявки и т.д.) используй только вызов инструментов. Отвечай кратко и по делу на русском.`; } /** * Преобразовать сообщения из БД в формат OpenAI messages[] (role, content; для assistant с tool_calls — role, content, tool_calls). */ function dbMessagesToOpenAI(rows) { const messages = []; for (const row of rows) { const msg = { role: row.role, content: row.content || '' }; if (row.toolCallsJson && Array.isArray(row.toolCallsJson) && row.toolCallsJson.length) { msg.tool_calls = row.toolCallsJson; } messages.push(msg); } return messages; } /** * Вызвать ИИ (OpenAI-compatible chat completions). * @param {object} options * @param {Array} options.messages — OpenAI format * @param {Array} options.tools — getToolsSchema() * @returns {Promise<{ content?: string, tool_calls?: Array }>} */ async function callChatCompletion({ messages, tools, aiChatUrl, aiApiKey }) { const url = aiChatUrl != null && aiChatUrl !== '' ? aiChatUrl : AI_CHAT_URL; const key = aiApiKey !== undefined ? aiApiKey : AI_API_KEY; const headers = { 'Content-Type': 'application/json' }; if (key) { headers['Authorization'] = `Bearer ${key}`; } const body = { model: AI_MODEL, messages, tools: tools && tools.length ? tools : undefined, tool_choice: tools && tools.length ? 'auto' : undefined, max_tokens: 2048, temperature: 0.3 }; const res = await axios.post(url, body, { headers, timeout: REQUEST_TIMEOUT_MS, validateStatus: (s) => s === 200 }); const choice = res.data.choices && res.data.choices[0]; if (!choice) { throw new Error('Пустой ответ от ИИ'); } const delta = choice.message || {}; return { content: delta.content || null, tool_calls: delta.tool_calls || null }; } /** * Обработать один раунд чата: вызов ИИ, при наличии tool_calls — выполнить инструменты и вернуть обновлённые messages + флаг «нужен ещё раунд». * collectedToolResults — массив, в который пушатся результаты вызовов { toolName, success, error? } для ответа клиенту. */ async function oneRound({ messages, tools, user, runToolContext, collectedToolResults, aiChatUrl, aiApiKey }) { const response = await callChatCompletion({ messages, tools, aiChatUrl, aiApiKey }); const assistantContent = response.content ? response.content.trim() : ''; const toolCalls = response.tool_calls; const nextMessages = [...messages]; if (toolCalls && toolCalls.length) { const assistantMsg = { role: 'assistant', content: assistantContent || '(вызов инструментов)', tool_calls: toolCalls }; nextMessages.push(assistantMsg); for (const tc of toolCalls) { const name = tc.function && tc.function.name; let args = {}; try { if (tc.function && tc.function.arguments) { args = typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments; } } catch (e) { args = {}; } const result = await runTool(name, args, user, runToolContext); if (collectedToolResults) { collectedToolResults.push({ toolName: name, success: result.success, error: result.error }); } const toolResult = { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }; nextMessages.push(toolResult); } return { messages: nextMessages, done: false, finalContent: null }; } return { messages: nextMessages, done: true, finalContent: assistantContent }; } /** * Получить ответ ИИ по истории и новому сообщению пользователя. Выполняет tool_calls в цикле (до MAX_TOOL_ITERATIONS). * @param {object} options * @param {Function} options.query — (sql, params) => Promise * @param {number} options.conversationId * @param {string} options.newUserMessage * @param {object} options.user — req.user * @param {object} options.userContext — { userName, role, allowedSections } для системного промпта * @param {object} options.runToolContext — { baseUrl, apiPrefix, getTokenForUser } * @param {string} [options.aiChatUrl] — URL ИИ (из панели или env) * @param {string} [options.aiApiKey] — API key (токен) * @returns {Promise<{ assistantMessage: string, toolResults: Array }>} */ async function getAIResponse({ query, conversationId, newUserMessage, user, userContext, runToolContext, aiChatUrl, aiApiKey }) { const tools = getToolsSchema(); const systemPrompt = buildSystemPrompt(userContext); const rows = await query( `SELECT role, content, tool_calls_json AS "toolCallsJson" FROM ai_messages WHERE conversation_id = $1 ORDER BY created_at ASC LIMIT $2`, [conversationId, HISTORY_MESSAGES_LIMIT] ); const history = dbMessagesToOpenAI(rows); const messages = [ { role: 'system', content: systemPrompt }, ...history, { role: 'user', content: newUserMessage } ]; let currentMessages = messages; let iterations = 0; let finalContent = ''; const toolResults = []; while (iterations < MAX_TOOL_ITERATIONS) { const round = await oneRound({ messages: currentMessages, tools, user, runToolContext, collectedToolResults: toolResults, aiChatUrl, aiApiKey }); currentMessages = round.messages; if (round.done) { finalContent = round.finalContent || ''; break; } iterations++; } if (!finalContent && iterations >= MAX_TOOL_ITERATIONS) { finalContent = 'Достигнут лимит шагов. Попробуйте упростить запрос.'; } return { assistantMessage: finalContent, toolResults }; } module.exports = { buildSystemPrompt, getAIResponse, callChatCompletion };