Files
mkd/backend/aiChatService.js

190 lines
7.5 KiB
JavaScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
/**
* Сервис ИИ-чата: системный промпт, загрузка истории, вызов 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
};