313 lines
14 KiB
JavaScript
Executable File
313 lines
14 KiB
JavaScript
Executable File
/**
|
||
* Реестр инструментов ИИ-чата: описание для модели (OpenAI tools) и выполнение через self-request с JWT.
|
||
*
|
||
* КАК ДОБАВИТЬ НОВЫЙ ИНСТРУМЕНТ:
|
||
* 1. В TOOLS_FOR_OPENAI — добавить объект { type: 'function', function: { name, description, parameters } }.
|
||
* parameters — JSON Schema (properties, required, additionalProperties: false). Описание (description)
|
||
* помогает ИИ задавать наводящие вопросы и правильно заполнять аргументы.
|
||
* 2. В TOOL_EXECUTORS — добавить запись: method ('GET'|'POST'|'PUT'), pathTemplate (с :param для path),
|
||
* опционально queryParams: { argKey: queryKey }, для POST/PUT — bodyMapping: { argKey: destKey } или _createdBy: true.
|
||
* 3. runTool подставит path, query string и body, для запросов от имени пользователя вызовет наш API с JWT.
|
||
*
|
||
* Модули для расширения: финансы (payment-invoices, calendar), заявки (applications), офис (meetings, documents),
|
||
* HR (employees, vacations), юр. отдел (contracts, court-cases), развитие (pipeline, OSS).
|
||
*/
|
||
|
||
const axios = require('axios');
|
||
|
||
/** Текст «что умеет» — возвращается по команде get_capabilities */
|
||
const CAPABILITIES_TEXT = `Я помощник в системе управления МКД. Вот что я умею:
|
||
|
||
**Дома и объекты**
|
||
• Показать список всех домов (МКД) — адреса, id, основные данные.
|
||
• Показать данные по одному дому по его id (паспорт, лицевые счета и т.д.).
|
||
|
||
**Участки**
|
||
• Показать список участков (районов обслуживания) — название, руководитель.
|
||
|
||
**Сотрудники**
|
||
• Показать список сотрудников (id и имя) — для поиска исполнителя или контакта.
|
||
|
||
**Финансы — счета на оплату**
|
||
• Показать список счетов на оплату (с пагинацией: страница и количество на странице).
|
||
• Создать счёт на оплату: контрагент, сумма, назначение (мероприятие/дом/участок/прочее), формат оплаты (наличные/безнал), список услуг (название и сумма). Могу задать уточняющие вопросы, если чего-то не хватает.
|
||
|
||
**Заявки (диспетчерская)**
|
||
• Создать заявку: обязательно адрес и описание; по желанию — заявитель, квартира, срок выполнения.
|
||
|
||
Просто напиши, что нужно: например «покажи дома», «создай счёт на 10 000 для ООО Рога», «создай заявку по адресу ул. Ленина 5 — течёт кран». Если данных не хватает, я спрошу.`;
|
||
|
||
/** Описания инструментов в формате OpenAI Chat Completions (tools) */
|
||
const TOOLS_FOR_OPENAI = [
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'get_capabilities',
|
||
description: 'Вернуть подробное описание всех возможностей помощника в программе МКД. Вызывай, когда пользователь спрашивает «что ты умеешь», «расскажи о возможностях», «какие команды», «помощь», «что можешь» и т.п. Аргументов нет.',
|
||
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'list_buildings',
|
||
description: 'Получить список всех домов (МКД). Возвращает массив домов с id, адресом и основными данными.',
|
||
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'get_building',
|
||
description: 'Получить данные одного дома по его id (например id из списка домов).',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'string', description: 'Идентификатор дома (id)' }
|
||
},
|
||
required: ['id'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'list_districts',
|
||
description: 'Получить список участков (районов обслуживания) с id, названием и руководителем.',
|
||
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'list_employees',
|
||
description: 'Получить список сотрудников (id, имя). Используй для выбора исполнителя или поиска по имени.',
|
||
parameters: { type: 'object', properties: {}, additionalProperties: false }
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'list_payment_invoices',
|
||
description: 'Получить список счетов на оплату. Можно указать limit и page для пагинации.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
limit: { type: 'number', description: 'Количество записей на странице (по умолчанию 10)' },
|
||
page: { type: 'number', description: 'Номер страницы (по умолчанию 1)' }
|
||
},
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'create_payment_invoice',
|
||
description: 'Создать счёт на оплату. Нужны: контрагент (contractorName), сумма (totalAmount), назначение (purposeType: event, building, district, other), формат оплаты (paymentFormat: cash, non_cash), список услуг (serviceItems: массив объектов { name, amount }). Опционально: purposeDescription, contractorInn, notes.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
contractorName: { type: 'string', description: 'Название контрагента (ИП/ООО)' },
|
||
totalAmount: { type: 'number', description: 'Общая сумма в рублях' },
|
||
purposeType: { type: 'string', enum: ['event', 'building', 'district', 'other'], description: 'Тип назначения платежа' },
|
||
paymentFormat: { type: 'string', enum: ['cash', 'non_cash'], description: 'Формат оплаты' },
|
||
serviceItems: {
|
||
type: 'array',
|
||
items: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string' },
|
||
amount: { type: 'number' }
|
||
},
|
||
required: ['name', 'amount']
|
||
},
|
||
description: 'Список услуг: каждый элемент { name, amount }'
|
||
},
|
||
purposeDescription: { type: 'string', description: 'Текстовое описание назначения' },
|
||
contractorInn: { type: 'string', description: 'ИНН контрагента' },
|
||
notes: { type: 'string', description: 'Заметки' }
|
||
},
|
||
required: ['contractorName', 'totalAmount', 'purposeType', 'paymentFormat', 'serviceItems'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
},
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'create_application',
|
||
description: 'Создать заявку (диспетчерская). Обязательно: адрес (address), описание (description). Опционально: contactName, apartment, deadlineAt (дата в ISO).',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
address: { type: 'string', description: 'Адрес объекта' },
|
||
description: { type: 'string', description: 'Описание заявки' },
|
||
contactName: { type: 'string', description: 'ФИО заявителя' },
|
||
apartment: { type: 'string', description: 'Квартира/помещение' },
|
||
deadlineAt: { type: 'string', description: 'Срок выполнения (ISO дата)' }
|
||
},
|
||
required: ['address', 'description'],
|
||
additionalProperties: false
|
||
}
|
||
}
|
||
}
|
||
];
|
||
|
||
/**
|
||
* Исполнение инструмента: method, path (шаблон с :param), опционально queryParams, bodyMapping.
|
||
* queryParams: объект { argKey: queryKey } — добавляет query string из args.
|
||
* bodyMapping: для POST/PUT — подстановка body; _createdBy: true — подставить user.userId в createdBy.
|
||
*/
|
||
const TOOL_EXECUTORS = {
|
||
list_buildings: {
|
||
method: 'GET',
|
||
pathTemplate: '/buildings',
|
||
queryParams: null,
|
||
bodyMapping: null
|
||
},
|
||
get_building: {
|
||
method: 'GET',
|
||
pathTemplate: '/buildings/:id',
|
||
queryParams: null,
|
||
bodyMapping: null
|
||
},
|
||
list_districts: {
|
||
method: 'GET',
|
||
pathTemplate: '/districts',
|
||
queryParams: null,
|
||
bodyMapping: null
|
||
},
|
||
list_employees: {
|
||
method: 'GET',
|
||
pathTemplate: '/employees/list',
|
||
queryParams: null,
|
||
bodyMapping: null
|
||
},
|
||
list_payment_invoices: {
|
||
method: 'GET',
|
||
pathTemplate: '/finance/payment-invoices',
|
||
queryParams: { limit: 'limit', page: 'page' },
|
||
bodyMapping: null
|
||
},
|
||
create_payment_invoice: {
|
||
method: 'POST',
|
||
pathTemplate: '/finance/payment-invoices',
|
||
queryParams: null,
|
||
bodyMapping: {
|
||
_createdBy: true,
|
||
contractorName: 'contractorName',
|
||
totalAmount: 'totalAmount',
|
||
purposeType: 'purposeType',
|
||
paymentFormat: 'paymentFormat',
|
||
serviceItems: 'serviceItems',
|
||
purposeDescription: 'purposeDescription',
|
||
contractorInn: 'contractorInn',
|
||
notes: 'notes'
|
||
}
|
||
},
|
||
create_application: {
|
||
method: 'POST',
|
||
pathTemplate: '/applications',
|
||
queryParams: null,
|
||
bodyMapping: {
|
||
address: 'address',
|
||
description: 'description',
|
||
contactName: 'contactName',
|
||
apartment: 'apartment',
|
||
deadlineAt: 'deadlineAt'
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Подставить параметры в path (например :id -> args.id)
|
||
*/
|
||
function buildPath(pathTemplate, args) {
|
||
let p = pathTemplate;
|
||
const re = /:(\w+)/g;
|
||
let m;
|
||
while ((m = re.exec(pathTemplate)) !== null) {
|
||
const key = m[1];
|
||
const val = args && args[key];
|
||
if (val === undefined || val === null) {
|
||
throw new Error(`Параметр "${key}" обязателен для пути ${pathTemplate}`);
|
||
}
|
||
p = p.replace(new RegExp(':' + key + '(?=/|$)'), encodeURIComponent(String(val)));
|
||
}
|
||
return p;
|
||
}
|
||
|
||
/**
|
||
* Выполнить инструмент от имени пользователя: внутренний HTTP-запрос к нашему API с JWT.
|
||
* @param {string} toolName
|
||
* @param {object} args — аргументы от ИИ
|
||
* @param {object} user — { userId, employeeId, role } (req.user)
|
||
* @param {object} context — { baseUrl, apiPrefix, getTokenForUser }
|
||
* @returns {Promise<{ success: boolean, data?: any, error?: string }>}
|
||
*/
|
||
async function runTool(toolName, args, user, context) {
|
||
if (toolName === 'get_capabilities') {
|
||
return { success: true, data: { text: CAPABILITIES_TEXT } };
|
||
}
|
||
const { baseUrl, apiPrefix, getTokenForUser } = context;
|
||
const executor = TOOL_EXECUTORS[toolName];
|
||
if (!executor) {
|
||
return { success: false, error: `Неизвестный инструмент: ${toolName}` };
|
||
}
|
||
const path = buildPath(executor.pathTemplate, args || {});
|
||
let url = `${baseUrl}${apiPrefix}${path}`;
|
||
if (executor.queryParams && args) {
|
||
const q = [];
|
||
for (const [argKey, queryKey] of Object.entries(executor.queryParams)) {
|
||
if (args[argKey] !== undefined && args[argKey] !== null) {
|
||
q.push(`${encodeURIComponent(queryKey)}=${encodeURIComponent(String(args[argKey]))}`);
|
||
}
|
||
}
|
||
if (q.length) url += '?' + q.join('&');
|
||
}
|
||
const token = getTokenForUser(user);
|
||
const headers = {
|
||
Authorization: `Bearer ${token}`,
|
||
'Content-Type': 'application/json'
|
||
};
|
||
let body = undefined;
|
||
if (executor.bodyMapping && (executor.method === 'POST' || executor.method === 'PUT')) {
|
||
body = {};
|
||
for (const [argKey, destKey] of Object.entries(executor.bodyMapping)) {
|
||
if (argKey === '_createdBy' && destKey === true) {
|
||
body.createdBy = user.userId;
|
||
continue;
|
||
}
|
||
if (args && argKey in args) {
|
||
body[destKey] = args[argKey];
|
||
}
|
||
}
|
||
}
|
||
try {
|
||
const res = await axios({
|
||
method: executor.method,
|
||
url,
|
||
headers,
|
||
data: body,
|
||
timeout: 30000,
|
||
validateStatus: () => true
|
||
});
|
||
if (res.status >= 200 && res.status < 300) {
|
||
return { success: true, data: res.data };
|
||
}
|
||
const errText = res.data && (res.data.error || res.data.message) ? String(res.data.error || res.data.message) : res.statusText;
|
||
return { success: false, error: `HTTP ${res.status}: ${errText}` };
|
||
} catch (err) {
|
||
const msg = err.response ? `HTTP ${err.response.status}` : err.message || 'Ошибка запроса';
|
||
return { success: false, error: msg };
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
getToolsSchema: () => TOOLS_FOR_OPENAI,
|
||
runTool,
|
||
TOOL_EXECUTORS
|
||
};
|