ДОМЕНЫ - ВОПРОСЫ - fix(ui-assistant-chat): render markdown bold, add readable block spacing, force scroll-to-bottom on send + ЮИ

This commit is contained in:
2026-04-12 17:53:31 +03:00
parent a717ea6b26
commit ae9daa50e7
10 changed files with 289 additions and 86 deletions
@@ -81,6 +81,32 @@ function formatMoney(value) {
}
return value.toFixed(2);
}
function formatNumberWithDots(value, fractionDigits = 0) {
if (!Number.isFinite(value)) {
if (fractionDigits > 0) {
return `0,${"0".repeat(fractionDigits)}`;
}
return "0";
}
const sign = value < 0 ? "-" : "";
const absolute = Math.abs(value);
const fixed = absolute.toFixed(Math.max(0, fractionDigits));
const [intPartRaw, fractionPartRaw] = fixed.split(".");
const groupedInt = intPartRaw.replace(/\B(?=(\d{3})+(?!\d))/g, ".");
if (fractionDigits <= 0) {
return `${sign}${groupedInt}`;
}
return `${sign}${groupedInt},${fractionPartRaw ?? "0".repeat(fractionDigits)}`;
}
function formatMoneyRub(value) {
return `${formatNumberWithDots(value, 2)}`;
}
function emphasizeNumericTokens(line) {
if (!line) {
return line;
}
return line.replace(/(?<!\*)\d(?:[\d.,:/-]*\d)?(?!\*)/g, (token) => `**${token}**`);
}
function parseIsoDateToken(value) {
const source = String(value ?? "").trim();
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
@@ -1891,6 +1917,7 @@ function composeFactualReply(intent, rows, options = {}) {
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const totalOutstandingAmount = confirmedBalances.reduce((sum, item) => sum + item.outstandingAmount, 0);
const periodScopeLine = !asOfDate && (periodFrom || periodTo)
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
@@ -1902,9 +1929,10 @@ function composeFactualReply(intent, rows, options = {}) {
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const lines = [
`Итого подтвержденный долг на ${formatDateRu(payablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`,
"",
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате (exact route).",
"- Эвристический shortlist в этом режиме не используется."
"- Режим результата: подтвержденный срез обязательств к оплате (exact route)."
];
lines.push("");
lines.push("Блок 2. Что учтено");
@@ -1918,25 +1946,25 @@ function composeFactualReply(intent, rows, options = {}) {
}
lines.push("");
lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${rows.length}.`);
lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`);
lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`);
lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${formatNumberWithDots(confirmedBalances.length)}.`);
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}.`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}.`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}.`);
lines.push("");
lines.push("Блок 5. Подтвержденные позиции к оплате");
if (confirmedBalances.length > 0) {
lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`));
lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`));
}
else {
lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено.");
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.join("\n"),
text: lines.map(emphasizeNumericTokens).join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@@ -1958,7 +1986,7 @@ function composeFactualReply(intent, rows, options = {}) {
const carryoverLine = asOfDate || periodFrom || periodTo
? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза."
: null;
const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма к проверке: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (lines, title, items, limit) => {
if (items.length === 0) {
return;
@@ -1971,9 +1999,9 @@ function composeFactualReply(intent, rows, options = {}) {
const lines = [
"Блок 1. Статус результата",
forcedFallbackFromConfirmed
? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
? "- Точный реестр обязательств сейчас недоступен, поэтому показан предварительный список на проверку."
: "- Формат результата: предварительный список на проверку.",
"- Это рабочий список для проверки, а не финальный платежный реестр.",
"",
"Блок 2. Как читать результат",
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
@@ -1982,8 +2010,8 @@ function composeFactualReply(intent, rows, options = {}) {
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов-кандидатов: ${counterparties.length}.`
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Контрагентов-кандидатов: ${formatNumberWithDots(counterparties.length)}.`
];
if (counterparties.length > 0) {
const categoryCounts = counterparties.reduce((acc, item) => {
@@ -1996,10 +2024,10 @@ function composeFactualReply(intent, rows, options = {}) {
const other = counterparties.filter((item) => item.category === "other");
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}`);
lines.push("");
lines.push("Блок 5. Кандидаты на проверку в первую очередь");
pushCategorySlice(lines, "5.1 Поставщики/подрядчики:", suppliers, 6);
@@ -2027,7 +2055,10 @@ function composeFactualReply(intent, rows, options = {}) {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const totalOutstandingAmount = confirmedBalances.reduce((sum, item) => sum + item.outstandingAmount, 0);
const lines = [
`Итого подтвержденный долг на ${formatDateRu(payablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`,
"",
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.",
"- Тип результата: подтвержденные остатки к оплате.",
@@ -2041,21 +2072,21 @@ function composeFactualReply(intent, rows, options = {}) {
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов с подтвержденным остатком: ${confirmedBalances.length}.`,
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Контрагентов с подтвержденным остатком: ${formatNumberWithDots(confirmedBalances.length)}.`,
"",
"Блок 4. Категории обязательств",
`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`,
`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`,
`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`,
`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`,
`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}`,
`- ${liabilityCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}`,
`- ${liabilityCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}`,
`- ${liabilityCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}`,
"",
"Блок 5. Крупнейшие подтвержденные позиции к оплате (по сумме остатка):",
...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`)
...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoneyRub(item.outstandingAmount)} | операций в срезе: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n"),
text: lines.map(emphasizeNumericTokens).join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@@ -2066,7 +2097,7 @@ function composeFactualReply(intent, rows, options = {}) {
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.join("\n"),
text: fallbackLines.map(emphasizeNumericTokens).join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
@@ -2077,7 +2108,7 @@ function composeFactualReply(intent, rows, options = {}) {
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n"),
text: lines.map(emphasizeNumericTokens).join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",