ДОМЕНЫ - ВОПРОСЫ - fix(ui-assistant-chat): render markdown bold, add readable block spacing, force scroll-to-bottom on send + ЮИ
This commit is contained in:
parent
a717ea6b26
commit
ae9daa50e7
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,35 @@ function formatMoney(value: number): string {
|
|||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatNumberWithDots(value: number, fractionDigits = 0): string {
|
||||
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: number): string {
|
||||
return `${formatNumberWithDots(value, 2)} ₽`;
|
||||
}
|
||||
|
||||
function emphasizeNumericTokens(line: string): string {
|
||||
if (!line) {
|
||||
return line;
|
||||
}
|
||||
return line.replace(/(?<!\*)\d(?:[\d.,:/-]*\d)?(?!\*)/g, (token) => `**${token}**`);
|
||||
}
|
||||
|
||||
function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null {
|
||||
const source = String(value ?? "").trim();
|
||||
const match = source.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
|
|
@ -2412,6 +2441,7 @@ export function composeFactualReply(
|
|||
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 ?? "...")}.`
|
||||
|
|
@ -2429,9 +2459,10 @@ export function composeFactualReply(
|
|||
);
|
||||
|
||||
const lines: string[] = [
|
||||
`Итого подтвержденный долг на ${formatDateRu(payablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`,
|
||||
"",
|
||||
"Блок 1. Статус результата",
|
||||
"- Режим результата: подтвержденный срез обязательств к оплате (exact route).",
|
||||
"- Эвристический shortlist в этом режиме не используется."
|
||||
"- Режим результата: подтвержденный срез обязательств к оплате (exact route)."
|
||||
];
|
||||
|
||||
lines.push("");
|
||||
|
|
@ -2447,15 +2478,15 @@ export function composeFactualReply(
|
|||
|
||||
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. Подтвержденные позиции к оплате");
|
||||
|
|
@ -2463,7 +2494,7 @@ export function composeFactualReply(
|
|||
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)}`
|
||||
`${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 {
|
||||
|
|
@ -2472,7 +2503,7 @@ export function composeFactualReply(
|
|||
|
||||
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",
|
||||
|
|
@ -2498,7 +2529,7 @@ export function composeFactualReply(
|
|||
: null;
|
||||
|
||||
const formatHeuristicItem = (item: PayablesCounterpartyRiskAggregate, index: number): string =>
|
||||
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
|
||||
`${index + 1}. ${item.name} | сумма к проверке: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
|
||||
|
||||
const pushCategorySlice = (
|
||||
lines: string[],
|
||||
|
|
@ -2518,9 +2549,9 @@ export function composeFactualReply(
|
|||
const lines = [
|
||||
"Блок 1. Статус результата",
|
||||
forcedFallbackFromConfirmed
|
||||
? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
|
||||
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
|
||||
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
|
||||
? "- Точный реестр обязательств сейчас недоступен, поэтому показан предварительный список на проверку."
|
||||
: "- Формат результата: предварительный список на проверку.",
|
||||
"- Это рабочий список для проверки, а не финальный платежный реестр.",
|
||||
"",
|
||||
"Блок 2. Как читать результат",
|
||||
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
|
||||
|
|
@ -2529,8 +2560,8 @@ export function composeFactualReply(
|
|||
...(carryoverLine ? [carryoverLine] : []),
|
||||
"",
|
||||
"Блок 3. Сводка выборки",
|
||||
`- Строк в выборке: ${rows.length}.`,
|
||||
`- Контрагентов-кандидатов: ${counterparties.length}.`
|
||||
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
|
||||
`- Контрагентов-кандидатов: ${formatNumberWithDots(counterparties.length)}.`
|
||||
];
|
||||
|
||||
if (counterparties.length > 0) {
|
||||
|
|
@ -2548,10 +2579,10 @@ export function composeFactualReply(
|
|||
|
||||
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. Кандидаты на проверку в первую очередь");
|
||||
|
|
@ -2585,7 +2616,10 @@ export function composeFactualReply(
|
|||
},
|
||||
{ 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: string[] = [
|
||||
`Итого подтвержденный долг на ${formatDateRu(payablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`,
|
||||
"",
|
||||
"Блок 1. Статус результата",
|
||||
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.",
|
||||
"- Тип результата: подтвержденные остатки к оплате.",
|
||||
|
|
@ -2599,24 +2633,24 @@ export function composeFactualReply(
|
|||
...(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)}`
|
||||
`${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",
|
||||
|
|
@ -2628,7 +2662,7 @@ export function composeFactualReply(
|
|||
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",
|
||||
|
|
@ -2640,7 +2674,7 @@ export function composeFactualReply(
|
|||
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",
|
||||
|
|
|
|||
|
|
@ -2544,11 +2544,12 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
|
|||
expect(reply.toLowerCase()).toContain("shortlist");
|
||||
} else {
|
||||
expect(result?.debug.balance_confirmed).toBe(true);
|
||||
expect(reply).toContain("Блок 1. Статус результата");
|
||||
expect(reply).toContain("\n\nБлок 2. Что учтено");
|
||||
expect(reply).toContain("\n\nБлок 3. Сводка");
|
||||
expect(reply).toContain("\n\nБлок 4. Категории обязательств");
|
||||
expect(reply).toContain("\n\nБлок 5. Подтвержденные позиции к оплате");
|
||||
expect(reply).toContain("Итого подтвержденный долг");
|
||||
expect(reply).toMatch(/Блок\s+(?:\*\*1\*\*|1)\.\s+Статус результата/u);
|
||||
expect(reply).toMatch(/\n\nБлок\s+(?:\*\*2\*\*|2)\.\s+Что учтено/u);
|
||||
expect(reply).toMatch(/\n\nБлок\s+(?:\*\*3\*\*|3)\.\s+Сводка/u);
|
||||
expect(reply).toMatch(/\n\nБлок\s+(?:\*\*4\*\*|4)\.\s+Категории обязательств/u);
|
||||
expect(reply).toMatch(/\n\nБлок\s+(?:\*\*5\*\*|5)\.\s+Подтвержденные позиции к оплате/u);
|
||||
expect(reply).not.toContain("эвристический");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -4,8 +4,8 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NDC AI Normalizer Playground</title>
|
||||
<script type="module" crossorigin src="/assets/index-CVIU9teH.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D4dtBq8A.css">
|
||||
<script type="module" crossorigin src="/assets/index-CEJTyo9A.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cx2J_KsF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,95 @@ function CommentBubbleIcon({ commented }: { commented: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
function normalizeAssistantMessageText(raw: string): string {
|
||||
return raw
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/([^\n])\s+(Блок\s+\d+\.)/gi, "$1\n\n$2")
|
||||
.replace(/([^\n])\s+(\d+\.\s)/g, "$1\n$2");
|
||||
}
|
||||
|
||||
function splitAssistantMessageBlocks(text: string): string[] {
|
||||
const normalized = normalizeAssistantMessageText(text);
|
||||
const lines = normalized.split("\n");
|
||||
const blocks: string[] = [];
|
||||
let current: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
if (current.length === 0) return;
|
||||
blocks.push(current.join("\n"));
|
||||
current = [];
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
const isBlockHeading = /^Блок\s+\d+\./i.test(trimmed);
|
||||
const isNumberedItem = /^\d+\.\s/.test(trimmed);
|
||||
if ((isBlockHeading || isNumberedItem) && current.length > 0) {
|
||||
flush();
|
||||
}
|
||||
|
||||
current.push(line);
|
||||
}
|
||||
|
||||
flush();
|
||||
return blocks.length > 0 ? blocks : [text];
|
||||
}
|
||||
|
||||
function renderInlineBold(text: string, keyPrefix: string): JSX.Element[] {
|
||||
const result: JSX.Element[] = [];
|
||||
const regex = /\*\*(.+?)\*\*/g;
|
||||
let lastIndex = 0;
|
||||
let partIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
result.push(<span key={`${keyPrefix}-t-${partIndex}`}>{text.slice(lastIndex, match.index)}</span>);
|
||||
partIndex += 1;
|
||||
}
|
||||
|
||||
result.push(<strong key={`${keyPrefix}-b-${partIndex}`}>{match[1]}</strong>);
|
||||
partIndex += 1;
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
result.push(<span key={`${keyPrefix}-t-${partIndex}`}>{text.slice(lastIndex)}</span>);
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : [<span key={`${keyPrefix}-raw`}>{text}</span>];
|
||||
}
|
||||
|
||||
function lineClassName(line: string): string {
|
||||
const trimmed = line.trimStart();
|
||||
if (/^Блок\s+\d+\./i.test(trimmed)) return "assistant-msg-line heading";
|
||||
if (/^\d+\.\s/.test(trimmed)) return "assistant-msg-line numbered";
|
||||
if (/^-\s/.test(trimmed)) return "assistant-msg-line bullet";
|
||||
return "assistant-msg-line";
|
||||
}
|
||||
|
||||
function renderAssistantMessageBody(text: string): JSX.Element[] {
|
||||
const blocks = splitAssistantMessageBlocks(text);
|
||||
return blocks.map((block, blockIndex) => {
|
||||
const lines = block.split("\n");
|
||||
return (
|
||||
<div key={`block-${blockIndex}`} className="assistant-msg-block">
|
||||
{lines.map((line, lineIndex) => (
|
||||
<p key={`line-${blockIndex}-${lineIndex}`} className={lineClassName(line)}>
|
||||
{renderInlineBold(line, `line-${blockIndex}-${lineIndex}`)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function AssistantPanel({
|
||||
sessionId,
|
||||
conversation,
|
||||
|
|
@ -97,9 +186,17 @@ export function AssistantPanel({
|
|||
const [copyState, setCopyState] = useState<"idle" | "success" | "error">("idle");
|
||||
const [copyModeLabel, setCopyModeLabel] = useState<"чат" | "тех">("чат");
|
||||
|
||||
function scrollChatToBottom(forceStick = false): void {
|
||||
if (!listRef.current) return;
|
||||
if (forceStick) {
|
||||
stickToBottomRef.current = true;
|
||||
}
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current && stickToBottomRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
if (stickToBottomRef.current) {
|
||||
scrollChatToBottom();
|
||||
}
|
||||
}, [conversation]);
|
||||
|
||||
|
|
@ -219,7 +316,7 @@ export function AssistantPanel({
|
|||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
<div className="assistant-msg-body">{item.text}</div>
|
||||
<div className="assistant-msg-body">{renderAssistantMessageBody(item.text)}</div>
|
||||
{item.role === "assistant" && item.debug ? (
|
||||
<details className="assistant-debug">
|
||||
<summary>Показать технический разбор</summary>
|
||||
|
|
@ -247,7 +344,15 @@ export function AssistantPanel({
|
|||
<input type="checkbox" checked={useMock} onChange={(event) => onUseMockChange(event.target.checked)} />
|
||||
Mock-режим
|
||||
</label>
|
||||
<button type="button" className="assistant-send-btn" onClick={() => onSend()} disabled={busy || !inputValue.trim()}>
|
||||
<button
|
||||
type="button"
|
||||
className="assistant-send-btn"
|
||||
onClick={() => {
|
||||
scrollChatToBottom(true);
|
||||
void onSend();
|
||||
}}
|
||||
disabled={busy || !inputValue.trim()}
|
||||
>
|
||||
{busy ? "Выполняю..." : "Отправить"}
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -548,11 +548,36 @@ button:disabled {
|
|||
}
|
||||
|
||||
.assistant-msg-body {
|
||||
white-space: pre-wrap;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
line-height: 1.35;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.assistant-msg-block {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.assistant-msg-line {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.assistant-msg-line.heading {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.assistant-msg-line.numbered {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.assistant-msg-line strong {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.assistant-trace {
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted);
|
||||
|
|
|
|||
Loading…
Reference in New Issue