v1.3.2: ИИ — stream:false+enable_thinking:false в upstream, max_tokens дефолт 1200 (извлечение 4096), клиент AbortController 100с; стресс-тест — сворачиваемая таблица на шаге 3 (расчёт через тот же calcRow на изменённой копии)
This commit is contained in:
parent
70585ea058
commit
f1cacb5c73
71
index.html
71
index.html
@ -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++; } }
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user