313 lines
14 KiB
JavaScript
313 lines
14 KiB
JavaScript
|
|
/**
|
|||
|
|
* Реестр инструментов ИИ-чата: описание для модели (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
|
|||
|
|
};
|