Compare commits

...

4 Commits

Author SHA1 Message Date
6a494319dd v1.3.3: стресс-таблица как основная (EBITDA ≤ 0 / ∞ / nf2), stressRow — guar тоже ×1.10 (долг вместе с гарантиями +10%), agentContext: коэффициенты 6 знаков + готовые на_экране 2 знака, инструкция ИИ использовать на_экране 2026-09-21 09:52:14 +00:00
f1cacb5c73 v1.3.2: ИИ — stream:false+enable_thinking:false в upstream, max_tokens дефолт 1200 (извлечение 4096), клиент AbortController 100с; стресс-тест — сворачиваемая таблица на шаге 3 (расчёт через тот же calcRow на изменённой копии) 2026-09-21 09:46:31 +00:00
70585ea058 v1.3.1: isDemo только по кнопке демо (сброс при импорте/JSON-restore, авто-сброс если демо-строк нет), подписи: агент через сервер Vibe AI, import — «Импорт показателей и нормативов со сверкой по сводной книге», убрана ссылка Мониторинг из header (monitor.html на месте) 2026-09-21 09:43:13 +00:00
b85d743c5a v1.3: полный KFU-агент (4 шага) + server.js (node:http, /api/ai, health), импорт сводной книги КФУ, стресс-тест, демо, monitor.html отдельным экраном 2026-09-21 09:41:24 +00:00
8 changed files with 2108 additions and 426 deletions

2
.gitignore vendored
View File

@ -8,3 +8,5 @@ dist/
.vibe42-run.pid
.DS_Store
.mimo/
.env
kfu-agent-original-source.txt

View File

@ -1,13 +1,36 @@
# КФУ — финансовая устойчивость
# КФУ-агент — расчёт К1–К3 по Политике финансовой устойчивости
Статическое приложение, созданное через чат Vibe AI. Запуск: откройте вкладку Превью проекта; сборка npm не требуется.
Приложение с сервером (Node.js, `node:http`, без тяжёлых зависимостей) + статический фронт.
Запуск — команда **`run`** (поставит зависимости по `package.json` и поднимет `server.js`).
Пилот работает с предоставленным шаблоном факта 2025: 15 компаний и одна консолидированная группа. Источники: ДОЛГ 4Q2025_24042026; EBITDA 2025_06042026; BS 4Q2025_06042026; Группа Фонда 4Q 2025. Контрольный свод: План 2026!G5:J20.
## Что умеет
1. **Данные** — три источника:
- выгрузка СУО (SAP BPC): листы ДОЛГ / EBITDA / Баланс, распознавание структуры, подбор строк,
EBITDA «снизу вверх» по статьям ОПиУ и готовая строкой, LTM-мостик для промежуточных периодов,
структура долга по компонентам с контролем по сумме, блок «Консолидация» (TOTALADJ) для Группы Фонда;
- отчётность МСФО — извлечение показателей ИИ (PDF распковывается сервером в текст, Excel в браузере,
сканы страниц — как изображения); результат — редактируемая сводка с LTM и итогами;
- ручной ввод.
2. **Реестр показателей** — компании и периоды, долг/гарантии/EBITDA/проценты/капитал, редактирование и удаление.
3. **Расчёт и зоны** — целевые и пороговые значения по компаниям (предзаполнены из сводного файла КФУ),
свод «как в сводном файле», коэффициенты К1–К3 с зонами (зелёная/жёлтая/красная), EBITDA ≤ 0 → красная,
выгрузка в Excel (листы: Свод, Долг Группы, КФУ, Параметры, Аудит) и CSV.
4. **Агент** — чат с расчётом по методам: видит реестр, зоны и методы (реестр); подсказки:
заключение по зонам, стресс-тест (долг +10%, EBITDA −15%, проценты +20%, капитал −5% — пересчитывается
детерминированно в `stressRow`, агенту передаются готовые значения), запас до порогов, методологические риски.
Загрузите .xlsx, откройте строку компании, при необходимости измените прочие/гарантии в млн тенге. Другие показатели сценарий не редактирует. Кнопка Вернуть сбрасывает изменение. Выгрузить отчёт .xlsx сохраняет снимок текущего сценария. Листы отчёта: КФУ, Источники, Диагностика, Сверка, Пояснения.
## Архитектура
- `index.html` — всё приложение (вкладки 1–4), SheetJS из `vendor/xlsx.full.min.js`.
- `monitor.html` + `kfu-client.js` — предыдущий экран «Мониторинг» (ссылка в шапке КФУ-агента).
- `server.js` — `node:http`: статика (allowlist файлов) + `POST /api/ai` (прокси к
`AI_BASE_URL/chat/completions` с `AI_API_KEY`/`AI_MODEL` из окружения) + `GET /api/health`.
Ключи ИИ живут только на сервере; браузер ходит в `api/ai` (относительный путь).
- PDF: клиент шлёт base64 в `/api/ai` (блок `file:pdf`), сервер распковывает `pdf-parse`;
пустой PDF (скан) → честная ошибка. Лимит тела 40 МБ, таймаут ИИ 90 с.
Проверены 64/64 исходных показателя (16 объектов × 4 показателя), сценарий/сброс, фильтры, пропуски данных, отрицательные показатели и экспорт. Сверка всегда относится к исходным данным без сценария.
## Запуск
`run` → ссылка из вывода. После правок — ещё раз `run`.
Публикация — кнопка «Опубликовать» (приложение: `https://code.vibe42.kz/apps/...`, не в pages).
Формулы Excel не пересчитываются: читаются сохранённые значения исходных ячеек, затем коэффициенты рассчитываются приложением. Перед загрузкой пересчитайте и сохраните книгу в Excel.
Пользовательский Excel читается в браузере, не отправляется приложением на сервер. Исходная книга и отчёты исключены из Git. Учебный пример содержит синтетические данные.
Данные (Excel) читаются в браузере и не покидают машину, кроме отправки PDF/снимков в ИИ для извлечения показателей.
Состояние (реестр, параметры, методика) сохраняется/загружается локальным JSON; исходные книги и отчёты исключены из Git.

