privet/lib/alem.js

191 lines
7.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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 };