Укрепить банковские границы денежных потоков phase97

This commit is contained in:
2026-05-13 01:53:05 +03:00
parent e997449785
commit 04e670ab76
21 changed files with 861 additions and 12 deletions
@@ -13,6 +13,24 @@ const ACCOUNT_REVERSE_PATTERN = /(?:^|[\s,.;:!?()\-])(\d{2}(?:[.,]\d{1,2})?)(?=\
const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№#]*?(\d{1,3})/iu;
const VALUE_ANALYTICS_SAMPLE_LIMIT = 1000;
const COUNTERPARTY_PATTERN = /(?:по\s+контрагенту|контрагент(?:у|а)?|по\s+контре|контра|по\s+компан(?:ии|ию|ия)|компан(?:ия|ии|ию)|по\s+организац(?:ии|ию|ия)|организац(?:ия|ии|ию)|по\s+поставщик(?:у|а)?|поставщик(?:у|а)?|по\s+клиент(?:у|а)?|клиент(?:у|а)?|по\s+покупател(?:ю|я)|покупател(?:ю|я)|по\s+партнер(?:у|а)?|партнер(?:у|а)?|by\s+counterparty|counterparty|by\s+company|company|by\s+supplier|supplier|by\s+vendor|vendor|by\s+customer|customer|by\s+client|client|by\s+partner|partner)\s+([^\r\n,.;:]+)/iu;
const KNOWN_FINANCIAL_COUNTERPARTY_ANCHORS = [
{
pattern: /(?<![\p{L}\p{N}])(?:\u043f\u0430\u043e\s+|\u0430\u043e\s+)?\u0441\u0431\u0435\u0440\u0431\u0430\u043d\u043a(?:\s*,?\s*(?:\u043f\u0430\u043e|\u0430\u043e))?(?![\p{L}\p{N}])/iu,
value: "\u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a"
},
{
pattern: /(?<![\p{L}\p{N}])(?:\u0431\u0430\u043d\u043a\s+)?\u0432\u0442\u0431(?![\p{L}\p{N}])/iu,
value: "\u0412\u0422\u0411"
},
{
pattern: /(?<![\p{L}\p{N}])\u0430\u043b\u044c\u0444\u0430[-\s]?\u0431\u0430\u043d\u043a(?![\p{L}\p{N}])/iu,
value: "\u0410\u041b\u042c\u0424\u0410-\u0411\u0410\u041d\u041a"
},
{
pattern: /(?<![\p{L}\p{N}])(?:\u0442\u0438\u043d\u044c\u043a\u043e\u0444\u0444|\u0442[-\s]?\u0431\u0430\u043d\u043a)(?![\p{L}\p{N}])/iu,
value: "\u0422-\u0411\u0410\u041d\u041a"
}
];
const CONTRACT_PATTERN = /(?:по\s+(?:договору|контракту)|(?:договор|контракт)(?:у|а)?\s*(?:№|#|n)?|by\s+contract|contract(?:\s*(?:no|number|#|n))?)\s+([^\r\n,.;:]+)/i;
const DATE_DMY_PATTERN = /\b(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2,4})\b/;
const DATE_YMD_PATTERN = /\b(20\d{2})[.\/-](\d{1,2})[.\/-](\d{1,2})\b/;
@@ -608,6 +626,18 @@ function isLikelyCounterpartyToken(rawToken) {
}
return !isCounterpartyNoiseToken(lowered);
}
function extractKnownFinancialCounterpartyAnchor(text) {
const source = String(text ?? "");
if (!source.trim()) {
return undefined;
}
for (const anchor of KNOWN_FINANCIAL_COUNTERPARTY_ANCHORS) {
if (anchor.pattern.test(source)) {
return anchor.value;
}
}
return undefined;
}
function isLowQualityCounterpartyAnchorValue(rawValue) {
const value = String(rawValue ?? "")
.trim()
@@ -619,6 +649,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (/(?:за\s+вс[её]\s+время|за\s+всю\s+истори(?:ю|и)|all\s+time|entire\s+period|full\s+history)/iu.test(value)) {
return true;
}
if (/^(?:или|это|там|может|можно|обычн\p{L}*|клиентск\p{L}*|банковск\p{L}*(?:\/|\s+и\s+)?финансов\p{L}*)\b/iu.test(value)) {
return true;
}
const tokens = value
.split(/[^a-zа-я0-9]+/iu)
.map((token) => token.trim())
@@ -1495,6 +1528,13 @@ function shouldExpandSampleForValueAnalytics(intent) {
intent === "supplier_payouts_profile" ||
intent === "contract_usage_and_value");
}
function shouldPreferKnownFinancialCounterpartyAnchor(intent) {
return (intent === "bank_operations_by_counterparty" ||
intent === "customer_revenue_and_payments" ||
intent === "supplier_payouts_profile" ||
intent === "list_documents_by_counterparty" ||
intent === "list_contracts_by_counterparty");
}
function extractAddressFilters(userMessage, intent) {
const rawText = String(userMessage ?? "").trim();
const text = normalizeMojibakeString(rawText);
@@ -1573,6 +1613,13 @@ function extractAddressFilters(userMessage, intent) {
}
}
const allowGenericCounterpartyAnchor = !isInventoryTraceIntent(intent);
const knownFinancialCounterparty = allowGenericCounterpartyAnchor && shouldPreferKnownFinancialCounterpartyAnchor(intent)
? extractKnownFinancialCounterpartyAnchor(text)
: undefined;
if (knownFinancialCounterparty && !filters.counterparty) {
filters.counterparty = knownFinancialCounterparty;
warnings.push("counterparty_anchor_derived_from_known_financial_name");
}
const counterpartyMatch = allowGenericCounterpartyAnchor ? text.match(COUNTERPARTY_PATTERN) : null;
if (counterpartyMatch && !filters.counterparty) {
filters.counterparty = cleanupAnchorValue(String(counterpartyMatch[1]));
@@ -1658,9 +1658,9 @@ function hasBidirectionalValueFlowComparisonSignal(text) {
return false;
}
const hasIncomingCue = /(?:\u0432\u0445\u043e\u0434\u044f\u0449|\u043f\u043e\u0441\u0442\u0443\u043f|\u043f\u043e\u043b\u0443\u0447|inflow|incoming)/iu.test(normalized);
const hasOutgoingCue = /(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442\u0438\u043b|\u043e\u043f\u043b\u0430\u0442|outflow|outgoing|payout)/iu.test(normalized);
const hasOutgoingCue = /(?:\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u0441\u043f\u0438\u0441\u0430\u043d|\u0437\u0430\u043f\u043b\u0430\u0442|\u043f\u043b\u0430\u0442\u0438\u043b|\u0432\u044b\u043f\u043b\u0430\u0442|\u0432\u044b\u043f\u043b\u0430\u0447|\u0443\u043f\u043b\u0430\u0442|\u043e\u043f\u043b\u0430\u0442|outflow|outgoing|payout|paid)/iu.test(normalized);
const hasComparisonCue = /(?:\u0431\u043e\u043b\u044c\u0448|\u043c\u0435\u043d\u044c\u0448|\u0441\u0440\u0430\u0432|\u0438\u043b\u0438|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|vs|versus)/iu.test(normalized);
const hasValueFlowCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436|\u043f\u043e\u0442\u043e\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|money|cash|flow)/iu.test(normalized);
const hasValueFlowCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436|\u0441\u0440\u0435\u0434\u0441\u0442\u0432|\u043f\u043e\u0442\u043e\u043a|\u043e\u0431\u043e\u0440\u043e\u0442|money|cash|funds|flow)/iu.test(normalized);
const hasNetAmountCue = /(?:сколько|сумм|итог|нетто|сальдо|минус|net|total|sum)/iu.test(normalized);
return hasIncomingCue && hasOutgoingCue && hasComparisonCue && (hasValueFlowCue || hasNetAmountCue);
}
@@ -1189,6 +1189,9 @@ function toNormalizedRows(rows) {
const organization = firstNonEmptyString(row.Организация, row.Organization, row.organization, row.organization_name, row.ОрганизацияПредставление);
const counterparty = firstNonEmptyString(row.Контрагент, row.Counterparty, row.counterparty);
const contract = firstNonEmptyString(row.Договор, row.Contract, row.contract);
const operationKind = firstNonEmptyString(row.ВидОперации, row.OperationKind, row.operation_kind, row.operationType);
const paymentPurpose = firstNonEmptyString(row.НазначениеПлатежа, row.PaymentPurpose, row.payment_purpose, row.paymentPurpose);
const comment = firstNonEmptyString(row.Комментарий, row.Comment, row.comment);
const analytics = collectAnalyticsStrings(row);
return {
period,
@@ -1202,7 +1205,10 @@ function toNormalizedRows(rows) {
warehouse,
organization,
counterparty,
contract
contract,
operation_kind: operationKind,
payment_purpose: paymentPurpose,
comment
};
})
.filter((item) => Boolean(item.period || item.registrator));
@@ -277,6 +277,77 @@ function isDirectBalanceQuestion(userMessage) {
}
return /(?:кто|кому|сколько|какой|какая|какие|есть\s+ли|долж|дебитор|кредитор|payables?|receivables?|who|how\s+much)/iu.test(text);
}
function hasBankIncomingRoleBoundaryQuestion(userMessage) {
const text = normalizeQuestionText(userMessage);
return (/(?:входящ|поступлен|клиентск|выручк|кредит|депозит|возврат)/iu.test(text) &&
/(?:банк|сбербанк|финанс)/iu.test(text));
}
function hasBankOutgoingRoleBoundaryQuestion(userMessage) {
const text = normalizeQuestionText(userMessage);
return (/(?:исходящ|списан|платеж|поставщик|закуп|выплат)/iu.test(text) &&
/(?:банк|сбербанк|финанс)/iu.test(text));
}
function bankOperationDirection(row) {
const text = normalizeQuestionText(`${row.registrator} ${row.operation_kind ?? ""}`);
if (/(?:поступлени[ея]\s+на\s+расчетн|bank\s+receipt|incoming)/iu.test(text)) {
return "incoming";
}
if (/(?:списани[ея]\s+с\s+расчетн|bank\s+payment|outgoing|write[-\s]?off)/iu.test(text)) {
return "outgoing";
}
return "unknown";
}
function bankOperationDirectionLabel(direction) {
if (direction === "incoming") {
return "входящее поступление";
}
if (direction === "outgoing") {
return "исходящее списание";
}
return "банковская операция без надежно распознанного направления";
}
function bankOperationEvidenceLine(rows) {
const sample = rows[0];
if (!sample) {
return "Проверенная строка 1С не найдена.";
}
const direction = bankOperationDirection(sample);
const parts = [`тип по документу: ${bankOperationDirectionLabel(direction)}`];
const operationKind = String(sample.operation_kind ?? "").trim();
const paymentPurpose = String(sample.payment_purpose ?? "").trim();
const contract = String(sample.contract ?? "").trim();
if (operationKind) {
parts.push(`вид операции: ${operationKind}`);
}
if (paymentPurpose) {
parts.push(`назначение платежа: ${paymentPurpose}`);
}
if (contract) {
parts.push(`договор: ${contract}`);
}
if (!operationKind && !paymentPurpose && !contract) {
parts.push("вид операции/назначение платежа/договор в материализованной строке не заполнены");
}
return `Основание 1С: ${parts.join("; ")}.`;
}
function bankRoleBoundaryLine(userMessage, rows) {
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
if (!incomingBoundary && !outgoingBoundary) {
return null;
}
const directions = rows.map(bankOperationDirection);
const hasIncomingRow = directions.includes("incoming");
const hasOutgoingRow = directions.includes("outgoing");
if (incomingBoundary) {
return hasIncomingRow
? "Выручкой от обычного клиента это не называю автоматически: для банка/финорганизации нужен вид операции, назначение платежа и договор; кредитный, депозитный или возвратный смысл без этих полей не исключаю и не притягиваю."
: hasOutgoingRow
? "В найденных строках по банку подтверждено исходящее списание, а входящее поступление от банка в этом срезе не подтверждено; клиентскую выручку, кредит или депозит по этой строке не доказываю."
: "Входящее поступление от банка в найденных строках не подтверждено; клиентскую выручку, кредитный или депозитный смысл без вида операции/назначения платежа не доказываю.";
}
return "Обычным поставщиком это не называю автоматически: для банка/финорганизации нужен вид операции, назначение платежа и договор; текущий срез подтверждает банковский платежный контур, а не бизнес-роль поставщика.";
}
function hasInventoryPurchaseDateActionFocus(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@@ -3820,9 +3891,15 @@ function composeFactualReplyBody(intent, rows, options = {}) {
};
}
if (intent === "bank_operations_by_counterparty") {
const rowCounterparties = uniqueStrings(rows
.map((row) => extractCounterpartyName(row))
.filter((item) => Boolean(item)));
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
const lines = [
`Коротко: найдено банковских операций по контрагенту — ${rows.length}.`,
"Показываю подтвержденные банковские операции из текущего среза.",
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"} — ${rows.length}.`,
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
bankOperationEvidenceLine(rows),
...formatTopRows(rows, rows.length)
];
return {
@@ -17,6 +17,23 @@ function toNonEmptyString(value) {
const text = String(value).trim();
return text.length > 0 ? text : null;
}
function normalizeQuestionText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/\s+/g, " ")
.trim();
}
function requestsFinancialCounterpartyBoundary(turnMeaning, graph) {
const text = normalizeQuestionText([
turnMeaning?.raw_message,
turnMeaning?.effective_message,
graph?.source_message,
graph?.question
].join(" "));
return (/(?:банк|сбербанк|финанс|кредит|депозит)/iu.test(text) &&
/(?:клиент|поставщик|выручк|топ|обычн|роль|поток)/iu.test(text));
}
function toStringList(value) {
if (!Array.isArray(value)) {
return [];
@@ -693,6 +710,12 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
: `; крупнейший получатель исходящих денег: ${topSupplier}`
: "";
const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : "";
const financialBoundaryRequested = requestsFinancialCounterpartyBoundary(turnMeaning, graph);
const requestedFinancialBoundaryLine = financialBoundaryRequested
? topCustomerLooksFinancial || topSupplierLooksFinancial
? "Отдельно по банкам: если денежный топ ведет банк/финансовая организация, это нельзя автоматически читать как обычного клиента или поставщика; нужны назначение платежа, вид операции и договор. Поэтому такой поток не является доказанной клиентской выручкой, обычной поставкой или чистой прибылью без отдельной проверки."
: "Отдельно по банкам: банк/финансовую организацию в денежных топах нельзя автоматически читать как обычного клиента или поставщика; нужны назначение платежа, вид операции и договор. Поэтому такой поток не является доказанной клиентской выручкой, обычной поставкой или чистой прибылью без отдельной проверки."
: null;
const graphReasonCodes = toStringList(graph?.reason_codes);
const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer");
const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary);
@@ -901,7 +924,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (!leaderYear || !leaderAmount) {
return null;
}
lines.push(`Коротко: в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`);
lines.push(`Коротко: ${organizationPrefix}в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`);
const netYear = toNonEmptyString(netLeader?.year_bucket);
const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
if (netYear && netYearAmount) {
@@ -912,6 +935,9 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (incomingAmount && outgoingAmount && netAmount) {
lines.push(`Сверка по окну: входящие ${incomingAmount}, исходящие ${outgoingAmount}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount}.`);
}
if (requestedFinancialBoundaryLine) {
lines.push(requestedFinancialBoundaryLine);
}
const yearRows = businessOverviewYearRowsLine(overview);
if (yearRows) {
lines.push(yearRows);
@@ -925,6 +951,9 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
? `Крупнейший входящий денежный источник в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}. По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой.${nonFinancialCustomer ? ` Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
: `Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`);
}
if (requestedFinancialBoundaryLine) {
lines.push(requestedFinancialBoundaryLine);
}
}
else {
return null;
@@ -705,12 +705,18 @@ function hasCrossScopeExecutiveSummarySignal(text) {
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test(text) &&
/(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test(text));
}
function hasPlainBusinessOverviewSignal(text) {
const hasPlainOverviewCue = /(?:\u0432\u0437\u0440\u043e\u0441\u043b\p{L}*[\s\S]{0,40}\u043e\u0431\u0437\u043e\u0440|\u043a\u0440\u0430\u0442\u043a\p{L}*\s+\u043e\u0431\u0437\u043e\u0440|\u043e\u0431\u0437\u043e\u0440[\s\S]{0,100}(?:\u0432\u0445\u043e\u0434\u044f\u0449|\u0438\u0441\u0445\u043e\u0434\u044f\u0449|\u043d\u0435\u0442\u0442\u043e|incoming|outgoing|net))/iu.test(text);
const hasCompanyOrOperatingScopeCue = /(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0431\u0438\u0437\u043d\u0435\u0441|\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\p{L}*|(?:19|20)\d{2}|company|organization|business)/iu.test(text);
return hasPlainOverviewCue && hasCompanyOrOperatingScopeCue;
}
function hasBusinessOverviewSignal(text) {
if (hasCrossScopeExecutiveSummarySignal(text) ||
hasOrganizationLevelEarningsOverviewSignal(text) ||
hasOrganizationLevelDebtPositionOverviewSignal(text) ||
hasOrganizationLevelDebtDueDateOverviewSignal(text) ||
hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) ||
hasPlainBusinessOverviewSignal(text) ||
hasOrganizationLevelSupplierQualityOverviewSignal(text)) {
return true;
}
@@ -1786,6 +1792,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
normalizedFollowupDateScope);
const clarificationLoopSeedApplied = Boolean(followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopSelectedChainId);
const turnMeaning = {
raw_message: repairedUserText ?? rawUserText ?? null,
effective_message: repairedEffectiveText ?? rawEffectiveText ?? null,
asked_domain_family: businessOverviewSignal
? "business_overview"
: lifecycleSignal
@@ -1882,6 +1890,12 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
followupDiscoverySeedApplicable)
};
const cleanTurnMeaning = {};
if (toNonEmptyString(turnMeaning.raw_message)) {
cleanTurnMeaning.raw_message = turnMeaning.raw_message;
}
if (toNonEmptyString(turnMeaning.effective_message)) {
cleanTurnMeaning.effective_message = turnMeaning.effective_message;
}
if (toNonEmptyString(turnMeaning.asked_domain_family)) {
cleanTurnMeaning.asked_domain_family = turnMeaning.asked_domain_family;
}