191 lines
7.8 KiB
JavaScript
191 lines
7.8 KiB
JavaScript
"use strict";
|
||
|
||
/**
|
||
* Вызов агента Alem «ИИ-агент КФУ» (открытый API).
|
||
*
|
||
* Базовый адрес: https://alem.ai.telecom.kz/app/api/v1/open-api
|
||
* (переменная окружения ALEM_BASE_URL уже содержит путь до /app,
|
||
* то есть ALEM_BASE_URL = https://alem.ai.telecom.kz/app —
|
||
* скрипт дописывает /api/v1/open-api/...).
|
||
*
|
||
* Шаг 1. Создать сессию:
|
||
* POST $ALEM_BASE_URL/api/v1/open-api/conversation
|
||
* Headers: Content-Type: application/json
|
||
* Authorization: Bearer $ALEM_API_KEY
|
||
* Body: { "template_id": "$ALEM_TEMPLATE_ID", "title": "...", "user_id": "kfu_app" }
|
||
* Ответ: { "data": { "session_id": "<uuid>" } }
|
||
*
|
||
* Шаг 2. Отправить сообщение в сессию (ответ идёт потоком SSE):
|
||
* POST $ALEM_BASE_URL/api/v1/open-api/conversation/<session_id>/send
|
||
* Headers: Content-Type: application/json
|
||
* Authorization: Bearer $ALEM_API_KEY
|
||
* Body: { "content": "<текст запроса>", "user_id": "kfu_app", "model_api_key": "$ALEM_API_KEY" }
|
||
*
|
||
* Ответ — поток Server-Sent Events, каждая строка вида "data: <json>".
|
||
* Итог — в последнем событии message_result:
|
||
* { "detail": { "full_content": "<полный текст ответа агента>" } }
|
||
* (см. extractSseText). Дальше текст разбирается на JSON (extractJsonFromText).
|
||
*
|
||
* Таймаут — 10 минут (ALEM_TIMEOUT_MS). Ключи приходят из окружения,
|
||
* в код и в git не пишутся.
|
||
*/
|
||
|
||
|
||
const ALEM_TIMEOUT_MS = 600000;
|
||
|
||
|
||
const ALEM_TIMEOUT_TEXT = "Alem timeout: no response within " + Math.round(ALEM_TIMEOUT_MS / 60000) + " min.";
|
||
|
||
function fetchWithTimeout(url, options, timeoutMs) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(function () { controller.abort(); }, timeoutMs);
|
||
return fetch(url, Object.assign({ signal: controller.signal }, options)).catch(function (err) {
|
||
clearTimeout(timer);
|
||
if (controller.signal.aborted) {
|
||
const e = new Error(ALEM_TIMEOUT_TEXT);
|
||
e.code = "ALEM_TIMEOUT";
|
||
throw e;
|
||
}
|
||
throw err;
|
||
}).finally(function () { clearTimeout(timer); });
|
||
}
|
||
|
||
|
||
function createAlemSession() {
|
||
const url = (process.env.ALEM_BASE_URL || "").replace(/\/+$/, "") + "/api/v1/open-api/conversation";
|
||
return fetchWithTimeout(url, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"Authorization": "Bearer " + process.env.ALEM_API_KEY
|
||
},
|
||
body: JSON.stringify({
|
||
template_id: process.env.ALEM_TEMPLATE_ID,
|
||
title: "KFU-agent-call",
|
||
user_id: "kfu_app"
|
||
})
|
||
}).then(function (resp) {
|
||
if (!resp.ok) { const e = new Error("Alem create HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : "")); e.code = "ALEM_HTTP_" + resp.status; throw e; }
|
||
return resp.json().then(function (body) {
|
||
const sid = body && body.data && body.data.session_id;
|
||
if (!sid) throw new Error("Alem no session_id: " + JSON.stringify(body).slice(0, 200));
|
||
return sid;
|
||
});
|
||
});
|
||
}
|
||
|
||
function extractSseText(raw) {
|
||
var answer = null;
|
||
var lines = String(raw).split(String.fromCharCode(10));
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var s = lines[i].trim();
|
||
if (!s.startsWith(String.fromCharCode(100,97,116,97,58))) continue;
|
||
var payload = s.slice(5).trim();
|
||
if (!payload || payload === String.fromCharCode(91,68,79,78,69,93)) continue;
|
||
try {
|
||
var ev = JSON.parse(payload);
|
||
if (ev && ev.detail && typeof ev.detail.full_content === String.fromCharCode(115,116,114,105,110,103) && ev.detail.full_content) {
|
||
answer = ev.detail.full_content;
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
return answer || String.fromCharCode(78,101,116,32,101,116,32,111,116,118,101,116);
|
||
}
|
||
|
||
function askAlem(sessionId, message) {
|
||
var url = (process.env.ALEM_BASE_URL || "").replace(/\/+$/, "") + sendPath(sessionId);
|
||
return fetchWithTimeout(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': 'Bearer ' + process.env.ALEM_API_KEY
|
||
},
|
||
body: JSON.stringify({ content: message, user_id: 'kfu_app', model_api_key: process.env.ALEM_API_KEY })
|
||
}, ALEM_TIMEOUT_MS).then(function (resp) {
|
||
if (!resp.ok) {
|
||
const e = new Error("Alem send HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : ""));
|
||
e.code = "ALEM_HTTP_" + resp.status;
|
||
throw e;
|
||
}
|
||
const reader = resp.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let raw = "";
|
||
let finished = false;
|
||
function settle(fn) {
|
||
if (!finished) {
|
||
finished = true;
|
||
try { reader.cancel(); } catch (e) { }
|
||
fn(extractSseText(raw));
|
||
}
|
||
}
|
||
function pump() {
|
||
reader.read().then(function (r) {
|
||
if (r.done) { settle(resolve); return; }
|
||
raw += dec.decode(r.value, { stream: true });
|
||
pump();
|
||
}).catch(function (err) {
|
||
if (!finished) { finished = true; reject(err.code === "ALEM_TIMEOUT" ? err : new Error("Alem stream read error: " + String((err && err.message) || err))); }
|
||
});
|
||
}
|
||
return new Promise(function (resolve, reject) { pump(); });
|
||
});
|
||
}
|
||
function sendPath(id) {
|
||
return '/api/v1/open-api/conversation/' + id + '/send';
|
||
}
|
||
|
||
function extractJsonFromText(text) {
|
||
var s = String(text || '');
|
||
var fenceStart = s.indexOf('```');
|
||
if (fenceStart !== -1) {
|
||
var inner = s.slice(fenceStart + 3);
|
||
var langEnd = inner.indexOf('```');
|
||
if (langEnd !== -1) inner = inner.slice(langEnd + 3);
|
||
var jsonStart = inner.indexOf('{');
|
||
var jsonEnd = inner.lastIndexOf('}');
|
||
if (jsonStart !== -1 && jsonEnd > jsonStart) {
|
||
try { return JSON.parse(inner.slice(jsonStart, jsonEnd + 1)); } catch (e) { }
|
||
}
|
||
}
|
||
var first = s.indexOf('{');
|
||
var last = s.lastIndexOf('}');
|
||
if (first !== -1 && last > first) {
|
||
try { return JSON.parse(s.slice(first, last + 1)); } catch (e) { }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function callKfuAgent(company, period, fileText) {
|
||
var prompt = [
|
||
'Расчёт коэффициентов финансовой устойчивости (КФУ).',
|
||
'Компания: ' + company,
|
||
'Отчётный период: ' + period,
|
||
'Данные отчёта:',
|
||
fileText,
|
||
'',
|
||
'Верни ТОЛЬКО один JSON-объект без пояснений со строгими полями:',
|
||
'{company, period, units, debt, ebitda_ltm, interest_ltm, equity, k1, k2, k3, zone_k1, zone_k2, zone_k3, zone_company, flags, sources, comment}.',
|
||
'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.',
|
||
'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).'
|
||
].join("\n");
|
||
const base = (process.env.ALEM_BASE_URL || "").replace(/\/+$/, "");
|
||
if (!base || !process.env.ALEM_API_KEY || !process.env.ALEM_TEMPLATE_ID) {
|
||
const e = new Error("Агент Alem не настроен: отсутствуют ALEM_BASE_URL / ALEM_API_KEY / ALEM_TEMPLATE_ID.");
|
||
e.code = "ALEM_NOT_CONFIGURED";
|
||
return Promise.reject(e);
|
||
}
|
||
return createAlemSession().then(function (sid) {
|
||
return askAlem(sid, prompt).then(function (text) {
|
||
var data = extractJsonFromText(text);
|
||
if (!data) {
|
||
const e = new Error('Ответ агента не JSON. Первые 300 символов: ' + String(text || '').replace(/\s+/g, ' ').trim().slice(0, 300));
|
||
e.code = "ALEM_NOT_JSON";
|
||
throw e;
|
||
}
|
||
return data;
|
||
});
|
||
});
|
||
}
|
||
|
||
module.exports = { callKfuAgent, extractSseText, extractJsonFromText };
|