fix: Alem error reasons, real file link + recalc button, error counts as submitted, Alem call docs

This commit is contained in:
samruk_3 2026-09-21 16:45:28 +00:00
parent 7c25b3f280
commit 09e179107c
4 changed files with 154 additions and 42 deletions

3
.gitignore vendored
View File

@ -4,5 +4,6 @@ uploads/
.vibe42-run.log .vibe42-run.log
.vibe42-run.pid .vibe42-run.pid
.env .env
package-lock.json
.kfu-svod-pw.txt .kfu-svod-pw.txt
.kfu-data/

View File

@ -25,12 +25,23 @@
.kfu-dot.yellow { background: var(--kt-ai-warning, var(--kt-ai-warn-token)); } .kfu-dot.yellow { background: var(--kt-ai-warning, var(--kt-ai-warn-token)); }
.kfu-dot.red { background: var(--kt-ai-danger); } .kfu-dot.red { background: var(--kt-ai-danger); }
.kfu-dot.none { background: var(--kt-ai-fg-faint, var(--kt-ai-fg-faint)); } .kfu-dot.none { background: var(--kt-ai-fg-faint, var(--kt-ai-fg-faint)); }
.kfu-table-wrap { overflow-x: auto; border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); } .kfu-table-wrap { border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); }
table.kfu-t { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 1180px; } table.kfu-t { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.kfu-t th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-card-border); font-weight: 600; font-size: 12px; color: var(--kt-ai-fg-muted); white-space: nowrap; background: var(--kt-ai-bg); position: sticky; top: 0; } .kfu-t th { text-align: left; padding: 8px 8px; border-bottom: 1px solid var(--kt-ai-card-border); font-weight: 600; font-size: 11.5px; color: var(--kt-ai-fg-muted); white-space: nowrap; background: var(--kt-ai-bg); overflow: hidden; text-overflow: ellipsis; }
.kfu-t td { padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-card-border); vertical-align: top; } .kfu-t td { padding: 8px 8px; border-bottom: 1px solid var(--kt-ai-card-border); vertical-align: top; overflow: hidden; text-overflow: clip; }
.kfu-t td.company-cell { white-space: normal; word-break: break-word; }
.kfu-sub { font-size: 12px; color: var(--kt-ai-fg-muted); margin-top: 2px; }
.kfu-toggle { background: none; border: 0; color: var(--kt-ai-primary); cursor: pointer; font-size: 12px; padding: 2px 0; margin-top: 4px; }
.kfu-toggle:hover { text-decoration: underline; }
.kfu-rowdetail { padding: 10px 14px 12px; background: var(--kt-ai-bg); }
.kfu-err { margin-top: 4px; font-size: 11.5px; color: var(--kt-ai-danger); max-width: 320px; }
.kfu-alem-note { font-size: 11px; color: var(--kt-ai-fg-faint, var(--kt-ai-fg-muted)); line-height: 1.55; margin-top: 10px; padding: 8px 12px; background: var(--kt-ai-card-bg); border: 1px dashed var(--kt-ai-card-border); border-radius: 10px; }
.kfu-alem-note code { font-size: 10.5px; color: var(--kt-ai-fg); }
.kfu-rowdetail .kfu-cell { margin-bottom: 6px; font-size: 12.5px; color: var(--kt-ai-fg); }
.kfu-rowdetail .lbl { font-size: 11.5px; color: var(--kt-ai-fg-muted); }
.kfu-t tr:last-child td { border-bottom: 0; } .kfu-t tr:last-child td { border-bottom: 0; }
.kfu-t td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .kfu-t td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; }
.kfu-t th.num { text-align: right; }
.kfu-t td.company { font-weight: 600; white-space: nowrap; max-width: 230px; overflow: hidden; text-overflow: ellipsis; } .kfu-t td.company { font-weight: 600; white-space: nowrap; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
.kfu-zone { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; white-space: nowrap; } .kfu-zone { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; white-space: nowrap; }
.kfu-flags { font-size: 12px; color: var(--kt-ai-fg-muted); max-width: 260px; } .kfu-flags { font-size: 12px; color: var(--kt-ai-fg-muted); max-width: 260px; }
@ -81,12 +92,15 @@
</div> </div>
<div class="kfu-table-wrap"> <div class="kfu-table-wrap">
<table class="kfu-t"> <table class="kfu-t">
<colgroup>
<col style="width:19%"><col style="width:11%"><col style="width:9%"><col style="width:8%"><col style="width:9%">
<col style="width:10%"><col style="width:9%"><col style="width:6.5%"><col style="width:6.5%"><col style="width:6.5%"><col style="width:6%">
</colgroup>
<thead> <thead>
<tr> <tr>
<th>Компания</th> <th class="th-company">Компания</th>
<th>Статус</th> <th>Статус</th>
<th>Когда загружен</th> <th>Загружен</th>
<th>Комментарий дочки</th>
<th class="num">Долг</th> <th class="num">Долг</th>
<th class="num">EBITDA 12М</th> <th class="num">EBITDA 12М</th>
<th class="num">Проц. расходы 12М</th> <th class="num">Проц. расходы 12М</th>
@ -94,21 +108,21 @@
<th class="num">К1</th> <th class="num">К1</th>
<th class="num">К2</th> <th class="num">К2</th>
<th class="num">К3</th> <th class="num">К3</th>
<th>Зона компании</th> <th>Зона</th>
<th>Замечания агента</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody id="tbody"></tbody> <tbody id="tbody"></tbody>
</table> </table>
</div> </div>
<p class="kfu-alem-note">Как считается: дочка загружает файл → приложение создаёт сессию в Alem: <code>POST /api/v1/open-api/conversation</code> (заголовок <code>Authorization: Bearer …</code>, body <code>{template_id, title, user_id}</code>) → затем текст отчёта уходит в <code>POST /api/v1/open-api/conversation/{session_id}/send</code> (поле <code>content</code>) → ответ приходит SSE-потоком, итог — в последнем событии <code>message_result.detail.full_content</code>, которое приложение разбирает как JSON. Ключи и <code>template_id</code> задаются в настройках приложения, в код не попадают.</p>
<section class="kfu-section"> <section class="kfu-section">
<h2>Настройки</h2> <h2>Настройки</h2>
<div class="kfu-setcard"> <div class="kfu-setcard">
<div class="kfu-setgrid"> <div class="kfu-setgrid">
<label class="kt-ai-field"><span>Период отчётности</span> <label class="kt-ai-field"><span>Период отчётности</span>
<input class="kt-ai-input" id="setPeriod" placeholder="Q2-2026"></label> <input class="kt-ai-input" id="setPeriod" placeholder="Q2-2026"></label>
<label class="kt-ai-field"><span>Срок предоставления (день.месяц)</span> <label class="kt-ai-field"><span>Срок предоставления (ДД.ММ.ГГГГ)</span>
<input class="kt-ai-input" id="setDeadline" placeholder="30.06.2026"></label> <input class="kt-ai-input" id="setDeadline" placeholder="30.06.2026"></label>
<label class="kt-ai-field"><span>Телефон ведущего</span> <label class="kt-ai-field"><span>Телефон ведущего</span>
<input class="kt-ai-input" id="setPhone" placeholder="+7 (___) ___-__-__"></label> <input class="kt-ai-input" id="setPhone" placeholder="+7 (___) ___-__-__"></label>
@ -117,7 +131,7 @@
<div> <div>
<h3 style="font-size:14px;margin:0 0 4px">Дочерние компании</h3> <h3 style="font-size:14px;margin:0 0 4px">Дочерние компании</h3>
<p style="font-size:12.5px;color:var(--kt-ai-fg-muted);margin:0 0 8px">Логин дочерней компании — её адрес в Аит (например, <code>jandamir_z</code>). Пароль генерируется системой.</p> <p style="font-size:12.5px;color:var(--kt-ai-fg-muted);margin:0 0 8px">Логин дочерней компании — её адрес в Aitu (например, <code>jandamir_z</code>). Пароль генерируется системой.</p>
<div id="compList"></div> <div id="compList"></div>
<div class="kfu-addrow"> <div class="kfu-addrow">
<input class="kt-ai-input" id="addName" placeholder="Название компании"> <input class="kt-ai-input" id="addName" placeholder="Название компании">
@ -146,10 +160,14 @@
} }
function fmtDateFull(iso) { function fmtDateFull(iso) {
if (!iso) return ""; if (!iso) return "";
var d = new Date(iso); var s = String(iso).trim();
var p2 = function (x) { return (x < 10 ? "0" : "") + x; }; var p2 = function (x) { return (x < 10 ? "0" : "") + x; };
var parts = String(iso).split("-"); var m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (m) return p2(+m[1]) + "." + p2(+m[2]) + "." + m[3];
var parts = s.split("-");
if (parts.length === 3) return parts[2] + "." + parts[1] + "." + parts[0]; if (parts.length === 3) return parts[2] + "." + parts[1] + "." + parts[0];
var d = new Date(s);
if (isNaN(d.getTime())) return s;
return p2(d.getDate()) + "." + p2(d.getMonth() + 1) + "." + d.getFullYear(); return p2(d.getDate()) + "." + p2(d.getMonth() + 1) + "." + d.getFullYear();
} }
function fmtNum(v) { function fmtNum(v) {
@ -172,14 +190,17 @@
(flags ? "<div class='kfu-flags'>" + esc(flags) + "</div>" : ""); (flags ? "<div class='kfu-flags'>" + esc(flags) + "</div>" : "");
} }
function stHtml(row) { function stHtml(row) {
if (!row || !row.status) return "<span class='kfu-status-pill kfu-st-no'>нет данных</span>"; if (!row || !row.status || row.status === "not_submitted") return "<span class='kfu-status-pill kfu-st-no'>не сдан</span>";
var m = { done: ["kfu-st-done", "сдан"], processing: ["kfu-st-processing", "расчёт"], error: ["kfu-st-error", "ошибка"] }; var m = { done: ["kfu-st-done", "сдан"], processing: ["kfu-st-processing", "обрабатывается"], error: ["kfu-st-error", "ошибка расчёта"] };
var it = m[row.status] || ["kfu-st-no", esc(row.status)]; var it = m[row.status] || ["kfu-st-no", esc(row.status)];
return "<span class='kfu-status-pill " + it[0] + "'>" + it[1] + "</span>"; return "<span class='kfu-status-pill " + it[0] + "'>" + it[1] + "</span>";
} }
var tbody = document.getElementById("tbody"); var tbody = document.getElementById("tbody");
var ROW_DATA = [];
var ROW_IDX = 0;
function render(rows, b) { function render(rows, b) {
var done = 0, proc = 0, err = 0; var done = 0, proc = 0, err = 0;
rows.forEach(function (r) { rows.forEach(function (r) {
@ -187,9 +208,12 @@
else if (r.status === "processing") proc++; else if (r.status === "processing") proc++;
else if (r.status === "error") err++; else if (r.status === "error") err++;
}); });
var submitted = rows.filter(function (r) { return r.status === "done" || r.status === "error"; }).length;
document.getElementById("cnt").innerHTML = document.getElementById("cnt").innerHTML =
esc(rows.length) + " компаний: <b>" + done + "</b> сдано, " + proc + " на расчёте, " + err + " ошибок"; esc(rows.length) + " компаний: <b>" + submitted + "</b> сдано (" + err + " с ошибкой расчёта), " + proc + " на расчёте";
document.getElementById("periodMeta").textContent = "период " + (b.period || "") + " · срок до " + fmtDateFull(b.deadline); document.getElementById("periodMeta").textContent = "период " + (b.period || "") + " · срок до " + fmtDateFull(b.deadline);
ROW_DATA = [];
ROW_IDX = 0;
var html = rows.map(function (r) { var html = rows.map(function (r) {
var acts = []; var acts = [];
if (r.fileId) { if (r.fileId) {
@ -198,11 +222,17 @@
if (r.status === "processing" || r.status === "error") { if (r.status === "processing" || r.status === "error") {
acts.push("<button onclick='kfRecalc(\"" + r.fileId + "\")'>пересчитать</button>"); acts.push("<button onclick='kfRecalc(\"" + r.fileId + "\")'>пересчитать</button>");
} }
var idx = ROW_IDX++;
ROW_DATA[idx] = r;
var hasDetail = !!(r.comment || (r.flags && r.flags.length) || r.agent_comment);
var actsStr = acts.length ? acts.join(" ") : "";
return "<tr>" + return "<tr>" +
"<td class='company' title='" + esc(r.company) + "'>" + esc(r.company) + "</td>" + "<td class='company company-cell' title='" + esc(r.company) + "'>" + esc(r.company) +
(hasDetail ? "<br><button class='kfu-toggle' onclick='kfToggle(" + idx + ")' id='tg" + idx + "'>подробнее</button>" : "") +
(r.error ? "<div class='kfu-err'>" + esc(r.error) + "</div>" : "") +
"</td>" +
"<td>" + stHtml(r) + "</td>" + "<td>" + stHtml(r) + "</td>" +
"<td>" + esc(fmtDate(r.uploadedAt)) + "</td>" + "<td>" + esc(fmtDate(r.uploadedAt)) + "</td>" +
"<td class='kfu-note'>" + esc(r.comment) + "</td>" +
"<td class='num'>" + fmtNum(r.debt) + "</td>" + "<td class='num'>" + fmtNum(r.debt) + "</td>" +
"<td class='num'>" + fmtNum(r.ebitda_ltm) + "</td>" + "<td class='num'>" + fmtNum(r.ebitda_ltm) + "</td>" +
"<td class='num'>" + fmtNum(r.interest_ltm) + "</td>" + "<td class='num'>" + fmtNum(r.interest_ltm) + "</td>" +
@ -211,9 +241,13 @@
"<td class='num'>" + fmtNum(r.k2) + "</td>" + "<td class='num'>" + fmtNum(r.k2) + "</td>" +
"<td class='num'>" + fmtNum(r.k3) + "</td>" + "<td class='num'>" + fmtNum(r.k3) + "</td>" +
"<td>" + zoneHtml(r) + "</td>" + "<td>" + zoneHtml(r) + "</td>" +
"<td class='kfu-note'>" + esc(r.agent_comment) + "</td>" + "<td class='kfu-act'>" + actsStr + "</td>" +
"<td class='kfu-act'>" + acts.join(" ") + "</td>" + "</tr>" +
"</tr>"; (hasDetail ? "<tr class='kfu-detail-row' id='dt" + idx + "' hidden><td colspan='12' class='kfu-rowdetail'>" +
(r.comment ? "<div class='kfu-cell'><span class='lbl'>Комментарий дочки:</span> " + esc(r.comment) + "</div>" : "") +
(r.agent_comment ? "<div class='kfu-cell'><span class='lbl'>Замечания агента:</span> " + esc(r.agent_comment) + "</div>" : "") +
((r.flags && r.flags.length) ? "<div class='kfu-cell'><span class='lbl'>Признаки:</span><br>" + r.flags.map(esc).join("<br>") + "</div>" : "") +
"</td></tr>" : "");
}).join(""); }).join("");
tbody.innerHTML = html; tbody.innerHTML = html;
document.getElementById("exportBtn").setAttribute("href", "api/export?t=" + Date.now()); document.getElementById("exportBtn").setAttribute("href", "api/export?t=" + Date.now());
@ -226,7 +260,7 @@
"<input class='kt-ai-input in name' value='" + esc(c.name) + "' placeholder='Название'>" + "<input class='kt-ai-input in name' value='" + esc(c.name) + "' placeholder='Название'>" +
"<input class='kt-ai-input in person' value='" + esc(c.person) + "' placeholder='Ответственный'>" + "<input class='kt-ai-input in person' value='" + esc(c.person) + "' placeholder='Ответственный'>" +
"<input class='kt-ai-input in phone' value='" + esc(c.phone) + "' placeholder='+7 ___'>" + "<input class='kt-ai-input in phone' value='" + esc(c.phone) + "' placeholder='+7 ___'>" +
"<span style='font-size:12.5px' class='lg' title='Логин для входа — передайте компании в Аит'>" + esc(c.login) + "</span>" + "<span style='font-size:12.5px' class='lg' title='Логин для входа — передайте компании в Aitu'>" + esc(c.login) + "</span>" +
"<div class='kfu-actions'>" + "<div class='kfu-actions'>" +
"<button class='act-save'>сохранить</button>" + "<button class='act-save'>сохранить</button>" +
"<button class='act-pw'>пароль</button>" + "<button class='act-pw'>пароль</button>" +
@ -256,8 +290,7 @@
var data = collect(); var data = collect();
var body = { companies: window.__companies.map(function (c) { var body = { companies: window.__companies.map(function (c) {
if (c.id !== id) return c; if (c.id !== id) return c;
var m = { id: c.id, name: data.name, person: data.person, phone: data.phone, login: data.login, active: c.active }; return { id: c.id, name: data.name, person: data.person, phone: data.phone, active: c.active };
return m;
}) }; }) };
fetch("api/settings", { fetch("api/settings", {
method: "POST", method: "POST",
@ -275,7 +308,7 @@
body: JSON.stringify({ action: "set_password" }) body: JSON.stringify({ action: "set_password" })
}).then(function (r) { return r.json(); }).then(function (b) { }).then(function (r) { return r.json(); }).then(function (b) {
if (!b.ok) { alert(b.error || "Ошибка"); return; } if (!b.ok) { alert(b.error || "Ошибка"); return; }
flash("Новый пароль: " + b.tempPassword + " — передайте компании через Аит."); flash("Новый пароль: " + b.tempPassword + " — передайте компании через Aitu.");
load(); load();
}); });
}); });
@ -300,12 +333,21 @@
window.__companies = s.companies || []; window.__companies = s.companies || [];
render(b.rows, b); render(b.rows, b);
document.getElementById("setPeriod").value = s.period || ""; document.getElementById("setPeriod").value = s.period || "";
document.getElementById("setDeadline").value = s.deadline || ""; document.getElementById("setDeadline").value = fmtDateFull(s.deadline);
document.getElementById("setPhone").value = s.consolidatorPhone || ""; document.getElementById("setPhone").value = s.consolidatorPhone || "";
renderCompanies(s.companies || []); renderCompanies(s.companies || []);
}); });
} }
window.kfToggle = function (i) {
var row = document.getElementById("dt" + i);
var btn = document.getElementById("tg" + i);
if (!row) return;
var open = row.hidden;
row.hidden = !open;
if (btn) btn.textContent = open ? "скрыть" : "подробнее";
};
window.kfRecalc = function (id) { window.kfRecalc = function (id) {
fetch("api/recalc?id=" + encodeURIComponent(id), { method: "POST" }) fetch("api/recalc?id=" + encodeURIComponent(id), { method: "POST" })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@ -321,7 +363,11 @@
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
period: document.getElementById("setPeriod").value, period: document.getElementById("setPeriod").value,
deadline: document.getElementById("setDeadline").value, deadline: function (v) {
var m = String(v || "").trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (!m) { var m2 = String(v || "").trim().match(/^(\d{4})-(\d{2})-(\d{2})/); if (m2) return m2[0]; return String(v || ""); }
return m[3] + "-" + m[2] + "-" + m[1];
}(document.getElementById("setDeadline").value),
consolidatorPhone: document.getElementById("setPhone").value consolidatorPhone: document.getElementById("setPhone").value
}) })
}).then(function (r) { return r.json(); }).then(function (b) { }).then(function (r) { return r.json(); }).then(function (b) {
@ -343,7 +389,7 @@
document.getElementById("addPhone").value = ""; document.getElementById("addPhone").value = "";
var el = document.createElement("p"); var el = document.createElement("p");
el.className = "kfu-pass"; el.className = "kfu-pass";
el.textContent = "Компания добавлена. Логин: " + b.login + ", пароль: " + b.tempPassword + " — передайте через Аит."; el.textContent = "Компания добавлена. Логин: " + b.login + ", пароль: " + b.tempPassword + " — передайте через Aitu.";
document.getElementById("compList").after(el); document.getElementById("compList").after(el);
setTimeout(function () { el.remove(); }, 30000); setTimeout(function () { el.remove(); }, 30000);
load(); load();

View File

@ -1,15 +1,53 @@
"use strict"; "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_MS = 600000;
const ALEM_TIMEOUT_TEXT = "Alem timeout: no response within " + Math.round(ALEM_TIMEOUT_MS / 60000) + " min.";
function fetchWithTimeout(url, options, timeoutMs) { function fetchWithTimeout(url, options, timeoutMs) {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(function () { controller.abort(); }, timeoutMs); const timer = setTimeout(function () { controller.abort(); }, timeoutMs);
return fetch(url, Object.assign({ signal: controller.signal }, options)).finally(function () { return fetch(url, Object.assign({ signal: controller.signal }, options)).catch(function (err) {
clearTimeout(timer); 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); });
} }
@ -27,7 +65,7 @@ function createAlemSession() {
user_id: "kfu_app" user_id: "kfu_app"
}) })
}).then(function (resp) { }).then(function (resp) {
if (!resp.ok) throw new Error("Alem create " + resp.status + ": " + resp.statusText); 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) { return resp.json().then(function (body) {
const sid = body && body.data && body.data.session_id; const sid = body && body.data && body.data.session_id;
if (!sid) throw new Error("Alem no session_id: " + JSON.stringify(body).slice(0, 200)); if (!sid) throw new Error("Alem no session_id: " + JSON.stringify(body).slice(0, 200));
@ -64,7 +102,7 @@ function askAlem(sessionId, message) {
}, },
body: JSON.stringify({ content: message, user_id: 'kfu_app', model_api_key: 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) { }, ALEM_TIMEOUT_MS).then(function (resp) {
if (!resp.ok) throw new Error('Alem send ' + resp.status + ': ' + resp.statusText); if (!resp.ok) { const e = new Error("Alem send HTTP " + resp.status + (resp.statusText ? " " + resp.statusText : "")); e.code = "ALEM_HTTP_" + resp.status; throw e; }
return resp.text().then(function (raw) { return resp.text().then(function (raw) {
return extractSseText(raw); return extractSseText(raw);
}); });
@ -108,10 +146,19 @@ function callKfuAgent(company, period, fileText) {
'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.', 'Числа — числом (без слов и валютного знака), zone_* — одно из: зелёная, жёлтая, красная, без оценки.',
'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).' 'flags — массив строк (пустой, если замечаний нет), sources — объект-источник цифр, comment — краткое замечание (или пустая строка).'
].join("\n"); ].join("\n");
if (!process.env.ALEM_BASE_URL || !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 createAlemSession().then(function (sid) {
return askAlem(sid, prompt).then(function (text) { return askAlem(sid, prompt).then(function (text) {
var data = extractJsonFromText(text); var data = extractJsonFromText(text);
if (!data) throw new Error('Alem не вернул JSON. Ответ: ' + String(text).slice(0, 300)); 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; return data;
}); });
}); });

View File

@ -7,8 +7,9 @@ const crypto = require("crypto");
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const ROOT = __dirname; const ROOT = __dirname;
const DATA_FILE = path.join(ROOT, "data.json"); let DATA_DIR = path.join(ROOT, "..", ".kfu-data");
const UPLOADS_DIR = path.join(ROOT, "uploads"); let DATA_FILE = path.join(DATA_DIR, "data.json");
let UPLOADS_DIR = path.join(DATA_DIR, "uploads");
const store = require("./lib/store"); const store = require("./lib/store");
const alem = require("./lib/alem"); const alem = require("./lib/alem");
@ -18,7 +19,8 @@ const PUBLIC_BASE = process.env.KFU_PUBLIC_BASE || "";
const SESSION_TTL_MS = 1000 * 60 * 60 * 12; const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
const MAX_UPLOAD = 50 * 1024 * 1024; const MAX_UPLOAD = 50 * 1024 * 1024;
fs.mkdirSync(UPLOADS_DIR, { recursive: true }); try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); }
catch (e) { DATA_DIR = ROOT; UPLOADS_DIR = path.join(ROOT, "uploads"); fs.mkdirSync(UPLOADS_DIR, { recursive: true }); }
const DB = store.load(); const DB = store.load();
let GENERATED_PASSWORD = DB._consolidatorTempPassword || null; let GENERATED_PASSWORD = DB._consolidatorTempPassword || null;
@ -205,13 +207,25 @@ function computeFromText(user, report, fileText) {
sendNotification("report_uploaded", user.companyName, DB.period, "done"); sendNotification("report_uploaded", user.companyName, DB.period, "done");
}).catch(function (err) { }).catch(function (err) {
report.status = "error"; report.status = "error";
report.error = String((err && err.message) || err).slice(0, 500); report.error = describeAlemError(err);
report.computedAt = iso(); report.computedAt = iso();
store.save(DB); store.save(DB);
sendNotification("report_uploaded", user.companyName, DB.period, "error"); sendNotification("report_uploaded", user.companyName, DB.period, "error");
}); });
} }
// ---------- helpers ----------
function describeAlemError(err) {
const msg = String((err && err.message) || err);
const code = err && err.code;
if (code === "ALEM_TIMEOUT") return "Агент не ответил за 10 минут. " + msg;
if (code === "ALEM_NOT_CONFIGURED") return "Агент Alem не настроен.";
if (code === "ALEM_NOT_JSON") return msg;
var m = msg.match(/^.*?HTTP (\d\d\d)/);
if (m) return "Ошибка HTTP " + m[1] + " от сервера Alem.";
return "Ошибка агента: " + msg;
}
// ---------- API handlers ---------- // ---------- API handlers ----------
function handleMe(req, res) { function handleMe(req, res) {
const user = currentUser(req); const user = currentUser(req);
@ -325,7 +339,7 @@ function dashboardRows() {
uploadedAt: r ? r.uploadedAt : null, uploadedAt: r ? r.uploadedAt : null,
person: u.person || "", person: u.person || "",
phone: u.phone || "", phone: u.phone || "",
submitted: st === "done", submitted: st === "done" || st === "error",
}; };
if (r) { if (r) {
row.comment = r.comment || ""; row.comment = r.comment || "";
@ -646,8 +660,12 @@ function serveStatic(req, res, urlPath) {
if (fs.existsSync(path.join(ROOT, file + ".html"))) file = file + ".html"; if (fs.existsSync(path.join(ROOT, file + ".html"))) file = file + ".html";
else file = "/index.html"; else file = "/index.html";
} }
const full = path.join(ROOT, file.replace(/\//g, path.sep)); const norm = path.normalize(file);
if (!full.startsWith(ROOT)) { res.writeHead(403); return res.end(); } const full = path.join(ROOT, norm);
if (norm.indexOf("..") !== -1 || (full !== ROOT && !full.startsWith(ROOT + path.sep))) {
res.writeHead(404, { "Content-Type": "application/json" });
return res.end("{}");
}
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) { if (!fs.existsSync(full) || !fs.statSync(full).isFile()) {
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
return res.end("<h1>404</h1>"); return res.end("<h1>404</h1>");