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);