1840
index.html

File diff suppressed because it is too large Load Diff

View File

@ -1098,6 +1098,8 @@
render();
}
window.KfuWorkbook = { analyzeWorkbook: analyzeWorkbook };
/* ---------- инициализация ---------- */
function init() {
var fileInput = $("#fileInput");
@ -1170,5 +1172,8 @@
render();
}
document.addEventListener("DOMContentLoaded", init);
document.addEventListener("DOMContentLoaded", function () {
if (!document.getElementById("uploadPanel")) return;
init();
});
})();

444
monitor.html Normal file
View File

@ -0,0 +1,444 @@
<!DOCTYPE html>
<!-- no-design-system -->
<!-- Дизайн: Стандартный дизайн. Финансовый рабочий экран КФУ. Палитра задана пользователем. -->
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>КФУ — финансовая устойчивость</title>
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="design-system/kt-ai-components.css">
<link rel="stylesheet" href="design-system/kt-ai-page.css">
<link rel="stylesheet" href="design-system/vibe-theme.css">
<script src="design-system/icons/kt-ai-icons.js"></script>
<script src="vendor/xlsx.full.min.js"></script>
<style>
/* Финансовые токены — пользовательские, поверх ДС */
:root {
--kfu-bg: #F4F6F8;
--kfu-surface: #FFFFFF;
--kfu-ink: #1E2430;
--kfu-ink-2: #5A6472;
--kfu-ink-3: #8A93A3;
--kfu-accent: #0D9AAA;
--kfu-accent-hover: #0A7E8C;
--kfu-accent-soft: #E4F4F5;
--kfu-border: #E1E5EA;
--kfu-border-2: #CBD2DB;
--kfu-risk: #C03D32;
--kfu-risk-bg: #FBEAE8;
--kfu-warn: #A5701A;
--kfu-warn-bg: #FDF4E3;
--kfu-ok: #247A4B;
--kfu-ok-bg: #E7F4EE;
--kfu-neutral: #5A6472;
--kfu-neutral-bg: #F0F2F5;
--kfu-radius: 10px;
--kfu-radius-sm: 6px;
--kfu-shadow: 0 1px 3px rgba(30,36,48,.06), 0 1px 2px rgba(30,36,48,.04);
--kfu-shadow-md: 0 4px 16px rgba(30,36,48,.08);
--kfu-max: 1180px;
}
@layer product {
[hidden] { display: none !important; }
*, *::before, *::after { box-sizing: border-box; }
a.kt-ai-btn { text-decoration: none; }
body { background: var(--kfu-bg); }
.kt-ai-sidebar { display: none !important; }
/* ---------- верхняя полоса ---------- */
.kfu-topbar { position: sticky; top: 0; z-index: var(--kt-ai-z-sticky);
background: var(--kfu-surface); border-bottom: 1px solid var(--kfu-border);
height: 56px; display: flex; align-items: center; padding: 0 24px; gap: 16px; }
.kfu-brand { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.kfu-brand-mark { width: 32px; height: 32px; border-radius: 8px; background: var(--kfu-ink);
color: #fff; display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: 700; letter-spacing: .3px; }
.kfu-brand-text { line-height: 1.2; }
.kfu-brand-text b { display: block; font-size: 14px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.01em; }
.kfu-brand-text span { display: block; font-size: 11px; color: var(--kfu-ink-3); }
.kfu-topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.kfu-file-badge { font-size: 12px; color: var(--kfu-ink-2); display: flex; align-items: center; gap: 8px;
background: var(--kfu-neutral-bg); padding: 5px 12px; border-radius: var(--kfu-radius-sm);
max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kfu-file-badge .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--kfu-accent); flex-shrink: 0; }
.kfu-btn { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 14px;
border-radius: var(--kfu-radius-sm); font-size: 13px; font-weight: 500; cursor: pointer;
border: 1px solid var(--kfu-border-2); background: var(--kfu-surface); color: var(--kfu-ink);
transition: background .12s, border-color .12s, box-shadow .12s; white-space: nowrap; }
.kfu-btn:hover { background: var(--kfu-bg); border-color: var(--kfu-accent); }
.kfu-btn:focus-visible { outline: 2px solid var(--kfu-accent); outline-offset: 1px; }
.kfu-btn.primary { background: var(--kfu-accent); color: #fff; border-color: var(--kfu-accent); }
.kfu-btn.primary:hover { background: var(--kfu-accent-hover); }
.kfu-btn svg { width: 15px; height: 15px; flex-shrink: 0; }
/* ---------- контент ---------- */
.kfu-wrap { max-width: var(--kfu-max); margin: 0 auto; padding: 24px 24px 64px; }
/* ---------- заголовок ---------- */
.kfu-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px;
margin-bottom: 20px; flex-wrap: wrap; }
.kfu-header-left { min-width: 0; }
.kfu-header h1 { font-size: 32px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.025em;
line-height: 1.15; margin: 0 0 6px; }
.kfu-header-sub { font-size: 15px; color: var(--kfu-ink-2); line-height: 1.4; margin: 0; }
.kfu-header-sub b { color: var(--kfu-ink); font-weight: 600; }
.kfu-recon-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: var(--kfu-radius-sm); background: var(--kfu-ok-bg); color: var(--kfu-ok);
font-size: 12px; font-weight: 500; white-space: nowrap; flex-shrink: 0; }
.kfu-recon-chip svg { width: 14px; height: 14px; }
/* ---------- сводка (горизонтальная полоса) ---------- */
.kfu-brief { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px;
background: var(--kfu-border); border: 1px solid var(--kfu-border); border-radius: var(--kfu-radius);
overflow: hidden; margin-bottom: 20px; box-shadow: var(--kfu-shadow); }
.kfu-brief-item { background: var(--kfu-surface); padding: 16px 20px; display: flex; align-items: center; gap: 14px; }
.kfu-brief-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.kfu-brief-dot.risk { background: var(--kfu-risk); }
.kfu-brief-dot.warn { background: var(--kfu-warn); }
.kfu-brief-dot.ok { background: var(--kfu-ok); }
.kfu-brief-dot.neutral { background: var(--kfu-neutral); }
.kfu-brief-num { font-size: 28px; font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums;
line-height: 1; color: var(--kfu-ink); }
.kfu-brief-num-b { font-size: 13px; font-weight: 500; color: var(--kfu-ink-3); margin-left: 6px; line-height: 1; }
.kfu-brief-lbl { font-size: 12px; color: var(--kfu-ink-3); margin-top: 2px; }
/* ---------- поиск + фильтры ---------- */
.kfu-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.kfu-search { position: relative; flex: 0 1 300px; min-width: 200px; }
.kfu-search svg { position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 15px; height: 15px; color: var(--kfu-ink-3); pointer-events: none; }
.kfu-search input { box-sizing: border-box; width: 100%; max-width: 100%; height: 34px; padding: 0 12px 0 32px;
border-radius: var(--kfu-radius-sm); border: 1px solid var(--kfu-border-2); background: var(--kfu-surface);
font-size: 13px; color: var(--kfu-ink); transition: border-color .12s, box-shadow .12s; }
.kfu-search input:focus { outline: none; border-color: var(--kfu-accent); box-shadow: 0 0 0 3px var(--kfu-accent-soft); }
.kfu-search input::placeholder { color: var(--kfu-ink-3); }
.kfu-chip-filter { display: inline-flex; align-items: center; height: 32px; padding: 0 14px;
border-radius: var(--kfu-radius-sm); border: 1px solid var(--kfu-border-2); background: var(--kfu-surface);
font-size: 13px; font-weight: 500; color: var(--kfu-ink-2); cursor: pointer;
transition: all .12s; white-space: nowrap; }
.kfu-chip-filter:hover { border-color: var(--kfu-accent); color: var(--kfu-ink); }
.kfu-chip-filter.active { background: var(--kfu-accent); color: #fff; border-color: var(--kfu-accent); }
.kfu-table-count { font-size: 13px; color: var(--kfu-ink-3); margin-left: auto; white-space: nowrap; }
/* ---------- таблица ---------- */
.kfu-table-card { background: var(--kfu-surface); border: 1px solid var(--kfu-border);
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); overflow-x: auto; }
.kfu-table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.kfu-table colgroup col:nth-child(1) { width: 26%; }
.kfu-table colgroup col:nth-child(2) { width: 12%; }
.kfu-table colgroup col:nth-child(3) { width: 10%; }
.kfu-table colgroup col:nth-child(4) { width: 10%; }
.kfu-table colgroup col:nth-child(5) { width: 10%; }
.kfu-table colgroup col:nth-child(6) { width: 32%; }
.kfu-table thead th { padding: 10px 14px; font-size: 11px; font-weight: 600; color: var(--kfu-ink-3);
text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--kfu-border);
background: var(--kfu-bg); text-align: right; white-space: nowrap; cursor: pointer; user-select: none;
position: sticky; top: 0; z-index: 1; }
.kfu-table thead th:last-child { text-align: left; white-space: nowrap; text-transform: none; font-weight: 500; }
.kfu-table thead th:first-child { text-align: left; }
.kfu-table thead th .th-sub { display: block; font-weight: 400; text-transform: none; letter-spacing: 0;
font-size: 10px; color: var(--kfu-ink-3); opacity: .7; margin-top: 1px; }
.kfu-table thead th.sorted-asc::after { content: " ↑"; color: var(--kfu-accent); }
.kfu-table thead th.sorted-desc::after { content: " ↓"; color: var(--kfu-accent); }
.kfu-table tbody tr { cursor: pointer; transition: background .1s; }
.kfu-table tbody tr:hover { background: var(--kfu-bg); }
.kfu-table tbody tr.is-group { background: var(--kfu-neutral-bg); }
.kfu-table tbody tr.is-group:hover { background: #E8EBEF; }
.kfu-table td { padding: 12px 14px; border-bottom: 1px solid var(--kfu-border); font-size: 14px;
text-align: right; font-variant-numeric: tabular-nums; vertical-align: middle; }
.kfu-table td:first-child { text-align: left; }
.kfu-table td:first-child .co-name { font-weight: 500; color: var(--kfu-ink); line-height: 1.3;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; word-break: break-word; }
.kfu-table td:first-child .co-sub { font-size: 11px; color: var(--kfu-ink-3); margin-top: 1px; }
/* метка учебного режима */
.kfu-mock-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px;
border-radius: var(--kfu-radius-sm); background: var(--kfu-warn-bg); color: var(--kfu-warn);
font-size: 12px; font-weight: 600; letter-spacing: .3px; text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
/* статус-метка */
.kfu-status { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 500;
padding: 3px 10px; border-radius: 5px; white-space: nowrap; }
.kfu-status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.kfu-status.s-red { background: var(--kfu-risk-bg); color: var(--kfu-risk); }
.kfu-status.s-yellow { background: var(--kfu-warn-bg); color: var(--kfu-warn); }
.kfu-status.s-green { background: var(--kfu-ok-bg); color: var(--kfu-ok); }
.kfu-status.s-nodefault { background: var(--kfu-neutral-bg); color: var(--kfu-neutral); }
.kfu-status.s-nodata { background: var(--kfu-neutral-bg); color: var(--kfu-neutral); }
/* ячейка коэффициента: число + точка */
.kfu-coef { display: inline-flex; align-items: baseline; gap: 6px; }
.kfu-coef .cv { font-weight: 500; font-variant-numeric: tabular-nums; }
.kfu-coef .cd { width: 6px; height: 6px; border-radius: 50%; align-self: center; flex-shrink: 0; }
.kfu-coef .cd.c-green { background: var(--kfu-ok); }
.kfu-coef .cd.c-yellow { background: var(--kfu-warn); }
.kfu-coef .cd.c-red { background: var(--kfu-risk); }
.kfu-coef .cd.c-nodefault { background: var(--kfu-neutral); }
.kfu-coef .cd.c-nodata { background: var(--kfu-neutral); opacity: .4; }
.kfu-coef .cv.cv-red { color: var(--kfu-risk); }
.kfu-coef .cv.cv-yellow { color: var(--kfu-warn); }
.kfu-coef .cv-none { color: var(--kfu-ink-3); font-size: 13px; }
.kfu-cause { font-size: 13px; color: var(--kfu-ink-2); line-height: 1.45; }
.kfu-table td:last-child { text-align: left; }
.kfu-cause.caution { color: var(--kfu-warn); font-weight: 500; }
.kfu-cause.danger { color: var(--kfu-risk); font-weight: 500; }
.kfu-dash { color: var(--kfu-ink-3); }
/* ---------- блок группы ---------- */
.kfu-group-block { margin-top: 16px; background: var(--kfu-surface); border: 1px solid var(--kfu-border);
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); overflow: hidden; }
.kfu-group-header { padding: 12px 16px; background: var(--kfu-neutral-bg); border-bottom: 1px solid var(--kfu-border);
font-size: 12px; font-weight: 600; color: var(--kfu-ink-2); text-transform: uppercase; letter-spacing: .4px; }
.kfu-group-block .kfu-table thead { display: none; }
.kfu-group-block .kfu-table td:first-child { font-weight: 600; }
/* ---------- диагностика ---------- */
.kfu-diag { margin-top: 20px; background: var(--kfu-surface); border: 1px solid var(--kfu-border);
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); }
.kfu-diag summary { padding: 12px 16px; font-size: 13px; font-weight: 500; color: var(--kfu-ink-2);
cursor: pointer; list-style: none; display: flex; align-items: center; gap: 8px;
font-variant-numeric: tabular-nums; }
.kfu-diag summary::-webkit-details-marker { display: none; }
.kfu-diag summary::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--kfu-warn); }
.kfu-diag[open] summary::before { background: var(--kfu-accent); }
.kfu-diag-list { padding: 0 16px 12px; max-height: 200px; overflow-y: auto; list-style: none; margin: 0; }
.kfu-diag-list li { padding: 6px 0; border-top: 1px solid var(--kfu-border); font-size: 12px;
color: var(--kfu-ink-2); font-family: var(--kt-ai-font-mono); font-variant-numeric: tabular-nums; }
.kfu-diag-list li:first-child { border-top: none; }
/* ---------- первая загрузка ---------- */
.kfu-upload { display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 72px 32px; background: var(--kfu-surface); border: 1px solid var(--kfu-border);
border-radius: var(--kfu-radius); box-shadow: var(--kfu-shadow); }
.kfu-upload-icon { width: 56px; height: 56px; border-radius: 14px; background: var(--kfu-accent-soft);
color: var(--kfu-accent); display: flex; align-items: center; justify-content: center; margin-bottom: 20px; }
.kfu-upload-icon svg { width: 28px; height: 28px; }
.kfu-upload h2 { font-size: 22px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.015em; margin: 0 0 8px; }
.kfu-upload p { font-size: 15px; color: var(--kfu-ink-2); max-width: 400px; line-height: 1.5; margin: 0 0 24px; }
.kfu-upload-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.kfu-upload-note { margin-top: 16px; font-size: 12px; color: var(--kfu-ink-3); }
/* ---------- drawer (карточка компании) ---------- */
.kfu-drawer { position: fixed; top: 0; right: 0; bottom: 0; width: 520px; max-width: 94vw;
background: var(--kfu-surface); border-left: 1px solid var(--kfu-border); box-shadow: var(--kfu-shadow-md);
z-index: 100; padding: 24px; overflow-y: auto; display: none; }
.kfu-drawer.open { display: block; animation: kfuSlide .18s ease-out; }
@keyframes kfuSlide { from { transform: translateX(24px); opacity: .4; } to { transform: none; opacity: 1; } }
.kfu-drawer-head { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
.kfu-drawer-head h2 { font-size: 22px; font-weight: 600; color: var(--kfu-ink); letter-spacing: -.02em;
line-height: 1.2; margin: 0 0 4px; flex: 1; }
.kfu-drawer-head .close-btn { margin-left: auto; flex-shrink: 0; width: 32px; height: 32px;
border-radius: var(--kfu-radius-sm); border: 1px solid var(--kfu-border); background: var(--kfu-surface);
cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--kfu-ink-2); }
.kfu-drawer-head .close-btn:hover { background: var(--kfu-bg); }
.kfu-drawer-head .close-btn svg { width: 16px; height: 16px; }
.kfu-drawer-section { margin-bottom: 20px; }
.kfu-drawer-section h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .6px;
color: var(--kfu-ink-3); margin: 0 0 10px; }
/* коэффициент в drawer */
.kfu-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.kfu-metric { border: 1px solid var(--kfu-border); border-radius: var(--kfu-radius-sm); padding: 12px;
background: var(--kfu-bg); }
.kfu-metric .mlabel { font-size: 11px; font-weight: 600; color: var(--kfu-ink-3);
text-transform: uppercase; letter-spacing: .3px; margin-bottom: 6px; display: flex; align-items: center; gap: 5px; }
.kfu-metric .mlabel .sdot { width: 6px; height: 6px; border-radius: 50%; }
.kfu-metric .mval { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; letter-spacing: -.01em;
color: var(--kfu-ink); }
.kfu-metric .mval.mval-red { color: var(--kfu-risk); }
.kfu-metric .mval.mval-yellow { color: var(--kfu-warn); }
.kfu-metric .mval.mval-none { color: var(--kfu-ink-3); font-size: 14px; font-weight: 500; }
.kfu-metric .mhint { font-size: 11px; color: var(--kfu-ink-3); margin-top: 4px; font-variant-numeric: tabular-nums; }
.kfu-metric .mwhy { font-size: 11px; color: var(--kfu-warn); margin-top: 3px; font-weight: 500; }
.kfu-metric .mformula { font-size: 10px; color: var(--kfu-ink-3); margin-top: 6px;
padding-top: 6px; border-top: 1px solid var(--kfu-border); font-family: var(--kt-ai-font-mono); }
/* исходные данные */
.kfu-sources { width: 100%; border-collapse: collapse; font-size: 13px; }
.kfu-sources td { padding: 7px 0; border-bottom: 1px solid var(--kfu-border); vertical-align: top; }
.kfu-sources td:last-child { text-align: right; font-family: var(--kt-ai-font-mono); font-size: 12px;
color: var(--kfu-ink-2); white-space: nowrap; padding-left: 12px; font-variant-numeric: tabular-nums; }
.kfu-sources .src-addr { font-size: 11px; color: var(--kfu-ink-3); font-family: var(--kt-ai-font-mono); }
/* сценарий */
.kfu-scenario-lbl { font-size: 13px; color: var(--kfu-ink-2); margin-bottom: 8px; line-height: 1.4; }
.kfu-scenario-lbl b { color: var(--kfu-ink); font-weight: 600; font-variant-numeric: tabular-nums; }
.kfu-scenario-row { display: flex; gap: 8px; align-items: stretch; }
.kfu-scenario-row .kfu-input { flex: 1; font-variant-numeric: tabular-nums; font-size: 14px;
height: 38px; border-radius: var(--kfu-radius-sm); padding: 0 12px; border: 1px solid var(--kfu-border-2);
background: var(--kfu-surface); color: var(--kfu-ink); }
.kfu-scenario-row .kfu-input:focus { outline: none; border-color: var(--kfu-accent); box-shadow: 0 0 0 3px var(--kfu-accent-soft); }
.kfu-scenario-hint { font-size: 12px; color: var(--kfu-ink-3); margin-top: 8px; line-height: 1.4; }
/* раскрываемый раздел источников */
.kfu-sources-block summary { font-size: 12px; font-weight: 500; color: var(--kfu-ink-2); cursor: pointer;
padding: 8px 0; list-style: none; display: flex; align-items: center; gap: 6px; }
.kfu-sources-block summary::-webkit-details-marker { display: none; }
.kfu-sources-block summary::after { content: " →"; font-size: 12px; transition: transform .15s; }
.kfu-sources-block[open] summary::after { transform: rotate(90deg); }
/* ---------- toast ---------- */
.kfu-toast-stack { position: fixed; bottom: 20px; right: 20px; z-index: 200; display: flex; flex-direction: column; gap: 8px; }
.kfu-toast { padding: 10px 16px; border-radius: 8px; background: var(--kfu-ink); color: #fff;
font-size: 13px; box-shadow: var(--kfu-shadow-md); display: flex; align-items: center; gap: 8px;
animation: kfuToastIn .15s ease-out; max-width: 400px; }
.kfu-toast.err { background: var(--kfu-risk); }
@keyframes kfuToastIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }
/* ---------- empty state ---------- */
.kfu-empty { padding: 40px 24px; text-align: center; color: var(--kfu-ink-3); font-size: 14px; }
.kfu-empty svg { width: 28px; height: 28px; margin-bottom: 8px; opacity: .4; }
}
</style>
</head>
<body class="kt-ai-app">
<!-- верхняя полоса -->
<header class="kfu-topbar">
<div class="kfu-brand">
<div class="kfu-brand-mark">КФУ</div>
<div class="kfu-brand-text">
<b>Финансовый мониторинг</b>
<span>Факт 2025</span>
</div>
</div>
<div class="kfu-topbar-right">
<span class="kfu-file-badge" id="fileBadge" hidden>
<span class="dot"></span>
<span id="fileBadgeText"></span>
</span>
<button class="kfu-btn" id="btnReplace" hidden>
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
Заменить файл
</button>
<button class="kfu-btn primary" id="btnExport" hidden>
<svg class="kt-icon" aria-hidden="true"><use href="#download"></use></svg>
Выгрузить отчёт
</button>
</div>
</header>
<div class="kfu-wrap">
<!-- заголовок -->
<div class="kfu-header" id="headerBlock">
<div class="kfu-header-left">
<h1>Финансовая устойчивость</h1>
<p class="kfu-header-sub" id="headerSub"></p>
</div>
<div style="display:flex;gap:8px;align-items:center">
<span class="kfu-mock-chip" id="mockChip" hidden>Учебный пример · синтетические данные</span>
<span class="kfu-recon-chip" id="reconChip" hidden>
<svg class="kt-icon" aria-hidden="true"><use href="#check"></use></svg>
<span id="reconChipText"></span>
</span>
</div>
</div>
<!-- сводка -->
<div class="kfu-brief" id="brief" hidden>
<div class="kfu-brief-item">
<span class="kfu-brief-dot risk"></span>
<div><span class="kfu-brief-num" id="briefRed">0</span><div class="kfu-brief-lbl">Нарушения</div></div>
</div>
<div class="kfu-brief-item">
<span class="kfu-brief-dot warn"></span>
<div><span class="kfu-brief-num" id="briefYellow">0</span><div class="kfu-brief-lbl">Под наблюдением</div></div>
</div>
<div class="kfu-brief-item">
<span class="kfu-brief-dot ok"></span>
<div><span class="kfu-brief-num" id="briefGreen">0</span><span class="kfu-brief-num-b" id="briefGreenSub"></span><div class="kfu-brief-lbl">В норме</div></div>
</div>
<div class="kfu-brief-item">
<span class="kfu-brief-dot neutral"></span>
<div><span class="kfu-brief-num" id="briefNoNorm">0</span><div class="kfu-brief-lbl">Без нормативов</div></div>
</div>
</div>
<!-- поиск + фильтры -->
<div class="kfu-toolbar" id="toolbar" hidden>
<div class="kfu-search">
<svg class="kt-icon" aria-hidden="true"><use href="#search"></use></svg>
<input type="text" id="q" placeholder="Поиск по названию…">
</div>
<button class="kfu-chip-filter active" data-zone="" id="zoneAll">Все</button>
<button class="kfu-chip-filter" data-zone="red" id="zoneRed">Нарушения</button>
<button class="kfu-chip-filter" data-zone="yellow" id="zoneYellow">Под наблюдением</button>
<button class="kfu-chip-filter" data-zone="nodefault" id="zoneNoNorm">Без нормативов</button>
<span class="kfu-table-count" id="tableCount"></span>
</div>
<!-- таблица компаний -->
<div class="kfu-table-card" id="tableWrap" hidden>
<table class="kfu-table">
<colgroup><col><col><col><col><col><col></colgroup>
<thead>
<tr>
<th data-k="name">Компания</th>
<th>Статус</th>
<th data-k="k1">К1<span class="th-sub">Debt/EBITDA</span></th>
<th data-k="k2">К2<span class="th-sub">EBITDA/Int</span></th>
<th data-k="k3">К3<span class="th-sub">Debt/Equity</span></th>
<th>Причина</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div class="kfu-empty" id="emptyState" hidden>
<svg class="kt-icon" aria-hidden="true"><use href="#search"></use></svg>
<div>Ничего не найдено. Измените фильтры.</div>
</div>
</div>
<!-- блок группы -->
<div class="kfu-group-block" id="groupBlock" hidden>
<div class="kfu-group-header">Консолидированная группа</div>
<table class="kfu-table" style="border-radius:0">
<colgroup><col><col><col><col><col><col></colgroup>
<tbody id="groupTbody"></tbody>
</table>
</div>
<!-- первая загрузка -->
<div class="kfu-upload" id="uploadPanel">
<div class="kfu-upload-icon">
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
</div>
<h2>Загрузите сводную книгу</h2>
<p>Получите коэффициенты, зоны риска и отчёт в .xlsx</p>
<div class="kfu-upload-actions">
<button class="kfu-btn primary" id="btnUpload" style="height:40px;padding:0 24px;font-size:14px">
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
Загрузить .xlsx
</button>
<button class="kfu-btn" id="btnSample" style="height:40px;padding:0 20px;font-size:14px">
<svg class="kt-icon" aria-hidden="true"><use href="#spreadsheet"></use></svg>
Учебный пример
</button>
</div>
<p style="font-size:12px;color:var(--kfu-ink-3);margin:16px 0 0">Файл обрабатывается в браузере и не отправляется на сервер</p>
</div>
<!-- диагностика -->
<details class="kfu-diag" id="diagBlock" hidden>
<summary id="diagSummary"></summary>
<ul class="kfu-diag-list" id="diagList"></ul>
</details>
</div>
<!-- drawer -->
<aside class="kfu-drawer" id="drawer" role="dialog" aria-modal="false" aria-labelledby="drawerTitle">
<div id="drawerBody"></div>
</aside>
<div class="kfu-toast-stack" id="toasts"></div>
<input type="file" id="fileInput" accept=".xlsx,.xls" style="display:none">
<script src="kfu-client.js"></script>
</body>
</html>

