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