diff --git a/index.html b/index.html
index e92e9ea..2df80cd 100644
--- a/index.html
+++ b/index.html
@@ -357,6 +357,11 @@ tr.overall-green td:first-child{box-shadow:inset 4px 0 0 var(--green)}
Зона строки — худшая из трёх; EBITDA ≤ 0 → К1 и К2 в красной зоне независимо от формулы.
+
+ Стресс-тест
+ Стресс-условие: долг вместе с гарантиями +10%, EBITDA −15%, процентные расходы +20%, капитал −5%. Формат: база → стресс (зона); «—» — нет данных, «EBITDA≤0» / «∞» — формула неприменима. Расчёт тот же, что в основной таблице; обновляется при изменении реестра. Агенту передаются те же цифры.
+
+
@@ -471,7 +476,7 @@ function ratios(r){
const k1 = (isNum(e) && e>0) ? debt/e : (isNum(e) ? -1 : null); // -1 => EBITDA<=0
const k2 = (isNum(e) && isNum(i) && i>0) ? e/i : ((isNum(e)&&isNum(i)&&i===0) ? Infinity : null);
const k3 = (isNum(q) && q!==0) ? debt/q : null;
- return {debt, k1, k2, k3, ebNeg: isNum(e) && e<=0, eqNeg: isNum(q) && q<=0};
+ return {debt, k1, k2, k3, ebNeg: isNum(e) && e<=0, eqNeg: isNum(q) && q<=0, e, q, i};
}
// dir 'le' : value must be <= target/threshold ; 'ge' : value must be >= target/threshold
function zone(val, t, p, dir, forceRed){
@@ -501,20 +506,19 @@ function calcRow(r){
const worst = [z1,z2,z3].reduce((a,b)=>ZRANK[b]>ZRANK[a]?b:a,'na');
return {...x, z1,z2,z3, worst, p};
}
-/* Стресс-тест: долг +10%, EBITDA −15%, проценты +20%, капитал −5% (расчёт детерминированный) */
+/* Стресс-тест (та же логика, что используется в расчёте): долг вместе с гарантиями +10%, EBITDA −15%, проценты +20%, капитал −5%.
+ Расчёт — исходный calcRow() на изменённой копии строки; null-значения сохраняются. */
function stressRow(r){
- const debt = ((r.debt||0)+(r.guar||0))*1.10;
- const e = isNum(r.ebitda) ? r.ebitda*0.85 : r.ebitda;
- const i = isNum(r.interest) ? r.interest*1.20 : r.interest;
- const q = isNum(r.equity) ? r.equity*0.95 : r.equity;
- const p = ensureParam(r.key, r.name);
- const z1 = zone(isNum(e)&&e>0?debt/e:(isNum(e)?-1:null), p.t1, p.p1, 'le', isNum(e)&&e<=0);
- const z2 = zone(isNum(e)&&isNum(i)&&i>0?e/i:(isNum(e)&&isNum(i)&&i===0?Infinity:null), p.t2, p.p2, 'ge', isNum(e)&&e<=0);
- const z3 = zone(isNum(q)&&q!==0?debt/q:null, p.t3, p.p3, 'le', isNum(q)&&q<=0);
- const worst=[z1,z2,z3].reduce((a,b)=>ZRANK[b]>ZRANK[a]?b:a,'na');
- return {debt:+debt.toFixed(3), ebitda:isNum(e)?+e.toFixed(3):null, interest:isNum(i)?+i.toFixed(3):null, equity:isNum(q)?+q.toFixed(3):null,
- K1:(isNum(e)&&e>0)?+(debt/e).toFixed(3):(isNum(e)?'EBITDA<=0':null), K2:(isNum(e)&&e<=0)?'EBITDA<=0':(isNum(i)&&i>0?+(e/i).toFixed(3):(isNum(i)&&i===0?'inf':null)),
- K3:(isNum(q)&&q!==0)?+(debt/q).toFixed(3):null, z1,z2,z3, worst};
+ const c = {...r};
+ c.debt = (isNum(r.debt) ? r.debt : 0) * 1.10;
+ if(isNum(r.ebitda)) c.ebitda = r.ebitda * 0.85;
+ if(isNum(r.interest)) c.interest = r.interest * 1.20;
+ if(isNum(r.equity)) c.equity = r.equity * 0.95;
+ const res = calcRow(c);
+ return {...res, debtStress: res.debt==null?null:+res.debt.toFixed(3),
+ ebitdaStress: res.e==null?null:+res.e.toFixed(3),
+ interestStress: res.i==null?null:+res.i.toFixed(3),
+ equityStress: res.q==null?null:+res.q.toFixed(3)};
}
/* ---------- registry ---------- */
@@ -614,6 +618,23 @@ function renderCalc(){
t.innerHTML = h;
$('#kpi').innerHTML = `
${S.registry.length}компаний/периодов
${cnt.green}зелёная зона
${cnt.yellow}жёлтая зона
${cnt.red}красная зона
${cnt.na}без оценки (нет целей/порогов)
`;
}
+function kfmt(v){ if(v==null) return '—'; if(v===Infinity) return '∞'; if(typeof v==='string') return esc(v); return nf2.format(v); }
+function zoneChip(z){ const nm = z==='na' ? 'н/д' : ZNAME[z]; return `${nm}`; }
+function renderStress(){
+ const t = $('#stressTable'); if(!t) return;
+ if(!S.registry.length){ t.innerHTML = '| Нет данных для стресс-теста. |
'; return; }
+ let h = `| Компания | К1 Долг/EBITDA (база → стресс) | Зона стресс | К2 EBITDA/Проценты (база → стресс) | Зона стресс | К3 Долг/Капитал (база → стресс) | Зона стресс | Итоговая зона (стресс) |
`;
+ for(const r of S.registry){
+ const b = calcRow(r), s = stressRow(r);
+ const arrow = (base, stress) => `${kfmt(base)} → ${kfmt(stress)}`;
+ h += `| ${esc(displayName(r.key,r.name))} |
+ ${arrow(b.k1, s.k1)} | ${zoneChip(s.z1)} |
+ ${arrow(b.k2, s.k2)} | ${zoneChip(s.z2)} |
+ ${arrow(b.k3, s.k3)} | ${zoneChip(s.z3)} |
+ ${zoneChip(s.worst)} |
`;
+ }
+ t.innerHTML = h;
+}
function renderPivot(){
const t=$('#pivotTable'); if(!t) return;
const periods=[...new Set(S.registry.map(r=>r.period||''))].sort();
@@ -638,7 +659,7 @@ function renderPivot(){
if(!keys.length) h+='| Нет данных. |
';
t.innerHTML=h;
}
-function renderAll(){ S.registry.sort(sortKeys); if(S.isDemo && !S.registry.some(r=>(r.source||'').startsWith('Демо'))){ S.isDemo=false; } $('#demoBanner').hidden = !S.isDemo; renderRegistry(); renderParams(); renderCalc(); renderPivot(); }
+function renderAll(){ S.registry.sort(sortKeys); if(S.isDemo && !S.registry.some(r=>(r.source||'').startsWith('Демо'))){ S.isDemo=false; } $('#demoBanner').hidden = !S.isDemo; renderRegistry(); renderParams(); renderCalc(); renderPivot(); renderStress(); }
/* ---------- export ---------- */
function calcMatrix(){
@@ -1150,10 +1171,20 @@ $('#btnSuoAllYears').onclick=()=>{
};
/* ===================== AI PROXY (server) ===================== */
-async function callClaude(messages, system){
- const body = { max_tokens:4096, messages };
+async function callClaude(messages, system, opts){
+ opts = opts || {};
+ const mt = opts.max_tokens || 1200;
+ const body = { max_tokens: mt, messages };
if(system) body.system = system;
- const res = await fetch('api/ai', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) });
+ const ctrl = new AbortController();
+ const t = setTimeout(()=>ctrl.abort(), 100000);
+ let res;
+ try {
+ res = await fetch('api/ai', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body), signal:ctrl.signal });
+ } catch(e){
+ if(e.name === 'AbortError') throw new Error('ИИ не ответил за 100 секунд. Проверьте связь с сервером ИИ на платформе; попробуйте повторить запрос позже или уменьшите объём данных.');
+ throw new Error('Нет ответа от сервера приложения: ' + (e.message || e));
+ } finally { clearTimeout(t); }
const data = await res.json().catch(()=>({}));
if(!res.ok || data.error) throw new Error(data.error || ('сервер вернул ' + res.status));
const text = data.content || '';
@@ -1210,7 +1241,7 @@ ${hint?'Указание: '+hint+'\n':''}
Правила: берём последнюю отчётную дату из документа; для баланса — значения на отчётную дату, для ОПиУ — за период с начала года (period_months = число месяцев периода); все суммы — в единицах отчётности (units), расходы и обязательства — положительными числами; если статьи нет — 0, если определить нельзя — null и пояснение во flags; da_and_impairment — износ, амортизация и обесценение за период (из примечаний или ОДДС); в sources — где найдена каждая величина.
Ответь только валидным JSON строго по схеме, без markdown и пояснений:
${EXTRACT_SCHEMA}`});
- const raw = await callClaude([{role:'user', content}], 'Ты — аналитик Департамента корпоративных финансов. Отвечаешь только JSON.');
+ const raw = await callClaude([{role:'user', content}], 'Ты — аналитик Департамента корпоративных финансов. Отвечаешь только JSON.', {max_tokens:4096});
let js; try{ js=parseJson(raw); }catch(e){ throw new Error('ИИ вернул не JSON: '+raw.slice(0,300)); }
S.ifrs.extracted = js; renderIfrsReview(js); st.textContent='Готово — проверьте значения ниже.';
}catch(err){ st.innerHTML=`${esc(err.message)}`; }
@@ -1340,7 +1371,7 @@ $('#btnAiMap').onclick = async () => {
const prompt = `Ниже — наименования строк из выгрузки СУО (SAP BPC). Сопоставь их с показателями методики КФУ. Верни только JSON, значения — точные строки из списков (или null, если строки нет):
{"debt_row":"...", "equity_row":"...", "interest_row":"...", "full_ebitda_row":"...", "items":{"rev":"выручка","sub":"государственные субсидии","cogs":"себестоимость","ga":"общие и административные расходы","sell":"расходы по транспортировке и реализации","assoc":"доля в доходах ассоциированных компаний","jv":"доля в доходах совместных предприятий","da_cogs":"износ и обесценение в себестоимости","da_ga":"износ и обесценение в ОАР","da_sell":"износ и обесценение в расходах по реализации"}}
Строки листа ДОЛГ:\n${dl.join('\n')}\n\nСтроки листа EBITDA (текущий период):\n${el.join('\n')}\n\nСтроки листа Баланс:\n${bl.join('\n')}`;
- const js = parseJson(await callClaude([{role:'user',content:prompt}], 'Отвечай только JSON.'));
+ const js = parseJson(await callClaude([{role:'user',content:prompt}], 'Отвечай только JSON.', {max_tokens:4096}));
const setSel=(id,label)=>{ if(!label) return false; const s=$('#'+id); const o=[...s.options].find(x=>norm(x.text.replace(/^стр\. \d+: /,''))===norm(label)); if(o){ s.value=o.value; return true; } return false; };
let n=0; if(setSel('selDebtRow',js.debt_row)) n++; if(setSel('selEqRow',js.equity_row)) n++; if(setSel('selIntRow',js.interest_row)) n++; if(setSel('selEbRow',js.full_ebitda_row)) n++;
S.suo.itemOverride = {}; for(const [k,v] of Object.entries(js.items||{})){ if(v) { S.suo.itemOverride[k]=v; n++; } }
diff --git a/server.js b/server.js
index 6c8c7d5..ebf36e5 100644
--- a/server.js
+++ b/server.js
@@ -100,14 +100,14 @@ async function handleAI(req, res) {
if (c === "" && m.role === "user") continue;
out.push({ role: m.role, content: c });
}
- const maxTok = Math.min(Math.max(parseInt(body.max_tokens, 10) || 4096, 256), 8192);
+ 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 }),
+ 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(() => ({}));
@@ -120,7 +120,7 @@ async function handleAI(req, res) {
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));
+ const what = e.name === "AbortError" ? "таймаут (90 с) — сервер ИИ не ответил в установленное время; уменьшите объём данных в запросе или повторите чуть позже" : (e.message || String(e));
return send(res, 502, { error: "ИИ недоступен: " + what.slice(0, 300) });
} finally {
clearTimeout(t);