Усилить answer contract и агентный аудит для phase105

This commit is contained in:
2026-05-21 09:00:08 +03:00
parent 9c86407937
commit bbc257fd6c
34 changed files with 1533 additions and 184 deletions
@@ -365,6 +365,63 @@ function bankOperationEvidenceLine(rows, preferredDirection = null) {
}
return `Основание 1С: ${parts.join("; ")}.`;
}
function classifyBankOperationSemanticBucket(row) {
const text = [
row.registrator,
row.operation_kind,
row.payment_purpose,
row.contract,
row.comment
]
.map((item) => String(item ?? "").toLowerCase())
.join(" ");
if (/(?:комисс|тариф|эквайр|обслуживан)/iu.test(text)) {
return "commission";
}
if (/(?:депозит|кредит|займ|овердрафт|процент|ссуд)/iu.test(text)) {
return "deposit_or_credit";
}
if (/(?:налог|ндс|взнос|бюджет|фнс|пфр|страхов)/iu.test(text)) {
return "tax_or_budget";
}
if (/(?:возврат|перевод|перечислен|переброс|пополн|инкасс|перенос)/iu.test(text)) {
return "transfer_or_return";
}
return "other";
}
function bankOperationSemanticBucketLabel(bucket) {
if (bucket === "commission") {
return "комиссии и банковое обслуживание";
}
if (bucket === "deposit_or_credit") {
return "депозиты, кредиты или проценты";
}
if (bucket === "tax_or_budget") {
return "налоги и бюджетные платежи";
}
if (bucket === "transfer_or_return") {
return "переводы, возвраты или перебросы";
}
return "прочие банковские операции";
}
function summarizeBankOperationSemantics(rows) {
if (rows.length === 0) {
return null;
}
const counts = new Map();
for (const row of rows) {
const bucket = classifyBankOperationSemanticBucket(row);
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
}
const ranked = Array.from(counts.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 3);
if (ranked.length === 0) {
return null;
}
const parts = ranked.map(([bucket, count]) => `${bankOperationSemanticBucketLabel(bucket)} — ${count}`);
return `По смыслу это скорее финансовый/банковский контур: ${parts.join("; ")}.`;
}
function bankRoleBoundaryLine(userMessage, rows) {
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
@@ -3931,13 +3988,31 @@ function composeFactualReplyBody(intent, rows, options = {}) {
.filter((item) => Boolean(item)));
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
const visibleRows = rows.slice(0, Math.min(rows.length, 5));
const visibleRows = [...rows]
.sort((left, right) => Math.abs(right.amount ?? 0) - Math.abs(left.amount ?? 0) ||
(String(right.period ?? "").localeCompare(String(left.period ?? ""), "ru")))
.slice(0, Math.min(rows.length, 5));
const semanticSummary = summarizeBankOperationSemantics(rows);
const compactEvidenceRows = visibleRows.map((row, index) => {
const direction = bankOperationDirectionLabel(bankOperationDirection(row));
const amount = formatMoneyRub(row.amount ?? 0);
const period = row.period ? formatDateRu(row.period) : "дата не указана";
const operationKind = String(row.operation_kind ?? "").trim();
const paymentPurpose = String(row.payment_purpose ?? "").trim();
const detail = operationKind || paymentPurpose
? ` | ${[operationKind, paymentPurpose].filter(Boolean).join("; ")}`
: "";
return `${index + 1}. ${period} | ${direction} | ${amount}${detail}`;
});
const lines = [
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"} — ${rows.length}.`,
summarizeBankOperationDirections(rows),
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
...formatTopRows(visibleRows, visibleRows.length)
...(semanticSummary ? [semanticSummary] : []),
"Примеры строк 1С:",
...compactEvidenceRows,
"Следующий шаг: могу отдельно разложить назначения платежа, договоры или отделить банковский контур от клиентского/поставщицкого."
];
if (rows.length > visibleRows.length) {
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length}; полный список остается в подтвержденном срезе.`);
@@ -30,6 +30,23 @@ function findFocusedCounterpartyValuePoint(profileRows, counterpartyHint, deps)
}
return profileRows.length === 1 ? profileRows[0] : null;
}
function hasProfitAmbiguityCue(normalizedQuestion) {
return /(?:заработ|прибыл|прибыль|доход|выручк)/iu.test(normalizedQuestion);
}
function buildCashflowBoundaryLine(isSupplier) {
return isSupplier
? "Граница ответа: это подтвержденный денежный поток по поставщику, а не итоговая задолженность."
: "Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.";
}
function buildCashflowNextStepLine(isSupplier, normalizedQuestion) {
if (isSupplier) {
return "Следующий шаг: могу отдельно показать остаток долга, просрочку или расшифровку по документам.";
}
if (hasProfitAmbiguityCue(normalizedQuestion)) {
return "Следующий шаг: могу отдельно проверить чистую прибыль по закрытию 90/91/99.";
}
return "Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.";
}
function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
if (intent === "counterparty_population_and_roles") {
const rowsByMarker = groupRowsByMarker(rows);
@@ -396,6 +413,8 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
/(?:клиент|заказчик|покупател|контрагент|customer|client|counterparty|buyer)/iu.test(normalizedQuestion);
const semanticSingleBestCounterparty = focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList;
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
const byCounterparty = new Map();
const byYear = new Map();
const deals = [];
@@ -490,10 +509,12 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
: "за доступное время";
const directAnswerLine = isSupplier
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`;
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям.`;
const summaryLines = [
directAnswerLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Контрагент в выборке: ${focusedCounterparty.name}.`,
@@ -511,11 +532,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const periodLine = options.periodFrom && options.periodTo
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
const directAnswerLine = isSupplier
? periodLine
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
const summaryLines = [
directAnswerLine,
periodLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Операций в выборке: ${totalOperations}.`,
@@ -538,11 +558,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const strongestYear = visible[0];
const directAnswerLine = isSupplier
? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`;
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (!isSupplier) {
lines.unshift(cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.unshift(cashflowNextStepLine);
}
}
lines.unshift(directAnswerLine);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
}
@@ -622,11 +648,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const directAnswerLine = singleCandidateOnly
? isSupplier
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`;
lines.unshift(directAnswerLine);
if (!isSupplier) {
lines.splice(1, 0, cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.splice(2, 0, cashflowNextStepLine);
}
}
}
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? item.total / item.ops : 0;
@@ -87,10 +87,9 @@ function composeInventoryReply(intent, rows, options, deps) {
const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate);
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const directAnswerLine = positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines = [directAnswerLine];
if (positions.length > 0) {
@@ -115,11 +114,14 @@ function composeInventoryReply(intent, rows, options, deps) {
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
`Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
]);
if (rows.length !== positions.length) {
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
}
if (positions.length > 0) {
lines.push("- Следующий шаг: могу раскрыть полный список, разложить остатки по складам или сравнить с другой датой.");
}
return positions.length > 0
? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong"))
: (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium"));