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