35
package-lock.json generated Normal file
View File

@ -0,0 +1,35 @@
{
"name": "kfu-agent",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kfu-agent",
"dependencies": {
"pdf-parse": "^1.1.1"
}
},
"node_modules/node-ensure": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/node-ensure/-/node-ensure-0.0.0.tgz",
"integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==",
"license": "MIT"
},
"node_modules/pdf-parse": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-1.1.4.tgz",
"integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==",
"license": "MIT",
"dependencies": {
"node-ensure": "^0.0.0"
},
"engines": {
"node": ">=6.8.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
}
}
}

7
package.json Normal file
View File

@ -0,0 +1,7 @@
{
"name": "kfu-agent",
"private": true,
"type": "commonjs",
"scripts": { "start": "node server.js" },
"dependencies": { "pdf-parse": "^1.1.1" }
}

160
server.js Normal file
View File

@ -0,0 +1,160 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = process.env.PORT || 3000;
const MAX_BODY = 40 * 1024 * 1024;
const AI_TIMEOUT_MS = 90000;
const ALLOWED = {
"/": "index.html",
"/index.html": "index.html",
"/monitor.html": "monitor.html",
"/kfu-client.js": "kfu-client.js",
"/vendor/xlsx.full.min.js": "vendor/xlsx.full.min.js",
"/design-system/kt-ai-fonts.css": "design-system/kt-ai-fonts.css",
"/design-system/kt-ai-tokens.css": "design-system/kt-ai-tokens.css",
"/design-system/kt-ai-components.css": "design-system/kt-ai-components.css",
"/design-system/kt-ai-page.css": "design-system/kt-ai-page.css",
"/design-system/kt-ai-print.css": "design-system/kt-ai-print.css",
"/design-system/vibe-theme.css": "design-system/vibe-theme.css",
"/design-system/kt-ai-chart-tip.js": "design-system/kt-ai-chart-tip.js",
"/design-system/kt-ai-composer.js": "design-system/kt-ai-composer.js",
"/design-system/kt-ai-feedback.js": "design-system/kt-ai-feedback.js",
"/design-system/kt-ai-orb.js": "design-system/kt-ai-orb.js",
"/design-system/icons/kt-ai-icons.js": "design-system/icons/kt-ai-icons.js",
"/design-system/icons/kt-ai-lucide-sprite.svg": "design-system/icons/kt-ai-lucide-sprite.svg",
"/design-system/fonts/InterVariable-subset.woff2": "design-system/fonts/InterVariable-subset.woff2",
"/design-system/fonts/JetBrainsMono-subset.woff2": "design-system/fonts/JetBrainsMono-subset.woff2"
};
const MIME = {
".html": "text/html", ".css": "text/css", ".js": "application/javascript",
".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
".ico": "image/x-icon", ".woff2": "font/woff2"
};
function loadPdfParse() {
try { return require("pdf-parse"); } catch (e) {}
if (process.env.NODE_PATH) {
for (const p of String(process.env.NODE_PATH).split(path.delimiter)) {
if (p) { try { return require(path.join(p, "pdf-parse")); } catch (e) {} }
}
}
return null;
}
function readBody(req) {
return new Promise((resolve, reject) => {
let s = "";
req.setEncoding("utf8");
req.on("data", (c) => { s += c; if (s.length > MAX_BODY) { reject(new Error("body too large")); req.destroy(); } });
req.on("end", () => { try { resolve(s ? JSON.parse(s) : {}); } catch (e) { reject(new Error("invalid JSON body")); } });
req.on("error", reject);
});
}
function send(res, code, obj) {
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(obj));
}
// Anthropic blocks -> OpenAI user content parts
async function toOpenAIContent(content, pdfParse) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return JSON.stringify(content);
const parts = [];
for (const b of content) {
if (!b || typeof b !== "object") continue;
if (b.type === "text" && typeof b.text === "string") {
parts.push({ type: "text", text: b.text });
} else if (b.type === "file" && b.file === "image" && b.data) {
parts.push({ type: "image_url", image_url: { url: "data:" + (b.media || "image/png") + ";base64," + b.data } });
} else if (b.type === "file" && b.file === "pdf" && b.data) {
if (!pdfParse) throw new Error("PDF-парсер недоступен на сервере");
const buf = Buffer.from(b.data, "base64");
if (buf.length > 32 * 1024 * 1024) throw new Error("PDF больше 32 МБ");
let t;
try { t = (await pdfParse(buf)).text; } catch (e) { throw new Error("не удалось разобрать PDF: " + (e.message || e)); }
t = String(t || "").trim();
if (!t) throw new Error("из PDF не удалось извлечь текст (скан?) — добавьте фрагменты текстом");
parts.push({ type: "text", text: (b.name ? "PDF «" + b.name + "»:\n" : "PDF:\n") + t.slice(0, 400000) });
}
}
return parts.length ? parts : "";
}
async function handleAI(req, res) {
const body = await readBody(req);
const model = process.env.AI_MODEL;
if (!process.env.AI_BASE_URL || !process.env.AI_API_KEY || !model) {
return send(res, 503, { error: "ИИ не настроен на сервере (нет AI_BASE_URL/AI_API_KEY/AI_MODEL). Перезапустите проект (run), чтобы обновить окружение." });
}
const pdfParse = loadPdfParse();
const out = [];
if (typeof body.system === "string" && body.system) out.push({ role: "system", content: body.system });
const msgs = Array.isArray(body.messages) ? body.messages : [];
if (!msgs.length) return send(res, 400, { error: "нет сообщений" });
for (const m of msgs) {
if (!m || (m.role !== "user" && m.role !== "assistant")) continue;
const c = await toOpenAIContent(m.content, pdfParse);
if (c === "" && m.role === "user") continue;
out.push({ role: m.role, content: c });
}
const maxTok = Math.min(Math.max(parseInt(body.max_tokens, 10) || 1200, 256), 8192);
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), AI_TIMEOUT_MS);
try {
const r = await fetch(String(process.env.AI_BASE_URL).replace(/\/+$/, "") + "/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY },
body: JSON.stringify({ model, messages: out, max_tokens: maxTok, stream: false, chat_template_kwargs: { enable_thinking: false } }),
signal: ctrl.signal
});
const j = await r.json().catch(() => ({}));
if (!r.ok) {
const msg = (j && (j.error && (j.error.message || j.error) || j.message)) || ("сервер ИИ вернул " + r.status);
const msgStr = typeof msg === "string" ? msg : JSON.stringify(msg);
return send(res, 502, { error: "ИИ: " + msgStr.slice(0, 500) });
}
const choice = (j.choices && j.choices[0]) || {};
const text = (choice.message && choice.message.content) || "";
return send(res, 200, { content: String(text), truncated: choice.finish_reason === "length" });
} catch (e) {
const what = e.name === "AbortError" ? "таймаут (90 с) — сервер ИИ не ответил в установленное время; уменьшите объём данных в запросе или повторите чуть позже" : (e.message || String(e));
return send(res, 502, { error: "ИИ недоступен: " + what.slice(0, 300) });
} finally {
clearTimeout(t);
}
}
http.createServer(async (req, res) => {
const url = (req.url || "/").split("?")[0];
try {
if (req.method === "POST" && url === "/api/ai") return await handleAI(req, res);
if ((req.method === "GET" || req.method === "POST") && url === "/api/health") {
return send(res, 200, { ok: true, version: "1.3", ai: !!(process.env.AI_BASE_URL && process.env.AI_API_KEY && process.env.AI_MODEL) });
}
if (req.method === "GET") {
let file = ALLOWED[url];
if (!file && (url === "/" || url.endsWith("/"))) file = "index.html";
if (!file && url.startsWith("/design-system/")) {
let rel = path.normalize(decodeURIComponent(url.replace(/%20/g, " ")));
if (!rel.startsWith("../") && !path.isAbsolute(rel)) file = rel;
}
if (file) {
const full = path.join(__dirname, file);
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
const ext = path.extname(full);
res.writeHead(200, { "Content-Type": (MIME[ext] || "application/octet-stream") + "; charset=utf-8", "Cache-Control": "no-cache" });
return res.end(fs.readFileSync(full));
}
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
return res.end("Not found");
}
res.writeHead(404, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ error: "not found" }));
} catch (e) {
try { send(res, 500, { error: (e && e.message) || "ошибка сервера" }); } catch (_) {}
}
}).listen(PORT, "0.0.0.0", () => console.log("КФУ-агент сервер на порту " + PORT));