v1.3.2: ИИ — stream:false+enable_thinking:false в upstream, max_tokens дефолт 1200 (извлечение 4096), клиент AbortController 100с; стресс-тест — сворачиваемая таблица на шаге 3 (расчёт через тот же calcRow на изменённой копии)

This commit is contained in:
Moldabayev_d 2026-09-21 09:46:31 +00:00
parent 70585ea058
commit f1cacb5c73
2 changed files with 54 additions and 23 deletions

View File

@ -357,6 +357,11 @@ tr.overall-green td:first-child{box-shadow:inset 4px 0 0 var(--green)}
<span class="small muted">Зона строки — худшая из трёх; EBITDA ≤ 0 → К1 и К2 в красной зоне независимо от формулы.</span> <span class="small muted">Зона строки — худшая из трёх; EBITDA ≤ 0 → К1 и К2 в красной зоне независимо от формулы.</span>
</div> </div>
</div> </div>
<details class="panel" id="stressPanel">
<summary style="cursor:pointer"><h2 style="display:inline">Стресс-тест</h2></summary>
<p class="small muted">Стресс-условие: долг вместе с гарантиями +10%, EBITDA −15%, процентные расходы +20%, капитал −5%. Формат: база → стресс (зона); «—» — нет данных, «EBITDA≤0» / «∞» — формула неприменима. Расчёт тот же, что в основной таблице; обновляется при изменении реестра. Агенту передаются те же цифры.</p>
<div class="tbl-wrap"><table id="stressTable"></table></div>
</details>
</section> </section>
<!-- ============ STEP 4: AGENT ============ --> <!-- ============ STEP 4: AGENT ============ -->
@ -471,7 +476,7 @@ function ratios(r){
const k1 = (isNum(e) && e>0) ? debt/e : (isNum(e) ? -1 : null); // -1 => EBITDA<=0 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 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; 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 // dir 'le' : value must be <= target/threshold ; 'ge' : value must be >= target/threshold
function zone(val, t, p, dir, forceRed){ 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'); const worst = [z1,z2,z3].reduce((a,b)=>ZRANK[b]>ZRANK[a]?b:a,'na');
return {...x, z1,z2,z3, worst, p}; return {...x, z1,z2,z3, worst, p};
} }
/* Стресс-тест: долг +10%, EBITDA −15%, проценты +20%, капитал −5% (расчёт детерминированный) */ /* Стресс-тест (та же логика, что используется в расчёте): долг вместе с гарантиями +10%, EBITDA −15%, проценты +20%, капитал −5%.
Расчёт — исходный calcRow() на изменённой копии строки; null-значения сохраняются. */
function stressRow(r){ function stressRow(r){
const debt = ((r.debt||0)+(r.guar||0))*1.10; const c = {...r};
const e = isNum(r.ebitda) ? r.ebitda*0.85 : r.ebitda; c.debt = (isNum(r.debt) ? r.debt : 0) * 1.10;
const i = isNum(r.interest) ? r.interest*1.20 : r.interest; if(isNum(r.ebitda)) c.ebitda = r.ebitda * 0.85;
const q = isNum(r.equity) ? r.equity*0.95 : r.equity; if(isNum(r.interest)) c.interest = r.interest * 1.20;
const p = ensureParam(r.key, r.name); if(isNum(r.equity)) c.equity = r.equity * 0.95;
const z1 = zone(isNum(e)&&e>0?debt/e:(isNum(e)?-1:null), p.t1, p.p1, 'le', isNum(e)&&e<=0); const res = calcRow(c);
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); return {...res, debtStress: res.debt==null?null:+res.debt.toFixed(3),
const z3 = zone(isNum(q)&&q!==0?debt/q:null, p.t3, p.p3, 'le', isNum(q)&&q<=0); ebitdaStress: res.e==null?null:+res.e.toFixed(3),
const worst=[z1,z2,z3].reduce((a,b)=>ZRANK[b]>ZRANK[a]?b:a,'na'); interestStress: res.i==null?null:+res.i.toFixed(3),
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, equityStress: res.q==null?null:+res.q.toFixed(3)};
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};
} }
/* ---------- registry ---------- */ /* ---------- registry ---------- */
@ -614,6 +618,23 @@ function renderCalc(){
t.innerHTML = h; t.innerHTML = h;
$('#kpi').innerHTML = `<div><b>${S.registry.length}</b><span>компаний/периодов</span></div><div class="green"><b>${cnt.green}</b><span>зелёная зона</span></div><div class="yellow"><b>${cnt.yellow}</b><span>жёлтая зона</span></div><div class="red"><b>${cnt.red}</b><span>красная зона</span></div><div><b>${cnt.na}</b><span>без оценки (нет целей/порогов)</span></div>`; $('#kpi').innerHTML = `<div><b>${S.registry.length}</b><span>компаний/периодов</span></div><div class="green"><b>${cnt.green}</b><span>зелёная зона</span></div><div class="yellow"><b>${cnt.yellow}</b><span>жёлтая зона</span></div><div class="red"><b>${cnt.red}</b><span>красная зона</span></div><div><b>${cnt.na}</b><span>без оценки (нет целей/порогов)</span></div>`;
} }
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 `<span class="zone ${z}">${nm}</span>`; }
function renderStress(){
const t = $('#stressTable'); if(!t) return;
if(!S.registry.length){ t.innerHTML = '<tr><td class="muted">Нет данных для стресс-теста.</td></tr>'; return; }
let h = `<tr><th>Компания</th><th class="num">К1 Долг/EBITDA (база → стресс)</th><th>Зона стресс</th><th class="num">К2 EBITDA/Проценты (база → стресс)</th><th>Зона стресс</th><th class="num">К3 Долг/Капитал (база → стресс)</th><th>Зона стресс</th><th>Итоговая зона (стресс)</th></tr>`;
for(const r of S.registry){
const b = calcRow(r), s = stressRow(r);
const arrow = (base, stress) => `${kfmt(base)} → <b>${kfmt(stress)}</b>`;
h += `<tr class="overall-${s.worst}"><td><b>${esc(displayName(r.key,r.name))}</b></td>
<td class="num">${arrow(b.k1, s.k1)}</td><td>${zoneChip(s.z1)}</td>
<td class="num">${arrow(b.k2, s.k2)}</td><td>${zoneChip(s.z2)}</td>
<td class="num">${arrow(b.k3, s.k3)}</td><td>${zoneChip(s.z3)}</td>
<td>${zoneChip(s.worst)}</td></tr>`;
}
t.innerHTML = h;
}
function renderPivot(){ function renderPivot(){
const t=$('#pivotTable'); if(!t) return; const t=$('#pivotTable'); if(!t) return;
const periods=[...new Set(S.registry.map(r=>r.period||''))].sort(); const periods=[...new Set(S.registry.map(r=>r.period||''))].sort();
@ -638,7 +659,7 @@ function renderPivot(){
if(!keys.length) h+='<tr><td class="muted">Нет данных.</td></tr>'; if(!keys.length) h+='<tr><td class="muted">Нет данных.</td></tr>';
t.innerHTML=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 ---------- */ /* ---------- export ---------- */
function calcMatrix(){ function calcMatrix(){
@ -1150,10 +1171,20 @@ $('#btnSuoAllYears').onclick=()=>{
}; };
/* ===================== AI PROXY (server) ===================== */ /* ===================== AI PROXY (server) ===================== */
async function callClaude(messages, system){ async function callClaude(messages, system, opts){
const body = { max_tokens:4096, messages }; opts = opts || {};
const mt = opts.max_tokens || 1200;
const body = { max_tokens: mt, messages };
if(system) body.system = system; 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(()=>({})); const data = await res.json().catch(()=>({}));
if(!res.ok || data.error) throw new Error(data.error || ('сервер вернул ' + res.status)); if(!res.ok || data.error) throw new Error(data.error || ('сервер вернул ' + res.status));
const text = data.content || ''; const text = data.content || '';
@ -1210,7 +1241,7 @@ ${hint?'Указание: '+hint+'\n':''}
Правила: берём последнюю отчётную дату из документа; для баланса — значения на отчётную дату, для ОПиУ — за период с начала года (period_months = число месяцев периода); все суммы — в единицах отчётности (units), расходы и обязательства — положительными числами; если статьи нет — 0, если определить нельзя — null и пояснение во flags; da_and_impairment — износ, амортизация и обесценение за период (из примечаний или ОДДС); в sources — где найдена каждая величина. Правила: берём последнюю отчётную дату из документа; для баланса — значения на отчётную дату, для ОПиУ — за период с начала года (period_months = число месяцев периода); все суммы — в единицах отчётности (units), расходы и обязательства — положительными числами; если статьи нет — 0, если определить нельзя — null и пояснение во flags; da_and_impairment — износ, амортизация и обесценение за период (из примечаний или ОДДС); в sources — где найдена каждая величина.
Ответь только валидным JSON строго по схеме, без markdown и пояснений: Ответь только валидным JSON строго по схеме, без markdown и пояснений:
${EXTRACT_SCHEMA}`}); ${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)); } let js; try{ js=parseJson(raw); }catch(e){ throw new Error('ИИ вернул не JSON: '+raw.slice(0,300)); }
S.ifrs.extracted = js; renderIfrsReview(js); st.textContent='Готово — проверьте значения ниже.'; S.ifrs.extracted = js; renderIfrsReview(js); st.textContent='Готово — проверьте значения ниже.';
}catch(err){ st.innerHTML=`<span style="color:var(--red)">${esc(err.message)}</span>`; } }catch(err){ st.innerHTML=`<span style="color:var(--red)">${esc(err.message)}</span>`; }
@ -1340,7 +1371,7 @@ $('#btnAiMap').onclick = async () => {
const prompt = `Ниже — наименования строк из выгрузки СУО (SAP BPC). Сопоставь их с показателями методики КФУ. Верни только JSON, значения — точные строки из списков (или null, если строки нет): 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":"износ и обесценение в расходах по реализации"}} {"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')}`; Строки листа ДОЛГ:\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; }; 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++; 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++; } } S.suo.itemOverride = {}; for(const [k,v] of Object.entries(js.items||{})){ if(v) { S.suo.itemOverride[k]=v; n++; } }

View File

@ -100,14 +100,14 @@ async function handleAI(req, res) {
if (c === "" && m.role === "user") continue; if (c === "" && m.role === "user") continue;
out.push({ role: m.role, content: c }); 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 ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), AI_TIMEOUT_MS); const t = setTimeout(() => ctrl.abort(), AI_TIMEOUT_MS);
try { try {
const r = await fetch(String(process.env.AI_BASE_URL).replace(/\/+$/, "") + "/chat/completions", { const r = await fetch(String(process.env.AI_BASE_URL).replace(/\/+$/, "") + "/chat/completions", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AI_API_KEY }, 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 signal: ctrl.signal
}); });
const j = await r.json().catch(() => ({})); const j = await r.json().catch(() => ({}));
@ -120,7 +120,7 @@ async function handleAI(req, res) {
const text = (choice.message && choice.message.content) || ""; const text = (choice.message && choice.message.content) || "";
return send(res, 200, { content: String(text), truncated: choice.finish_reason === "length" }); return send(res, 200, { content: String(text), truncated: choice.finish_reason === "length" });
} catch (e) { } 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) }); return send(res, 502, { error: "ИИ недоступен: " + what.slice(0, 300) });
} finally { } finally {
clearTimeout(t); clearTimeout(t);