diff --git a/docs/orchestration/address_truth_harness_phase97_financial_counterparty_flow_hints.json b/docs/orchestration/address_truth_harness_phase97_financial_counterparty_flow_hints.json new file mode 100644 index 0000000..40a70bf --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase97_financial_counterparty_flow_hints.json @@ -0,0 +1,145 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase97_financial_counterparty_flow_hints", + "domain": "address_phase97_financial_counterparty_flow_hints", + "title": "Phase 97 financial counterparty flow hints replay", + "description": "Focused semantic replay for the Open-World Schema/Primitive Discovery slice: bank-like counterparties must not be described as ordinary suppliers/customers when operation, payment purpose, contract, or comment fields indicate banking commission, credit, deposit, tax/budget, or payroll-like flows. The replay also keeps a normal counterparty value-flow canary.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_sberbank_outgoing_is_not_plain_supplier", + "title": "Sberbank outgoing money is explained as bank flow, not ordinary supplier dependency", + "question": "По ООО Альтернатива Плюс за 2020 отдельно посмотри платежи в СБЕРБАНК: это обычный поставщик или банковский/финансовый поток? Дай коротко и по проверенным данным 1С.", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)сбербанк|банк|финансов", + "(?i)не.*обычн.*поставщик|не.*поставщик|банковск|финансов", + "(?i)комисс|назначени|вид операции|платеж|списан", + "(?i)2020|1с|провер" + ], + "forbidden_answer_patterns": [ + "(?i)главный поставщик.*сбербанк", + "(?i)обычный поставщик.*сбербанк", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_", + "(?i)snapshot_items", + "(?i)answer_object" + ], + "criticality": "critical", + "semantic_tags": [ + "financial_counterparty_flow_hint", + "bank_like_supplier_boundary", + "supplier_payouts_profile" + ] + }, + { + "step_id": "step_02_sberbank_incoming_is_not_plain_customer", + "title": "Sberbank incoming money is not overclaimed as normal customer revenue", + "question": "А если по этой же компании СБЕРБАНК встречается во входящих поступлениях, это клиентская выручка или там может быть кредитный/депозитный банковский смысл? Не притягивай, скажи что подтверждено.", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_answer_patterns_all": [ + "(?i)сбербанк|банк|финансов", + "(?i)не.*клиентск|не.*обычн.*клиент|не.*выручк|кредит|депозит|возврат", + "(?i)вид операции|договор|поступлен|1с|провер", + "(?i)подтвержд|не подтвержд|не доказ" + ], + "forbidden_answer_patterns": [ + "(?i)сбербанк.*главный клиент", + "(?i)сбербанк.*обычный клиент", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_", + "(?i)snapshot_items", + "(?i)answer_object" + ], + "criticality": "critical", + "semantic_tags": [ + "financial_counterparty_flow_hint", + "bank_like_customer_boundary", + "customer_revenue_and_payments" + ] + }, + { + "step_id": "step_03_business_overview_keeps_bank_boundary", + "title": "Business overview keeps bank-like leaders bounded by flow meaning", + "question": "Теперь дай взрослый краткий обзор ООО Альтернатива Плюс за 2020: входящие, исходящие, нетто и отдельно отметь, если в топах есть банк, почему его нельзя читать как обычного клиента или поставщика.", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "business_overview", + "expected_catalog_selected_matches_top": true, + "required_answer_patterns_all": [ + "(?i)альтернатива|компан|организац", + "(?i)входящ|поступлен|исходящ|списан|нетто", + "(?i)банк|финансов|сбербанк|не.*обычн.*клиент|не.*обычн.*поставщик", + "(?i)прибыл|марж|не подтвержд|не доказ|не является" + ], + "forbidden_answer_patterns": [ + "(?i)сбербанк.*обычный поставщик", + "(?i)сбербанк.*обычный клиент", + "(?i)чистая прибыль.*точно", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_", + "(?i)snapshot_items", + "(?i)answer_object" + ], + "criticality": "critical", + "semantic_tags": [ + "business_overview", + "financial_counterparty_flow_hint", + "profit_margin_boundary" + ] + }, + { + "step_id": "step_04_normal_counterparty_value_flow_canary", + "title": "Normal counterparty value-flow still works after bank-flow questions", + "question": "А теперь отдельно по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_catalog_alignment_status": "selected_matches_top", + "expected_catalog_chain_top_match": "value_flow_comparison", + "expected_catalog_selected_matches_top": true, + "required_answer_patterns_all": [ + "(?i)свк|группа", + "(?i)2020", + "(?i)получил|входящ|поступлен", + "(?i)заплат|исходящ|списан", + "(?i)нетто|сальдо|разниц" + ], + "forbidden_answer_patterns": [ + "(?i)сбербанк", + "(?i)уточните организац", + "(?i)какую компанию", + "(?i)route_candidate", + "(?i)primitive", + "(?i)planner_", + "(?i)catalog_" + ], + "criticality": "critical", + "semantic_tags": [ + "counterparty_net_cash_flow", + "stale_scope_guard", + "canary" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 957ba09..dca64c7 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -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: /(? 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])); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 0f950fb..e9bc132 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -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); } diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 49c54bd..97c7e03 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -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)); diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 18dfafe..aa13e40 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -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 { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 61e086a..149ac19 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -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; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 1d98f8a..36cb592 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -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; } diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 7551037..0ffaf9b 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -8,6 +8,25 @@ const LIMIT_PATTERN = /(?:\btop\b|\blimit\b|первые|топ)[\s\-–—_:№ 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: Array<{ pattern: RegExp; value: string }> = [ + { + pattern: + /(? token.trim()) @@ -1741,6 +1780,16 @@ function shouldExpandSampleForValueAnalytics(intent: AddressIntent): boolean { ); } +function shouldPreferKnownFinancialCounterpartyAnchor(intent: AddressIntent): boolean { + 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" + ); +} + export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction { const rawText = String(userMessage ?? "").trim(); const text = normalizeMojibakeString(rawText); @@ -1828,6 +1877,14 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent } 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])); diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 06ffb9b..63c7be3 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2136,7 +2136,7 @@ function hasBidirectionalValueFlowComparisonSignal(text: string): boolean { 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( + /(?:\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 = @@ -2144,7 +2144,7 @@ function hasBidirectionalValueFlowComparisonSignal(text: string): boolean { 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( + /(?:\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); diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index 993bef8..f9da31d 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -88,6 +88,9 @@ interface NormalizedAddressRow { organization?: string | null; counterparty?: string | null; contract?: string | null; + operation_kind?: string | null; + payment_purpose?: string | null; + comment?: string | null; } interface AddressTryHandleOptions { @@ -1465,6 +1468,14 @@ function toNormalizedRows(rows: Array>): NormalizedAddre ); 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 { @@ -1479,7 +1490,10 @@ function toNormalizedRows(rows: Array>): NormalizedAddre warehouse, organization, counterparty, - contract + contract, + operation_kind: operationKind, + payment_purpose: paymentPurpose, + comment }; }) .filter((item) => Boolean(item.period || item.registrator)); diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 5890144..4244234 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -31,6 +31,9 @@ export interface ComposeStageRow { organization?: string | null; counterparty?: string | null; contract?: string | null; + operation_kind?: string | null; + payment_purpose?: string | null; + comment?: string | null; } export interface VatDirectSourceProbeItem { @@ -416,6 +419,90 @@ function isDirectBalanceQuestion(userMessage: string | null | undefined): boolea ); } +function hasBankIncomingRoleBoundaryQuestion(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + return ( + /(?:входящ|поступлен|клиентск|выручк|кредит|депозит|возврат)/iu.test(text) && + /(?:банк|сбербанк|финанс)/iu.test(text) + ); +} + +function hasBankOutgoingRoleBoundaryQuestion(userMessage: string | null | undefined): boolean { + const text = normalizeQuestionText(userMessage); + return ( + /(?:исходящ|списан|платеж|поставщик|закуп|выплат)/iu.test(text) && + /(?:банк|сбербанк|финанс)/iu.test(text) + ); +} + +function bankOperationDirection(row: ComposeStageRow): "incoming" | "outgoing" | "unknown" { + 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: "incoming" | "outgoing" | "unknown"): string { + if (direction === "incoming") { + return "входящее поступление"; + } + if (direction === "outgoing") { + return "исходящее списание"; + } + return "банковская операция без надежно распознанного направления"; +} + +function bankOperationEvidenceLine(rows: ComposeStageRow[]): string { + 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: string | null | undefined, rows: ComposeStageRow[]): string | null { + 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: string | null | undefined): boolean { const text = normalizeQuestionText(userMessage); if (!text) { @@ -4876,9 +4963,17 @@ function composeFactualReplyBody( } if (intent === "bank_operations_by_counterparty") { + const rowCounterparties = uniqueStrings( + rows + .map((row) => extractCounterpartyName(row)) + .filter((item): item is string => 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 { diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index 2929e34..dddba87 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -21,6 +21,8 @@ export type AssistantMcpDiscoveryEvidenceStatus = "confirmed" | "inferred_only" export type AssistantMcpDiscoveryAnswerPermission = "confirmed_answer" | "bounded_inference" | "checked_sources_only"; export interface AssistantMcpDiscoveryTurnMeaningRef { + raw_message?: string | null; + effective_message?: string | null; asked_domain_family?: string | null; asked_action_family?: string | null; asked_aggregation_axis?: string | null; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 810b463..7480f8a 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -39,6 +39,27 @@ function toNonEmptyString(value: unknown): string | null { return text.length > 0 ? text : null; } +function normalizeQuestionText(value: unknown): string { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/\s+/g, " ") + .trim(); +} + +function requestsFinancialCounterpartyBoundary(turnMeaning: Record | null, graph: Record | null): boolean { + 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: unknown): string[] { if (!Array.isArray(value)) { return []; @@ -815,6 +836,12 @@ function buildCompactBusinessOverviewReply( : `; крупнейший получатель исходящих денег: ${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); @@ -1082,7 +1109,7 @@ function buildCompactBusinessOverviewReply( return null; } lines.push( - `Коротко: в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.` + `Коротко: ${organizationPrefix}в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.` ); const netYear = toNonEmptyString(netLeader?.year_bucket); const netYearAmount = moneyText(netLeader?.net_amount_human_ru); @@ -1094,6 +1121,9 @@ function buildCompactBusinessOverviewReply( 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); @@ -1110,6 +1140,9 @@ function buildCompactBusinessOverviewReply( : `Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.` ); } + if (requestedFinancialBoundaryLine) { + lines.push(requestedFinancialBoundaryLine); + } } else { return null; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index cce51f4..4d6430f 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -984,6 +984,18 @@ function hasCrossScopeExecutiveSummarySignal(text: string): boolean { ); } +function hasPlainBusinessOverviewSignal(text: string): boolean { + 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: string): boolean { if ( hasCrossScopeExecutiveSummarySignal(text) || @@ -991,6 +1003,7 @@ function hasBusinessOverviewSignal(text: string): boolean { hasOrganizationLevelDebtPositionOverviewSignal(text) || hasOrganizationLevelDebtDueDateOverviewSignal(text) || hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) || + hasPlainBusinessOverviewSignal(text) || hasOrganizationLevelSupplierQualityOverviewSignal(text) ) { return true; @@ -2387,6 +2400,8 @@ export function buildAssistantMcpDiscoveryTurnInput( ); const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { + raw_message: repairedUserText ?? rawUserText ?? null, + effective_message: repairedEffectiveText ?? rawEffectiveText ?? null, asked_domain_family: businessOverviewSignal ? "business_overview" @@ -2492,6 +2507,12 @@ export function buildAssistantMcpDiscoveryTurnInput( }; const cleanTurnMeaning: AssistantMcpDiscoveryTurnMeaningRef = {}; + 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; } diff --git a/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts b/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts index 3db15cd..b419996 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverBidirectionalValueFlow.test.ts @@ -10,4 +10,13 @@ describe("address intent resolver bidirectional value-flow arbitration", () => { expect(result.intent).toBe("unknown"); expect(result.reasons).toContain("unicode_bidirectional_value_flow_deferred_to_discovery"); }); + + it("keeps normalized received-paid-net funds wording out of inventory routes", () => { + const result = resolveAddressIntent( + "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0441\u0443\u043c\u043c\u0443 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432, \u0441\u0443\u043c\u043c\u044b \u0432\u044b\u043f\u043b\u0430\u0447\u0435\u043d\u043d\u044b\u0445 \u0441\u0440\u0435\u0434\u0441\u0442\u0432 \u0438 \u0447\u0438\u0441\u0442\u044b\u0439 \u043e\u0441\u0442\u0430\u0442\u043e\u043a (\u043d\u0435\u0442\u0442\u043e) \u0434\u043b\u044f \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430 '\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a' \u0437\u0430 2020 \u0433\u043e\u0434." + ); + + expect(result.intent).toBe("unknown"); + expect(result.reasons).toContain("unicode_bidirectional_value_flow_deferred_to_discovery"); + }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 21ce977..940df30 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -561,6 +561,34 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).not.toContain("live address lane"); }); + it("keeps bank counterparty classification bounded for incoming revenue questions", () => { + const reply = composeFactualReply( + "bank_operations_by_counterparty", + [ + { + period: "2020-12-16T16:20:51Z", + registrator: "Списание с расчетного счета 00000000293 от 16.12.2020 16:20:51", + account_dt: "0", + account_kt: "0", + amount: 60, + analytics: ["СБЕРБАНК, ПАО", "0"], + counterparty: "СБЕРБАНК, ПАО" + } + ], + { + counterpartyHint: "СБЕРБАНК", + userMessage: + "А если СБЕРБАНК встречается во входящих поступлениях, это клиентская выручка или кредитный/депозитный банковский смысл?" + } + ); + + expect(reply.text).toContain("по СБЕРБАНК"); + expect(reply.text).toContain("входящее поступление от банка в этом срезе не подтверждено"); + expect(reply.text).toContain("клиентскую выручку"); + expect(reply.text).toContain("Основание 1С"); + expect(reply.text).toContain("вид операции/назначение платежа/договор"); + }); + it("renders readable russian header for contracts-by-counterparty list", () => { const reply = composeFactualReply("list_contracts_by_counterparty", [ { @@ -2264,6 +2292,16 @@ describe("address intent resolver expansion (M2.3a)", () => { expect(result.intent).toBe("bank_operations_by_counterparty"); }); + it("prefers explicit bank name over supplier-vs-financial comparison text", () => { + const result = extractAddressFilters( + "\u041f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043f\u043e\u0441\u043c\u043e\u0442\u0440\u0438 \u043f\u043b\u0430\u0442\u0435\u0436\u0438 \u0432 \u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a: \u044d\u0442\u043e \u043e\u0431\u044b\u0447\u043d\u044b\u0439 \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a \u0438\u043b\u0438 \u0431\u0430\u043d\u043a\u043e\u0432\u0441\u043a\u0438\u0439/\u0444\u0438\u043d\u0430\u043d\u0441\u043e\u0432\u044b\u0439 \u043f\u043e\u0442\u043e\u043a?", + "bank_operations_by_counterparty" + ); + + expect(result.extracted_filters.counterparty).toBe("\u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a"); + expect(result.warnings).toContain("counterparty_anchor_derived_from_known_financial_name"); + }); + it("resolves documents forming balance intent", () => { const result = resolveAddressIntent("which documents form balance for account 62 as of 2020-07-31"); expect(result.intent).toBe("documents_forming_balance"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 530f9b6..7e5fa20 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -309,6 +309,13 @@ describe("assistant MCP discovery response candidate", () => { entryPoint({ turn_input: { adapter_status: "ready", + turn_meaning_ref: { + raw_message: + "Дай краткий обзор ООО Альтернатива Плюс за 2020 и отметь, если в топах есть банк, почему его нельзя читать как обычного клиента или поставщика.", + effective_message: + "Дать краткий обзор ООО Альтернатива Плюс за 2020: входящие, исходящие, нетто и банковскую границу.", + explicit_organization_scope: "ООО Альтернатива Плюс" + }, data_need_graph: { business_fact_family: "business_overview", ranking_need: "top_desc", @@ -375,9 +382,12 @@ describe("assistant MCP discovery response candidate", () => { ); expect(candidate.reply_text).toContain("в доступном проверенном срезе 1С"); + expect(candidate.reply_text).toContain("по компании ООО Альтернатива Плюс"); expect(candidate.reply_text).toContain("лидирует 2015"); expect(candidate.reply_text).toContain("2015"); expect(candidate.reply_text).toContain("136 723 459,73 руб."); + expect(candidate.reply_text).toContain("банк/финансовую организацию"); + expect(candidate.reply_text).toContain("нельзя автоматически читать как обычного клиента или поставщика"); expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности"); expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль"); expect(candidate.reply_text).toContain("проверка достигла лимита строк"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index cade8de..1d09961 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1641,6 +1641,38 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_value_flow_signal_detected"); }); + it("routes plain short overview with money axes into business overview over value-flow", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0422\u0435\u043f\u0435\u0440\u044c \u0434\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u043a\u0440\u0430\u0442\u043a\u0438\u0439 \u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020: \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435, \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435, \u043d\u0435\u0442\u0442\u043e \u0438 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043e\u0442\u043c\u0435\u0442\u044c, \u0435\u0441\u043b\u0438 \u0432 \u0442\u043e\u043f\u0430\u0445 \u0435\u0441\u0442\u044c \u0431\u0430\u043d\u043a.", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + explicit_intent_candidate: "customer_revenue_and_payments", + explicit_entity_candidates: [{ value: "\u0438\u043b\u0438 \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0430" }], + stale_replay_forbidden: false + }, + predecomposeContract: { + entities: { + organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" + }, + period: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + explicit_date_scope: "2020" + }); + expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); + expect(result.reason_codes).not.toContain("mcp_discovery_value_flow_signal_detected"); + }); + it("keeps explicit year out of the organization scope for raw business overview wording", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: diff --git a/llm_normalizer/data/autorun_generators/history.json b/llm_normalizer/data/autorun_generators/history.json index 862266c..1519b27 100644 --- a/llm_normalizer/data/autorun_generators/history.json +++ b/llm_normalizer/data/autorun_generators/history.json @@ -1,4 +1,52 @@ [ + { + "generation_id": "gen-ag05122250-4451a8", + "created_at": "2026-05-12T22:50:23+00:00", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 97 financial counterparty flow hints replay", + "count": 4, + "domain": "address_phase97_financial_counterparty_flow_hints", + "questions": [ + "По ООО Альтернатива Плюс за 2020 отдельно посмотри платежи в СБЕРБАНК: это обычный поставщик или банковский/финансовый поток? Дай коротко и по проверенным данным 1С.", + "А если по этой же компании СБЕРБАНК встречается во входящих поступлениях, это клиентская выручка или там может быть кредитный/депозитный банковский смысл? Не притягивай, скажи что подтверждено.", + "Теперь дай взрослый краткий обзор ООО Альтернатива Плюс за 2020: входящие, исходящие, нетто и отдельно отметь, если в топах есть банк, почему его нельзя читать как обычного клиента или поставщика.", + "А теперь отдельно по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?" + ], + "generated_by": "codex_agent", + "saved_case_set_file": "assistant_autogen_saved_user_sessions_20260512225023_gen-ag05122250-4451a8.json", + "context": { + "llm_provider": null, + "model": null, + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "autogen_personality_id": null, + "autogen_personality_prompt": null, + "source_session_id": null, + "saved_session_file": "assistant_saved_session_20260512225023_gen-ag05122250-4451a8.json", + "saved_case_set_kind": "agent_semantic_scenario", + "agent_run": true, + "agent_focus": "Focused semantic replay for the Open-World Schema/Primitive Discovery slice: bank-like counterparties must not be described as ordinary suppliers/customers when operation, payment purpose, contract, or comment fields indicate banking commission, credit, deposit, tax/budget, or payroll-like flows. The replay also keeps a normal counterparty value-flow canary.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase97_financial_counterparty_flow_hints.json", + "scenario_id": "address_truth_harness_phase97_financial_counterparty_flow_hints", + "semantic_tags": [ + "bank_like_customer_boundary", + "bank_like_supplier_boundary", + "business_overview", + "canary", + "counterparty_net_cash_flow", + "customer_revenue_and_payments", + "financial_counterparty_flow_hint", + "profit_margin_boundary", + "stale_scope_guard", + "supplier_payouts_profile" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase97_financial_counterparty_flow_hints_live4", + "saved_after_validated_replay": true + } + }, { "generation_id": "gen-ag05122057-c9786e", "created_at": "2026-05-12T20:57:28+00:00", diff --git a/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512225023_gen-ag05122250-4451a8.json b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512225023_gen-ag05122250-4451a8.json new file mode 100644 index 0000000..94a2ec8 --- /dev/null +++ b/llm_normalizer/data/autorun_generators/saved_sessions/assistant_saved_session_20260512225023_gen-ag05122250-4451a8.json @@ -0,0 +1,135 @@ +{ + "saved_at": "2026-05-12T22:50:23+00:00", + "generation_id": "gen-ag05122250-4451a8", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 97 financial counterparty flow hints replay", + "agent_run": true, + "questions": [ + "По ООО Альтернатива Плюс за 2020 отдельно посмотри платежи в СБЕРБАНК: это обычный поставщик или банковский/финансовый поток? Дай коротко и по проверенным данным 1С.", + "А если по этой же компании СБЕРБАНК встречается во входящих поступлениях, это клиентская выручка или там может быть кредитный/депозитный банковский смысл? Не притягивай, скажи что подтверждено.", + "Теперь дай взрослый краткий обзор ООО Альтернатива Плюс за 2020: входящие, исходящие, нетто и отдельно отметь, если в топах есть банк, почему его нельзя читать как обычного клиента или поставщика.", + "А теперь отдельно по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?" + ], + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Focused semantic replay for the Open-World Schema/Primitive Discovery slice: bank-like counterparties must not be described as ordinary suppliers/customers when operation, payment purpose, contract, or comment fields indicate banking commission, credit, deposit, tax/budget, or payroll-like flows. The replay also keeps a normal counterparty value-flow canary.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase97_financial_counterparty_flow_hints.json", + "scenario_id": "address_truth_harness_phase97_financial_counterparty_flow_hints", + "semantic_tags": [ + "bank_like_customer_boundary", + "bank_like_supplier_boundary", + "business_overview", + "canary", + "counterparty_net_cash_flow", + "customer_revenue_and_payments", + "financial_counterparty_flow_hint", + "profit_margin_boundary", + "stale_scope_guard", + "supplier_payouts_profile" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase97_financial_counterparty_flow_hints_live4", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase97_financial_counterparty_flow_hints_live4", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 4, + "steps_passed": 4, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "saved_after_validated_replay": true + } + }, + "source_session_id": null, + "session": { + "session_id": null, + "mode": "agent_semantic_run", + "items": [ + { + "message_id": "agent-user-001", + "role": "user", + "text": "По ООО Альтернатива Плюс за 2020 отдельно посмотри платежи в СБЕРБАНК: это обычный поставщик или банковский/финансовый поток? Дай коротко и по проверенным данным 1С.", + "created_at": "2026-05-12T22:50:23+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-002", + "role": "user", + "text": "А если по этой же компании СБЕРБАНК встречается во входящих поступлениях, это клиентская выручка или там может быть кредитный/депозитный банковский смысл? Не притягивай, скажи что подтверждено.", + "created_at": "2026-05-12T22:50:23+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-003", + "role": "user", + "text": "Теперь дай взрослый краткий обзор ООО Альтернатива Плюс за 2020: входящие, исходящие, нетто и отдельно отметь, если в топах есть банк, почему его нельзя читать как обычного клиента или поставщика.", + "created_at": "2026-05-12T22:50:23+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + }, + { + "message_id": "agent-user-004", + "role": "user", + "text": "А теперь отдельно по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?", + "created_at": "2026-05-12T22:50:23+00:00", + "reply_type": null, + "trace_id": null, + "debug": null + } + ], + "agent_run": true, + "metadata": { + "assistant_prompt_version": null, + "decomposition_prompt_version": null, + "prompt_fingerprint": null, + "agent_focus": "Focused semantic replay for the Open-World Schema/Primitive Discovery slice: bank-like counterparties must not be described as ordinary suppliers/customers when operation, payment purpose, contract, or comment fields indicate banking commission, credit, deposit, tax/budget, or payroll-like flows. The replay also keeps a normal counterparty value-flow canary.", + "architecture_phase": "turnaround_11", + "source_spec_file": "X:\\1C\\NDC_1C\\docs\\orchestration\\address_truth_harness_phase97_financial_counterparty_flow_hints.json", + "scenario_id": "address_truth_harness_phase97_financial_counterparty_flow_hints", + "semantic_tags": [ + "bank_like_customer_boundary", + "bank_like_supplier_boundary", + "business_overview", + "canary", + "counterparty_net_cash_flow", + "customer_revenue_and_payments", + "financial_counterparty_flow_hint", + "profit_margin_boundary", + "stale_scope_guard", + "supplier_payouts_profile" + ], + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase97_financial_counterparty_flow_hints_live4", + "saved_after_validated_replay": true, + "save_gate": { + "schema_version": "agent_semantic_save_gate_v1", + "validation_status": "accepted_live_replay", + "validated_run_dir": "artifacts\\domain_runs\\phase97_financial_counterparty_flow_hints_live4", + "final_status": "accepted", + "review_overall_status": "pass", + "business_overall_status": "pass", + "steps_total": 4, + "steps_passed": 4, + "steps_failed": 0, + "steps_with_business_failures": 0, + "steps_with_business_warnings": 0, + "acceptance_gate_passed": true, + "saved_after_validated_replay": true + } + } + } +} diff --git a/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512225023_gen-ag05122250-4451a8.json b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512225023_gen-ag05122250-4451a8.json new file mode 100644 index 0000000..27bf4cc --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_autogen_saved_user_sessions_20260512225023_gen-ag05122250-4451a8.json @@ -0,0 +1,37 @@ +{ + "suite_id": "assistant_saved_session_gen-ag05122250-4451a8", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_suite_v0_1", + "generated_at": "2026-05-12T22:50:23+00:00", + "generation_id": "gen-ag05122250-4451a8", + "mode": "saved_user_sessions", + "title": "AGENT | Phase 97 financial counterparty flow hints replay", + "domain": "address_phase97_financial_counterparty_flow_hints", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "agent_saved_user_sessions", + "title": "AGENT | Phase 97 financial counterparty flow hints replay", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "По ООО Альтернатива Плюс за 2020 отдельно посмотри платежи в СБЕРБАНК: это обычный поставщик или банковский/финансовый поток? Дай коротко и по проверенным данным 1С." + }, + { + "user_message": "А если по этой же компании СБЕРБАНК встречается во входящих поступлениях, это клиентская выручка или там может быть кредитный/депозитный банковский смысл? Не притягивай, скажи что подтверждено." + }, + { + "user_message": "Теперь дай взрослый краткий обзор ООО Альтернатива Плюс за 2020: входящие, исходящие, нетто и отдельно отметь, если в топах есть банк, почему его нельзя читать как обычного клиента или поставщика." + }, + { + "user_message": "А теперь отдельно по Группа СВК за 2020: сколько денег получили, сколько заплатили и какое нетто?" + } + ] + } + ] +}