Files
mkd/backend/aiChatService.js
2026-02-04 00:17:04 +05:00

190 lines
7.5 KiB
JavaScript
Executable File
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.
/**
* Сервис ИИ-чата: системный промпт, загрузка истории, вызов 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<rows>
* @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
};