diff --git a/llm_normalizer/backend/dist/config.js b/llm_normalizer/backend/dist/config.js index dae3413..9d36323 100644 --- a/llm_normalizer/backend/dist/config.js +++ b/llm_normalizer/backend/dist/config.js @@ -77,7 +77,7 @@ exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(process.env.FEAT exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, ""); exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default"; exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000); -exports.ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24))); +exports.ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 128))); exports.VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]); exports.VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]); exports.DATA_DIR = process.env.DATA_DIR ?? path_1.default.resolve(exports.MODULE_ROOT, "data"); diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 7be419b..957ba09 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -1733,8 +1733,7 @@ function extractAddressFilters(userMessage, intent) { const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") || warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_phrase"); - const preserveDerivedPeriodWindow = usesAsOfPrimaryWindow(intent) || - intent === "inventory_on_hand_as_of_date" || + const preserveDerivedPeriodWindow = intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; if (periodWasDerivedHeuristically && !warnings.includes("exact_historical_period_window_requested")) { warnings.push("exact_historical_period_window_requested"); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 5c10bf8..72ef097 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1672,7 +1672,8 @@ function hasVatPeriodInspectionBridgeSignal(text) { const hasPeriodCue = /(?:\b(?:19|20)\d{2}\b|за\s+(?:\d{4}|год|период|квартал|месяц|январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)|\b[1-4]\s*(?:кв|квартал))/iu.test(normalized); const hasInspectionCue = /(?:что\s+с|позици|основан|не\s+хватает|налогов[а-яё]*\s+вывод|вывод|декларац|книга\s+(?:продаж|покупок)|расшифр|разбор)/iu.test(normalized); const forecastOnlyCue = /(?:прогноз|план|примерн|ориентировочн)/iu.test(normalized) && !hasInspectionCue; - return hasPeriodCue && hasInspectionCue && !forecastOnlyCue; + const hasVatMovementInspectionCue = /(?:покаж|движен|операц|по\s+сч(?:е|ё)т|покаж|движен|операц|РїРѕ\s+СЃС‡(?:Рµ|С‘)С‚|show|movement|movements|operation|operations|account)/iu.test(normalized); + return hasPeriodCue && (hasInspectionCue || hasVatMovementInspectionCue) && !forecastOnlyCue; } function resolveUnicodeAddressIntentBridge(text) { const normalized = String(text ?? "").trim().toLowerCase(); @@ -2044,6 +2045,16 @@ function resolveAddressIntent(userMessage) { reasons }; } + const hasExplicitVatLiabilityPeriodBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && + /(?:\b(?:19|20)\d{2}\b|\u0437\u0430\s+(?:\d{4}|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446))/iu.test(text) && + /(?:\u043a\u0430\u043a\u043e\u0439|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u043d\u0430\u0447\u0438\u0441\u043b|\u0443\u043f\u043b\u0430\u0447|\u0443\u043f\u043b\u0430\u0442|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u043e\u043a\u0443\u043f|\u0432\u044b\u0447\u0435\u0442|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043f\u043e\u0437\u0438\u0446|liability|payable|charged|paid|sales|purchase|deduction|position)/iu.test(text); + if (hasExplicitVatLiabilityPeriodBridge) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "high", + reasons: ["vat_liability_explicit_period_bridge_signal_detected"] + }; + } const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && /(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) && /(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text); diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 6f9aeea..64727d4 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -197,6 +197,51 @@ const OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` УПОРЯДОЧИТЬ ПО Сумма __ORDER_DIRECTION__ `; +const DEBT_DUE_DATE_AGING_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "DUE_DATE_OPEN_BALANCE" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты, + "debit_open_balance" КАК НаправлениеОстатка +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +__WHERE_DT__ +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "DUE_DATE_OPEN_BALANCE" КАК Регистратор, + "" КАК СчетДт, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокКт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты, + "credit_open_balance" КАК НаправлениеОстатка +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +__WHERE_KT__ +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ __AS_OF_EXPR__ КАК Период, @@ -723,7 +768,7 @@ const BASE_RECIPES = [ purpose: "Build customer value ranking and incoming deal profile from bank inflow docs", required_filters: [], optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], - default_limit: 20, + default_limit: 200, account_scope_mode: "preferred", query_template: "customer_revenue_profile" }, @@ -733,7 +778,7 @@ const BASE_RECIPES = [ purpose: "Build supplier payout ranking and outgoing deal profile from bank outflow docs", required_filters: [], optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], - default_limit: 20, + default_limit: 200, account_scope_mode: "preferred", query_template: "supplier_payout_profile" }, @@ -778,6 +823,17 @@ const BASE_RECIPES = [ account_scope_mode: "preferred", query_template: "vat_liability_confirmed_tax_period_profile" }, + { + recipe_id: "address_accounting_financial_result_for_organization_v1", + intent: "accounting_financial_result_for_organization", + purpose: "Build reviewed accounting financial-result aggregate from 90/91/99 period-close movements", + required_filters: ["period_from", "period_to"], + optional_filters: ["organization", "limit", "sort"], + default_limit: 32, + account_scope: ["90", "91", "99"], + account_scope_mode: "strict", + query_template: "accounting_financial_result_profile" + }, { recipe_id: "address_inventory_on_hand_as_of_date_v1", intent: "inventory_on_hand_as_of_date", @@ -888,6 +944,17 @@ const BASE_RECIPES = [ account_scope_mode: "strict", query_template: "open_contracts_confirmed_as_of_balance_profile" }, + { + recipe_id: "address_debt_due_date_aging_for_organization_v1", + intent: "debt_due_date_aging_for_organization", + purpose: "Check open 60/62/76 settlements against contract payment-term fields and settlement document dates before claiming overdue debt", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 400, + account_scope: ["60", "62", "76"], + account_scope_mode: "strict", + query_template: "debt_due_date_aging_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -1093,6 +1160,32 @@ function buildContractValueWhereClause(filters, fieldPath, contractFieldPath) { `${contractFieldPath} <> ЗНАЧЕНИЕ(Справочник.ДоговорыКонтрагентов.ПустаяСсылка)` ]); } +function buildContractReferenceCondition(filters, fieldPaths) { + const contract = typeof filters.contract === "string" ? filters.contract.trim() : ""; + if (!contract) { + return null; + } + const contractTokens = Array.from(new Set(contract + .split(/[^A-Za-zА-Яа-яЁё0-9]+/u) + .map((token) => token.trim()) + .filter((token) => token.length >= 3) + .filter((token) => !["договор", "дог"].includes(token.toLowerCase())))); + const tokens = contractTokens.length > 0 ? contractTokens : [contract]; + const clauses = fieldPaths + .map((fieldPath) => String(fieldPath ?? "").trim()) + .filter((fieldPath) => fieldPath.length > 0) + .map((fieldPath) => { + const tokenConditions = tokens.map((token) => { + const escapedToken = toQueryStringLiteral(token); + return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`; + }); + return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`; + }); + if (clauses.length === 0) { + return null; + } + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; +} function normalizeAccountTokenForQuery(value) { const source = String(value ?? "").trim().replace(",", "."); const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/); @@ -1178,6 +1271,35 @@ function buildAccountPrefixPredicate(fieldPath, prefixes) { const clauses = normalizedPrefixes.map((prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`); return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; } +function buildDebtDueDateAgingWhereClause(filters, amountFieldPath, accountPredicate) { + const conditions = [ + `${amountFieldPath} > 0`, + `(${accountPredicate})`, + buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]), + buildCounterpartyReferenceCondition(filters, ["Остатки.Субконто1"]), + buildContractReferenceCondition(filters, ["Остатки.Субконто2"]) + ].filter((item) => Boolean(item)); + return `ГДЕ\n ${conditions.join("\n И ")}`; +} +function buildDebtDueDateAgingQuery(filters, resolvedLimit) { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + const accountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]); + return DEBT_DUE_DATE_AGING_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__WHERE_DT__", buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокДт", accountPredicate)) + .replaceAll("__WHERE_KT__", buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокКт", accountPredicate)) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); +} function buildInventoryMovementQuery(filters, resolvedLimit, side) { const debitPredicate = buildAccountPrefixPredicate("Движения.СчетДт", ["41.01"]); const creditPredicate = buildAccountPrefixPredicate("Движения.СчетКт", ["41.01"]); @@ -1246,6 +1368,148 @@ function buildCounterpartyReferenceCondition(filters, fieldPaths) { } return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; } +const ORGANIZATION_REFERENCE_STOP_WORDS = new Set([ + "ооо", + "зао", + "оао", + "ао", + "пао", + "ип", + "на", + "за", + "по", + "конец", + "начало", + "год", + "года", + "период", + "можно", + "точно", + "понять", + "какая", + "какой", + "какие", + "какую", + "компания", + "компании", + "организация", + "организации", + "дебиторка", + "дебиторки", + "кредиторка", + "кредиторки", + "просрочена", + "просроченные", + "просрочка", + "срок", + "оплаты", + "прибыль", + "маржа", + "ндс" +]); +const ORGANIZATION_REFERENCE_BOUNDARY_WORDS = new Set([ + "на", + "за", + "конец", + "начало", + "можно", + "точно", + "понять", + "какая", + "какой", + "какие", + "какую", + "дебиторка", + "дебиторки", + "кредиторка", + "кредиторки", + "просрочена", + "просроченные", + "просрочка", + "прибыль", + "маржа", + "ндс" +]); +function organizationReferenceTokens(organization) { + const rawTokens = organization + .split(/[^A-Za-zА-Яа-яЁё0-9]+/u) + .map((token) => token.trim()) + .filter((token) => token.length > 0); + const boundaryIndex = rawTokens.findIndex((token) => { + const lower = token.toLowerCase(); + return /^\d+$/.test(token) || ORGANIZATION_REFERENCE_BOUNDARY_WORDS.has(lower); + }); + const scopedTokens = boundaryIndex > 0 ? rawTokens.slice(0, boundaryIndex) : rawTokens; + return Array.from(new Set(scopedTokens + .filter((token) => token.length >= 3) + .filter((token) => !/^\d+$/.test(token)) + .filter((token) => !ORGANIZATION_REFERENCE_STOP_WORDS.has(token.toLowerCase())))).slice(0, 4); +} +function buildOrganizationReferenceCondition(filters, fieldPaths) { + const organization = typeof filters.organization === "string" ? filters.organization.trim() : ""; + if (!organization) { + return null; + } + const organizationTokens = organizationReferenceTokens(organization); + const tokens = organizationTokens.length > 0 ? organizationTokens : [organization]; + const clauses = fieldPaths + .map((fieldPath) => String(fieldPath ?? "").trim()) + .filter((fieldPath) => fieldPath.length > 0) + .map((fieldPath) => { + const tokenConditions = tokens.map((token) => { + const escapedToken = toQueryStringLiteral(token); + return `(Организации.Наименование ПОДОБНО "%${escapedToken}%" ИЛИ Организации.НаименованиеПолное ПОДОБНО "%${escapedToken}%")`; + }); + const referenceSubquery = `(ВЫБРАТЬ Организации.Ссылка ИЗ Справочник.Организации КАК Организации ` + + `ГДЕ ${tokenConditions.length === 1 ? tokenConditions[0] : tokenConditions.join(" И ")})`; + return `${fieldPath} В ${referenceSubquery}`; + }); + if (clauses.length === 0) { + return null; + } + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; +} +function buildAccountingFinancialResultAggregateSelect(filters, marker, debitLabel, creditLabel, debitPrefixes, creditPrefixes) { + const whereClause = buildWhereClause(filters, "Движения.Период", [ + debitPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетДт", debitPrefixes) : null, + creditPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетКт", creditPrefixes) : null, + buildOrganizationReferenceCondition(filters, ["Движения.Организация"]) + ].filter((item) => Boolean(item))); + return ` +ВЫБРАТЬ + ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, + "${marker}" КАК Регистратор, + "${debitLabel}" КАК СчетДт, + "${creditLabel}" КАК СчетКт, + ЕСТЬNULL(СУММА(Движения.Сумма), 0) КАК Сумма, + "" КАК СубконтоДт1, + "" КАК СубконтоДт2, + "" КАК СубконтоДт3, + "" КАК СубконтоКт1, + "" КАК СубконтоКт2, + "" КАК СубконтоКт3, + "" КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения +${whereClause}`; +} +function buildAccountingFinancialResultQuery(filters) { + const rows = [ + ["ACC90_REVENUE_KT", "ANY", "90.01", [], ["90.01"]], + ["ACC90_COST_DT", "90.02", "ANY", ["90.02"], []], + ["ACC90_SELLING_DT", "90.07", "ANY", ["90.07"], []], + ["ACC90_ADMIN_DT", "90.08", "ANY", ["90.08"], []], + ["ACC90_RESULT_TO_99_PROFIT", "90.09", "99", ["90.09"], ["99"]], + ["ACC90_RESULT_FROM_99_LOSS", "99", "90.09", ["99"], ["90.09"]], + ["ACC91_RESULT_TO_99_PROFIT", "91.09", "99", ["91.09"], ["99"]], + ["ACC91_RESULT_FROM_99_LOSS", "99", "91.09", ["99"], ["91.09"]], + ["ACC99_TO84_PROFIT_TRANSFER", "99", "84", ["99"], ["84"]], + ["ACC84_TO99_LOSS_TRANSFER", "84", "99", ["84"], ["99"]] + ]; + return rows + .map(([marker, debitLabel, creditLabel, debitPrefixes, creditPrefixes]) => buildAccountingFinancialResultAggregateSelect(filters, marker, debitLabel, creditLabel, [...debitPrefixes], [...creditPrefixes]).trim()) + .join("\nОБЪЕДИНИТЬ ВСЕ\n"); +} function buildInventorySaleDocumentQuery(filters, resolvedLimit) { const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE @@ -1324,6 +1588,8 @@ function maxLimitForIntent(intent) { intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period" || + intent === "accounting_financial_result_for_organization" || + intent === "debt_due_date_aging_for_organization" || intent === "inventory_on_hand_as_of_date" || intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || @@ -1374,7 +1640,8 @@ function buildAddressRecipePlan(recipe, filters) { recipe.query_template === "counterparty_roles_profile" || recipe.query_template === "contract_usage_profile" || recipe.query_template === "vat_payable_forecast_profile" || - recipe.query_template === "vat_liability_confirmed_tax_period_profile"; + recipe.query_template === "vat_liability_confirmed_tax_period_profile" || + recipe.query_template === "accounting_financial_result_profile"; const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit))) : recipe.default_limit; @@ -1467,97 +1734,65 @@ function buildAddressRecipePlan(recipe, filters) { .replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период")) .replaceAll("__PERIOD_TO_EXPR__", periodToExpr); })() - : recipe.query_template === "vat_payable_confirmed_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) - : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) - : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES)) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "inventory_on_hand_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) - : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) + : recipe.query_template === "accounting_financial_result_profile" + ? buildAccountingFinancialResultQuery(filters) + : recipe.query_template === "debt_due_date_aging_profile" + ? buildDebtDueDateAgingQuery(filters, resolvedLimit) + : recipe.query_template === "vat_payable_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"])) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "inventory_purchase_provenance_profile" - ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_purchase_documents_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") - : recipe.query_template === "inventory_supplier_stock_overlap_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") - : recipe.query_template === "inventory_sale_trace_profile" - ? buildInventorySaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_profitability_profile" - ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_trading_margin_proxy_profile" - ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_purchase_to_sale_chain_profile" + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES)) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : recipe.query_template === "inventory_on_hand_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : recipe.query_template === "inventory_purchase_provenance_profile" + ? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_purchase_documents_profile" + ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + : recipe.query_template === "inventory_supplier_stock_overlap_profile" + ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + : recipe.query_template === "inventory_sale_trace_profile" + ? buildInventorySaleDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_profitability_profile" ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) - : recipe.query_template === "inventory_aging_by_purchase_date_profile" - ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") - : recipe.query_template === "contracts_by_counterparty_profile" - ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) - : recipe.query_template === "open_contracts_confirmed_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) - : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) - : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"])) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "payables_confirmed_as_of_balance_profile" - ? (() => { - const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 - ? toDateTimeExpr(filters.as_of_date, true) - : null) ?? - (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 - ? toDateTimeExpr(filters.period_to, true) - : null) ?? - (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 - ? toDateTimeExpr(filters.period_from, true) - : null) ?? - "ТЕКУЩАЯДАТА()"; - return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE - .replaceAll("__LIMIT__", String(resolvedLimit)) - .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); - })() - : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + : recipe.query_template === "inventory_trading_margin_proxy_profile" + ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_purchase_to_sale_chain_profile" + ? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit) + : recipe.query_template === "inventory_aging_by_purchase_date_profile" + ? buildInventoryMovementQuery(filters, resolvedLimit, "dt") + : recipe.query_template === "contracts_by_counterparty_profile" + ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) + : recipe.query_template === "open_contracts_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 ? toDateTimeExpr(filters.as_of_date, true) @@ -1569,23 +1804,59 @@ function buildAddressRecipePlan(recipe, filters) { ? toDateTimeExpr(filters.period_from, true) : null) ?? "ТЕКУЩАЯДАТА()"; - return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE .replaceAll("__LIMIT__", String(resolvedLimit)) .replaceAll("__AS_OF_EXPR__", asOfExpr) - .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() - : MOVEMENTS_QUERY_TEMPLATE - .replace("__LIMIT__", String(resolvedLimit)) - .replace("__WHERE_CLAUSE__", (() => { - const extraConditions = []; - const accountCondition = buildMovementAccountCondition(filters); - if (accountCondition) { - extraConditions.push(accountCondition); - } - return buildWhereClause(filters, "Движения.Период", extraConditions); - })()) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + : recipe.query_template === "payables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : MOVEMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", (() => { + const extraConditions = []; + const accountCondition = buildMovementAccountCondition(filters); + if (accountCondition) { + extraConditions.push(accountCondition); + } + return buildWhereClause(filters, "Движения.Период", extraConditions); + })()) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); return { recipe, query, diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 5ccce18..9742151 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.contractCandidatesFromRows = contractCandidatesFromRows; exports.composeFactualReply = composeFactualReply; exports.inferReplyType = inferReplyType; +const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher"); const replyPackaging_1 = require("./replyPackaging"); const counterpartyAnalyticsReplyBuilders_1 = require("./counterpartyAnalyticsReplyBuilders"); const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders"); @@ -515,10 +516,12 @@ function detectValueRankingFocus(userMessage) { if (asksTotalMoneyEarned) { return "total_flow"; } + const hasCounterpartyRankingSubject = /(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart|\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442)/iu.test(text); + const asksExplicitYearBreakdown = /(?:РїРѕ\s+годам|Р·Р°\s+какие\s+РіРѕРґС‹|динамик\w*\s+РїРѕ\s+РіРѕРґ|yearly\s+breakdown|by\s+year|\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\u043a\u0430\u043a\u0438\u0435\s+\u0433\u043e\u0434\u044b|\u0434\u0438\u043d\u0430\u043c\u0438\u043a\w*\s+\u043f\u043e\s+\u0433\u043e\u0434)/iu.test(text); const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) && /(?:год|года|годы|year|years|по\s+годам)/iu.test(text) && /(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text); - if (asksYearlyRevenueRanking) { + if (asksYearlyRevenueRanking && (!hasCounterpartyRankingSubject || asksExplicitYearBreakdown)) { return "top_years_by_total"; } if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) { @@ -2516,7 +2519,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error"); const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6); const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; - lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); + lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); if (visibleProbeRows.length > 0) { lines.push(...visibleProbeRows.map((item, index) => { const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; @@ -2536,7 +2539,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия."); } else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*."); + lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*."); } if (!vatActivityDetected) { lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`); @@ -2588,12 +2591,15 @@ function composeFactualReplyBody(intent, rows, options = {}) { const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); const vatProbe = options.vatDirectSourceProbe ?? null; + const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint); + const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : ""; const lines = [ - `Коротко: подтвержденный НДС к уплате за налоговый период — ${formatConfirmedMoney(vatToPay)}.`, + `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`, `Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`, "Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.", "", "Что вошло в расчет:", + ...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []), `- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`, `- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`, `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, @@ -2602,14 +2608,14 @@ function composeFactualReplyBody(intent, rows, options = {}) { if (vatProbe && vatProbe.status === "ok") { const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; - lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); + lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`); if (vatProbe.errors.length > 0) { lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); } - lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников."); + lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников."); } else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."); + lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."); if (vatProbe.errors.length > 0) { lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); } @@ -2679,7 +2685,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { const vatProbe = options.vatDirectSourceProbe ?? null; if (vatProbe && vatProbe.status === "ok") { const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; - lines.push("", "Блок 2.1. MCP-проверка VAT-источников", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`); + lines.push("", "Блок 2.1. Проверка VAT-источников в 1С", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`); if (vatProbe.probedSources.length > 0) { lines.push(...vatProbe.probedSources.slice(0, 4).map((item, index) => { const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName; @@ -2696,7 +2702,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { } } else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Блок 2.1. MCP-проверка VAT-источников", "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."); + lines.push("", "Блок 2.1. Проверка VAT-источников в 1С", "- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."); } lines.push("", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции"); if (accountRows.length > 0) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index 5c4421c..1d3fabb 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -48,9 +48,9 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const includeTotal = focus === "full_profile" || focus === "total_only"; const includeRoles = focus === "full_profile" || focus === "roles_only"; const directLead = focus === "suppliers_only" - ? `Поставщиков (только supplier-роль): ${supplierOnly}.` + ? `Поставщиков с ролью поставщика: ${supplierOnly}.` : focus === "customers_only" - ? `Заказчиков (только customer-роль): ${customerOnly}.` + ? `Заказчиков с ролью покупателя: ${customerOnly}.` : focus === "mixed_only" ? `Контрагентов со смешанной ролью: ${mixedActive}.` : includeTotal && totalCounterparties > 0 @@ -74,9 +74,9 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { } if (includeRoles) { if (resolvedActive > 0 || activeCounterparties > 0) { - lines.push("Роли контрагентов по активности:"); - lines.push(`Заказчики (только customer-роль): ${customerOnly}.`); - lines.push(`Поставщики (только supplier-роль): ${supplierOnly}.`); + lines.push("Распределение ролей по активности:"); + lines.push(`Заказчики с ролью покупателя: ${customerOnly}.`); + lines.push(`Поставщики с ролью поставщика: ${supplierOnly}.`); lines.push(`Смешанные (и покупатель, и поставщик): ${mixedActive}.`); lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`); if (otherCounterparties !== null) { @@ -88,10 +88,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { } } if (focus === "suppliers_only") { - lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`); + lines.push(`Поставщиков с ролью поставщика: ${supplierOnly}.`); } if (focus === "customers_only") { - lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`); + lines.push(`Заказчиков с ролью покупателя: ${customerOnly}.`); } if (focus === "mixed_only") { lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`); @@ -387,6 +387,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const limit = deps.detectRankingLimit(options.userMessage, 20); const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage); const normalizedQuestion = deps.normalizeQuestionText(options.userMessage); + const asksSingleBestCounterparty = focus === "top_by_total" && + /(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) && + /(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибол|РїСЂРёРЅ[её]СЃ)/iu.test(normalizedQuestion) && + !/(?:\btop\b|топ|рейтинг|список|первые|покажи\s+топ|дай\s+топ|покаж\w*\s+топ|дай\s+топ)/iu.test(normalizedQuestion); + const effectiveLimit = asksSingleBestCounterparty ? 1 : limit; const byCounterparty = new Map(); const byYear = new Map(); const deals = []; @@ -554,7 +559,7 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { ? `Топ-${visible.length} поставщиков по максимальной разовой выплате:` : `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`; lines.unshift(heading); - lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`)); + lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`)); return (0, replyContracts_1.buildFactualListReply)(lines); } if (focus === "top_by_avg_check_min_ops") { @@ -592,8 +597,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`)); return (0, replyContracts_1.buildFactualListReply)(lines); } - const visible = rankedByTotal.slice(0, limit); + const visible = rankedByTotal.slice(0, effectiveLimit); const singleCandidateOnly = rankedByTotal.length === 1; + const rankingPeriodLabel = options.periodFrom && options.periodTo + ? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}` + : "за доступное время"; const heading = singleCandidateOnly ? isSupplier ? "Найденный поставщик по сумме выплат:" @@ -603,14 +611,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { : `Топ-${visible.length} заказчиков по сумме поступлений:`; const leadingCounterparty = visible[0] ?? null; lines.unshift(heading); + if (options.periodFrom && options.periodTo) { + lines.push(`Период рейтинга: ${rankingPeriodLabel}.`); + } if (leadingCounterparty) { const directAnswerLine = singleCandidateOnly ? isSupplier ? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` : `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.` : isSupplier - ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` - : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${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); } lines.push(...visible.map((item, index) => { diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js index ef3c7af..c4a354a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryAnswerAdapter.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = void 0; exports.buildAssistantMcpDiscoveryAnswerDraft = buildAssistantMcpDiscoveryAnswerDraft; +const counterpartyRoleHeuristics_1 = require("./counterpartyRoleHeuristics"); exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = "assistant_mcp_discovery_answer_draft_v1"; function normalizeReasonCode(value) { const normalized = value @@ -371,6 +372,26 @@ function metadataRouteFamilyLabelRu(routeFamily) { } return null; } +function isInventoryReserveBoundaryTurn(pilot) { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "inventory_reserve_boundary" || unsupported === "inventory_reserve_liquidation_boundary"; +} +function isProfitMarginBoundaryTurn(pilot) { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "profit_margin_boundary" || unsupported === "profit_margin_boundary"; +} +function isDebtDueDateBoundaryTurn(pilot) { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "debt_due_date_boundary" || unsupported === "debt_due_date_boundary"; +} +function isVendorRiskBoundaryTurn(pilot) { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "vendor_risk_procurement_boundary" || unsupported === "vendor_risk_procurement_boundary"; +} function businessOverviewInventoryUnknownLabel(overview) { if (overview.inventory_staleness_risk_proxy) { return "резервы/списания/ликвидационная стоимость склада"; @@ -433,6 +454,67 @@ function inlineBusinessOverviewAmount(value) { .replace(/\s*руб\.$/u, " рублей") .replace(/[\s.]+$/u, ""); } +function isFinancialInstitutionBucket(bucket) { + if (!bucket) { + return false; + } + return (bucket.counterparty_role_hint === "bank_or_financial_institution" || + (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(bucket.axis_value)); +} +function firstNonFinancialInstitutionBucket(buckets) { + return (buckets ?? []).find((bucket) => !isFinancialInstitutionBucket(bucket)) ?? null; +} +function rankedBucketAmountLabel(bucket) { + return `${bucket.axis_value} — ${bucket.total_amount_human_ru}`; +} +function businessOverviewIncomingLeaderLine(overview) { + const leader = overview.top_customers[0]; + if (!leader) { + return null; + } + if (!isFinancialInstitutionBucket(leader)) { + return `Самый крупный подтвержденный клиент в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`; + } + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1)); + const nonFinancialText = nonFinancial + ? ` Крупнейший небанковский входящий контрагент в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.` + : ""; + return (`Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` + + "По названию это банк/финансовая организация, поэтому без проверки назначения платежа не называю это клиентской выручкой или бизнес-заказчиком." + + nonFinancialText); +} +function businessOverviewOutgoingLeaderLine(overview) { + const leader = overview.top_suppliers?.[0]; + if (!leader) { + return null; + } + if (!isFinancialInstitutionBucket(leader)) { + return `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`; + } + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1)); + const nonFinancialText = nonFinancial + ? ` Крупнейший небанковский получатель исходящих денег в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.` + : ""; + return (`Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` + + "По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком." + + nonFinancialText); +} +function businessOverviewSupplierBoundaryBasis(overview) { + const leader = overview.top_suppliers?.[0] ?? null; + if (!leader) { + return "есть только общий срез исходящих платежей без надежного vendor-risk профиля"; + } + const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount); + if (isFinancialInstitutionBucket(leader)) { + const base = share + ? `крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})` + : `крупнейший получатель исходящих денег: ${rankedBucketAmountLabel(leader)}`; + return `${base}; по названию это банк/финансовая организация, поэтому этот факт нельзя считать доказанной зависимостью от одного обычного поставщика`; + } + return share + ? `крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})` + : `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${rankedBucketAmountLabel(leader)}`; +} function businessOverviewHeadlineMetricsLine(overview) { const parts = []; if (overview.incoming_customer_revenue.rows_with_amount > 0) { @@ -444,14 +526,72 @@ function businessOverviewHeadlineMetricsLine(overview) { if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) { parts.push(`расчетное операционное нетто ${inlineBusinessOverviewAmount(overview.net_amount_human_ru)}`); } + if (overview.accounting_financial_result) { + const result = overview.accounting_financial_result; + const direction = result.final_result_direction === "profit" + ? "учетная прибыль" + : result.final_result_direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const amount = result.final_result_direction === "loss" + ? `минус ${inlineBusinessOverviewAmount(result.final_result_amount_human_ru)}` + : inlineBusinessOverviewAmount(result.final_result_amount_human_ru); + const margin = result.net_margin_to_revenue_pct === null + ? "маржа к выручке 90.01 не рассчитана" + : `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`; + parts.push(`${direction} 90/91/99 ${amount}; ${margin}`); + } const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); if (strongestIncomingYear) { parts.push(`самый сильный год по подтвержденным входящим поступлениям ${strongestIncomingYear.year_bucket}: ${inlineBusinessOverviewAmount(strongestIncomingYear.incoming_total_amount_human_ru)}`); } return parts.length > 0 - ? `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат` + ? overview.accounting_financial_result + ? `${parts.join("; ")}. Финрезультат ограничен найденными строками 1С и не является внешним аудитом или юридически подтвержденной отчетностью` + : `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат` : null; } +function businessOverviewAccountingFinancialResultText(overview) { + const result = overview.accounting_financial_result; + if (!result) { + return null; + } + const direction = result.final_result_direction === "profit" + ? "учетная прибыль" + : result.final_result_direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const signedAmount = result.final_result_direction === "loss" + ? `минус ${result.final_result_amount_human_ru}` + : result.final_result_amount_human_ru; + const marginText = result.net_margin_to_revenue_pct === null + ? "маржа к выручке 90.01 не рассчитана" + : `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`; + const basis = result.final_transfer_basis === "account_99_to_84_period_close" + ? "по закрытию 99 на 84" + : "по закрытию 90/91 на 99"; + return `По бухгалтерскому маршруту 90/91/99 за ${result.period_scope} подтвержден ${direction}: ${signedAmount}; ${marginText}. Основа: ${basis}, ${result.period_close_rows_with_amount} строк(и) закрытия периода с суммой. Это учетный финрезультат по найденным строкам 1С, не внешний аудит и не юридически подтвержденная отчетность.`; +} +function businessOverviewDebtDueDateAgingText(overview) { + const aging = overview.debt_due_date_aging; + if (!aging) { + return null; + } + if (aging.evidence_status === "confirmed_overdue") { + const top = aging.top_overdue_items?.[0] ?? null; + const topText = top + ? ` Самая старая строка: due date ${top.due_date}, просрочка ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.` + : ""; + return `Due-date aging на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`; + } + if (aging.evidence_status === "no_payment_terms_configured") { + return `Due-date aging на ${aging.as_of_date} проверен по открытым расчетам: брутто ${aging.gross_open_amount_human_ru}, строк с суммой ${aging.rows_with_amount}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`; + } + if (aging.evidence_status === "insufficient_due_date_basis") { + return `Due-date aging на ${aging.as_of_date} запускался, но по строкам с установленным сроком оплаты не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`; + } + return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`; +} function headlineFor(mode, pilot) { const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || pilot.derived_value_flow?.aggregation_axis === "month"; @@ -469,6 +609,35 @@ function headlineFor(mode, pilot) { } if (isBusinessOverviewPilot(pilot) && pilot.derived_business_overview && mode === "confirmed_with_bounded_inference") { const overview = pilot.derived_business_overview; + if (isProfitMarginBoundaryTurn(pilot)) { + const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview); + if (accountingFinancialResultText) { + return accountingFinancialResultText; + } + return "Нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финрезультат."; + } + if (isDebtDueDateBoundaryTurn(pilot)) { + const dueDateText = businessOverviewDebtDueDateAgingText(overview); + if (dueDateText) { + return dueDateText; + } + return "Нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута по договорам, срокам оплаты и погашению расчетов."; + } + if (isInventoryReserveBoundaryTurn(pilot)) { + const inventoryBasis = overview.inventory_staleness_risk_proxy + ? "есть только складской staleness-risk proxy по найденным строкам" + : overview.inventory_position || overview.inventory_turnover_proxy + ? "есть только ограниченные складские proxy-сигналы по найденным строкам" + : "нет отдельного складского среза на дату и проверки учетной политики резервов"; + return `Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя; ${inventoryBasis}. Можно честно говорить только о необходимости отдельной проверки склада, списаний/резервов и ликвидационной стоимости, не превращая proxy в доказанный факт резерва.`; + } + if (isVendorRiskBoundaryTurn(pilot)) { + const supplierLeader = overview.top_suppliers?.[0] ?? null; + const proxyLabel = isFinancialInstitutionBucket(supplierLeader) + ? "outgoing cash concentration proxy" + : "procurement concentration proxy"; + return `Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${businessOverviewSupplierBoundaryBasis(overview)}. Это сигнал концентрации исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов.`; + } const families = []; if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) { @@ -492,6 +661,9 @@ function headlineFor(mode, pilot) { if (overview.tax_position) { families.push("НДС-позиция"); } + if (overview.accounting_financial_result) { + families.push("учетный финрезультат 90/91/99"); + } if (overview.trading_margin_proxy) { families.push("торговый margin proxy"); } @@ -507,6 +679,9 @@ function headlineFor(mode, pilot) { if (overview.debt_staleness_risk_proxy) { families.push("staleness risk proxy открытых расчетов"); } + if (overview.debt_due_date_aging) { + families.push("due-date aging открытых расчетов"); + } if (overview.inventory_position) { families.push("складской срез на дату"); } @@ -516,18 +691,22 @@ function headlineFor(mode, pilot) { if (overview.inventory_staleness_risk_proxy) { families.push("staleness risk proxy склада"); } - const unknownFamilies = [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"]; + const unknownFamilies = overview.accounting_financial_result + ? ["аудированная/юридически подтвержденная прибыль"] + : [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"]; if (!overview.tax_position) { unknownFamilies.push("НДС"); } if (!overview.debt_position) { unknownFamilies.push("долговой срез"); } - unknownFamilies.push(overview.debt_staleness_risk_proxy - ? "договорные сроки оплаты/due-date просрочка" - : overview.debt_open_settlement_quality - ? "due-date просрочка" - : "качество открытых расчетов"); + if (!overview.debt_due_date_aging) { + unknownFamilies.push(overview.debt_staleness_risk_proxy + ? "договорные сроки оплаты/due-date просрочка" + : overview.debt_open_settlement_quality + ? "due-date просрочка" + : "качество открытых расчетов"); + } unknownFamilies.push(businessOverviewInventoryUnknownLabel(overview)); const metricLead = businessOverviewHeadlineMetricsLine(overview); if (metricLead) { @@ -725,9 +904,14 @@ function buildMustNotClaim(pilot) { claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); claims.push("Do not present business overview debt staleness risk proxy as confirmed overdue debt, contractual delinquency, credit risk, or due-date aging."); + claims.push("Do not claim contractual overdue debt unless the due-date aging route found configured payment terms and enough settlement-date evidence."); claims.push("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health."); claims.push("Do not present business overview inventory turnover proxy as full inventory liquidity, FIFO turnover, obsolescence analysis, or liquidation value."); claims.push("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value."); + if (pilot.derived_business_overview?.top_customers?.some(isFinancialInstitutionBucket) || + pilot.derived_business_overview?.top_suppliers?.some(isFinancialInstitutionBucket)) { + claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof."); + } if (pilot.derived_business_overview?.missing_proof_families?.length) { claims.push("Do not present business overview missing proof families as checked, executed, or confirmed routes."); } @@ -736,6 +920,9 @@ function buildMustNotClaim(pilot) { if (pilot.derived_ranked_value_flow) { claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization."); claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist."); + if (pilot.derived_ranked_value_flow.ranked_values.some(isFinancialInstitutionBucket)) { + claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof."); + } } if (isDocumentPilot(pilot)) { claims.push("Do not claim full document history outside the checked period."); @@ -885,24 +1072,38 @@ function derivedRankedValueFlowConfirmedLine(pilot) { return null; } const leader = ranking.ranked_values[0]; + const leaderLooksFinancial = isFinancialInstitutionBucket(leader); const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : ""; const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне"; + const roleCaveat = leaderLooksFinancial + ? ranking.value_flow_direction === "outgoing_supplier_payout" + ? " По названию это банк/финансовая организация, поэтому без назначения платежа/договора не называю это обычным поставщиком." + : " По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой или бизнес-заказчиком." + : ""; if (ranking.ranked_values.length === 1) { - const singleLead = ranking.value_flow_direction === "outgoing_supplier_payout" - ? "В проверенных исходящих платежах найден один контрагент" - : "В проверенных входящих поступлениях найден один контрагент"; + const singleLead = leaderLooksFinancial + ? ranking.value_flow_direction === "outgoing_supplier_payout" + ? "В проверенных исходящих платежах найден один банковский/финансовый получатель" + : "В проверенных входящих поступлениях найден один банковский/финансовый источник" + : ranking.value_flow_direction === "outgoing_supplier_payout" + ? "В проверенных исходящих платежах найден один контрагент" + : "В проверенных входящих поступлениях найден один контрагент"; const limitCaveat = ranking.coverage_limited_by_probe_limit ? " Лимит строк проверки достигнут; сравнение с другими контрагентами может быть неполным." : " Других контрагентов в этом проверенном срезе не найдено, поэтому это не полноценный сравнительный рейтинг."; - return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${limitCaveat}`; + return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${limitCaveat}`; } - const directionLead = ranking.ranking_need === "bottom_asc" + const directionLead = leaderLooksFinancial ? ranking.value_flow_direction === "outgoing_supplier_payout" - ? "Меньше всего заплатили контрагенту" - : "Меньше всего денег принёс контрагент" - : ranking.value_flow_direction === "outgoing_supplier_payout" - ? "Больше всего заплатили контрагенту" - : "Больше всего денег принёс контрагент"; + ? "Крупнейший получатель исходящих денег" + : "Крупнейший входящий денежный источник" + : ranking.ranking_need === "bottom_asc" + ? ranking.value_flow_direction === "outgoing_supplier_payout" + ? "Меньше всего заплатили контрагенту" + : "Меньше всего денег принёс контрагент" + : ranking.value_flow_direction === "outgoing_supplier_payout" + ? "Больше всего заплатили контрагенту" + : "Больше всего денег принёс контрагент"; const tail = ranking.ranked_values .slice(1, 3) .map((bucket) => `${bucket.axis_value} — ${bucket.total_amount_human_ru}`) @@ -911,7 +1112,7 @@ function derivedRankedValueFlowConfirmedLine(pilot) { const limitCaveat = ranking.coverage_limited_by_probe_limit ? " Лимит строк проверки достигнут; рейтинг может быть неполным." : ""; - return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`; + return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${trail}${limitCaveat}`; } function derivedValueFlowConfirmedLine(pilot) { const flow = pilot.derived_value_flow; @@ -1070,13 +1271,13 @@ function derivedBusinessOverviewConfirmedLines(pilot) { if (strongestIncomingYear) { lines.push(`Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} — ${strongestIncomingYear.incoming_total_amount_human_ru} по ${strongestIncomingYear.incoming_rows_with_amount} строкам с суммой. Это не бухгалтерская прибыль.`); } - const leader = overview.top_customers[0]; - if (leader) { - lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + const incomingLeaderLine = businessOverviewIncomingLeaderLine(overview); + if (incomingLeaderLine) { + lines.push(incomingLeaderLine); } - const supplierLeader = overview.top_suppliers?.[0]; - if (supplierLeader) { - lines.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.`); + const outgoingLeaderLine = businessOverviewOutgoingLeaderLine(overview); + if (outgoingLeaderLine) { + lines.push(outgoingLeaderLine); } if (overview.yearly_breakdown?.length) { lines.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(overview.yearly_breakdown.length)}.`); @@ -1124,6 +1325,12 @@ function derivedBusinessOverviewConfirmedLines(pilot) { : "сбалансирован"; lines.push(`НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.`); } + if (overview.accounting_financial_result) { + const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview); + if (accountingFinancialResultText) { + lines.push(accountingFinancialResultText); + } + } if (overview.trading_margin_proxy) { const proxy = overview.trading_margin_proxy; const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`; @@ -1156,6 +1363,10 @@ function derivedBusinessOverviewConfirmedLines(pilot) { const counterpartyText = proxy.top_contract_counterparty ? ` / ${proxy.top_contract_counterparty}` : ""; lines.push(`Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.`); } + const dueDateText = businessOverviewDebtDueDateAgingText(overview); + if (dueDateText) { + lines.push(dueDateText); + } if (overview.inventory_position) { const leader = overview.inventory_position.top_items[0]; const leaderText = leader @@ -1203,6 +1414,13 @@ function businessOverviewCustomerConcentrationLine(overview) { return null; } const share = percentText(leader.total_amount, overview.incoming_customer_revenue.total_amount); + if (isFinancialInstitutionBucket(leader)) { + const base = share + ? `Крупнейший входящий денежный источник ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru})` + : `Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}`; + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1)); + return `${base}. По названию это банк/финансовая организация, поэтому это не доказывает клиентскую выручку или зависимость от клиента.${nonFinancial ? ` Крупнейший небанковский входящий контрагент: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`; + } return share ? `Концентрация входящего потока: крупнейший подтвержденный клиент ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru}). Это сигнал зависимости от клиента, а не полный customer-risk аудит.` : `Крупнейший подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`; @@ -1213,6 +1431,13 @@ function businessOverviewSupplierConcentrationLine(overview) { return null; } const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount); + if (isFinancialInstitutionBucket(leader)) { + const base = share + ? `Концентрация исходящего потока: крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru})` + : `Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}`; + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1)); + return `${base}. По названию это банк/финансовая организация, поэтому это не доказательство зависимости от обычного поставщика без проверки назначения платежа/договора.${nonFinancial ? ` Крупнейший небанковский получатель исходящих денег: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`; + } return share ? `Концентрация исходящего потока: крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru}). Это сигнал procurement concentration по найденным строкам, а не полный vendor-risk аудит или структура всех расходов.` : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`; @@ -1257,6 +1482,18 @@ function businessOverviewRiskSynthesisLine(overview) { : `маржинальность proxy ${overview.trading_margin_proxy.margin_to_revenue_pct}%`; signals.push(`торговый спред proxy ${overview.trading_margin_proxy.gross_spread_proxy_human_ru}, ${marginText}`); } + if (overview.accounting_financial_result) { + const result = overview.accounting_financial_result; + const direction = result.final_result_direction === "profit" + ? "учетная прибыль" + : result.final_result_direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const marginText = result.net_margin_to_revenue_pct === null + ? "маржа к выручке 90.01 не рассчитана" + : `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`; + signals.push(`${direction} 90/91/99 ${result.final_result_amount_human_ru}, ${marginText}`); + } if (overview.debt_position) { const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" ? `дебиторка больше кредиторки на ${overview.debt_position.net_debt_position_amount_human_ru}` @@ -1275,6 +1512,16 @@ function businessOverviewRiskSynthesisLine(overview) { if (overview.debt_staleness_risk_proxy) { signals.push(`staleness risk proxy открытых расчетов: ${debtStalenessRiskBandRu(overview.debt_staleness_risk_proxy.risk_band)}, возраст ${overview.debt_staleness_risk_proxy.max_contract_age_days} дн., концентрация старейшего крупного договора ${overview.debt_staleness_risk_proxy.top_contract_share_pct}%`); } + if (overview.debt_due_date_aging) { + const aging = overview.debt_due_date_aging; + signals.push(aging.evidence_status === "confirmed_overdue" + ? `due-date aging: подтвержденная просрочка ${aging.overdue_amount_human_ru}, строк ${aging.overdue_rows}` + : aging.evidence_status === "no_payment_terms_configured" + ? "due-date aging: проверено, но сроки оплаты в договорах не установлены; подтвержденной просрочки нет" + : aging.evidence_status === "insufficient_due_date_basis" + ? "due-date aging: не хватило даты расчетного документа для честного расчета просрочки" + : `due-date aging: проверено, подтвержденной просрочки не найдено`); + } if (overview.document_activity_profile) { const topDocument = overview.document_activity_profile.top_document_types[0]; const topSection = overview.document_activity_profile.top_account_sections[0]; @@ -1411,6 +1658,9 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { if (pilot.derived_business_overview?.tax_position) { pushReason(reasonCodes, "answer_contains_business_overview_tax_position"); } + if (pilot.derived_business_overview?.accounting_financial_result) { + pushReason(reasonCodes, "answer_contains_business_overview_accounting_financial_result"); + } if (pilot.derived_business_overview?.trading_margin_proxy) { pushReason(reasonCodes, "answer_contains_business_overview_trading_margin_proxy"); } @@ -1441,6 +1691,10 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) { if (pilot.derived_business_overview?.debt_staleness_risk_proxy) { pushReason(reasonCodes, "answer_contains_business_overview_debt_staleness_risk_proxy"); } + if (pilot.derived_business_overview?.debt_due_date_aging) { + pushReason(reasonCodes, "answer_contains_business_overview_debt_due_date_aging"); + pushReason(reasonCodes, `answer_contains_business_overview_debt_due_date_aging_${pilot.derived_business_overview.debt_due_date_aging.evidence_status}`); + } if (pilot.derived_business_overview?.inventory_position) { pushReason(reasonCodes, "answer_contains_business_overview_inventory_position"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDebugAttachment.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDebugAttachment.js index ebe70e6..26360e2 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDebugAttachment.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDebugAttachment.js @@ -33,6 +33,11 @@ function isMcpDiscoveryEntryPointContract(value) { return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" && record?.policy_owner === "assistantMcpDiscoveryRuntimeEntryPoint"); } +function isRouteCandidateContract(value) { + const record = toRecordObject(value); + return (record?.schema_version === "assistant_mcp_route_candidate_v1" && + record?.policy_owner === "assistantMcpDiscoveryRuntimeBridge"); +} function resolveEntryPoint(input) { if (isMcpDiscoveryEntryPointContract(input.entryPoint)) { return input.entryPoint; @@ -47,6 +52,7 @@ function buildAssistantMcpDiscoveryDebugAttachmentFields(input) { const bridge = toRecordObject(entryPoint?.bridge); const planner = toRecordObject(bridge?.planner); const chainAlignment = toRecordObject(planner?.catalog_chain_template_alignment); + const routeCandidate = isRouteCandidateContract(bridge?.route_candidate) ? bridge.route_candidate : null; const answerDraft = toRecordObject(bridge?.answer_draft); return { assistant_mcp_discovery_entry_point_v1: entryPoint, @@ -59,6 +65,16 @@ function buildAssistantMcpDiscoveryDebugAttachmentFields(input) { mcp_discovery_catalog_chain_alignment_status: toNonEmptyString(chainAlignment?.alignment_status), mcp_discovery_catalog_chain_top_match: toNonEmptyString(chainAlignment?.top_chain_template_match), mcp_discovery_catalog_chain_selected_matches_top: chainAlignment?.selected_chain_matches_top === true, + mcp_discovery_route_candidate_v1: routeCandidate, + mcp_discovery_route_candidate_status: toNonEmptyString(routeCandidate?.candidate_status), + mcp_discovery_route_candidate_fact_family: toNonEmptyString(routeCandidate?.business_fact_family), + mcp_discovery_route_candidate_action_family: toNonEmptyString(routeCandidate?.action_family), + mcp_discovery_route_candidate_proof_expectation: toNonEmptyString(routeCandidate?.proof_expectation), + mcp_discovery_route_candidate_missing_axes: toStringArray(routeCandidate?.missing_axes), + mcp_discovery_route_candidate_provided_axes: toStringArray(routeCandidate?.provided_axes), + mcp_discovery_route_candidate_executable_now: routeCandidate?.executable_now === true, + mcp_discovery_route_candidate_enablement_reason: toNonEmptyString(routeCandidate?.enablement_reason), + mcp_discovery_route_candidate_next_action: toNonEmptyString(routeCandidate?.recommended_next_action), mcp_discovery_answer_mode: toNonEmptyString(answerDraft?.answer_mode), mcp_discovery_business_fact_answer_allowed: bridge?.business_fact_answer_allowed === true, mcp_discovery_user_facing_response_allowed: bridge?.user_facing_response_allowed === true, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js index 51d9bca..65a0864 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPilotExecutor.js @@ -6,6 +6,7 @@ const addressMcpClient_1 = require("./addressMcpClient"); const assistantMcpDiscoveryRuntimeAdapter_1 = require("./assistantMcpDiscoveryRuntimeAdapter"); const assistantMcpDiscoveryPolicy_1 = require("./assistantMcpDiscoveryPolicy"); const addressRecipeCatalog_1 = require("./addressRecipeCatalog"); +const counterpartyRoleHeuristics_1 = require("./counterpartyRoleHeuristics"); exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION = "assistant_mcp_discovery_pilot_executor_v1"; const DEFAULT_DEPS = { executeAddressMcpQuery: addressMcpClient_1.executeAddressMcpQuery, @@ -200,6 +201,16 @@ function buildBusinessOverviewDebtFilters(planner) { sort: "period_asc" }; } +function shouldRunDebtDueDateAgingProbe(planner) { + const actionFamily = toNonEmptyString(planner.data_need_graph?.action_family); + const turnActionFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_action_family); + const unsupportedFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family); + const proofExpectation = toNonEmptyString(planner.data_need_graph?.proof_expectation); + const combined = [actionFamily, turnActionFamily, unsupportedFamily, proofExpectation] + .filter((item) => Boolean(item)) + .join(" "); + return /(?:debt_due_date_boundary|due[-_ ]?date|overdue|aging|просроч|срок\s+оплат|дебиторк|кредиторск)/iu.test(combined); +} function buildBusinessOverviewInventoryFilters(planner) { const meaning = planner.discovery_plan.turn_meaning_ref; const organization = toNonEmptyString(meaning?.explicit_organization_scope); @@ -231,6 +242,17 @@ function buildBusinessOverviewTradingMarginFilters(planner) { sort: "period_asc" }; } +function buildBusinessOverviewAccountingFinancialResultFilters(planner) { + const filters = buildBusinessOverviewTradingMarginFilters(planner); + if (!filters) { + return null; + } + return { + ...filters, + limit: Math.max(32, planner.discovery_plan.execution_budget.max_rows_per_probe), + sort: "period_asc" + }; +} function buildInventoryExactFilters(planner) { const meaning = planner.discovery_plan.turn_meaning_ref; const subject = firstEntityCandidate(planner); @@ -715,7 +737,8 @@ async function executeCoverageAwareValueFlowQuery(input) { }); executedProbeCount += 1; probeResults.push(queryResultToProbeResult(input.primitiveId, broadResult)); - const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= input.maxRowsPerProbe; + const broadLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, broadRecipePlan.limit)); + const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= broadLimitThreshold; if (broadResult.error) { pushUnique(queryLimitations, broadResult.error); return { @@ -772,7 +795,8 @@ async function executeCoverageAwareValueFlowQuery(input) { pushUnique(queryLimitations, chunkResult.error); continue; } - if (chunkResult.matched_rows >= input.maxRowsPerProbe) { + const chunkLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, chunkPlan.limit)); + if (chunkResult.matched_rows >= chunkLimitThreshold) { anyChunkLimited = true; } chunkResults.push(chunkResult); @@ -1604,6 +1628,13 @@ function extractContractDateFromText(value) { if (!text || !/(?:договор|contract|дог\.)/iu.test(text)) { return null; } + return extractAnyDateFromText(text); +} +function extractAnyDateFromText(value) { + const text = toNonEmptyString(value); + if (!text) { + return null; + } const isoLikeMatch = text.match(/(\d{4})[-./](\d{1,2})[-./](\d{1,2})/); if (isoLikeMatch) { return normalizeDateParts(isoLikeMatch[1], isoLikeMatch[2], isoLikeMatch[3]); @@ -1614,6 +1645,59 @@ function extractContractDateFromText(value) { } return null; } +function rowContractDateValue(row) { + const explicit = rowTextValue(row, ["ДатаДоговора", "ContractDate", "contract_date"]); + return extractAnyDateFromText(explicit) ?? rowOpenSettlementContractStartDateValue(row); +} +function rowSettlementDocumentDateValue(row) { + const explicit = rowTextValue(row, [ + "ДатаДокументаРасчетов", + "SettlementDocumentDate", + "settlement_document_date" + ]); + const settlementDocument = rowTextValue(row, [ + "ДокументРасчетов", + "SettlementDocument", + "settlement_document" + ]); + return extractAnyDateFromText(explicit) ?? extractAnyDateFromText(settlementDocument) ?? extractAnyDateFromText(rowDocumentValue(row)); +} +function rowPaymentTermIsSetValue(row) { + const candidate = rowTextValue(row, ["УстановленСрокОплаты", "PaymentTermIsSet", "payment_term_is_set"]); + if (typeof row["УстановленСрокОплаты"] === "boolean") { + return row["УстановленСрокОплаты"] === true; + } + if (typeof row["PaymentTermIsSet"] === "boolean") { + return row["PaymentTermIsSet"] === true; + } + if (!candidate) { + return false; + } + return /^(?:true|истина|да|yes|1)$/iu.test(candidate.trim()); +} +function rowPaymentTermDaysValue(row) { + const value = rowNumberValue(row, ["СрокОплаты", "PaymentTermDays", "payment_term_days"]); + if (value === null || !Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} +function addDaysToIsoDate(isoDate, days) { + const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match || !Number.isFinite(days)) { + return null; + } + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + date.setUTCDate(date.getUTCDate() + Math.trunc(days)); + if (Number.isNaN(date.getTime())) { + return null; + } + return [ + String(date.getUTCFullYear()).padStart(4, "0"), + String(date.getUTCMonth() + 1).padStart(2, "0"), + String(date.getUTCDate()).padStart(2, "0") + ].join("-"); +} function earlierIsoDate(left, right) { if (!left) { return right; @@ -2038,7 +2122,8 @@ function deriveRankedValueFlow(result, input) { axis_value: axisValue, rows_with_amount: bucket.rows_with_amount, total_amount: bucket.total_amount, - total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount), + counterparty_role_hint: (0, counterpartyRoleHeuristics_1.counterpartyRoleHintForName)(axisValue) })) .sort((left, right) => { const amountDelta = right.total_amount - left.total_amount; @@ -2199,6 +2284,99 @@ function deriveBusinessOverviewTaxPosition(result, periodScope) { inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows" }; } +function accountingFinancialResultMarkerAmount(result, marker) { + let total = 0; + for (const row of result.rows) { + if (String(rowDocumentValue(row) ?? "") !== marker) { + continue; + } + const amount = rowAmountValue(row); + if (amount !== null && Number.isFinite(amount)) { + total += amount; + } + } + return total; +} +function accountingFinancialResultNonZeroCount(values) { + return values.filter((value) => Math.abs(value) > 0).length; +} +function deriveBusinessOverviewAccountingFinancialResult(result, periodScope) { + if (!result || result.error || result.matched_rows <= 0 || !periodScope) { + return null; + } + const salesRevenueAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_REVENUE_KT"); + const costOfSalesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_COST_DT"); + const sellingExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_SELLING_DT"); + const adminExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_ADMIN_DT"); + const salesProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_TO_99_PROFIT"); + const salesLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_FROM_99_LOSS"); + const otherProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_TO_99_PROFIT"); + const otherLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_FROM_99_LOSS"); + const profitTransferTo84 = accountingFinancialResultMarkerAmount(result, "ACC99_TO84_PROFIT_TRANSFER"); + const lossTransferFrom84 = accountingFinancialResultMarkerAmount(result, "ACC84_TO99_LOSS_TRANSFER"); + const amountSignals = [ + salesRevenueAccounting, + costOfSalesAccounting, + sellingExpensesAccounting, + adminExpensesAccounting, + salesProfitTo99, + salesLossFrom99, + otherProfitTo99, + otherLossFrom99, + profitTransferTo84, + lossTransferFrom84 + ]; + const rowsWithAmount = accountingFinancialResultNonZeroCount(amountSignals); + if (rowsWithAmount <= 0) { + return null; + } + const salesResultAmount = salesProfitTo99 - salesLossFrom99; + const otherResultAmount = otherProfitTo99 - otherLossFrom99; + const hasFinalTransfer = profitTransferTo84 > 0 || lossTransferFrom84 > 0; + const finalResultAmount = hasFinalTransfer + ? profitTransferTo84 - lossTransferFrom84 + : salesResultAmount + otherResultAmount; + const finalResultDirection = finalResultAmount > 0 + ? "profit" + : finalResultAmount < 0 + ? "loss" + : "balanced"; + const netMarginToRevenuePct = salesRevenueAccounting > 0 ? percentageOfTotal(finalResultAmount, salesRevenueAccounting) : null; + const periodCloseRowsWithAmount = accountingFinancialResultNonZeroCount([ + salesProfitTo99, + salesLossFrom99, + otherProfitTo99, + otherLossFrom99, + profitTransferTo84, + lossTransferFrom84 + ]); + return { + period_scope: periodScope, + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + sales_revenue_accounting: salesRevenueAccounting, + sales_revenue_accounting_human_ru: formatAmountHumanRu(salesRevenueAccounting), + cost_of_sales_accounting: costOfSalesAccounting, + cost_of_sales_accounting_human_ru: formatAmountHumanRu(costOfSalesAccounting), + selling_expenses_accounting: sellingExpensesAccounting, + selling_expenses_accounting_human_ru: formatAmountHumanRu(sellingExpensesAccounting), + admin_expenses_accounting: adminExpensesAccounting, + admin_expenses_accounting_human_ru: formatAmountHumanRu(adminExpensesAccounting), + sales_result_amount: salesResultAmount, + sales_result_amount_human_ru: formatAmountHumanRu(Math.abs(salesResultAmount)), + other_result_amount: otherResultAmount, + other_result_amount_human_ru: formatAmountHumanRu(Math.abs(otherResultAmount)), + final_result_amount: finalResultAmount, + final_result_amount_human_ru: formatAmountHumanRu(Math.abs(finalResultAmount)), + final_result_direction: finalResultDirection, + net_margin_to_revenue_pct: netMarginToRevenuePct, + final_transfer_basis: hasFinalTransfer + ? "account_99_to_84_period_close" + : "account_90_91_to_99_period_close", + period_close_rows_with_amount: periodCloseRowsWithAmount, + inference_basis: "account_90_91_99_period_close_aggregate_confirmed_1c_rows" + }; +} function deriveBusinessOverviewTradingMarginProxy(result, periodScope) { if (!result || result.error || result.matched_rows <= 0 || !periodScope) { return null; @@ -2554,6 +2732,121 @@ function deriveBusinessOverviewDebtStalenessRiskProxy(quality) { inference_basis: "contract_date_age_and_open_balance_concentration_confirmed_1c_rows" }; } +function deriveBusinessOverviewDebtDueDateAging(input) { + if (!input.debtAsOfDate || !input.dueDateResult || input.dueDateResult.error || input.dueDateResult.matched_rows <= 0) { + return null; + } + const overdueItems = []; + let rowsWithAmount = 0; + let grossOpenAmount = 0; + let rowsWithPaymentTerms = 0; + let rowsWithoutPaymentTerms = 0; + let rowsWithoutDocumentDate = 0; + let overdueAmount = 0; + let notYetDueAmount = 0; + let notYetDueRows = 0; + for (const row of input.dueDateResult.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + const absAmount = Math.abs(amount); + if (absAmount <= 0) { + continue; + } + rowsWithAmount += 1; + grossOpenAmount += absAmount; + const paymentTermIsSet = rowPaymentTermIsSetValue(row); + const paymentTermDays = rowPaymentTermDaysValue(row); + if (!paymentTermIsSet || paymentTermDays === null) { + rowsWithoutPaymentTerms += 1; + continue; + } + rowsWithPaymentTerms += 1; + const documentDate = rowSettlementDocumentDateValue(row) ?? rowContractDateValue(row); + if (!documentDate) { + rowsWithoutDocumentDate += 1; + continue; + } + const dueDate = addDaysToIsoDate(documentDate, paymentTermDays); + if (!dueDate) { + rowsWithoutDocumentDate += 1; + continue; + } + const overdueDays = dueDate < input.debtAsOfDate ? daysBetweenIsoDates(dueDate, input.debtAsOfDate) : null; + if (overdueDays !== null && overdueDays > 0) { + overdueAmount += absAmount; + overdueItems.push({ + counterparty: rowCounterpartyValue(row), + contract: rowContractValue(row), + settlement_document: rowTextValue(row, [ + "ДокументРасчетов", + "SettlementDocument", + "settlement_document" + ]) ?? rowDocumentValue(row), + document_date: documentDate, + due_date: dueDate, + payment_term_days: paymentTermDays, + overdue_days: overdueDays, + amount: absAmount, + amount_human_ru: formatAmountHumanRu(absAmount) + }); + } + else { + notYetDueRows += 1; + notYetDueAmount += absAmount; + } + } + if (rowsWithAmount <= 0) { + return null; + } + const topOverdueItems = overdueItems + .sort((left, right) => { + const daysDelta = right.overdue_days - left.overdue_days; + if (daysDelta !== 0) { + return daysDelta; + } + const amountDelta = right.amount - left.amount; + return amountDelta !== 0 ? amountDelta : String(left.contract ?? "").localeCompare(String(right.contract ?? ""), "ru"); + }) + .slice(0, 5) + .map((item) => ({ + ...item, + share_of_overdue_amount_pct: percentageOfTotal(item.amount, overdueAmount) + })); + const oldestDueDate = overdueItems + .map((item) => item.due_date) + .sort()[0] ?? null; + const maxOverdueDays = overdueItems.reduce((max, item) => max === null ? item.overdue_days : Math.max(max, item.overdue_days), null); + const evidenceStatus = overdueItems.length > 0 + ? "confirmed_overdue" + : rowsWithPaymentTerms <= 0 + ? "no_payment_terms_configured" + : rowsWithoutDocumentDate >= rowsWithPaymentTerms + ? "insufficient_due_date_basis" + : "no_overdue_found"; + return { + as_of_date: input.debtAsOfDate, + rows_matched: input.dueDateResult.matched_rows, + rows_with_amount: rowsWithAmount, + gross_open_amount: grossOpenAmount, + gross_open_amount_human_ru: formatAmountHumanRu(grossOpenAmount), + rows_with_payment_terms: rowsWithPaymentTerms, + rows_without_payment_terms: rowsWithoutPaymentTerms, + rows_without_document_date: rowsWithoutDocumentDate, + overdue_rows: overdueItems.length, + overdue_amount: overdueAmount, + overdue_amount_human_ru: formatAmountHumanRu(overdueAmount), + not_yet_due_rows: notYetDueRows, + not_yet_due_amount: notYetDueAmount, + not_yet_due_amount_human_ru: formatAmountHumanRu(notYetDueAmount), + oldest_due_date: oldestDueDate, + max_overdue_days: maxOverdueDays, + top_overdue_items: topOverdueItems, + evidence_status: evidenceStatus, + inference_basis: "contract_payment_terms_and_settlement_document_dates_from_open_balance_rows" + }; +} function debtStalenessRiskBandRu(riskBand) { if (riskBand === "high") { return "высокая зона внимания"; @@ -2748,7 +3041,7 @@ function buildBusinessOverviewMissingProofFamilies(input) { items.push(item); } }; - if (missing.has("profit_margin") || missing.has("accounting_profit_margin")) { + if ((missing.has("profit_margin") || missing.has("accounting_profit_margin")) && !input.accountingFinancialResult) { pushUnique({ family: "accounting_profit_margin", current_status: input.tradingMarginProxy ? "proxy_only_currently" : "reviewed_route_not_wired", @@ -2759,7 +3052,7 @@ function buildBusinessOverviewMissingProofFamilies(input) { must_not_claim: "clean_profit_accounting_margin_or_full_pnl" }); } - if (missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) { + if ((missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) && !input.debtDueDateAging) { pushUnique({ family: "debt_due_date_aging_quality", current_status: input.debtStalenessRiskProxy @@ -2830,6 +3123,7 @@ function deriveBusinessOverview(input) { }); const activityPeriod = deriveActivityPeriod(input.lifecycleResult); const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope); + const accountingFinancialResult = deriveBusinessOverviewAccountingFinancialResult(input.accountingFinancialResultResult, input.periodScope); const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope); const debtPosition = deriveBusinessOverviewDebtPosition({ receivablesResult: input.receivablesResult, @@ -2844,6 +3138,10 @@ function deriveBusinessOverview(input) { const counterpartyProfile = deriveBusinessOverviewCounterpartyProfile(input.counterpartyProfileResult, input.periodScope); const contractUsageProfile = deriveBusinessOverviewContractUsageProfile(input.contractUsageProfileResult, input.periodScope); const debtStalenessRiskProxy = deriveBusinessOverviewDebtStalenessRiskProxy(debtOpenSettlementQuality); + const debtDueDateAging = deriveBusinessOverviewDebtDueDateAging({ + dueDateResult: input.dueDateAgingResult, + debtAsOfDate: input.debtAsOfDate + }); const inventoryPosition = deriveBusinessOverviewInventoryPosition({ inventoryOnHandResult: input.inventoryOnHandResult, inventoryAgingResult: input.inventoryAgingResult, @@ -2862,10 +3160,12 @@ function deriveBusinessOverview(input) { outgoing.rows_with_amount > 0, Boolean(activityPeriod), Boolean(taxPosition), + Boolean(accountingFinancialResult), Boolean(tradingMarginProxy), Boolean(debtPosition), Boolean(debtOpenSettlementQuality), Boolean(debtStalenessRiskProxy), + Boolean(debtDueDateAging), Boolean(documentActivityProfile), Boolean(counterpartyProfile), Boolean(contractUsageProfile), @@ -2879,9 +3179,9 @@ function deriveBusinessOverview(input) { const netAmount = incoming.total_amount - outgoing.total_amount; const hasBusinessOverviewProfileSignal = Boolean(documentActivityProfile || counterpartyProfile || contractUsageProfile); const missingSignalFamilies = [ - tradingMarginProxy ? "accounting_profit_margin" : "profit_margin", + accountingFinancialResult ? null : tradingMarginProxy ? "accounting_profit_margin" : "profit_margin", debtPosition ? null : "debt_position", - debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", + debtDueDateAging ? null : debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", taxPosition ? null : "tax_position", inventoryPosition ? inventoryStalenessRiskProxy @@ -2894,9 +3194,11 @@ function deriveBusinessOverview(input) { ].filter((item) => Boolean(item)); const missingProofFamilies = buildBusinessOverviewMissingProofFamilies({ missingSignalFamilies, + accountingFinancialResult, tradingMarginProxy, debtOpenSettlementQuality, debtStalenessRiskProxy, + debtDueDateAging, inventoryPosition, inventoryTurnoverProxy, inventoryStalenessRiskProxy, @@ -2915,10 +3217,12 @@ function deriveBusinessOverview(input) { yearly_breakdown: yearlyBreakdown, activity_period: activityPeriod, tax_position: taxPosition, + accounting_financial_result: accountingFinancialResult, trading_margin_proxy: tradingMarginProxy, debt_position: debtPosition, debt_open_settlement_quality: debtOpenSettlementQuality, debt_staleness_risk_proxy: debtStalenessRiskProxy, + debt_due_date_aging: debtDueDateAging, inventory_position: inventoryPosition, inventory_turnover_proxy: inventoryTurnoverProxy, inventory_staleness_risk_proxy: inventoryStalenessRiskProxy, @@ -2929,9 +3233,9 @@ function deriveBusinessOverview(input) { checked_signal_count: checkedSignalCount, missing_signal_families: missingSignalFamilies, missing_proof_families: missingProofFamilies, - inference_basis: hasBusinessOverviewProfileSignal || inventoryPosition + inference_basis: hasBusinessOverviewProfileSignal || inventoryPosition || accountingFinancialResult ? "business_overview_from_confirmed_1c_multi_family_rows" - : debtOpenSettlementQuality + : debtOpenSettlementQuality || debtDueDateAging ? "business_overview_from_confirmed_1c_multi_family_rows" : taxPosition && debtPosition ? "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows" @@ -2956,6 +3260,9 @@ function summarizeBusinessOverviewRows(input) { if (input.taxResult && !input.taxResult.error) { parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`); } + if (input.accountingFinancialResultResult && !input.accountingFinancialResultResult.error) { + parts.push(`${input.accountingFinancialResultResult.fetched_rows} accounting financial-result aggregate rows fetched, ${input.accountingFinancialResultResult.matched_rows} matched`); + } if (input.tradingMarginResult && !input.tradingMarginResult.error) { parts.push(`${input.tradingMarginResult.fetched_rows} trading-margin document rows fetched, ${input.tradingMarginResult.matched_rows} matched`); } @@ -2968,6 +3275,9 @@ function summarizeBusinessOverviewRows(input) { if (input.openContractsResult && !input.openContractsResult.error) { parts.push(`${input.openContractsResult.fetched_rows} open-contract rows fetched, ${input.openContractsResult.matched_rows} matched`); } + if (input.dueDateAgingResult && !input.dueDateAgingResult.error) { + parts.push(`${input.dueDateAgingResult.fetched_rows} due-date aging rows fetched, ${input.dueDateAgingResult.matched_rows} matched`); + } if (input.documentActivityProfileResult && !input.documentActivityProfileResult.error) { parts.push(`${input.documentActivityProfileResult.fetched_rows} document/account-section profile rows fetched, ${input.documentActivityProfileResult.matched_rows} matched`); } @@ -3000,11 +3310,29 @@ function buildBusinessOverviewConfirmedFacts(derived) { } if (derived.top_customers.length > 0) { const leader = derived.top_customers[0]; - facts.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + if ((0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(leader.axis_value)) { + facts.push(`Крупнейший входящий денежный источник в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа это не доказанный клиент или бизнес-выручка.`); + const nonFinancialLeader = derived.top_customers.slice(1).find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value)); + if (nonFinancialLeader) { + facts.push(`Крупнейший небанковский входящий контрагент в проверенном срезе: ${nonFinancialLeader.axis_value} — ${nonFinancialLeader.total_amount_human_ru}.`); + } + } + else { + facts.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + } } if (derived.top_suppliers.length > 0) { const leader = derived.top_suppliers[0]; - facts.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + if ((0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(leader.axis_value)) { + facts.push(`Крупнейший получатель исходящих денег в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора это не доказанный обычный поставщик.`); + const nonFinancialLeader = derived.top_suppliers.slice(1).find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value)); + if (nonFinancialLeader) { + facts.push(`Крупнейший небанковский получатель исходящих денег в проверенном срезе: ${nonFinancialLeader.axis_value} — ${nonFinancialLeader.total_amount_human_ru}.`); + } + } + else { + facts.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + } } if (derived.yearly_breakdown.length > 0) { facts.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(derived.yearly_breakdown.length)}.`); @@ -3087,6 +3415,25 @@ function buildBusinessOverviewConfirmedFacts(derived) { const counterpartyText = proxy.top_contract_counterparty ? ` / ${proxy.top_contract_counterparty}` : ""; facts.push(`Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.`); } + if (derived.debt_due_date_aging) { + const aging = derived.debt_due_date_aging; + if (aging.evidence_status === "confirmed_overdue") { + const top = aging.top_overdue_items[0]; + const topText = top + ? ` Самая старая просрочка: ${top.due_date}, ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.` + : ""; + facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`); + } + else if (aging.evidence_status === "no_payment_terms_configured") { + facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} проверен по ${aging.rows_with_amount} строкам с суммой: брутто открытых остатков ${aging.gross_open_amount_human_ru}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`); + } + else if (aging.evidence_status === "insufficient_due_date_basis") { + facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} запускался по ${aging.rows_with_payment_terms} строкам с установленным сроком оплаты, но в найденных строках не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`); + } + else { + facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`); + } + } if (derived.inventory_position) { const leader = derived.inventory_position.top_items[0]; const leaderText = leader @@ -3133,6 +3480,9 @@ function buildBusinessOverviewInferredFacts(derived) { const supplierSharePct = supplierLeader ? percentageOfTotal(supplierLeader.total_amount, derived.outgoing_supplier_payout.total_amount) : null; + const supplierLeaderIsFinancial = supplierLeader + ? (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(supplierLeader.axis_value) + : false; const strongestIncomingYear = [...derived.yearly_breakdown] .filter((bucket) => bucket.incoming_total_amount > 0) .sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0]; @@ -3142,9 +3492,13 @@ function buildBusinessOverviewInferredFacts(derived) { return [ `Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`, supplierLeader - ? supplierSharePct !== null - ? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.` - : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.` + ? supplierLeaderIsFinancial + ? supplierSharePct !== null + ? `Крупнейший получатель исходящих денег ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). По названию это банк/финансовая организация, поэтому это outgoing cash concentration proxy, а не доказанный vendor-risk по обычному поставщику.` + : `Крупнейший получатель исходящих денег в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому это не доказанный обычный поставщик.` + : supplierSharePct !== null + ? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.` + : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.` : null, strongestIncomingYear ? `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} (${strongestIncomingYear.incoming_total_amount_human_ru}).` @@ -3803,10 +4157,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { let outgoingResult = null; let lifecycleResult = null; let taxResult = null; + let accountingFinancialResultResult = null; let tradingMarginResult = null; let receivablesResult = null; let payablesResult = null; let openContractsResult = null; + let dueDateAgingResult = null; let documentActivityProfileResult = null; let counterpartyProfileResult = null; let contractUsageProfileResult = null; @@ -3816,8 +4172,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const lifecycleFilters = buildLifecycleFilters(planner); const profileFilters = buildBusinessOverviewProfileFilters(planner); const taxFilters = buildBusinessOverviewTaxFilters(planner); + const accountingFinancialResultFilters = buildBusinessOverviewAccountingFinancialResultFilters(planner); const tradingMarginFilters = buildBusinessOverviewTradingMarginFilters(planner); const debtFilters = buildBusinessOverviewDebtFilters(planner); + const debtDueDateAgingProbeEnabled = shouldRunDebtDueDateAgingProbe(planner); const inventoryFilters = buildBusinessOverviewInventoryFilters(planner); const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date); const inventoryAsOfDate = toNonEmptyString(inventoryFilters?.as_of_date); @@ -3830,6 +4188,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const taxSelection = taxFilters ? (0, addressRecipeCatalog_1.selectAddressRecipe)("vat_liability_confirmed_for_tax_period", taxFilters) : null; + const accountingFinancialResultSelection = accountingFinancialResultFilters + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("accounting_financial_result_for_organization", accountingFinancialResultFilters) + : null; const tradingMarginSelection = tradingMarginFilters ? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_trading_margin_proxy_for_organization", tradingMarginFilters) : null; @@ -3842,6 +4203,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { const openContractsSelection = debtFilters ? (0, addressRecipeCatalog_1.selectAddressRecipe)("open_contracts_confirmed_as_of_date", debtFilters) : null; + const dueDateAgingSelection = debtFilters && debtDueDateAgingProbeEnabled + ? (0, addressRecipeCatalog_1.selectAddressRecipe)("debt_due_date_aging_for_organization", debtFilters) + : null; const inventoryOnHandSelection = inventoryFilters ? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_on_hand_as_of_date", inventoryFilters) : null; @@ -3909,6 +4273,16 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available"); pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe"); } + if (accountingFinancialResultSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_selected"); + } + else if (!accountingFinancialResultFilters) { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_probe_skipped_without_explicit_period"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_not_available"); + pushUnique(queryLimitations, "Business overview accounting financial-result probe requires an executable 90/91/99 period-close recipe"); + } if (tradingMarginSelection?.selected_recipe) { pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_selected"); } @@ -3939,6 +4313,19 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_not_available"); pushUnique(queryLimitations, "Business overview open-settlement quality probe requires executable open-contracts as-of-date recipe"); } + if (dueDateAgingSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_selected"); + } + else if (!debtFilters) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_explicit_as_of_date"); + } + else if (!debtDueDateAgingProbeEnabled) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_boundary_need"); + } + else { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_not_available"); + pushUnique(queryLimitations, "Business overview due-date aging probe requires executable contract payment-term/open-balance recipe"); + } if (inventoryOnHandSelection?.selected_recipe) { pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_recipe_selected"); if (inventoryAgingSelection?.selected_recipe) { @@ -3982,6 +4369,14 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { account_scope: taxPlan.account_scope }); } + if (accountingFinancialResultSelection?.selected_recipe) { + const accountingFinancialResultPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(accountingFinancialResultSelection.selected_recipe, accountingFinancialResultFilters); + accountingFinancialResultResult = await runtimeDeps.executeAddressMcpQuery({ + query: accountingFinancialResultPlan.query, + limit: accountingFinancialResultPlan.limit, + account_scope: accountingFinancialResultPlan.account_scope + }); + } if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { const receivablesPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(receivablesSelection.selected_recipe, debtFilters); receivablesResult = await runtimeDeps.executeAddressMcpQuery({ @@ -4004,6 +4399,14 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { account_scope: openContractsPlan.account_scope }); } + if (dueDateAgingSelection?.selected_recipe) { + const dueDateAgingPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(dueDateAgingSelection.selected_recipe, debtFilters); + dueDateAgingResult = await runtimeDeps.executeAddressMcpQuery({ + query: dueDateAgingPlan.query, + limit: dueDateAgingPlan.limit, + account_scope: dueDateAgingPlan.account_scope + }); + } if (inventoryOnHandSelection?.selected_recipe) { const inventoryOnHandPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(inventoryOnHandSelection.selected_recipe, inventoryFilters); inventoryOnHandResult = await runtimeDeps.executeAddressMcpQuery({ @@ -4025,6 +4428,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (taxResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, taxResult)); } + if (accountingFinancialResultResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, accountingFinancialResultResult)); + } if (receivablesResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, receivablesResult)); } @@ -4034,6 +4440,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (openContractsResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, openContractsResult)); } + if (dueDateAgingResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, dueDateAgingResult)); + } if (inventoryOnHandResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryOnHandResult)); } @@ -4059,6 +4468,13 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { else if (taxResult) { pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_executed"); } + if (accountingFinancialResultResult?.error) { + pushUnique(queryLimitations, accountingFinancialResultResult.error); + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_error"); + } + else if (accountingFinancialResultResult) { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_executed"); + } if (receivablesResult?.error) { pushUnique(queryLimitations, receivablesResult.error); pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_error"); @@ -4084,6 +4500,13 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { else if (openContractsResult) { pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_executed"); } + if (dueDateAgingResult?.error) { + pushUnique(queryLimitations, dueDateAgingResult.error); + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_error"); + } + else if (dueDateAgingResult) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_executed"); + } if (inventoryOnHandResult?.error) { pushUnique(queryLimitations, inventoryOnHandResult.error); pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_error"); @@ -4194,10 +4617,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { outgoingResult, lifecycleResult, taxResult, + accountingFinancialResultResult, tradingMarginResult, receivablesResult, payablesResult, openContractsResult, + dueDateAgingResult, documentActivityProfileResult, counterpartyProfileResult, contractUsageProfileResult, @@ -4234,6 +4659,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (derivedBusinessOverview.tax_position) { pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows"); } + if (derivedBusinessOverview.accounting_financial_result) { + pushReason(reasonCodes, "pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows"); + } if (derivedBusinessOverview.trading_margin_proxy) { pushReason(reasonCodes, "pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows"); } @@ -4249,6 +4677,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { if (derivedBusinessOverview.debt_staleness_risk_proxy) { pushReason(reasonCodes, "pilot_derived_business_overview_debt_staleness_risk_proxy_from_confirmed_rows"); } + if (derivedBusinessOverview.debt_due_date_aging) { + pushReason(reasonCodes, "pilot_derived_business_overview_debt_due_date_aging_from_confirmed_rows"); + pushReason(reasonCodes, `pilot_derived_business_overview_debt_due_date_aging_${derivedBusinessOverview.debt_due_date_aging.evidence_status}`); + } if (derivedBusinessOverview.inventory_position) { pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows"); } @@ -4267,10 +4699,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) { outgoingResult, lifecycleResult, taxResult, + accountingFinancialResultResult, tradingMarginResult, receivablesResult, payablesResult, openContractsResult, + dueDateAgingResult, documentActivityProfileResult, counterpartyProfileResult, contractUsageProfileResult, diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index 5e4fadd..9f8a923 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -19,7 +19,7 @@ exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES = [ ]; const DEFAULT_DISCOVERY_BUDGET = { max_probe_count: 3, - max_rows_per_probe: 100 + max_rows_per_probe: 200 }; const MAX_PROBE_COUNT = 36; const MAX_ROWS_PER_PROBE = 500; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 3054d06..f21995a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -2,6 +2,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = void 0; exports.buildAssistantMcpDiscoveryResponseCandidate = buildAssistantMcpDiscoveryResponseCandidate; +const counterpartyRoleHeuristics_1 = require("./counterpartyRoleHeuristics"); exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_discovery_response_candidate_v1"; function toRecordObject(value) { if (!value || typeof value !== "object" || Array.isArray(value)) { @@ -67,7 +68,26 @@ function hasInternalMechanics(value) { function userFacingLines(values) { return uniqueStrings(values).filter((line) => !hasInternalMechanics(line)); } +function sanitizeUserFacingMechanics(value) { + return String(value ?? "").replace(/MCP-срез(?:ом|у|е|а)?/giu, (match) => { + const normalized = match.toLowerCase(); + if (normalized.endsWith("ом")) { + return "срезом 1С"; + } + if (normalized.endsWith("у")) { + return "срезу 1С"; + } + if (normalized.endsWith("е")) { + return "срезе 1С"; + } + if (normalized.endsWith("а")) { + return "среза 1С"; + } + return "срез 1С"; + }); +} function localizeLine(value) { + const sanitizedValue = sanitizeUserFacingMechanics(value); if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки активности в запрошенном срезе."; } @@ -88,7 +108,7 @@ function localizeLine(value) { return `В 1С проверены входящие и исходящие денежные строки в запрошенном срезе: ${incoming}, ${outgoing}.`; } if (/^Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count$/i.test(value)) { - return "Запрошенный период уперся в лимит строк MCP; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды."; + return "Запрошенный период достиг лимита строк; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды."; } const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i); if (counterpartyMatch) { @@ -113,10 +133,10 @@ function localizeLine(value) { } const movementRowsMatch = value.match(/^1C movement rows were found for counterparty\s+(.+)$/i); if (movementRowsMatch) { - return `Р’ 1РЎ найдены строки движений РїРѕ контрагенту ${movementRowsMatch[1]}.`; + return `В 1С найдены строки движений по контрагенту ${movementRowsMatch[1]}.`; } if (/^1C movement rows were found for the requested scope$/i.test(value)) { - return "Р’ 1РЎ найдены строки движений РїРѕ запрошенному контуру."; + return "В 1С найдены строки движений по запрошенному контуру."; } const supplierPayoutMatch = value.match(/^1C supplier-payout rows were found for counterparty\s+(.+)$/i); if (supplierPayoutMatch) { @@ -144,7 +164,7 @@ function localizeLine(value) { return "Срез документов ограничен только подтвержденными строками документов в проверенном окне."; } if (/^Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope$/i.test(value)) { - return "Срез движений ограничен только подтвержденными строками движений РІ проверенном РѕРєРЅРµ."; + return "Срез движений ограничен только подтвержденными строками движений в проверенном окне."; } if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) { return "Сумма входящих поступлений рассчитана только по подтвержденным строкам поступлений в 1С."; @@ -227,10 +247,10 @@ function localizeLine(value) { return "Полный срез документов без явно проверенного периода не подтвержден."; } if (/^Full movement history outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { - return "Полный исторический срез движений РІРЅРµ проверенного периода этим РїРѕРёСЃРєРѕРј РЅРµ подтвержден."; + return "Полный исторический срез движений вне проверенного периода этим поиском не подтвержден."; } if (/^Full movement history is not proven without an explicit checked period$/i.test(value)) { - return "Полный срез движений без СЏРІРЅРѕ проверенного периода РЅРµ подтвержден."; + return "Полный срез движений без явно проверенного периода не подтвержден."; } if (/^Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { return "Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден."; @@ -245,10 +265,10 @@ function localizeLine(value) { return "Полный двусторонний денежный поток за все время без явно проверенного периода не подтвержден."; } if (/^Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit$/i.test(value)) { - return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк."; + return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк."; } if (/^Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit$/i.test(value)) { - return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк хотя бы по одной стороне."; + return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк хотя бы по одной стороне."; } if (/^Requested period coverage was recovered through monthly 1C value-flow probes$/i.test(value)) { return "Покрытие запрошенного периода восстановлено помесячными проверками 1С."; @@ -268,7 +288,7 @@ function localizeLine(value) { if (/^Complete requested-period coverage for bidirectional value-flow is not proven by the available checked rows$/i.test(value)) { return "Полное покрытие запрошенного периода по двустороннему денежному потоку не подтверждено доступными проверенными строками."; } - return value; + return sanitizedValue; } function section(title, lines) { const clean = userFacingLines(lines.map(localizeLine)); @@ -345,7 +365,7 @@ function businessOverviewCoverageLimitLine(overview) { limited.push("исходящие"); } return limited.length > 0 - ? `Важно: ${limited.join(" и ")} уперлись в лимит выборки MCP, поэтому это проверенный срез найденных строк, а не гарантированно полный бухгалтерский оборот.` + ? `Важно: по направлению ${limited.join(" и ")} проверка достигла лимита строк; это расширенный проверенный срез найденных строк, но не гарантия полного бухгалтерского оборота без отдельной полной выгрузки.` : null; } function businessOverviewYearRowsLine(overview) { @@ -371,6 +391,30 @@ function firstOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") { const amount = moneyText(first?.[amountKey]); return label && amount ? `${label} — ${sentenceAmount(amount) ?? amount}` : null; } +function firstNonFinancialOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") { + if (!Array.isArray(rows)) { + return null; + } + for (const row of rows) { + const item = toRecordObject(row); + const label = toNonEmptyString(item?.axis_value); + if (!label || (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(label)) { + continue; + } + const amount = moneyText(item?.[amountKey]); + if (amount) { + return `${label} — ${sentenceAmount(amount) ?? amount}`; + } + } + return null; +} +function overviewAxisLooksFinancial(row) { + if (!row) { + return false; + } + return (row.counterparty_role_hint === "bank_or_financial_institution" || + (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(row.axis_value)); +} function businessOverviewTaxLine(overview) { const tax = toRecordObject(overview.tax_position); if (!tax) { @@ -487,7 +531,7 @@ function buildCompactBidirectionalValueFlowReply(entryPoint, draft) { lines.push(`Основа: ${basis.join("; ")}.`); } if (flow.coverage_limited_by_probe_limit === true) { - lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); + lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); } lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна."); const fallbackNextStep = toNonEmptyString(draft.next_step_line); @@ -610,16 +654,158 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null); const customerName = toNonEmptyString(topCustomer?.axis_value); const customerAmount = moneyText(topCustomer?.total_amount_human_ru); + const topCustomerLooksFinancial = overviewAxisLooksFinancial(topCustomer); + const nonFinancialCustomer = firstNonFinancialOverviewAxisLabel(topCustomerLooksFinancial ? overview.top_customers : []); const topCustomerLead = customerName && customerAmount - ? `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}` + ? topCustomerLooksFinancial + ? `; крупнейший входящий денежный источник: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount} (похоже на банк/финорганизацию, не называю это клиентской выручкой без назначения платежа)${nonFinancialCustomer ? `; крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}` : ""}` + : `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}` : ""; + const topSupplierRecord = toRecordObject(Array.isArray(overview.top_suppliers) ? overview.top_suppliers[0] : null); const topSupplier = firstOverviewAxisLabel(overview.top_suppliers); - const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${topSupplier}` : ""; + const topSupplierLooksFinancial = overviewAxisLooksFinancial(topSupplierRecord); + const nonFinancialSupplier = firstNonFinancialOverviewAxisLabel(topSupplierLooksFinancial ? overview.top_suppliers : []); + const topSupplierLead = topSupplier + ? topSupplierLooksFinancial + ? `; крупнейший получатель исходящих денег: ${topSupplier} (похоже на банк/финорганизацию, не называю это обычным поставщиком без назначения платежа/договора)${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}` + : `; крупнейший получатель исходящих денег: ${topSupplier}` + : ""; const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : ""; const graphReasonCodes = toStringList(graph?.reason_codes); const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer"); const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary); const lines = []; + const actionFamily = toNonEmptyString(turnMeaning?.asked_action_family); + const unsupportedFamily = toNonEmptyString(turnMeaning?.unsupported_but_understood_family); + const profitMarginBoundary = actionFamily === "profit_margin_boundary" || unsupportedFamily === "profit_margin_boundary"; + const debtDueDateBoundary = actionFamily === "debt_due_date_boundary" || unsupportedFamily === "debt_due_date_boundary"; + const vendorRiskBoundary = actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary"; + const inventoryReserveBoundary = actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary"; + if (profitMarginBoundary) { + const accountingFinancialResult = toRecordObject(overview.accounting_financial_result); + if (accountingFinancialResult) { + const direction = toNonEmptyString(accountingFinancialResult.final_result_direction); + const amount = moneyText(accountingFinancialResult.final_result_amount_human_ru); + const periodScope = toNonEmptyString(accountingFinancialResult.period_scope) ?? period; + const marginPct = typeof accountingFinancialResult.net_margin_to_revenue_pct === "number" && + Number.isFinite(accountingFinancialResult.net_margin_to_revenue_pct) + ? `${accountingFinancialResult.net_margin_to_revenue_pct}%` + : null; + const directionText = direction === "profit" + ? "учетная прибыль" + : direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const amountText = amount + ? direction === "loss" + ? `минус ${amount}` + : amount + : "сумма не распознана"; + lines.push(`Коротко: по бухгалтерскому маршруту 90/91/99 за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к выручке 90.01 ${marginPct}` : "; маржа к выручке 90.01 не рассчитана"}.`); + lines.push("Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность."); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + const headline = toNonEmptyString(draft.headline); + const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim(); + lines.push(cleanHeadline + ? `Коротко: ${localizeLine(cleanHeadline)}` + : "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финансовый результат."); + const boundaryLines = userFacingLines([ + ...toStringList(draft.confirmed_lines), + ...toStringList(draft.inference_lines), + ...toStringList(draft.unknown_lines) + ]) + .filter((line) => /(?:прибыл|марж|финанс|p\s*&\s*l|p&l|расход|себестоим|закрыт|profit|margin|financial)/iu.test(line)) + .slice(0, 2); + if (boundaryLines.length > 0) { + lines.push(...boundaryLines.map(localizeLine)); + } + lines.push("Для точного P&L нужны отдельный маршрут по себестоимости, расходам, закрытию периода и финрезультату; текущий proxy нельзя выдавать за подтвержденную чистую прибыль или маржу."); + if (limitLine) { + lines.push(limitLine); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + if (debtDueDateBoundary) { + const dueDateAging = toRecordObject(overview.debt_due_date_aging); + if (dueDateAging) { + const status = toNonEmptyString(dueDateAging.evidence_status); + const asOfDate = toNonEmptyString(dueDateAging.as_of_date) ?? "проверенную дату"; + const overdueAmount = moneyText(dueDateAging.overdue_amount_human_ru); + const grossAmount = moneyText(dueDateAging.gross_open_amount_human_ru); + const rowsWithPaymentTerms = typeof dueDateAging.rows_with_payment_terms === "number" && Number.isFinite(dueDateAging.rows_with_payment_terms) + ? dueDateAging.rows_with_payment_terms + : null; + const rowsWithAmount = typeof dueDateAging.rows_with_amount === "number" && Number.isFinite(dueDateAging.rows_with_amount) + ? dueDateAging.rows_with_amount + : null; + if (status === "confirmed_overdue") { + lines.push(`Коротко: на ${asOfDate} подтвержденная просрочка есть: ${overdueAmount ?? "сумма не распознана"} по ${dueDateAging.overdue_rows ?? "найденным"} строкам.`); + lines.push("Основа ответа: открытые расчеты 60/62/76, договорный срок оплаты и дата расчетного документа; это уже due-date route, не старение договора как proxy."); + } + else if (status === "no_payment_terms_configured") { + lines.push(`Коротко: на ${asOfDate} подтвержденной просрочки нет: открытые расчеты проверены${grossAmount ? ` на ${grossAmount}` : ""}, но в найденных договорах срок оплаты не установлен.`); + lines.push(rowsWithAmount !== null + ? `Проверено строк с суммой: ${rowsWithAmount}. Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.` + : "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой."); + } + else if (status === "insufficient_due_date_basis") { + lines.push(`Коротко: due-date route запущен на ${asOfDate}, но просрочка не подтверждена: по строкам с установленным сроком оплаты не хватило даты расчетного документа.`); + if (rowsWithPaymentTerms !== null) { + lines.push(`Строк с установленным сроком оплаты: ${rowsWithPaymentTerms}; нужен документ-основание с датой для расчета due date.`); + } + } + else { + lines.push(`Коротко: due-date route на ${asOfDate} проверен, подтвержденной просрочки не найдено${rowsWithPaymentTerms !== null ? `; строк с установленным сроком оплаты ${rowsWithPaymentTerms}` : ""}.`); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + const headline = toNonEmptyString(draft.headline); + const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim(); + lines.push(cleanHeadline + ? `Коротко: ${localizeLine(cleanHeadline)}` + : "Коротко: нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута."); + lines.push("Проверить нужно отдельно: договоры, сроки оплаты, погашение и закрытие задолженности; без этого нельзя доказать overdue/due-date aging."); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + if (vendorRiskBoundary) { + const supplierBasis = topSupplier + ? topSupplierLooksFinancial + ? `крупнейший получатель исходящих денег: ${topSupplier}; по названию это банк/финансовая организация, поэтому это не доказанная зависимость от обычного поставщика${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}` + : `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${topSupplier}` + : outgoingAmount + ? `исходящие платежи/закупочный поток в проверенном срезе: ${outgoingAmount}` + : "есть только ограниченный срез исходящих платежей без полного vendor-risk профиля"; + const proxyLabel = topSupplierLooksFinancial ? "outgoing cash concentration proxy" : "procurement concentration proxy"; + lines.push(`Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${supplierBasis}.`); + lines.push("Это сигнал концентрации закупок/исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов."); + lines.push("Для точного вывода нужен отдельный reviewed vendor-risk route: поставщики, договорные условия, качество поставок, сроки, доля в закупках и полная структура расходов."); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + if (inventoryReserveBoundary) { + const headline = toNonEmptyString(draft.headline); + const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim(); + lines.push(cleanHeadline + ? `Коротко: ${localizeLine(cleanHeadline)}` + : "Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя."); + const boundaryLines = userFacingLines([ + ...toStringList(draft.unknown_lines), + ...toStringList(draft.limitation_lines) + ]) + .filter((line) => /(?:резерв|неликвид|склад|товар|reserve|obsolete|inventory|stock)/iu.test(line)) + .slice(0, 2); + if (boundaryLines.length > 0) { + lines.push(...boundaryLines.map(localizeLine)); + } + lines.push("Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; proxy-сигналы нельзя выдавать за доказанный факт резерва."); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) { lines.push(`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`); lines.push(previousCounterpartySummary.line); @@ -640,7 +826,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { if (!leaderYear || !leaderAmount) { return null; } - lines.push(`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`); + lines.push(`Коротко: в доступном проверенном срезе 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) { @@ -660,7 +846,9 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { lines.push(`Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`); lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); if (!directMoneyAnswer && customerName && customerAmount) { - lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); + lines.push(topCustomerLooksFinancial + ? `Крупнейший входящий денежный источник в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}. По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой.${nonFinancialCustomer ? ` Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}` + : `Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); } } else { @@ -671,10 +859,14 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { `Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`); } if (!directMoneyAnswer && topSupplier) { - lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`); + lines.push(topSupplierLooksFinancial + ? `Крупнейший получатель исходящих денег: ${topSupplier}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком.${nonFinancialSupplier ? ` Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}` + : `Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`); } if (!directMoneyAnswer && (topCustomer || topSupplier)) { - lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль."); + lines.push(topCustomerLooksFinancial || topSupplierLooksFinancial + ? "Важно по ролям: текущий денежный срез подтверждает источники и получателей денег, но банковские контрагенты требуют проверки назначения платежа/счетов и не доказывают роль клиента или поставщика." + : "Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль."); } if (!directMoneyAnswer) { lines.push(`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`); diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js index 8951d78..34af03b 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeBridge.js @@ -1,12 +1,13 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0; +exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0; exports.runAssistantMcpDiscoveryRuntimeBridge = runAssistantMcpDiscoveryRuntimeBridge; const assistantMcpDiscoveryAnswerAdapter_1 = require("./assistantMcpDiscoveryAnswerAdapter"); const assistantMcpDiscoveryPilotExecutor_1 = require("./assistantMcpDiscoveryPilotExecutor"); const assistantMcpDiscoveryPlanner_1 = require("./assistantMcpDiscoveryPlanner"); exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_v1"; exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = "assistant_mcp_discovery_loop_state_v1"; +exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_route_candidate_v1"; function normalizeReasonCode(value) { const normalized = value .trim() @@ -58,6 +59,21 @@ function loopStatusFor(bridgeStatus) { } return "ready_for_next_hop"; } +function routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily) { + if (bridgeStatus === "blocked" || pilot.pilot_status === "blocked") { + return "blocked"; + } + if (bridgeStatus === "needs_clarification" || pilot.pilot_status === "skipped_needs_clarification") { + return "needs_user_scope"; + } + if (bridgeStatus === "unsupported" || pilot.pilot_status === "unsupported") { + return "needs_route_enablement"; + } + if (missingProofFamily) { + return "needs_route_enablement"; + } + return "ready_for_reviewed_execution"; +} function flattenAxes(pilot, source) { const result = []; for (const step of pilot.dry_run.execution_steps) { @@ -83,6 +99,119 @@ function entityCandidatesFromPlanner(planner) { const values = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? []; return uniqueStrings(values); } +function firstNonEmpty(values) { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) { + return text; + } + } + return null; +} +function routeCandidateProofFamiliesFor(actionFamily, proofExpectation) { + const combined = `${actionFamily ?? ""} ${proofExpectation ?? ""}`.trim().toLowerCase(); + const result = []; + const add = (family) => { + if (!result.includes(family)) { + result.push(family); + } + }; + if (!combined || combined === "broad_evaluation bounded_inference") { + return result; + } + if (/(?:inventory|stock|warehouse|reserve|liquidation|write[-_ ]?off|obsolete|obsolescence)/iu.test(combined)) { + add("inventory_reserve_liquidation_quality"); + } + if (/(?:debt|due[-_ ]?date|overdue|aging|credit[-_ ]?risk)/iu.test(combined)) { + add("debt_due_date_aging_quality"); + } + if (/(?:vendor|supplier|procurement|sourcing)/iu.test(combined)) { + add("vendor_risk_procurement_quality"); + } + if (/(?:profit|margin|pnl|p&l|financial[-_ ]?result)/iu.test(combined)) { + add("accounting_profit_margin"); + } + return result; +} +function routeCandidateMissingProofFamily(planner, pilot) { + if (planner.data_need_graph?.business_fact_family !== "business_overview") { + return null; + } + const wantedFamilies = routeCandidateProofFamiliesFor(planner.data_need_graph?.action_family ?? null, planner.data_need_graph?.proof_expectation ?? null); + if (wantedFamilies.length <= 0) { + return null; + } + const missingProofFamilies = pilot.derived_business_overview?.missing_proof_families ?? []; + return missingProofFamilies.find((item) => wantedFamilies.includes(item.family)) ?? null; +} +function routeCandidateEnablementReason(status, pilot, missingAxes, missingProofFamily) { + if (status === "ready_for_reviewed_execution") { + return null; + } + if (status === "needs_user_scope") { + return missingAxes.length > 0 + ? `Missing scope axes: ${missingAxes.join(", ")}` + : "Selected chain needs user clarification before MCP execution"; + } + if (missingProofFamily) { + return [ + `Missing reviewed proof family: ${missingProofFamily.family}`, + `next_required_evidence=${missingProofFamily.next_required_evidence}`, + missingProofFamily.current_supported_evidence + ? `current_supported_evidence=${missingProofFamily.current_supported_evidence}` + : null, + `must_not_claim=${missingProofFamily.must_not_claim}` + ] + .filter((item) => Boolean(item)) + .join("; "); + } + return firstNonEmpty([ + ...pilot.query_limitations, + ...pilot.evidence.unknown_facts, + "Selected chain is not safely executable by the reviewed MCP runtime yet" + ]); +} +function routeCandidateNextAction(status) { + if (status === "ready_for_reviewed_execution") { + return "Execute through the reviewed runtime bridge and truth gate."; + } + if (status === "needs_user_scope") { + return "Ask the user for the missing scope axes before MCP execution."; + } + if (status === "needs_route_enablement") { + return "Create or wire a reviewed exact route for the selected chain before treating the fact as answerable."; + } + return "Do not execute until the blocking reason is resolved."; +} +function buildRouteCandidate(planner, pilot, bridgeStatus) { + const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? []; + const providedAxes = flattenAxes(pilot, "provided_axes"); + const missingAxes = plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"); + const missingProofFamily = routeCandidateMissingProofFamily(planner, pilot); + const candidateStatus = routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily); + return { + schema_version: exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + candidate_status: candidateStatus, + selected_chain_id: planner.selected_chain_id, + selected_chain_summary: planner.selected_chain_summary, + nearest_catalog_chain_template: planner.catalog_chain_template_alignment.top_chain_template_match, + catalog_alignment_status: planner.catalog_chain_template_alignment.alignment_status, + business_fact_family: planner.data_need_graph?.business_fact_family ?? null, + action_family: planner.data_need_graph?.action_family ?? null, + proof_expectation: planner.data_need_graph?.proof_expectation ?? null, + required_axes: [...planner.required_axes], + provided_axes: providedAxes, + missing_axes: missingAxes, + executable_now: candidateStatus === "ready_for_reviewed_execution", + enablement_reason: routeCandidateEnablementReason(candidateStatus, pilot, missingAxes, missingProofFamily), + recommended_next_action: routeCandidateNextAction(candidateStatus), + forbidden_overclaim_flags: uniqueStrings([ + ...(planner.data_need_graph?.forbidden_overclaim_flags ?? []), + ...(missingProofFamily ? [missingProofFamily.must_not_claim] : []) + ]) + }; +} function buildLoopState(planner, pilot, bridgeStatus) { const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? []; return { @@ -120,10 +249,13 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) { const answerDraft = (0, assistantMcpDiscoveryAnswerAdapter_1.buildAssistantMcpDiscoveryAnswerDraft)(pilot); const bridgeStatus = bridgeStatusFor(pilot, answerDraft); const loopState = buildLoopState(planner, pilot, bridgeStatus); + const routeCandidate = buildRouteCandidate(planner, pilot, bridgeStatus); const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]); pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`); pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer"); pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`); + pushReason(reasonCodes, "runtime_bridge_route_candidate_built"); + pushReason(reasonCodes, `runtime_bridge_route_candidate_${routeCandidate.candidate_status}`); return { schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION, policy_owner: "assistantMcpDiscoveryRuntimeBridge", @@ -133,6 +265,7 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) { pilot, answer_draft: answerDraft, loop_state: loopState, + route_candidate: routeCandidate, user_facing_response_allowed: bridgeStatus !== "blocked", business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft), requires_user_clarification: bridgeStatus === "needs_clarification", diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 74293e7..1d98f8a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -130,6 +130,11 @@ function isGarbageSemanticAnchorCandidate(value) { "всему", "всей", "всем", + "год", + "года", + "году", + "годом", + "годы", "выводу", "выводам", "аудиту", @@ -626,6 +631,30 @@ function hasOrganizationLevelEarningsOverviewSignal(text) { /(?:\u043a\u0430\u043a\w*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043c\u044b\b|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time|what|which|how\s+much|show|give|company|business|organization|our|we|us)/iu.test(text); return hasYearRankingCue || hasCompanyEarningsCue || hasCompanyProfitMarginCue; } +function hasOrganizationLevelProfitMarginBoundaryOverviewSignal(text) { + if (!text) { + return false; + } + const hasProfitMarginCue = /(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit(?:ability)?|margin|financial\s+result)/iu.test(text); + const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043c\u044b\b|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\b(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e|\u043e\u0430\u043e)\b|(?:19|20)\d{2}|company|business|organization|our|we|us)/iu.test(text); + return hasProfitMarginCue && hasCompanyScopeCue; +} +function hasProfitMarginBoundaryFollowupSignal(text) { + if (!text) { + return false; + } + const hasProfitOrResultCue = /(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u0443\u0431\u044b\u0442\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit|loss|margin|financial\s+result)/iu.test(text); + const hasFollowupShape = /(?:\u044d\u0442\u043e|\u0438\u0442\u043e\u0433|\u0438\u0442\u043e\u0433\u043e|\u043f\u043e\u043b\u0443\u0447\w*|\u043a\u043e\u0440\u043e\u0442\w*|\u0432\s+\u0438\u0442\u043e\u0433\u0435|\u043c\u043e\u0436\u043d\u043e\s+(?:\u043b\u0438\s+)?\u0441\u043a\u0430\u0437\u0430\u0442\u044c|is\s+it|result|short|brief)/iu.test(text); + return hasProfitOrResultCue && hasFollowupShape; +} +function hasDebtDueDateBoundaryFollowupSignal(text) { + if (!text) { + return false; + } + const hasDueDateCue = /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447\w*|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437\w*|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\w*|due[-\s]?date|overdue|debt\s+aging|aging)/iu.test(text); + const hasFollowupShape = /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\w*|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|why|short|brief|so)/iu.test(text); + return hasDueDateCue && hasFollowupShape; +} function hasOrganizationLevelDebtDueDateOverviewSignal(text) { if (!text) { return false; @@ -711,6 +740,12 @@ function hasExplicitVatQuestionSignal(text) { return (/(?:\u043d\u0434\u0441|vat)/iu.test(text) && /(?:\u0437\u0430|\u043d\u0430|\u043f\u0435\u0440\u0438\u043e\u0434|\u043f\u043e\u0437\u0438\u0446|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u043d\u0430\u043b\u043e\u0433\u043e\u0432\p{L}*\s+\u0432\u044b\u0432\u043e\u0434|tax\s+period|tax\s+position)/iu.test(text)); } +function hasExplicitVatMovementEvidenceSignal(text) { + if (!/(?:\u043d\u0434\u0441|vat)/iu.test(text)) { + return false; + } + return hasMovementEvidenceFollowupSignal(text); +} function hasBusinessOverviewSeparateCounterpartySignal(text) { if (!text) { return false; @@ -1102,6 +1137,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? ""); const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText); const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText); + const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText); const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal); const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText) && @@ -1114,6 +1150,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { (hasValueFlowSignal(rawText) || hasValueRankingSignal(rawText) || rawBidirectionalValueFlowSignal); const rawMetadataSignal = !rawLifecycleSignal && !rawValueFlowSignal && + !explicitVatMovementEvidenceSignal && !rawReferentialDocumentExclusionSignal && hasMetadataSignal(rawText); const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText); @@ -1128,7 +1165,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(dateScopeSignalText); const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText); const rawDateScope = collectDateScopeFromRawText(dateScopeSignalText); - const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; + const rawMetadataScopeHint = rawMetadataSignal || explicitVatMovementEvidenceSignal ? metadataScopeHintFromRawText(rawText) : null; const rawTopicSwitchSignal = hasExplicitTopicSwitchSignal(rawText); const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; const rawScopedEntityCandidate = !predecomposeEntities.counterparty && @@ -1147,11 +1184,41 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); const broadBusinessEvaluationUnsupported = unsupported === "broad_business_evaluation"; - const businessOverviewSignal = rawBusinessOverviewSignal || - broadBusinessEvaluationUnsupported || + const seededBusinessOverviewSignal = broadBusinessEvaluationUnsupported || rawDomain === "business_summary" || rawDomain === "business_overview" || rawAction === "broad_evaluation"; + const inventoryReserveBusinessOverviewSignal = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(rawText); + const debtDueDateFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasDebtDueDateBoundaryFollowupSignal(rawText); + const debtDueDateBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) || + debtDueDateFollowupBusinessOverviewSignal; + const supplierQualityBusinessOverviewSignal = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText); + const profitMarginFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText); + const profitMarginBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + hasOrganizationLevelProfitMarginBoundaryOverviewSignal(rawText)) || + profitMarginFollowupBusinessOverviewSignal; + const businessOverviewActionFamily = inventoryReserveBusinessOverviewSignal + ? "inventory_reserve_boundary" + : debtDueDateBusinessOverviewSignal + ? "debt_due_date_boundary" + : supplierQualityBusinessOverviewSignal + ? "vendor_risk_procurement_boundary" + : profitMarginBusinessOverviewSignal + ? "profit_margin_boundary" + : "broad_evaluation"; + const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal + ? "inventory_reserve_liquidation_boundary" + : debtDueDateBusinessOverviewSignal + ? "debt_due_date_boundary" + : supplierQualityBusinessOverviewSignal + ? "vendor_risk_procurement_boundary" + : profitMarginBusinessOverviewSignal + ? "profit_margin_boundary" + : "broad_business_evaluation"; + const businessOverviewSignal = rawBusinessOverviewSignal || + seededBusinessOverviewSignal; const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)); const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal ? businessOverviewSeparateCounterpartyCandidateFromText(rawText) @@ -1176,7 +1243,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { : rawAssistantTurnMeaningOrganizationScope; const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText); const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText); - const currentTurnFreshOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization; + const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope; const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope; const followupCounterpartyIsMetadataOrganizationScope = Boolean(followupSeed.subjectResolutionOptional && followupSeed.counterparty && @@ -1500,9 +1567,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable ? "metadata lane clarification" : semanticNeedFor({ - domain: businessOverviewSignal ? "business_overview" : rawDomain ?? seededDomain, - action: businessOverviewSignal ? "broad_evaluation" : rawAction ?? seededAction, - unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported, + domain: explicitVatMovementEvidenceSignal + ? "movements" + : businessOverviewSignal + ? "business_overview" + : rawDomain ?? seededDomain, + action: explicitVatMovementEvidenceSignal + ? "list_movements" + : businessOverviewSignal + ? businessOverviewActionFamily + : rawAction ?? seededAction, + unsupported: explicitVatMovementEvidenceSignal + ? "movement_evidence" + : businessOverviewSignal + ? businessOverviewUnsupportedFamily + : unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, @@ -1530,7 +1609,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { followupSeed.discoveryEntity ?? followupSeed.metadataSelectedEntitySet ?? null; - const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) && + const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable || + metadataGroundedDocumentLaneApplicable || + explicitVatMovementEvidenceSignal) && !effectiveFollowupCounterparty && metadataLaneCarryoverAvailable); const groundedFollowupEntity = metadataScopedLaneWithoutSubject @@ -1711,17 +1792,19 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" - : metadataGroundedMovementLaneApplicable + : explicitVatMovementEvidenceSignal ? "movements" - : metadataGroundedDocumentLaneApplicable - ? "documents" - : entityResolutionSignal - ? "entity_resolution" - : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable - ? "metadata" - : rawDomain ?? seededDomain, + : metadataGroundedMovementLaneApplicable + ? "movements" + : metadataGroundedDocumentLaneApplicable + ? "documents" + : entityResolutionSignal + ? "entity_resolution" + : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + ? "metadata" + : rawDomain ?? seededDomain, asked_action_family: businessOverviewSignal - ? "broad_evaluation" + ? businessOverviewActionFamily : lifecycleSignal ? "activity_duration" : valueFlowSignal @@ -1730,15 +1813,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) { : payoutSignal ? "payout" : rawAction ?? seededAction ?? "turnover" - : metadataGroundedMovementLaneApplicable + : explicitVatMovementEvidenceSignal ? "list_movements" - : metadataGroundedDocumentLaneApplicable - ? "list_documents" - : entityResolutionSignal - ? "search_business_entity" - : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable - ? metadataActionFromRawText(rawText) ?? seededAction - : rawAction ?? seededAction, + : metadataGroundedMovementLaneApplicable + ? "list_movements" + : metadataGroundedDocumentLaneApplicable + ? "list_documents" + : entityResolutionSignal + ? "search_business_entity" + : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + ? metadataActionFromRawText(rawText) ?? seededAction + : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, seeded_ranking_need: valueFlowSignal && followupSeed.rankingNeed && !rawEntitySearchOverridesStaleScope ? followupSeed.rankingNeed @@ -1757,7 +1842,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { explicit_date_scope: explicitDateScope, subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined, unsupported_but_understood_family: businessOverviewSignal - ? "broad_business_evaluation" + ? businessOverviewUnsupportedFamily : unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" @@ -1771,20 +1856,23 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "movement_evidence" : metadataGroundedDocumentLaneApplicable ? "document_evidence" - : metadataAmbiguityLaneClarificationApplicable - ? "metadata_lane_choice_clarification" - : entityResolutionSignal - ? "entity_resolution" - : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable - ? "1c_metadata_surface" - : followupDiscoverySeedApplicable - ? seededUnsupported - : null), + : explicitVatMovementEvidenceSignal + ? "movement_evidence" + : metadataAmbiguityLaneClarificationApplicable + ? "metadata_lane_choice_clarification" + : entityResolutionSignal + ? "entity_resolution" + : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + ? "1c_metadata_surface" + : followupDiscoverySeedApplicable + ? seededUnsupported + : null), stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || businessOverviewSignal || unsupported || lifecycleSignal || valueFlowSignal || + explicitVatMovementEvidenceSignal || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || metadataAmbiguityLaneClarificationApplicable || @@ -1841,11 +1929,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { } const currentTurnValueFlowExactOverrideApplicable = Boolean(valueFlowSignal && explicitIntentCandidate && - rawValueFlowAggregateQuestionSignal && + (rawValueFlowAggregateQuestionSignal || hasValueRankingSignal(rawText)) && semanticDataNeed && (entityCandidates.length > 0 || explicitOrganizationScope || openScopeValueFlowWithoutResolvedCounterparty)); const runDiscovery = shouldRunDiscovery({ - unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported, + unsupported: explicitVatMovementEvidenceSignal + ? "movement_evidence" + : businessOverviewSignal + ? "broad_business_evaluation" + : unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, @@ -1860,6 +1952,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { metadataGroundedDocumentLaneApplicable || groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: businessOverviewSignal || + explicitVatMovementEvidenceSignal || Boolean(entityResolutionClarificationCandidate) || organizationClarificationFollowupApplicable || periodClarificationFollowupApplicable || @@ -1883,17 +1976,19 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "followup_context" : metadataGroundedDocumentLaneApplicable ? "followup_context" - : predecomposeContract - ? "predecompose_contract" - : lifecycleSignal - ? "raw_text" - : valueFlowSignal + : explicitVatMovementEvidenceSignal + ? "raw_text" + : predecomposeContract + ? "predecompose_contract" + : lifecycleSignal ? "raw_text" - : entityResolutionSignal + : valueFlowSignal ? "raw_text" - : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + : entityResolutionSignal ? "raw_text" - : "none"; + : rawMetadataSignal || effectiveMetadataFollowupSeedApplicable + ? "raw_text" + : "none"; if (lifecycleSignal) { pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected"); } @@ -1903,6 +1998,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (rawMetadataSignal) { pushReason(reasonCodes, "mcp_discovery_metadata_signal_detected"); } + if (explicitVatMovementEvidenceSignal) { + pushReason(reasonCodes, "mcp_discovery_vat_movement_evidence_signal_detected"); + } if (entityResolutionSignal) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_signal_detected"); } @@ -2026,6 +2124,12 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (businessOverviewContinuationSignal) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context"); } + if (profitMarginFollowupBusinessOverviewSignal) { + pushReason(reasonCodes, "mcp_discovery_business_overview_profit_margin_followup_boundary"); + } + if (debtDueDateFollowupBusinessOverviewSignal) { + pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary"); + } if (explicitVatSuppressesBusinessOverviewContinuation) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"); } diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index cf9bbcc..4c6f3e8 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -313,6 +313,13 @@ function createAssistantRoutePolicy(deps) { const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized); return hasRequestCue && hasTemporalCue; } + function hasOrganizationClarificationTextCue(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + return /(? toNonEmptyString(item)).filter(Boolean) + : []; + const currentTurnPredecomposeOrganization = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.entities?.organization) ?? + (toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_kind) === "organization" + ? toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_value) + : null); + const routeCandidateOrganizationClarificationDetected = Boolean(followupContext && + followupLoopStatus === "awaiting_clarification" && + followupLoopSelectedChainId && + followupLoopPendingAxes.includes("organization") && + (currentTurnPredecomposeOrganization || + explicitOrganizationClarificationSelection || + [ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage + ].some((message) => hasOrganizationClarificationTextCue(message)))); const protectedInventoryShortFollowup = Boolean(followupContext && (isInventorySelectedObjectIntent(followupPreviousIntent) || (followupPreviousIntent === "inventory_on_hand_as_of_date" && @@ -524,6 +552,7 @@ function createAssistantRoutePolicy(deps) { "net_value_flow" ].includes(String(toNonEmptyString(assistantTurnMeaning?.asked_action_family) ?? "")) || /(?:нетто|сальдо|сколько\s+мы\s+(?:получили|заплатили)|incoming|outgoing)/iu.test(analyticsSample))); + const effectiveGroundedValueFlowFollowupContextDetected = groundedValueFlowFollowupContextDetected || routeCandidateOrganizationClarificationDetected; const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane && [ "address_intent_resolver_detected", @@ -533,14 +562,15 @@ function createAssistantRoutePolicy(deps) { ].includes(String(baseToolGate?.reason ?? ""))) || Boolean(baseToolGate?.runAddressLane && String(baseToolGate?.reason ?? "") === "followup_context_detected" && - groundedValueFlowFollowupContextDetected); + effectiveGroundedValueFlowFollowupContextDetected); const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && (llmFirstUnsupportedCandidate || llmContractMode === null) && !baseToolGatePreservesAddressLane && - !groundedValueFlowFollowupContextDetected && + !effectiveGroundedValueFlowFollowupContextDetected && !protectedInventoryShortFollowup && - !organizationClarificationContinuationDetected); + !organizationClarificationContinuationDetected && + !routeCandidateOrganizationClarificationDetected); const lastAddressAssistantDebug = sessionItems ? findLastAddressAssistantItem(sessionItems)?.debug ?? null : null; @@ -583,7 +613,7 @@ function createAssistantRoutePolicy(deps) { !turnMeaningIntentCandidate && !dataScopeMetaQuery && !dangerOrCoercionSignal && - !groundedValueFlowFollowupContextDetected && + !effectiveGroundedValueFlowFollowupContextDetected && !organizationClarificationContinuationDetected); const hardMetaMode = resolveHardMetaMode({ dataScopeMetaQuery, @@ -748,7 +778,7 @@ function createAssistantRoutePolicy(deps) { !dataScopeMetaQuery && !capabilityMetaQuery && !dangerOrCoercionSignal && - !groundedValueFlowFollowupContextDetected && + !effectiveGroundedValueFlowFollowupContextDetected && !organizationClarificationContinuationDetected); if (unsupportedCurrentTurnMeaningBoundary) { return { diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 0736573..43c7ca9 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2123,6 +2123,9 @@ function isAddressLaneDebugPayload(debug) { if (typeof debug.mcp_call_status === "string" && debug.mcp_call_status.trim().length > 0) { return true; } + if (debug.mcp_discovery_response_applied === true && debug.assistant_mcp_discovery_entry_point_v1) { + return true; + } if (typeof debug.anchor_type === "string" && debug.anchor_type.trim().length > 0) { return true; } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 2fc0fbd..4535dff 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -120,6 +120,22 @@ function createAssistantTransitionPolicy(deps) { const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean); return samples.some((sample) => /(?:итог|summary|резюм|вывод|что\s+(?:мы\s+)?подтверд|что\s+понят|что\s+можно|что\s+нельзя|собери\s+коротк)/iu.test(sample) && /(?:контрагент|группа\s+свк|свк|отдельн)/iu.test(sample)); } + function hasBusinessOverviewBoundaryFollowupCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + const hasBoundaryCue = /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u0443\u0431\u044b\u0442|\u043c\u0430\u0440\u0436|\u0440\u0435\u0437\u0435\u0440\u0432|\u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|due[-\s]?date|overdue|aging|profit|loss|margin|vendor|risk)/iu.test(normalized); + const hasFollowupShape = /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\u043a|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|\u0438\u0442\u043e\u0433|why|short|brief|so)/iu.test(normalized); + return hasBoundaryCue && hasFollowupShape; + } + function hasOrganizationClarificationTextCue(text) { + const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + return /(? hasOrganizationClarificationTextCue(message)))); const hasValueFlowCarryoverSourceHint = sourceIntentHint === "customer_revenue_and_payments" || sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" || sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" || sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1"; + const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1"; const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue); const navigationFocusObjectHint = navigationSessionState.focusObject; const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) && @@ -521,20 +556,28 @@ function createAssistantTransitionPolicy(deps) { const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) ? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) : false; + const businessOverviewBoundaryFollowupPrimary = hasBusinessOverviewCarryoverSourceHint && hasBusinessOverviewBoundaryFollowupCue(userMessage); + const businessOverviewBoundaryFollowupAlternate = hasBusinessOverviewCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) + ? hasBusinessOverviewBoundaryFollowupCue(String(alternateMessage ?? "")) + : false; const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage); let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || shortValueFlowRetargetPrimary || + businessOverviewBoundaryFollowupPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || - explicitSummaryBundleReuseSignal; + explicitSummaryBundleReuseSignal || + mcpDiscoveryOrganizationClarificationContinuation; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || - explicitSummaryBundleReuseSignal + explicitSummaryBundleReuseSignal || + mcpDiscoveryOrganizationClarificationContinuation : false; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) @@ -557,6 +600,7 @@ function createAssistantTransitionPolicy(deps) { let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || + mcpDiscoveryOrganizationClarificationContinuation || hasImplicitContinuationSignal || hasSuggestedIntentPivotSignal || inventoryShortFollowupPrimary || @@ -570,6 +614,8 @@ function createAssistantTransitionPolicy(deps) { Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupPrimary || + businessOverviewBoundaryFollowupAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -579,6 +625,7 @@ function createAssistantTransitionPolicy(deps) { const hasConcreteFollowupReference = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || + mcpDiscoveryOrganizationClarificationContinuation || inventoryShortFollowupPrimary || inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || @@ -590,6 +637,8 @@ function createAssistantTransitionPolicy(deps) { Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupPrimary || + businessOverviewBoundaryFollowupAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -617,6 +666,7 @@ function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && + !mcpDiscoveryOrganizationClarificationContinuation && !hasIndexReferenceSignal && !explicitSummaryBundleReuseSignal) { return null; @@ -632,6 +682,7 @@ function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && + !mcpDiscoveryOrganizationClarificationContinuation && !hasIndexReferenceSignal && !explicitSummaryBundleReuseSignal) { return null; @@ -650,10 +701,10 @@ function createAssistantTransitionPolicy(deps) { const sourceDiscoveryMetadataAmbiguityEntitySets = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityEntitySets)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityResolutionStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityResolutionStatus)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryLoopStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopStatus)(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryLoopSelectedChainId = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSelectedChainId)(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryLoopPendingAxes = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopPendingAxes)(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryLoopProvidedAxes = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopProvidedAxes)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryLoopStatus = sourceDiscoveryLoopStatusHint; + const sourceDiscoveryLoopSelectedChainId = sourceDiscoveryLoopSelectedChainIdHint; + const sourceDiscoveryLoopPendingAxes = sourceDiscoveryLoopPendingAxesHint; + const sourceDiscoveryLoopProvidedAxes = sourceDiscoveryLoopProvidedAxesHint; const sourceDiscoveryLoopAskedDomainFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopAskedDomainFamily)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryLoopAskedActionFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopAskedActionFamily)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryLoopUnsupportedFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopUnsupportedFamily)(carryoverSourceDebug, deps.toNonEmptyString); @@ -689,6 +740,7 @@ function createAssistantTransitionPolicy(deps) { explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasOrganizationClarificationContinuation && + !mcpDiscoveryOrganizationClarificationContinuation && !hasImplicitContinuationSignal && !hasIndexReferenceSignal && !hasInventoryRootTemporalFollowupPrimary && @@ -697,6 +749,8 @@ function createAssistantTransitionPolicy(deps) { !hasInventoryRootRestatementAlternate && !inventoryShortFollowupPrimary && !inventoryShortFollowupAlternate && + !businessOverviewBoundaryFollowupPrimary && + !businessOverviewBoundaryFollowupAlternate && !foreignAccountingPivotOverInventory && !deps.hasFollowupMarker(userMessage) && !deps.hasReferentialPointer(userMessage) && @@ -753,24 +807,29 @@ function createAssistantTransitionPolicy(deps) { hasSuggestedIntentPivotSignal || Boolean(debtRoleSwapPrimary) || shortValueFlowRetargetPrimary || + businessOverviewBoundaryFollowupPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || explicitSummaryBundleReuseSignal || - hasInventoryRootTemporalFollowupPrimary; + hasInventoryRootTemporalFollowupPrimary || + mcpDiscoveryOrganizationClarificationContinuation; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || hasSuggestedIntentPivotSignal || Boolean(debtRoleSwapAlternate) || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || explicitSummaryBundleReuseSignal || - hasInventoryRootTemporalFollowupAlternate + hasInventoryRootTemporalFollowupAlternate || + mcpDiscoveryOrganizationClarificationContinuation : false; hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || + mcpDiscoveryOrganizationClarificationContinuation || hasSuggestedIntentPivotSignal || hasImplicitContinuationSignal || inventoryShortFollowupPrimary || @@ -782,6 +841,8 @@ function createAssistantTransitionPolicy(deps) { Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupPrimary || + businessOverviewBoundaryFollowupAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) diff --git a/llm_normalizer/backend/dist/services/counterpartyRoleHeuristics.js b/llm_normalizer/backend/dist/services/counterpartyRoleHeuristics.js new file mode 100644 index 0000000..f6c9f2c --- /dev/null +++ b/llm_normalizer/backend/dist/services/counterpartyRoleHeuristics.js @@ -0,0 +1,46 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.isLikelyFinancialInstitutionCounterparty = isLikelyFinancialInstitutionCounterparty; +exports.counterpartyRoleHintForName = counterpartyRoleHintForName; +const FINANCIAL_INSTITUTION_PATTERNS = [ + /(?:^|[\s"«(,-])банк(?:$|[\s"»),.-])/u, + /сбербанк/u, + /(?:^|[\s"«(,-])сбер(?:$|[\s"»),.-])/u, + /(?:^|[\s"«(,-])втб(?:$|[\s"»),.-])/u, + /альфа[\s-]*банк/u, + /тинькофф/u, + /(?:^|[\s"«(,-])т[\s-]*банк(?:$|[\s"»),.-])/u, + /газпромбанк/u, + /росбанк/u, + /райффайзен/u, + /совкомбанк/u, + /промсвязьбанк/u, + /(?:^|[\s"«(,-])псб(?:$|[\s"»),.-])/u, + /(?:^|[\s"«(,-])мкб(?:$|[\s"»),.-])/u, + /ак[\s-]*барс/u, + /уралсиб/u, + /юникредит/u, + /почта[\s-]*банк/u, + /(?:^|[\s"«(,-])открытие(?:$|[\s"»),.-])/u, + /кредитн(?:ая|ый|ое|ые)\s+организац/u +]; +function normalizeCounterpartyRoleText(value) { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[._]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} +function isLikelyFinancialInstitutionCounterparty(value) { + const normalized = normalizeCounterpartyRoleText(value); + if (!normalized) { + return false; + } + return FINANCIAL_INSTITUTION_PATTERNS.some((pattern) => pattern.test(normalized)); +} +function counterpartyRoleHintForName(value) { + return isLikelyFinancialInstitutionCounterparty(value) + ? "bank_or_financial_institution" + : "ordinary_counterparty"; +} diff --git a/llm_normalizer/backend/src/config.ts b/llm_normalizer/backend/src/config.ts index 9a04edb..23fc776 100644 --- a/llm_normalizer/backend/src/config.ts +++ b/llm_normalizer/backend/src/config.ts @@ -174,7 +174,7 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? " ); export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default"; export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000); -export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24))); +export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 128))); export const VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]); export const VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]); diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 14a16bd..7551037 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -2014,7 +2014,6 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_phrase"); const preserveDerivedPeriodWindow = - usesAsOfPrimaryWindow(intent) || intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; if (periodWasDerivedHeuristically && !warnings.includes("exact_historical_period_window_requested")) { diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index b1d9dac..b690c1b 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2166,7 +2166,11 @@ function hasVatPeriodInspectionBridgeSignal(text: string): boolean { normalized ); const forecastOnlyCue = /(?:прогноз|план|примерн|ориентировочн)/iu.test(normalized) && !hasInspectionCue; - return hasPeriodCue && hasInspectionCue && !forecastOnlyCue; + const hasVatMovementInspectionCue = + /(?:покаж|движен|операц|по\s+сч(?:е|ё)т|покаж|движен|операц|РїРѕ\s+СЃС‡(?:Рµ|С‘)С‚|show|movement|movements|operation|operations|account)/iu.test( + normalized + ); + return hasPeriodCue && (hasInspectionCue || hasVatMovementInspectionCue) && !forecastOnlyCue; } function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolution | null { @@ -3034,6 +3038,22 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } + const hasExplicitVatLiabilityPeriodBridge = + /(?:\u043d\u0434\u0441|vat)/iu.test(text) && + /(?:\b(?:19|20)\d{2}\b|\u0437\u0430\s+(?:\d{4}|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446))/iu.test( + text + ) && + /(?:\u043a\u0430\u043a\u043e\u0439|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u043d\u0430\u0447\u0438\u0441\u043b|\u0443\u043f\u043b\u0430\u0447|\u0443\u043f\u043b\u0430\u0442|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u043e\u043a\u0443\u043f|\u0432\u044b\u0447\u0435\u0442|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043f\u043e\u0437\u0438\u0446|liability|payable|charged|paid|sales|purchase|deduction|position)/iu.test( + text + ); + if (hasExplicitVatLiabilityPeriodBridge) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "high", + reasons: ["vat_liability_explicit_period_bridge_signal_detected"] + }; + } + const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && /(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test( diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index fe97119..67541e8 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -207,6 +207,52 @@ const OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` Сумма __ORDER_DIRECTION__ `; +const DEBT_DUE_DATE_AGING_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "DUE_DATE_OPEN_BALANCE" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты, + "debit_open_balance" КАК НаправлениеОстатка +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +__WHERE_DT__ +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "DUE_DATE_OPEN_BALANCE" КАК Регистратор, + "" КАК СчетДт, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокКт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты, + ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты, + "credit_open_balance" КАК НаправлениеОстатка +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +__WHERE_KT__ +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; + const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ __AS_OF_EXPR__ КАК Период, @@ -747,7 +793,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ purpose: "Build customer value ranking and incoming deal profile from bank inflow docs", required_filters: [], optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], - default_limit: 20, + default_limit: 200, account_scope_mode: "preferred", query_template: "customer_revenue_profile" }, @@ -757,7 +803,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ purpose: "Build supplier payout ranking and outgoing deal profile from bank outflow docs", required_filters: [], optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], - default_limit: 20, + default_limit: 200, account_scope_mode: "preferred", query_template: "supplier_payout_profile" }, @@ -802,6 +848,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "preferred", query_template: "vat_liability_confirmed_tax_period_profile" }, + { + recipe_id: "address_accounting_financial_result_for_organization_v1", + intent: "accounting_financial_result_for_organization", + purpose: "Build reviewed accounting financial-result aggregate from 90/91/99 period-close movements", + required_filters: ["period_from", "period_to"], + optional_filters: ["organization", "limit", "sort"], + default_limit: 32, + account_scope: ["90", "91", "99"], + account_scope_mode: "strict", + query_template: "accounting_financial_result_profile" + }, { recipe_id: "address_inventory_on_hand_as_of_date_v1", intent: "inventory_on_hand_as_of_date", @@ -912,6 +969,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "strict", query_template: "open_contracts_confirmed_as_of_balance_profile" }, + { + recipe_id: "address_debt_due_date_aging_for_organization_v1", + intent: "debt_due_date_aging_for_organization", + purpose: "Check open 60/62/76 settlements against contract payment-term fields and settlement document dates before claiming overdue debt", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 400, + account_scope: ["60", "62", "76"], + account_scope_mode: "strict", + query_template: "debt_due_date_aging_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -1146,6 +1214,37 @@ function buildContractValueWhereClause(filters: AddressFilterSet, fieldPath: str ]); } +function buildContractReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null { + const contract = typeof filters.contract === "string" ? filters.contract.trim() : ""; + if (!contract) { + return null; + } + const contractTokens = Array.from( + new Set( + contract + .split(/[^A-Za-zА-Яа-яЁё0-9]+/u) + .map((token) => token.trim()) + .filter((token) => token.length >= 3) + .filter((token) => !["договор", "дог"].includes(token.toLowerCase())) + ) + ); + const tokens = contractTokens.length > 0 ? contractTokens : [contract]; + const clauses = fieldPaths + .map((fieldPath) => String(fieldPath ?? "").trim()) + .filter((fieldPath) => fieldPath.length > 0) + .map((fieldPath) => { + const tokenConditions = tokens.map((token) => { + const escapedToken = toQueryStringLiteral(token); + return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`; + }); + return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`; + }); + if (clauses.length === 0) { + return null; + } + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; +} + function normalizeAccountTokenForQuery(value: string): string { const source = String(value ?? "").trim().replace(",", "."); const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/); @@ -1251,6 +1350,48 @@ function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): str return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; } +function buildDebtDueDateAgingWhereClause( + filters: AddressFilterSet, + amountFieldPath: string, + accountPredicate: string +): string { + const conditions = [ + `${amountFieldPath} > 0`, + `(${accountPredicate})`, + buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]), + buildCounterpartyReferenceCondition(filters, ["Остатки.Субконто1"]), + buildContractReferenceCondition(filters, ["Остатки.Субконто2"]) + ].filter((item): item is string => Boolean(item)); + return `ГДЕ\n ${conditions.join("\n И ")}`; +} + +function buildDebtDueDateAgingQuery(filters: AddressFilterSet, resolvedLimit: number): string { + const asOfExpr = + (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + const accountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]); + return DEBT_DUE_DATE_AGING_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll( + "__WHERE_DT__", + buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокДт", accountPredicate) + ) + .replaceAll( + "__WHERE_KT__", + buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокКт", accountPredicate) + ) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); +} + function buildInventoryMovementQuery( filters: AddressFilterSet, resolvedLimit: number, @@ -1340,6 +1481,179 @@ function buildCounterpartyReferenceCondition(filters: AddressFilterSet, fieldPat return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; } +const ORGANIZATION_REFERENCE_STOP_WORDS = new Set([ + "ооо", + "зао", + "оао", + "ао", + "пао", + "ип", + "на", + "за", + "по", + "конец", + "начало", + "год", + "года", + "период", + "можно", + "точно", + "понять", + "какая", + "какой", + "какие", + "какую", + "компания", + "компании", + "организация", + "организации", + "дебиторка", + "дебиторки", + "кредиторка", + "кредиторки", + "просрочена", + "просроченные", + "просрочка", + "срок", + "оплаты", + "прибыль", + "маржа", + "ндс" +]); + +const ORGANIZATION_REFERENCE_BOUNDARY_WORDS = new Set([ + "на", + "за", + "конец", + "начало", + "можно", + "точно", + "понять", + "какая", + "какой", + "какие", + "какую", + "дебиторка", + "дебиторки", + "кредиторка", + "кредиторки", + "просрочена", + "просроченные", + "просрочка", + "прибыль", + "маржа", + "ндс" +]); + +function organizationReferenceTokens(organization: string): string[] { + const rawTokens = organization + .split(/[^A-Za-zА-Яа-яЁё0-9]+/u) + .map((token) => token.trim()) + .filter((token) => token.length > 0); + const boundaryIndex = rawTokens.findIndex((token) => { + const lower = token.toLowerCase(); + return /^\d+$/.test(token) || ORGANIZATION_REFERENCE_BOUNDARY_WORDS.has(lower); + }); + const scopedTokens = boundaryIndex > 0 ? rawTokens.slice(0, boundaryIndex) : rawTokens; + return Array.from( + new Set( + scopedTokens + .filter((token) => token.length >= 3) + .filter((token) => !/^\d+$/.test(token)) + .filter((token) => !ORGANIZATION_REFERENCE_STOP_WORDS.has(token.toLowerCase())) + ) + ).slice(0, 4); +} + +function buildOrganizationReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null { + const organization = typeof filters.organization === "string" ? filters.organization.trim() : ""; + if (!organization) { + return null; + } + const organizationTokens = organizationReferenceTokens(organization); + const tokens = organizationTokens.length > 0 ? organizationTokens : [organization]; + const clauses = fieldPaths + .map((fieldPath) => String(fieldPath ?? "").trim()) + .filter((fieldPath) => fieldPath.length > 0) + .map((fieldPath) => { + const tokenConditions = tokens.map((token) => { + const escapedToken = toQueryStringLiteral(token); + return `(Организации.Наименование ПОДОБНО "%${escapedToken}%" ИЛИ Организации.НаименованиеПолное ПОДОБНО "%${escapedToken}%")`; + }); + const referenceSubquery = + `(ВЫБРАТЬ Организации.Ссылка ИЗ Справочник.Организации КАК Организации ` + + `ГДЕ ${tokenConditions.length === 1 ? tokenConditions[0] : tokenConditions.join(" И ")})`; + return `${fieldPath} В ${referenceSubquery}`; + }); + if (clauses.length === 0) { + return null; + } + return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`; +} + +function buildAccountingFinancialResultAggregateSelect( + filters: AddressFilterSet, + marker: string, + debitLabel: string, + creditLabel: string, + debitPrefixes: string[], + creditPrefixes: string[] +): string { + const whereClause = buildWhereClause( + filters, + "Движения.Период", + [ + debitPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетДт", debitPrefixes) : null, + creditPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетКт", creditPrefixes) : null, + buildOrganizationReferenceCondition(filters, ["Движения.Организация"]) + ].filter((item): item is string => Boolean(item)) + ); + return ` +ВЫБРАТЬ + ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период, + "${marker}" КАК Регистратор, + "${debitLabel}" КАК СчетДт, + "${creditLabel}" КАК СчетКт, + ЕСТЬNULL(СУММА(Движения.Сумма), 0) КАК Сумма, + "" КАК СубконтоДт1, + "" КАК СубконтоДт2, + "" КАК СубконтоДт3, + "" КАК СубконтоКт1, + "" КАК СубконтоКт2, + "" КАК СубконтоКт3, + "" КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения +${whereClause}`; +} + +function buildAccountingFinancialResultQuery(filters: AddressFilterSet): string { + const rows = [ + ["ACC90_REVENUE_KT", "ANY", "90.01", [], ["90.01"]], + ["ACC90_COST_DT", "90.02", "ANY", ["90.02"], []], + ["ACC90_SELLING_DT", "90.07", "ANY", ["90.07"], []], + ["ACC90_ADMIN_DT", "90.08", "ANY", ["90.08"], []], + ["ACC90_RESULT_TO_99_PROFIT", "90.09", "99", ["90.09"], ["99"]], + ["ACC90_RESULT_FROM_99_LOSS", "99", "90.09", ["99"], ["90.09"]], + ["ACC91_RESULT_TO_99_PROFIT", "91.09", "99", ["91.09"], ["99"]], + ["ACC91_RESULT_FROM_99_LOSS", "99", "91.09", ["99"], ["91.09"]], + ["ACC99_TO84_PROFIT_TRANSFER", "99", "84", ["99"], ["84"]], + ["ACC84_TO99_LOSS_TRANSFER", "84", "99", ["84"], ["99"]] + ] as const; + return rows + .map(([marker, debitLabel, creditLabel, debitPrefixes, creditPrefixes]) => + buildAccountingFinancialResultAggregateSelect( + filters, + marker, + debitLabel, + creditLabel, + [...debitPrefixes], + [...creditPrefixes] + ).trim() + ) + .join("\nОБЪЕДИНИТЬ ВСЕ\n"); +} + function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string { const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]); return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE @@ -1458,6 +1772,8 @@ function maxLimitForIntent(intent: AddressIntent): number { intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period" || + intent === "accounting_financial_result_for_organization" || + intent === "debt_due_date_aging_for_organization" || intent === "inventory_on_hand_as_of_date" || intent === "inventory_purchase_provenance_for_item" || intent === "inventory_purchase_documents_for_item" || @@ -1518,7 +1834,8 @@ export function buildAddressRecipePlan( recipe.query_template === "counterparty_roles_profile" || recipe.query_template === "contract_usage_profile" || recipe.query_template === "vat_payable_forecast_profile" || - recipe.query_template === "vat_liability_confirmed_tax_period_profile"; + recipe.query_template === "vat_liability_confirmed_tax_period_profile" || + recipe.query_template === "accounting_financial_result_profile"; const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit) ? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit))) @@ -1659,6 +1976,10 @@ export function buildAddressRecipePlan( .replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период")) .replaceAll("__PERIOD_TO_EXPR__", periodToExpr); })() + : recipe.query_template === "accounting_financial_result_profile" + ? buildAccountingFinancialResultQuery(filters) + : recipe.query_template === "debt_due_date_aging_profile" + ? buildDebtDueDateAgingQuery(filters, resolvedLimit) : recipe.query_template === "vat_payable_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index b52806a..412863c 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -705,11 +705,19 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR if (asksTotalMoneyEarned) { return "total_flow"; } + const hasCounterpartyRankingSubject = + /(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart|\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442)/iu.test( + text + ); + const asksExplicitYearBreakdown = + /(?:РїРѕ\s+годам|Р·Р°\s+какие\s+РіРѕРґС‹|динамик\w*\s+РїРѕ\s+РіРѕРґ|yearly\s+breakdown|by\s+year|\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\u043a\u0430\u043a\u0438\u0435\s+\u0433\u043e\u0434\u044b|\u0434\u0438\u043d\u0430\u043c\u0438\u043a\w*\s+\u043f\u043e\s+\u0433\u043e\u0434)/iu.test( + text + ); const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) && /(?:год|года|годы|year|years|по\s+годам)/iu.test(text) && /(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text); - if (asksYearlyRevenueRanking) { + if (asksYearlyRevenueRanking && (!hasCounterpartyRankingSubject || asksExplicitYearBreakdown)) { return "top_years_by_total"; } if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) { @@ -3214,7 +3222,7 @@ function composeFactualReplyBody( const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; lines.push( "", - "Покрытие VAT-источников через MCP:", + "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, @@ -3241,7 +3249,7 @@ function composeFactualReplyBody( } lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия."); } else if (vatProbe && vatProbe.status === "error") { - lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*."); + lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*."); } if (!vatActivityDetected) { @@ -3328,13 +3336,16 @@ function composeFactualReplyBody( options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null; const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value)); const vatProbe = options.vatDirectSourceProbe ?? null; + const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint); + const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : ""; const lines = [ - `Коротко: подтвержденный НДС к уплате за налоговый период — ${formatConfirmedMoney(vatToPay)}.`, + `Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel} — ${formatConfirmedMoney(vatToPay)}.`, `Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`, "Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.", "", "Что вошло в расчет:", + ...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []), `- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`, `- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`, `- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`, @@ -3346,7 +3357,7 @@ function composeFactualReplyBody( const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length; lines.push( "", - "Покрытие VAT-источников через MCP:", + "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, @@ -3355,11 +3366,11 @@ function composeFactualReplyBody( if (vatProbe.errors.length > 0) { lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`); } - lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников."); + lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников."); } else if (vatProbe && vatProbe.status === "error") { lines.push( "", - "Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).", + "Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия." ); if (vatProbe.errors.length > 0) { @@ -3453,7 +3464,7 @@ function composeFactualReplyBody( const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length; lines.push( "", - "Блок 2.1. MCP-проверка VAT-источников", + "Блок 2.1. Проверка VAT-источников в 1С", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.` @@ -3478,8 +3489,8 @@ function composeFactualReplyBody( } else if (vatProbe && vatProbe.status === "error") { lines.push( "", - "Блок 2.1. MCP-проверка VAT-источников", - "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)." + "Блок 2.1. Проверка VAT-источников в 1С", + "- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)." ); } diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index 8aab809..dec52cd 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -148,9 +148,9 @@ export function composeCounterpartyAnalyticsReply( const includeRoles = focus === "full_profile" || focus === "roles_only"; const directLead = focus === "suppliers_only" - ? `Поставщиков (только supplier-роль): ${supplierOnly}.` + ? `Поставщиков с ролью поставщика: ${supplierOnly}.` : focus === "customers_only" - ? `Заказчиков (только customer-роль): ${customerOnly}.` + ? `Заказчиков с ролью покупателя: ${customerOnly}.` : focus === "mixed_only" ? `Контрагентов со смешанной ролью: ${mixedActive}.` : includeTotal && totalCounterparties > 0 @@ -175,9 +175,9 @@ export function composeCounterpartyAnalyticsReply( if (includeRoles) { if (resolvedActive > 0 || activeCounterparties > 0) { - lines.push("Роли контрагентов по активности:"); - lines.push(`Заказчики (только customer-роль): ${customerOnly}.`); - lines.push(`Поставщики (только supplier-роль): ${supplierOnly}.`); + lines.push("Распределение ролей по активности:"); + lines.push(`Заказчики с ролью покупателя: ${customerOnly}.`); + lines.push(`Поставщики с ролью поставщика: ${supplierOnly}.`); lines.push(`Смешанные (и покупатель, и поставщик): ${mixedActive}.`); lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`); if (otherCounterparties !== null) { @@ -189,10 +189,10 @@ export function composeCounterpartyAnalyticsReply( } if (focus === "suppliers_only") { - lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`); + lines.push(`Поставщиков с ролью поставщика: ${supplierOnly}.`); } if (focus === "customers_only") { - lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`); + lines.push(`Заказчиков с ролью покупателя: ${customerOnly}.`); } if (focus === "mixed_only") { lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`); @@ -525,6 +525,16 @@ export function composeCounterpartyAnalyticsReply( const limit = deps.detectRankingLimit(options.userMessage, 20); const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage); const normalizedQuestion = deps.normalizeQuestionText(options.userMessage); + const asksSingleBestCounterparty = + focus === "top_by_total" && + /(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) && + /(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибол|РїСЂРёРЅ[её]СЃ)/iu.test( + normalizedQuestion + ) && + !/(?:\btop\b|топ|рейтинг|список|первые|покажи\s+топ|дай\s+топ|покаж\w*\s+топ|дай\s+топ)/iu.test( + normalizedQuestion + ); + const effectiveLimit = asksSingleBestCounterparty ? 1 : limit; const byCounterparty = new Map(); const byYear = new Map(); @@ -728,7 +738,7 @@ export function composeCounterpartyAnalyticsReply( lines.push( ...visible.map( (item, index) => - `${index + 1}. ${item.name} | max single: ${item.maxSingle} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}` + `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}` ) ); return buildFactualListReply(lines); @@ -786,8 +796,12 @@ export function composeCounterpartyAnalyticsReply( return buildFactualListReply(lines); } - const visible = rankedByTotal.slice(0, limit); + const visible = rankedByTotal.slice(0, effectiveLimit); const singleCandidateOnly = rankedByTotal.length === 1; + const rankingPeriodLabel = + options.periodFrom && options.periodTo + ? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}` + : "за доступное время"; const heading = singleCandidateOnly ? isSupplier ? "Найденный поставщик по сумме выплат:" @@ -797,14 +811,17 @@ export function composeCounterpartyAnalyticsReply( : `Топ-${visible.length} заказчиков по сумме поступлений:`; const leadingCounterparty = visible[0] ?? null; lines.unshift(heading); + if (options.periodFrom && options.periodTo) { + lines.push(`Период рейтинга: ${rankingPeriodLabel}.`); + } if (leadingCounterparty) { const directAnswerLine = singleCandidateOnly ? isSupplier ? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.` : `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.` : isSupplier - ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` - : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${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); } lines.push( diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts index 1579340..a37df78 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryAnswerAdapter.ts @@ -1,4 +1,5 @@ import type { AssistantMcpDiscoveryPilotExecutionContract } from "./assistantMcpDiscoveryPilotExecutor"; +import { isLikelyFinancialInstitutionCounterparty } from "./counterpartyRoleHeuristics"; export const ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = "assistant_mcp_discovery_answer_draft_v1" as const; @@ -26,6 +27,7 @@ export interface AssistantMcpDiscoveryAnswerDraftContract { } type BusinessOverview = NonNullable; +type BusinessOverviewRankedBucket = BusinessOverview["top_customers"][number]; function normalizeReasonCode(value: string): string | null { const normalized = value @@ -483,6 +485,30 @@ function metadataRouteFamilyLabelRu( return null; } +function isInventoryReserveBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "inventory_reserve_boundary" || unsupported === "inventory_reserve_liquidation_boundary"; +} + +function isProfitMarginBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "profit_margin_boundary" || unsupported === "profit_margin_boundary"; +} + +function isDebtDueDateBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "debt_due_date_boundary" || unsupported === "debt_due_date_boundary"; +} + +function isVendorRiskBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean { + const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family; + const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family; + return action === "vendor_risk_procurement_boundary" || unsupported === "vendor_risk_procurement_boundary"; +} + function businessOverviewInventoryUnknownLabel(overview: BusinessOverview): string { if (overview.inventory_staleness_risk_proxy) { return "резервы/списания/ликвидационная стоимость склада"; @@ -543,6 +569,81 @@ function inlineBusinessOverviewAmount(value: string): string { .replace(/[\s.]+$/u, ""); } +function isFinancialInstitutionBucket(bucket: BusinessOverviewRankedBucket | null | undefined): boolean { + if (!bucket) { + return false; + } + return ( + bucket.counterparty_role_hint === "bank_or_financial_institution" || + isLikelyFinancialInstitutionCounterparty(bucket.axis_value) + ); +} + +function firstNonFinancialInstitutionBucket( + buckets: BusinessOverviewRankedBucket[] | null | undefined +): BusinessOverviewRankedBucket | null { + return (buckets ?? []).find((bucket) => !isFinancialInstitutionBucket(bucket)) ?? null; +} + +function rankedBucketAmountLabel(bucket: BusinessOverviewRankedBucket): string { + return `${bucket.axis_value} — ${bucket.total_amount_human_ru}`; +} + +function businessOverviewIncomingLeaderLine(overview: BusinessOverview): string | null { + const leader = overview.top_customers[0]; + if (!leader) { + return null; + } + if (!isFinancialInstitutionBucket(leader)) { + return `Самый крупный подтвержденный клиент в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`; + } + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1)); + const nonFinancialText = nonFinancial + ? ` Крупнейший небанковский входящий контрагент в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.` + : ""; + return ( + `Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` + + "По названию это банк/финансовая организация, поэтому без проверки назначения платежа не называю это клиентской выручкой или бизнес-заказчиком." + + nonFinancialText + ); +} + +function businessOverviewOutgoingLeaderLine(overview: BusinessOverview): string | null { + const leader = overview.top_suppliers?.[0]; + if (!leader) { + return null; + } + if (!isFinancialInstitutionBucket(leader)) { + return `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`; + } + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1)); + const nonFinancialText = nonFinancial + ? ` Крупнейший небанковский получатель исходящих денег в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.` + : ""; + return ( + `Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` + + "По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком." + + nonFinancialText + ); +} + +function businessOverviewSupplierBoundaryBasis(overview: BusinessOverview): string { + const leader = overview.top_suppliers?.[0] ?? null; + if (!leader) { + return "есть только общий срез исходящих платежей без надежного vendor-risk профиля"; + } + const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount); + if (isFinancialInstitutionBucket(leader)) { + const base = share + ? `крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})` + : `крупнейший получатель исходящих денег: ${rankedBucketAmountLabel(leader)}`; + return `${base}; по названию это банк/финансовая организация, поэтому этот факт нельзя считать доказанной зависимостью от одного обычного поставщика`; + } + return share + ? `крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})` + : `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${rankedBucketAmountLabel(leader)}`; +} + function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string | null { const parts: string[] = []; if (overview.incoming_customer_revenue.rows_with_amount > 0) { @@ -554,6 +655,24 @@ function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) { parts.push(`расчетное операционное нетто ${inlineBusinessOverviewAmount(overview.net_amount_human_ru)}`); } + if (overview.accounting_financial_result) { + const result = overview.accounting_financial_result; + const direction = + result.final_result_direction === "profit" + ? "учетная прибыль" + : result.final_result_direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const amount = + result.final_result_direction === "loss" + ? `минус ${inlineBusinessOverviewAmount(result.final_result_amount_human_ru)}` + : inlineBusinessOverviewAmount(result.final_result_amount_human_ru); + const margin = + result.net_margin_to_revenue_pct === null + ? "маржа к выручке 90.01 не рассчитана" + : `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`; + parts.push(`${direction} 90/91/99 ${amount}; ${margin}`); + } const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview); if (strongestIncomingYear) { parts.push( @@ -561,10 +680,59 @@ function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string ); } return parts.length > 0 - ? `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат` + ? overview.accounting_financial_result + ? `${parts.join("; ")}. Финрезультат ограничен найденными строками 1С и не является внешним аудитом или юридически подтвержденной отчетностью` + : `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат` : null; } +function businessOverviewAccountingFinancialResultText(overview: BusinessOverview): string | null { + const result = overview.accounting_financial_result; + if (!result) { + return null; + } + const direction = + result.final_result_direction === "profit" + ? "учетная прибыль" + : result.final_result_direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const signedAmount = + result.final_result_direction === "loss" + ? `минус ${result.final_result_amount_human_ru}` + : result.final_result_amount_human_ru; + const marginText = + result.net_margin_to_revenue_pct === null + ? "маржа к выручке 90.01 не рассчитана" + : `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`; + const basis = + result.final_transfer_basis === "account_99_to_84_period_close" + ? "по закрытию 99 на 84" + : "по закрытию 90/91 на 99"; + return `По бухгалтерскому маршруту 90/91/99 за ${result.period_scope} подтвержден ${direction}: ${signedAmount}; ${marginText}. Основа: ${basis}, ${result.period_close_rows_with_amount} строк(и) закрытия периода с суммой. Это учетный финрезультат по найденным строкам 1С, не внешний аудит и не юридически подтвержденная отчетность.`; +} + +function businessOverviewDebtDueDateAgingText(overview: BusinessOverview): string | null { + const aging = overview.debt_due_date_aging; + if (!aging) { + return null; + } + if (aging.evidence_status === "confirmed_overdue") { + const top = aging.top_overdue_items?.[0] ?? null; + const topText = top + ? ` Самая старая строка: due date ${top.due_date}, просрочка ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.` + : ""; + return `Due-date aging на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`; + } + if (aging.evidence_status === "no_payment_terms_configured") { + return `Due-date aging на ${aging.as_of_date} проверен по открытым расчетам: брутто ${aging.gross_open_amount_human_ru}, строк с суммой ${aging.rows_with_amount}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`; + } + if (aging.evidence_status === "insufficient_due_date_basis") { + return `Due-date aging на ${aging.as_of_date} запускался, но по строкам с установленным сроком оплаты не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`; + } + return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`; +} + function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string { const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" || @@ -583,6 +751,35 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD } if (isBusinessOverviewPilot(pilot) && pilot.derived_business_overview && mode === "confirmed_with_bounded_inference") { const overview = pilot.derived_business_overview; + if (isProfitMarginBoundaryTurn(pilot)) { + const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview); + if (accountingFinancialResultText) { + return accountingFinancialResultText; + } + return "Нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финрезультат."; + } + if (isDebtDueDateBoundaryTurn(pilot)) { + const dueDateText = businessOverviewDebtDueDateAgingText(overview); + if (dueDateText) { + return dueDateText; + } + return "Нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута по договорам, срокам оплаты и погашению расчетов."; + } + if (isInventoryReserveBoundaryTurn(pilot)) { + const inventoryBasis = overview.inventory_staleness_risk_proxy + ? "есть только складской staleness-risk proxy по найденным строкам" + : overview.inventory_position || overview.inventory_turnover_proxy + ? "есть только ограниченные складские proxy-сигналы по найденным строкам" + : "нет отдельного складского среза на дату и проверки учетной политики резервов"; + return `Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя; ${inventoryBasis}. Можно честно говорить только о необходимости отдельной проверки склада, списаний/резервов и ликвидационной стоимости, не превращая proxy в доказанный факт резерва.`; + } + if (isVendorRiskBoundaryTurn(pilot)) { + const supplierLeader = overview.top_suppliers?.[0] ?? null; + const proxyLabel = isFinancialInstitutionBucket(supplierLeader) + ? "outgoing cash concentration proxy" + : "procurement concentration proxy"; + return `Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${businessOverviewSupplierBoundaryBasis(overview)}. Это сигнал концентрации исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов.`; + } const families: string[] = []; if ( overview.incoming_customer_revenue.rows_with_amount > 0 || @@ -608,6 +805,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (overview.tax_position) { families.push("НДС-позиция"); } + if (overview.accounting_financial_result) { + families.push("учетный финрезультат 90/91/99"); + } if (overview.trading_margin_proxy) { families.push("торговый margin proxy"); } @@ -623,6 +823,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (overview.debt_staleness_risk_proxy) { families.push("staleness risk proxy открытых расчетов"); } + if (overview.debt_due_date_aging) { + families.push("due-date aging открытых расчетов"); + } if (overview.inventory_position) { families.push("складской срез на дату"); } @@ -632,20 +835,24 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD if (overview.inventory_staleness_risk_proxy) { families.push("staleness risk proxy склада"); } - const unknownFamilies = [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"]; + const unknownFamilies = overview.accounting_financial_result + ? ["аудированная/юридически подтвержденная прибыль"] + : [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"]; if (!overview.tax_position) { unknownFamilies.push("НДС"); } if (!overview.debt_position) { unknownFamilies.push("долговой срез"); } - unknownFamilies.push( - overview.debt_staleness_risk_proxy + if (!overview.debt_due_date_aging) { + unknownFamilies.push( + overview.debt_staleness_risk_proxy ? "договорные сроки оплаты/due-date просрочка" : overview.debt_open_settlement_quality ? "due-date просрочка" : "качество открытых расчетов" - ); + ); + } unknownFamilies.push(businessOverviewInventoryUnknownLabel(overview)); const metricLead = businessOverviewHeadlineMetricsLine(overview); if (metricLead) { @@ -851,9 +1058,16 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis."); claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt."); claims.push("Do not present business overview debt staleness risk proxy as confirmed overdue debt, contractual delinquency, credit risk, or due-date aging."); + claims.push("Do not claim contractual overdue debt unless the due-date aging route found configured payment terms and enough settlement-date evidence."); claims.push("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health."); claims.push("Do not present business overview inventory turnover proxy as full inventory liquidity, FIFO turnover, obsolescence analysis, or liquidation value."); claims.push("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value."); + if ( + pilot.derived_business_overview?.top_customers?.some(isFinancialInstitutionBucket) || + pilot.derived_business_overview?.top_suppliers?.some(isFinancialInstitutionBucket) + ) { + claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof."); + } if (pilot.derived_business_overview?.missing_proof_families?.length) { claims.push("Do not present business overview missing proof families as checked, executed, or confirmed routes."); } @@ -862,6 +1076,9 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract): if (pilot.derived_ranked_value_flow) { claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization."); claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist."); + if (pilot.derived_ranked_value_flow.ranked_values.some(isFinancialInstitutionBucket)) { + claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof."); + } } if (isDocumentPilot(pilot)) { claims.push("Do not claim full document history outside the checked period."); @@ -1028,26 +1245,40 @@ function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotEx return null; } const leader = ranking.ranked_values[0]; + const leaderLooksFinancial = isFinancialInstitutionBucket(leader); const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : ""; const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне"; + const roleCaveat = leaderLooksFinancial + ? ranking.value_flow_direction === "outgoing_supplier_payout" + ? " По названию это банк/финансовая организация, поэтому без назначения платежа/договора не называю это обычным поставщиком." + : " По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой или бизнес-заказчиком." + : ""; if (ranking.ranked_values.length === 1) { const singleLead = - ranking.value_flow_direction === "outgoing_supplier_payout" - ? "В проверенных исходящих платежах найден один контрагент" - : "В проверенных входящих поступлениях найден один контрагент"; + leaderLooksFinancial + ? ranking.value_flow_direction === "outgoing_supplier_payout" + ? "В проверенных исходящих платежах найден один банковский/финансовый получатель" + : "В проверенных входящих поступлениях найден один банковский/финансовый источник" + : ranking.value_flow_direction === "outgoing_supplier_payout" + ? "В проверенных исходящих платежах найден один контрагент" + : "В проверенных входящих поступлениях найден один контрагент"; const limitCaveat = ranking.coverage_limited_by_probe_limit ? " Лимит строк проверки достигнут; сравнение с другими контрагентами может быть неполным." : " Других контрагентов в этом проверенном срезе не найдено, поэтому это не полноценный сравнительный рейтинг."; - return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${limitCaveat}`; + return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${limitCaveat}`; } const directionLead = - ranking.ranking_need === "bottom_asc" + leaderLooksFinancial ? ranking.value_flow_direction === "outgoing_supplier_payout" - ? "Меньше всего заплатили контрагенту" - : "Меньше всего денег принёс контрагент" - : ranking.value_flow_direction === "outgoing_supplier_payout" - ? "Больше всего заплатили контрагенту" - : "Больше всего денег принёс контрагент"; + ? "Крупнейший получатель исходящих денег" + : "Крупнейший входящий денежный источник" + : ranking.ranking_need === "bottom_asc" + ? ranking.value_flow_direction === "outgoing_supplier_payout" + ? "Меньше всего заплатили контрагенту" + : "Меньше всего денег принёс контрагент" + : ranking.value_flow_direction === "outgoing_supplier_payout" + ? "Больше всего заплатили контрагенту" + : "Больше всего денег принёс контрагент"; const tail = ranking.ranked_values .slice(1, 3) .map((bucket) => `${bucket.axis_value} — ${bucket.total_amount_human_ru}`) @@ -1056,7 +1287,7 @@ function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotEx const limitCaveat = ranking.coverage_limited_by_probe_limit ? " Лимит строк проверки достигнут; рейтинг может быть неполным." : ""; - return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`; + return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${trail}${limitCaveat}`; } function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null { @@ -1247,15 +1478,13 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} — ${strongestIncomingYear.incoming_total_amount_human_ru} по ${strongestIncomingYear.incoming_rows_with_amount} строкам с суммой. Это не бухгалтерская прибыль.` ); } - const leader = overview.top_customers[0]; - if (leader) { - lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`); + const incomingLeaderLine = businessOverviewIncomingLeaderLine(overview); + if (incomingLeaderLine) { + lines.push(incomingLeaderLine); } - const supplierLeader = overview.top_suppliers?.[0]; - if (supplierLeader) { - lines.push( - `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.` - ); + const outgoingLeaderLine = businessOverviewOutgoingLeaderLine(overview); + if (outgoingLeaderLine) { + lines.push(outgoingLeaderLine); } if (overview.yearly_breakdown?.length) { lines.push( @@ -1316,6 +1545,12 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot `НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.` ); } + if (overview.accounting_financial_result) { + const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview); + if (accountingFinancialResultText) { + lines.push(accountingFinancialResultText); + } + } if (overview.trading_margin_proxy) { const proxy = overview.trading_margin_proxy; const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`; @@ -1359,6 +1594,10 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot `Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.` ); } + const dueDateText = businessOverviewDebtDueDateAgingText(overview); + if (dueDateText) { + lines.push(dueDateText); + } if (overview.inventory_position) { const leader = overview.inventory_position.top_items[0]; const leaderText = leader @@ -1416,6 +1655,13 @@ function businessOverviewCustomerConcentrationLine(overview: BusinessOverview): return null; } const share = percentText(leader.total_amount, overview.incoming_customer_revenue.total_amount); + if (isFinancialInstitutionBucket(leader)) { + const base = share + ? `Крупнейший входящий денежный источник ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru})` + : `Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}`; + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1)); + return `${base}. По названию это банк/финансовая организация, поэтому это не доказывает клиентскую выручку или зависимость от клиента.${nonFinancial ? ` Крупнейший небанковский входящий контрагент: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`; + } return share ? `Концентрация входящего потока: крупнейший подтвержденный клиент ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru}). Это сигнал зависимости от клиента, а не полный customer-risk аудит.` : `Крупнейший подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`; @@ -1427,6 +1673,13 @@ function businessOverviewSupplierConcentrationLine(overview: BusinessOverview): return null; } const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount); + if (isFinancialInstitutionBucket(leader)) { + const base = share + ? `Концентрация исходящего потока: крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru})` + : `Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}`; + const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1)); + return `${base}. По названию это банк/финансовая организация, поэтому это не доказательство зависимости от обычного поставщика без проверки назначения платежа/договора.${nonFinancial ? ` Крупнейший небанковский получатель исходящих денег: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`; + } return share ? `Концентрация исходящего потока: крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru}). Это сигнал procurement concentration по найденным строкам, а не полный vendor-risk аудит или структура всех расходов.` : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.`; @@ -1474,6 +1727,20 @@ function businessOverviewRiskSynthesisLine(overview: BusinessOverview): string | : `маржинальность proxy ${overview.trading_margin_proxy.margin_to_revenue_pct}%`; signals.push(`торговый спред proxy ${overview.trading_margin_proxy.gross_spread_proxy_human_ru}, ${marginText}`); } + if (overview.accounting_financial_result) { + const result = overview.accounting_financial_result; + const direction = + result.final_result_direction === "profit" + ? "учетная прибыль" + : result.final_result_direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const marginText = + result.net_margin_to_revenue_pct === null + ? "маржа к выручке 90.01 не рассчитана" + : `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`; + signals.push(`${direction} 90/91/99 ${result.final_result_amount_human_ru}, ${marginText}`); + } if (overview.debt_position) { const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable" @@ -1495,6 +1762,18 @@ function businessOverviewRiskSynthesisLine(overview: BusinessOverview): string | `staleness risk proxy открытых расчетов: ${debtStalenessRiskBandRu(overview.debt_staleness_risk_proxy.risk_band)}, возраст ${overview.debt_staleness_risk_proxy.max_contract_age_days} дн., концентрация старейшего крупного договора ${overview.debt_staleness_risk_proxy.top_contract_share_pct}%` ); } + if (overview.debt_due_date_aging) { + const aging = overview.debt_due_date_aging; + signals.push( + aging.evidence_status === "confirmed_overdue" + ? `due-date aging: подтвержденная просрочка ${aging.overdue_amount_human_ru}, строк ${aging.overdue_rows}` + : aging.evidence_status === "no_payment_terms_configured" + ? "due-date aging: проверено, но сроки оплаты в договорах не установлены; подтвержденной просрочки нет" + : aging.evidence_status === "insufficient_due_date_basis" + ? "due-date aging: не хватило даты расчетного документа для честного расчета просрочки" + : `due-date aging: проверено, подтвержденной просрочки не найдено` + ); + } if (overview.document_activity_profile) { const topDocument = overview.document_activity_profile.top_document_types[0]; const topSection = overview.document_activity_profile.top_account_sections[0]; @@ -1648,6 +1927,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft( if (pilot.derived_business_overview?.tax_position) { pushReason(reasonCodes, "answer_contains_business_overview_tax_position"); } + if (pilot.derived_business_overview?.accounting_financial_result) { + pushReason(reasonCodes, "answer_contains_business_overview_accounting_financial_result"); + } if (pilot.derived_business_overview?.trading_margin_proxy) { pushReason(reasonCodes, "answer_contains_business_overview_trading_margin_proxy"); } @@ -1678,6 +1960,10 @@ export function buildAssistantMcpDiscoveryAnswerDraft( if (pilot.derived_business_overview?.debt_staleness_risk_proxy) { pushReason(reasonCodes, "answer_contains_business_overview_debt_staleness_risk_proxy"); } + if (pilot.derived_business_overview?.debt_due_date_aging) { + pushReason(reasonCodes, "answer_contains_business_overview_debt_due_date_aging"); + pushReason(reasonCodes, `answer_contains_business_overview_debt_due_date_aging_${pilot.derived_business_overview.debt_due_date_aging.evidence_status}`); + } if (pilot.derived_business_overview?.inventory_position) { pushReason(reasonCodes, "answer_contains_business_overview_inventory_position"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDebugAttachment.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDebugAttachment.ts index 43c15a2..c9d7dda 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDebugAttachment.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDebugAttachment.ts @@ -1,4 +1,5 @@ import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint"; +import type { AssistantMcpRouteCandidateContract } from "./assistantMcpDiscoveryRuntimeBridge"; export interface AssistantMcpDiscoveryDebugAttachmentFields { assistant_mcp_discovery_entry_point_v1: AssistantMcpDiscoveryRuntimeEntryPointContract | null; @@ -11,6 +12,16 @@ export interface AssistantMcpDiscoveryDebugAttachmentFields { mcp_discovery_catalog_chain_alignment_status: string | null; mcp_discovery_catalog_chain_top_match: string | null; mcp_discovery_catalog_chain_selected_matches_top: boolean; + mcp_discovery_route_candidate_v1: AssistantMcpRouteCandidateContract | null; + mcp_discovery_route_candidate_status: string | null; + mcp_discovery_route_candidate_fact_family: string | null; + mcp_discovery_route_candidate_action_family: string | null; + mcp_discovery_route_candidate_proof_expectation: string | null; + mcp_discovery_route_candidate_missing_axes: string[]; + mcp_discovery_route_candidate_provided_axes: string[]; + mcp_discovery_route_candidate_executable_now: boolean; + mcp_discovery_route_candidate_enablement_reason: string | null; + mcp_discovery_route_candidate_next_action: string | null; mcp_discovery_answer_mode: string | null; mcp_discovery_business_fact_answer_allowed: boolean; mcp_discovery_user_facing_response_allowed: boolean; @@ -59,6 +70,14 @@ function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcp ); } +function isRouteCandidateContract(value: unknown): value is AssistantMcpRouteCandidateContract { + const record = toRecordObject(value); + return ( + record?.schema_version === "assistant_mcp_route_candidate_v1" && + record?.policy_owner === "assistantMcpDiscoveryRuntimeBridge" + ); +} + function resolveEntryPoint(input: AttachAssistantMcpDiscoveryDebugInput): AssistantMcpDiscoveryRuntimeEntryPointContract | null { if (isMcpDiscoveryEntryPointContract(input.entryPoint)) { return input.entryPoint; @@ -77,6 +96,7 @@ export function buildAssistantMcpDiscoveryDebugAttachmentFields( const bridge = toRecordObject(entryPoint?.bridge); const planner = toRecordObject(bridge?.planner); const chainAlignment = toRecordObject(planner?.catalog_chain_template_alignment); + const routeCandidate = isRouteCandidateContract(bridge?.route_candidate) ? bridge.route_candidate : null; const answerDraft = toRecordObject(bridge?.answer_draft); return { @@ -90,6 +110,16 @@ export function buildAssistantMcpDiscoveryDebugAttachmentFields( mcp_discovery_catalog_chain_alignment_status: toNonEmptyString(chainAlignment?.alignment_status), mcp_discovery_catalog_chain_top_match: toNonEmptyString(chainAlignment?.top_chain_template_match), mcp_discovery_catalog_chain_selected_matches_top: chainAlignment?.selected_chain_matches_top === true, + mcp_discovery_route_candidate_v1: routeCandidate, + mcp_discovery_route_candidate_status: toNonEmptyString(routeCandidate?.candidate_status), + mcp_discovery_route_candidate_fact_family: toNonEmptyString(routeCandidate?.business_fact_family), + mcp_discovery_route_candidate_action_family: toNonEmptyString(routeCandidate?.action_family), + mcp_discovery_route_candidate_proof_expectation: toNonEmptyString(routeCandidate?.proof_expectation), + mcp_discovery_route_candidate_missing_axes: toStringArray(routeCandidate?.missing_axes), + mcp_discovery_route_candidate_provided_axes: toStringArray(routeCandidate?.provided_axes), + mcp_discovery_route_candidate_executable_now: routeCandidate?.executable_now === true, + mcp_discovery_route_candidate_enablement_reason: toNonEmptyString(routeCandidate?.enablement_reason), + mcp_discovery_route_candidate_next_action: toNonEmptyString(routeCandidate?.recommended_next_action), mcp_discovery_answer_mode: toNonEmptyString(answerDraft?.answer_mode), mcp_discovery_business_fact_answer_allowed: bridge?.business_fact_answer_allowed === true, mcp_discovery_user_facing_response_allowed: bridge?.user_facing_response_allowed === true, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts index 6ed9596..e7a6c4a 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPilotExecutor.ts @@ -18,6 +18,11 @@ import { type AssistantMcpDiscoveryProbeResult } from "./assistantMcpDiscoveryPolicy"; import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog"; +import { + counterpartyRoleHintForName, + isLikelyFinancialInstitutionCounterparty, + type CounterpartyRoleHint +} from "./counterpartyRoleHeuristics"; import type { AddressFilterSet, AddressIntent } from "../types/addressQuery"; export const ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION = @@ -83,6 +88,7 @@ export interface AssistantMcpDiscoveryRankedValueFlowBucket { rows_with_amount: number; total_amount: number; total_amount_human_ru: string; + counterparty_role_hint?: CounterpartyRoleHint; } export interface AssistantMcpDiscoveryDerivedRankedValueFlow { @@ -228,10 +234,12 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverview { yearly_breakdown: AssistantMcpDiscoveryBusinessOverviewYearBucket[]; activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null; tax_position: AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition | null; + accounting_financial_result: AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult | null; trading_margin_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null; debt_position: AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition | null; debt_open_settlement_quality: AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null; debt_staleness_risk_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskProxy | null; + debt_due_date_aging: AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging | null; inventory_position: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null; inventory_turnover_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryTurnoverProxy | null; inventory_staleness_risk_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryStalenessRiskProxy | null; @@ -264,6 +272,31 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition { inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows"; } +export interface AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult { + period_scope: string; + rows_matched: number; + rows_with_amount: number; + sales_revenue_accounting: number; + sales_revenue_accounting_human_ru: string; + cost_of_sales_accounting: number; + cost_of_sales_accounting_human_ru: string; + selling_expenses_accounting: number; + selling_expenses_accounting_human_ru: string; + admin_expenses_accounting: number; + admin_expenses_accounting_human_ru: string; + sales_result_amount: number; + sales_result_amount_human_ru: string; + other_result_amount: number; + other_result_amount_human_ru: string; + final_result_amount: number; + final_result_amount_human_ru: string; + final_result_direction: "profit" | "loss" | "balanced"; + net_margin_to_revenue_pct: number | null; + final_transfer_basis: "account_99_to_84_period_close" | "account_90_91_to_99_period_close"; + period_close_rows_with_amount: number; + inference_basis: "account_90_91_99_period_close_aggregate_confirmed_1c_rows"; +} + export interface AssistantMcpDiscoveryBusinessOverviewTradingItemBucket { item: string; sales_revenue: number; @@ -373,6 +406,45 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskPr inference_basis: "contract_date_age_and_open_balance_concentration_confirmed_1c_rows"; } +export interface AssistantMcpDiscoveryBusinessOverviewDebtDueDateAgingBucket { + counterparty: string | null; + contract: string | null; + settlement_document: string | null; + document_date: string; + due_date: string; + payment_term_days: number; + overdue_days: number; + amount: number; + amount_human_ru: string; + share_of_overdue_amount_pct: number | null; +} + +export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging { + as_of_date: string; + rows_matched: number; + rows_with_amount: number; + gross_open_amount: number; + gross_open_amount_human_ru: string; + rows_with_payment_terms: number; + rows_without_payment_terms: number; + rows_without_document_date: number; + overdue_rows: number; + overdue_amount: number; + overdue_amount_human_ru: string; + not_yet_due_rows: number; + not_yet_due_amount: number; + not_yet_due_amount_human_ru: string; + oldest_due_date: string | null; + max_overdue_days: number | null; + top_overdue_items: AssistantMcpDiscoveryBusinessOverviewDebtDueDateAgingBucket[]; + evidence_status: + | "confirmed_overdue" + | "no_overdue_found" + | "no_payment_terms_configured" + | "insufficient_due_date_basis"; + inference_basis: "contract_payment_terms_and_settlement_document_dates_from_open_balance_rows"; +} + export interface AssistantMcpDiscoveryBusinessOverviewInventoryItemBucket { item: string; rows_with_amount: number; @@ -723,6 +795,17 @@ function buildBusinessOverviewDebtFilters(planner: AssistantMcpDiscoveryPlannerC }; } +function shouldRunDebtDueDateAgingProbe(planner: AssistantMcpDiscoveryPlannerContract): boolean { + const actionFamily = toNonEmptyString(planner.data_need_graph?.action_family); + const turnActionFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_action_family); + const unsupportedFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family); + const proofExpectation = toNonEmptyString(planner.data_need_graph?.proof_expectation); + const combined = [actionFamily, turnActionFamily, unsupportedFamily, proofExpectation] + .filter((item): item is string => Boolean(item)) + .join(" "); + return /(?:debt_due_date_boundary|due[-_ ]?date|overdue|aging|просроч|срок\s+оплат|дебиторк|кредиторск)/iu.test(combined); +} + function buildBusinessOverviewInventoryFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet | null { const meaning = planner.discovery_plan.turn_meaning_ref; const organization = toNonEmptyString(meaning?.explicit_organization_scope); @@ -756,6 +839,20 @@ function buildBusinessOverviewTradingMarginFilters(planner: AssistantMcpDiscover }; } +function buildBusinessOverviewAccountingFinancialResultFilters( + planner: AssistantMcpDiscoveryPlannerContract +): AddressFilterSet | null { + const filters = buildBusinessOverviewTradingMarginFilters(planner); + if (!filters) { + return null; + } + return { + ...filters, + limit: Math.max(32, planner.discovery_plan.execution_budget.max_rows_per_probe), + sort: "period_asc" + }; +} + function buildInventoryExactFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet { const meaning = planner.discovery_plan.turn_meaning_ref; const subject = firstEntityCandidate(planner); @@ -1359,7 +1456,8 @@ async function executeCoverageAwareValueFlowQuery(input: { }); executedProbeCount += 1; probeResults.push(queryResultToProbeResult(input.primitiveId, broadResult)); - const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= input.maxRowsPerProbe; + const broadLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, broadRecipePlan.limit)); + const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= broadLimitThreshold; if (broadResult.error) { pushUnique(queryLimitations, broadResult.error); @@ -1424,7 +1522,8 @@ async function executeCoverageAwareValueFlowQuery(input: { pushUnique(queryLimitations, chunkResult.error); continue; } - if (chunkResult.matched_rows >= input.maxRowsPerProbe) { + const chunkLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, chunkPlan.limit)); + if (chunkResult.matched_rows >= chunkLimitThreshold) { anyChunkLimited = true; } chunkResults.push(chunkResult); @@ -2402,6 +2501,14 @@ function extractContractDateFromText(value: string | null): string | null { if (!text || !/(?:договор|contract|дог\.)/iu.test(text)) { return null; } + return extractAnyDateFromText(text); +} + +function extractAnyDateFromText(value: string | null): string | null { + const text = toNonEmptyString(value); + if (!text) { + return null; + } const isoLikeMatch = text.match(/(\d{4})[-./](\d{1,2})[-./](\d{1,2})/); if (isoLikeMatch) { return normalizeDateParts(isoLikeMatch[1], isoLikeMatch[2], isoLikeMatch[3]); @@ -2413,6 +2520,64 @@ function extractContractDateFromText(value: string | null): string | null { return null; } +function rowContractDateValue(row: Record): string | null { + const explicit = rowTextValue(row, ["ДатаДоговора", "ContractDate", "contract_date"]); + return extractAnyDateFromText(explicit) ?? rowOpenSettlementContractStartDateValue(row); +} + +function rowSettlementDocumentDateValue(row: Record): string | null { + const explicit = rowTextValue(row, [ + "ДатаДокументаРасчетов", + "SettlementDocumentDate", + "settlement_document_date" + ]); + const settlementDocument = rowTextValue(row, [ + "ДокументРасчетов", + "SettlementDocument", + "settlement_document" + ]); + return extractAnyDateFromText(explicit) ?? extractAnyDateFromText(settlementDocument) ?? extractAnyDateFromText(rowDocumentValue(row)); +} + +function rowPaymentTermIsSetValue(row: Record): boolean { + const candidate = rowTextValue(row, ["УстановленСрокОплаты", "PaymentTermIsSet", "payment_term_is_set"]); + if (typeof row["УстановленСрокОплаты"] === "boolean") { + return row["УстановленСрокОплаты"] === true; + } + if (typeof row["PaymentTermIsSet"] === "boolean") { + return row["PaymentTermIsSet"] === true; + } + if (!candidate) { + return false; + } + return /^(?:true|истина|да|yes|1)$/iu.test(candidate.trim()); +} + +function rowPaymentTermDaysValue(row: Record): number | null { + const value = rowNumberValue(row, ["СрокОплаты", "PaymentTermDays", "payment_term_days"]); + if (value === null || !Number.isFinite(value) || value <= 0) { + return null; + } + return Math.trunc(value); +} + +function addDaysToIsoDate(isoDate: string, days: number): string | null { + const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match || !Number.isFinite(days)) { + return null; + } + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + date.setUTCDate(date.getUTCDate() + Math.trunc(days)); + if (Number.isNaN(date.getTime())) { + return null; + } + return [ + String(date.getUTCFullYear()).padStart(4, "0"), + String(date.getUTCMonth() + 1).padStart(2, "0"), + String(date.getUTCDate()).padStart(2, "0") + ].join("-"); +} + function earlierIsoDate(left: string | null, right: string | null): string | null { if (!left) { return right; @@ -2908,7 +3073,8 @@ function deriveRankedValueFlow( axis_value: axisValue, rows_with_amount: bucket.rows_with_amount, total_amount: bucket.total_amount, - total_amount_human_ru: formatAmountHumanRu(bucket.total_amount) + total_amount_human_ru: formatAmountHumanRu(bucket.total_amount), + counterparty_role_hint: counterpartyRoleHintForName(axisValue) })) .sort((left, right) => { const amountDelta = right.total_amount - left.total_amount; @@ -3097,6 +3263,116 @@ function deriveBusinessOverviewTaxPosition( }; } +function accountingFinancialResultMarkerAmount( + result: AddressMcpQueryExecutorResult, + marker: string +): number { + let total = 0; + for (const row of result.rows) { + if (String(rowDocumentValue(row) ?? "") !== marker) { + continue; + } + const amount = rowAmountValue(row); + if (amount !== null && Number.isFinite(amount)) { + total += amount; + } + } + return total; +} + +function accountingFinancialResultNonZeroCount( + values: number[] +): number { + return values.filter((value) => Math.abs(value) > 0).length; +} + +function deriveBusinessOverviewAccountingFinancialResult( + result: AddressMcpQueryExecutorResult | null, + periodScope: string | null +): AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult | null { + if (!result || result.error || result.matched_rows <= 0 || !periodScope) { + return null; + } + + const salesRevenueAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_REVENUE_KT"); + const costOfSalesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_COST_DT"); + const sellingExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_SELLING_DT"); + const adminExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_ADMIN_DT"); + const salesProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_TO_99_PROFIT"); + const salesLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_FROM_99_LOSS"); + const otherProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_TO_99_PROFIT"); + const otherLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_FROM_99_LOSS"); + const profitTransferTo84 = accountingFinancialResultMarkerAmount(result, "ACC99_TO84_PROFIT_TRANSFER"); + const lossTransferFrom84 = accountingFinancialResultMarkerAmount(result, "ACC84_TO99_LOSS_TRANSFER"); + + const amountSignals = [ + salesRevenueAccounting, + costOfSalesAccounting, + sellingExpensesAccounting, + adminExpensesAccounting, + salesProfitTo99, + salesLossFrom99, + otherProfitTo99, + otherLossFrom99, + profitTransferTo84, + lossTransferFrom84 + ]; + const rowsWithAmount = accountingFinancialResultNonZeroCount(amountSignals); + if (rowsWithAmount <= 0) { + return null; + } + + const salesResultAmount = salesProfitTo99 - salesLossFrom99; + const otherResultAmount = otherProfitTo99 - otherLossFrom99; + const hasFinalTransfer = profitTransferTo84 > 0 || lossTransferFrom84 > 0; + const finalResultAmount = hasFinalTransfer + ? profitTransferTo84 - lossTransferFrom84 + : salesResultAmount + otherResultAmount; + const finalResultDirection = + finalResultAmount > 0 + ? "profit" + : finalResultAmount < 0 + ? "loss" + : "balanced"; + const netMarginToRevenuePct = + salesRevenueAccounting > 0 ? percentageOfTotal(finalResultAmount, salesRevenueAccounting) : null; + const periodCloseRowsWithAmount = accountingFinancialResultNonZeroCount([ + salesProfitTo99, + salesLossFrom99, + otherProfitTo99, + otherLossFrom99, + profitTransferTo84, + lossTransferFrom84 + ]); + + return { + period_scope: periodScope, + rows_matched: result.matched_rows, + rows_with_amount: rowsWithAmount, + sales_revenue_accounting: salesRevenueAccounting, + sales_revenue_accounting_human_ru: formatAmountHumanRu(salesRevenueAccounting), + cost_of_sales_accounting: costOfSalesAccounting, + cost_of_sales_accounting_human_ru: formatAmountHumanRu(costOfSalesAccounting), + selling_expenses_accounting: sellingExpensesAccounting, + selling_expenses_accounting_human_ru: formatAmountHumanRu(sellingExpensesAccounting), + admin_expenses_accounting: adminExpensesAccounting, + admin_expenses_accounting_human_ru: formatAmountHumanRu(adminExpensesAccounting), + sales_result_amount: salesResultAmount, + sales_result_amount_human_ru: formatAmountHumanRu(Math.abs(salesResultAmount)), + other_result_amount: otherResultAmount, + other_result_amount_human_ru: formatAmountHumanRu(Math.abs(otherResultAmount)), + final_result_amount: finalResultAmount, + final_result_amount_human_ru: formatAmountHumanRu(Math.abs(finalResultAmount)), + final_result_direction: finalResultDirection, + net_margin_to_revenue_pct: netMarginToRevenuePct, + final_transfer_basis: hasFinalTransfer + ? "account_99_to_84_period_close" + : "account_90_91_to_99_period_close", + period_close_rows_with_amount: periodCloseRowsWithAmount, + inference_basis: "account_90_91_99_period_close_aggregate_confirmed_1c_rows" + }; +} + function deriveBusinessOverviewTradingMarginProxy( result: AddressMcpQueryExecutorResult | null, periodScope: string | null @@ -3505,6 +3781,135 @@ function deriveBusinessOverviewDebtStalenessRiskProxy( }; } +function deriveBusinessOverviewDebtDueDateAging(input: { + dueDateResult: AddressMcpQueryExecutorResult | null; + debtAsOfDate: string | null; +}): AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging | null { + if (!input.debtAsOfDate || !input.dueDateResult || input.dueDateResult.error || input.dueDateResult.matched_rows <= 0) { + return null; + } + + const overdueItems: Array> = []; + let rowsWithAmount = 0; + let grossOpenAmount = 0; + let rowsWithPaymentTerms = 0; + let rowsWithoutPaymentTerms = 0; + let rowsWithoutDocumentDate = 0; + let overdueAmount = 0; + let notYetDueAmount = 0; + let notYetDueRows = 0; + + for (const row of input.dueDateResult.rows) { + const amount = rowAmountValue(row); + if (amount === null) { + continue; + } + const absAmount = Math.abs(amount); + if (absAmount <= 0) { + continue; + } + rowsWithAmount += 1; + grossOpenAmount += absAmount; + + const paymentTermIsSet = rowPaymentTermIsSetValue(row); + const paymentTermDays = rowPaymentTermDaysValue(row); + if (!paymentTermIsSet || paymentTermDays === null) { + rowsWithoutPaymentTerms += 1; + continue; + } + + rowsWithPaymentTerms += 1; + const documentDate = rowSettlementDocumentDateValue(row) ?? rowContractDateValue(row); + if (!documentDate) { + rowsWithoutDocumentDate += 1; + continue; + } + const dueDate = addDaysToIsoDate(documentDate, paymentTermDays); + if (!dueDate) { + rowsWithoutDocumentDate += 1; + continue; + } + const overdueDays = dueDate < input.debtAsOfDate ? daysBetweenIsoDates(dueDate, input.debtAsOfDate) : null; + if (overdueDays !== null && overdueDays > 0) { + overdueAmount += absAmount; + overdueItems.push({ + counterparty: rowCounterpartyValue(row), + contract: rowContractValue(row), + settlement_document: rowTextValue(row, [ + "ДокументРасчетов", + "SettlementDocument", + "settlement_document" + ]) ?? rowDocumentValue(row), + document_date: documentDate, + due_date: dueDate, + payment_term_days: paymentTermDays, + overdue_days: overdueDays, + amount: absAmount, + amount_human_ru: formatAmountHumanRu(absAmount) + }); + } else { + notYetDueRows += 1; + notYetDueAmount += absAmount; + } + } + + if (rowsWithAmount <= 0) { + return null; + } + + const topOverdueItems = overdueItems + .sort((left, right) => { + const daysDelta = right.overdue_days - left.overdue_days; + if (daysDelta !== 0) { + return daysDelta; + } + const amountDelta = right.amount - left.amount; + return amountDelta !== 0 ? amountDelta : String(left.contract ?? "").localeCompare(String(right.contract ?? ""), "ru"); + }) + .slice(0, 5) + .map((item) => ({ + ...item, + share_of_overdue_amount_pct: percentageOfTotal(item.amount, overdueAmount) + })); + const oldestDueDate = overdueItems + .map((item) => item.due_date) + .sort()[0] ?? null; + const maxOverdueDays = overdueItems.reduce( + (max, item) => max === null ? item.overdue_days : Math.max(max, item.overdue_days), + null + ); + const evidenceStatus: AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging["evidence_status"] = + overdueItems.length > 0 + ? "confirmed_overdue" + : rowsWithPaymentTerms <= 0 + ? "no_payment_terms_configured" + : rowsWithoutDocumentDate >= rowsWithPaymentTerms + ? "insufficient_due_date_basis" + : "no_overdue_found"; + + return { + as_of_date: input.debtAsOfDate, + rows_matched: input.dueDateResult.matched_rows, + rows_with_amount: rowsWithAmount, + gross_open_amount: grossOpenAmount, + gross_open_amount_human_ru: formatAmountHumanRu(grossOpenAmount), + rows_with_payment_terms: rowsWithPaymentTerms, + rows_without_payment_terms: rowsWithoutPaymentTerms, + rows_without_document_date: rowsWithoutDocumentDate, + overdue_rows: overdueItems.length, + overdue_amount: overdueAmount, + overdue_amount_human_ru: formatAmountHumanRu(overdueAmount), + not_yet_due_rows: notYetDueRows, + not_yet_due_amount: notYetDueAmount, + not_yet_due_amount_human_ru: formatAmountHumanRu(notYetDueAmount), + oldest_due_date: oldestDueDate, + max_overdue_days: maxOverdueDays, + top_overdue_items: topOverdueItems, + evidence_status: evidenceStatus, + inference_basis: "contract_payment_terms_and_settlement_document_dates_from_open_balance_rows" + }; +} + function debtStalenessRiskBandRu( riskBand: AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskProxy["risk_band"] ): string { @@ -3737,9 +4142,11 @@ function inventoryStalenessRiskBandRu( function buildBusinessOverviewMissingProofFamilies(input: { missingSignalFamilies: string[]; + accountingFinancialResult: AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult | null; tradingMarginProxy: AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null; debtOpenSettlementQuality: AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null; debtStalenessRiskProxy: AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskProxy | null; + debtDueDateAging: AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging | null; inventoryPosition: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null; inventoryTurnoverProxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryTurnoverProxy | null; inventoryStalenessRiskProxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryStalenessRiskProxy | null; @@ -3753,7 +4160,7 @@ function buildBusinessOverviewMissingProofFamilies(input: { } }; - if (missing.has("profit_margin") || missing.has("accounting_profit_margin")) { + if ((missing.has("profit_margin") || missing.has("accounting_profit_margin")) && !input.accountingFinancialResult) { pushUnique({ family: "accounting_profit_margin", current_status: input.tradingMarginProxy ? "proxy_only_currently" : "reviewed_route_not_wired", @@ -3765,7 +4172,7 @@ function buildBusinessOverviewMissingProofFamilies(input: { }); } - if (missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) { + if ((missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) && !input.debtDueDateAging) { pushUnique({ family: "debt_due_date_aging_quality", current_status: input.debtStalenessRiskProxy @@ -3826,10 +4233,12 @@ function deriveBusinessOverview(input: { outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; lifecycleResult: AddressMcpQueryExecutorResult | null; taxResult: AddressMcpQueryExecutorResult | null; + accountingFinancialResultResult: AddressMcpQueryExecutorResult | null; tradingMarginResult: AddressMcpQueryExecutorResult | null; receivablesResult: AddressMcpQueryExecutorResult | null; payablesResult: AddressMcpQueryExecutorResult | null; openContractsResult: AddressMcpQueryExecutorResult | null; + dueDateAgingResult: AddressMcpQueryExecutorResult | null; documentActivityProfileResult: AddressMcpQueryExecutorResult | null; counterpartyProfileResult: AddressMcpQueryExecutorResult | null; contractUsageProfileResult: AddressMcpQueryExecutorResult | null; @@ -3860,6 +4269,10 @@ function deriveBusinessOverview(input: { }); const activityPeriod = deriveActivityPeriod(input.lifecycleResult); const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope); + const accountingFinancialResult = deriveBusinessOverviewAccountingFinancialResult( + input.accountingFinancialResultResult, + input.periodScope + ); const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope); const debtPosition = deriveBusinessOverviewDebtPosition({ receivablesResult: input.receivablesResult, @@ -3883,6 +4296,10 @@ function deriveBusinessOverview(input: { input.periodScope ); const debtStalenessRiskProxy = deriveBusinessOverviewDebtStalenessRiskProxy(debtOpenSettlementQuality); + const debtDueDateAging = deriveBusinessOverviewDebtDueDateAging({ + dueDateResult: input.dueDateAgingResult, + debtAsOfDate: input.debtAsOfDate + }); const inventoryPosition = deriveBusinessOverviewInventoryPosition({ inventoryOnHandResult: input.inventoryOnHandResult, inventoryAgingResult: input.inventoryAgingResult, @@ -3901,10 +4318,12 @@ function deriveBusinessOverview(input: { outgoing.rows_with_amount > 0, Boolean(activityPeriod), Boolean(taxPosition), + Boolean(accountingFinancialResult), Boolean(tradingMarginProxy), Boolean(debtPosition), Boolean(debtOpenSettlementQuality), Boolean(debtStalenessRiskProxy), + Boolean(debtDueDateAging), Boolean(documentActivityProfile), Boolean(counterpartyProfile), Boolean(contractUsageProfile), @@ -3921,9 +4340,9 @@ function deriveBusinessOverview(input: { documentActivityProfile || counterpartyProfile || contractUsageProfile ); const missingSignalFamilies = [ - tradingMarginProxy ? "accounting_profit_margin" : "profit_margin", + accountingFinancialResult ? null : tradingMarginProxy ? "accounting_profit_margin" : "profit_margin", debtPosition ? null : "debt_position", - debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", + debtDueDateAging ? null : debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality", taxPosition ? null : "tax_position", inventoryPosition ? inventoryStalenessRiskProxy @@ -3936,9 +4355,11 @@ function deriveBusinessOverview(input: { ].filter((item): item is string => Boolean(item)); const missingProofFamilies = buildBusinessOverviewMissingProofFamilies({ missingSignalFamilies, + accountingFinancialResult, tradingMarginProxy, debtOpenSettlementQuality, debtStalenessRiskProxy, + debtDueDateAging, inventoryPosition, inventoryTurnoverProxy, inventoryStalenessRiskProxy, @@ -3957,10 +4378,12 @@ function deriveBusinessOverview(input: { yearly_breakdown: yearlyBreakdown, activity_period: activityPeriod, tax_position: taxPosition, + accounting_financial_result: accountingFinancialResult, trading_margin_proxy: tradingMarginProxy, debt_position: debtPosition, debt_open_settlement_quality: debtOpenSettlementQuality, debt_staleness_risk_proxy: debtStalenessRiskProxy, + debt_due_date_aging: debtDueDateAging, inventory_position: inventoryPosition, inventory_turnover_proxy: inventoryTurnoverProxy, inventory_staleness_risk_proxy: inventoryStalenessRiskProxy, @@ -3973,9 +4396,9 @@ function deriveBusinessOverview(input: { missing_signal_families: missingSignalFamilies, missing_proof_families: missingProofFamilies, inference_basis: - hasBusinessOverviewProfileSignal || inventoryPosition + hasBusinessOverviewProfileSignal || inventoryPosition || accountingFinancialResult ? "business_overview_from_confirmed_1c_multi_family_rows" - : debtOpenSettlementQuality + : debtOpenSettlementQuality || debtDueDateAging ? "business_overview_from_confirmed_1c_multi_family_rows" : taxPosition && debtPosition ? "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows" @@ -3992,10 +4415,12 @@ function summarizeBusinessOverviewRows(input: { outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null; lifecycleResult: AddressMcpQueryExecutorResult | null; taxResult: AddressMcpQueryExecutorResult | null; + accountingFinancialResultResult: AddressMcpQueryExecutorResult | null; tradingMarginResult: AddressMcpQueryExecutorResult | null; receivablesResult: AddressMcpQueryExecutorResult | null; payablesResult: AddressMcpQueryExecutorResult | null; openContractsResult: AddressMcpQueryExecutorResult | null; + dueDateAgingResult: AddressMcpQueryExecutorResult | null; documentActivityProfileResult: AddressMcpQueryExecutorResult | null; counterpartyProfileResult: AddressMcpQueryExecutorResult | null; contractUsageProfileResult: AddressMcpQueryExecutorResult | null; @@ -4015,6 +4440,9 @@ function summarizeBusinessOverviewRows(input: { if (input.taxResult && !input.taxResult.error) { parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`); } + if (input.accountingFinancialResultResult && !input.accountingFinancialResultResult.error) { + parts.push(`${input.accountingFinancialResultResult.fetched_rows} accounting financial-result aggregate rows fetched, ${input.accountingFinancialResultResult.matched_rows} matched`); + } if (input.tradingMarginResult && !input.tradingMarginResult.error) { parts.push(`${input.tradingMarginResult.fetched_rows} trading-margin document rows fetched, ${input.tradingMarginResult.matched_rows} matched`); } @@ -4027,6 +4455,9 @@ function summarizeBusinessOverviewRows(input: { if (input.openContractsResult && !input.openContractsResult.error) { parts.push(`${input.openContractsResult.fetched_rows} open-contract rows fetched, ${input.openContractsResult.matched_rows} matched`); } + if (input.dueDateAgingResult && !input.dueDateAgingResult.error) { + parts.push(`${input.dueDateAgingResult.fetched_rows} due-date aging rows fetched, ${input.dueDateAgingResult.matched_rows} matched`); + } if (input.documentActivityProfileResult && !input.documentActivityProfileResult.error) { parts.push(`${input.documentActivityProfileResult.fetched_rows} document/account-section profile rows fetched, ${input.documentActivityProfileResult.matched_rows} matched`); } @@ -4064,15 +4495,39 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv } if (derived.top_customers.length > 0) { const leader = derived.top_customers[0]; - facts.push( - `Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.` - ); + if (isLikelyFinancialInstitutionCounterparty(leader.axis_value)) { + facts.push( + `Крупнейший входящий денежный источник в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа это не доказанный клиент или бизнес-выручка.` + ); + const nonFinancialLeader = derived.top_customers.slice(1).find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value)); + if (nonFinancialLeader) { + facts.push( + `Крупнейший небанковский входящий контрагент в проверенном срезе: ${nonFinancialLeader.axis_value} — ${nonFinancialLeader.total_amount_human_ru}.` + ); + } + } else { + facts.push( + `Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.` + ); + } } if (derived.top_suppliers.length > 0) { const leader = derived.top_suppliers[0]; - facts.push( - `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.` - ); + if (isLikelyFinancialInstitutionCounterparty(leader.axis_value)) { + facts.push( + `Крупнейший получатель исходящих денег в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора это не доказанный обычный поставщик.` + ); + const nonFinancialLeader = derived.top_suppliers.slice(1).find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value)); + if (nonFinancialLeader) { + facts.push( + `Крупнейший небанковский получатель исходящих денег в проверенном срезе: ${nonFinancialLeader.axis_value} — ${nonFinancialLeader.total_amount_human_ru}.` + ); + } + } else { + facts.push( + `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value} — ${leader.total_amount_human_ru}.` + ); + } } if (derived.yearly_breakdown.length > 0) { facts.push( @@ -4179,6 +4634,30 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv `Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.` ); } + if (derived.debt_due_date_aging) { + const aging = derived.debt_due_date_aging; + if (aging.evidence_status === "confirmed_overdue") { + const top = aging.top_overdue_items[0]; + const topText = top + ? ` Самая старая просрочка: ${top.due_date}, ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.` + : ""; + facts.push( + `Due-date aging открытых расчетов на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}` + ); + } else if (aging.evidence_status === "no_payment_terms_configured") { + facts.push( + `Due-date aging открытых расчетов на ${aging.as_of_date} проверен по ${aging.rows_with_amount} строкам с суммой: брутто открытых остатков ${aging.gross_open_amount_human_ru}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.` + ); + } else if (aging.evidence_status === "insufficient_due_date_basis") { + facts.push( + `Due-date aging открытых расчетов на ${aging.as_of_date} запускался по ${aging.rows_with_payment_terms} строкам с установленным сроком оплаты, но в найденных строках не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.` + ); + } else { + facts.push( + `Due-date aging открытых расчетов на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.` + ); + } + } if (derived.inventory_position) { const leader = derived.inventory_position.top_items[0]; const leaderText = leader @@ -4237,6 +4716,9 @@ function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerive const supplierSharePct = supplierLeader ? percentageOfTotal(supplierLeader.total_amount, derived.outgoing_supplier_payout.total_amount) : null; + const supplierLeaderIsFinancial = supplierLeader + ? isLikelyFinancialInstitutionCounterparty(supplierLeader.axis_value) + : false; const strongestIncomingYear = [...derived.yearly_breakdown] .filter((bucket) => bucket.incoming_total_amount > 0) .sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0]; @@ -4246,9 +4728,13 @@ function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerive return [ `Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`, supplierLeader - ? supplierSharePct !== null - ? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.` - : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.` + ? supplierLeaderIsFinancial + ? supplierSharePct !== null + ? `Крупнейший получатель исходящих денег ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). По названию это банк/финансовая организация, поэтому это outgoing cash concentration proxy, а не доказанный vendor-risk по обычному поставщику.` + : `Крупнейший получатель исходящих денег в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому это не доказанный обычный поставщик.` + : supplierSharePct !== null + ? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.` + : `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value} — ${supplierLeader.total_amount_human_ru}.` : null, strongestIncomingYear ? `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} (${strongestIncomingYear.incoming_total_amount_human_ru}).` @@ -5032,10 +5518,12 @@ export async function executeAssistantMcpDiscoveryPilot( let outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null; let lifecycleResult: AddressMcpQueryExecutorResult | null = null; let taxResult: AddressMcpQueryExecutorResult | null = null; + let accountingFinancialResultResult: AddressMcpQueryExecutorResult | null = null; let tradingMarginResult: AddressMcpQueryExecutorResult | null = null; let receivablesResult: AddressMcpQueryExecutorResult | null = null; let payablesResult: AddressMcpQueryExecutorResult | null = null; let openContractsResult: AddressMcpQueryExecutorResult | null = null; + let dueDateAgingResult: AddressMcpQueryExecutorResult | null = null; let documentActivityProfileResult: AddressMcpQueryExecutorResult | null = null; let counterpartyProfileResult: AddressMcpQueryExecutorResult | null = null; let contractUsageProfileResult: AddressMcpQueryExecutorResult | null = null; @@ -5045,8 +5533,10 @@ export async function executeAssistantMcpDiscoveryPilot( const lifecycleFilters = buildLifecycleFilters(planner); const profileFilters = buildBusinessOverviewProfileFilters(planner); const taxFilters = buildBusinessOverviewTaxFilters(planner); + const accountingFinancialResultFilters = buildBusinessOverviewAccountingFinancialResultFilters(planner); const tradingMarginFilters = buildBusinessOverviewTradingMarginFilters(planner); const debtFilters = buildBusinessOverviewDebtFilters(planner); + const debtDueDateAgingProbeEnabled = shouldRunDebtDueDateAgingProbe(planner); const inventoryFilters = buildBusinessOverviewInventoryFilters(planner); const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date); const inventoryAsOfDate = toNonEmptyString(inventoryFilters?.as_of_date); @@ -5059,6 +5549,9 @@ export async function executeAssistantMcpDiscoveryPilot( const taxSelection = taxFilters ? selectAddressRecipe("vat_liability_confirmed_for_tax_period", taxFilters) : null; + const accountingFinancialResultSelection = accountingFinancialResultFilters + ? selectAddressRecipe("accounting_financial_result_for_organization", accountingFinancialResultFilters) + : null; const tradingMarginSelection = tradingMarginFilters ? selectAddressRecipe("inventory_trading_margin_proxy_for_organization", tradingMarginFilters) : null; @@ -5071,6 +5564,9 @@ export async function executeAssistantMcpDiscoveryPilot( const openContractsSelection = debtFilters ? selectAddressRecipe("open_contracts_confirmed_as_of_date", debtFilters) : null; + const dueDateAgingSelection = debtFilters && debtDueDateAgingProbeEnabled + ? selectAddressRecipe("debt_due_date_aging_for_organization", debtFilters) + : null; const inventoryOnHandSelection = inventoryFilters ? selectAddressRecipe("inventory_on_hand_as_of_date", inventoryFilters) : null; @@ -5135,6 +5631,14 @@ export async function executeAssistantMcpDiscoveryPilot( pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available"); pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe"); } + if (accountingFinancialResultSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_selected"); + } else if (!accountingFinancialResultFilters) { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_probe_skipped_without_explicit_period"); + } else { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_not_available"); + pushUnique(queryLimitations, "Business overview accounting financial-result probe requires an executable 90/91/99 period-close recipe"); + } if (tradingMarginSelection?.selected_recipe) { pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_selected"); } else if (!tradingMarginFilters) { @@ -5159,6 +5663,16 @@ export async function executeAssistantMcpDiscoveryPilot( pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_not_available"); pushUnique(queryLimitations, "Business overview open-settlement quality probe requires executable open-contracts as-of-date recipe"); } + if (dueDateAgingSelection?.selected_recipe) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_selected"); + } else if (!debtFilters) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_explicit_as_of_date"); + } else if (!debtDueDateAgingProbeEnabled) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_boundary_need"); + } else { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_not_available"); + pushUnique(queryLimitations, "Business overview due-date aging probe requires executable contract payment-term/open-balance recipe"); + } if (inventoryOnHandSelection?.selected_recipe) { pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_recipe_selected"); if (inventoryAgingSelection?.selected_recipe) { @@ -5200,6 +5714,17 @@ export async function executeAssistantMcpDiscoveryPilot( account_scope: taxPlan.account_scope }); } + if (accountingFinancialResultSelection?.selected_recipe) { + const accountingFinancialResultPlan = buildAddressRecipePlan( + accountingFinancialResultSelection.selected_recipe, + accountingFinancialResultFilters! + ); + accountingFinancialResultResult = await runtimeDeps.executeAddressMcpQuery({ + query: accountingFinancialResultPlan.query, + limit: accountingFinancialResultPlan.limit, + account_scope: accountingFinancialResultPlan.account_scope + }); + } if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) { const receivablesPlan = buildAddressRecipePlan(receivablesSelection.selected_recipe, debtFilters!); receivablesResult = await runtimeDeps.executeAddressMcpQuery({ @@ -5222,6 +5747,14 @@ export async function executeAssistantMcpDiscoveryPilot( account_scope: openContractsPlan.account_scope }); } + if (dueDateAgingSelection?.selected_recipe) { + const dueDateAgingPlan = buildAddressRecipePlan(dueDateAgingSelection.selected_recipe, debtFilters!); + dueDateAgingResult = await runtimeDeps.executeAddressMcpQuery({ + query: dueDateAgingPlan.query, + limit: dueDateAgingPlan.limit, + account_scope: dueDateAgingPlan.account_scope + }); + } if (inventoryOnHandSelection?.selected_recipe) { const inventoryOnHandPlan = buildAddressRecipePlan(inventoryOnHandSelection.selected_recipe, inventoryFilters!); inventoryOnHandResult = await runtimeDeps.executeAddressMcpQuery({ @@ -5243,6 +5776,9 @@ export async function executeAssistantMcpDiscoveryPilot( if (taxResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, taxResult)); } + if (accountingFinancialResultResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, accountingFinancialResultResult)); + } if (receivablesResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, receivablesResult)); } @@ -5252,6 +5788,9 @@ export async function executeAssistantMcpDiscoveryPilot( if (openContractsResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, openContractsResult)); } + if (dueDateAgingResult) { + probeResults.push(queryResultToProbeResult(step.primitive_id, dueDateAgingResult)); + } if (inventoryOnHandResult) { probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryOnHandResult)); } @@ -5276,6 +5815,12 @@ export async function executeAssistantMcpDiscoveryPilot( } else if (taxResult) { pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_executed"); } + if (accountingFinancialResultResult?.error) { + pushUnique(queryLimitations, accountingFinancialResultResult.error); + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_error"); + } else if (accountingFinancialResultResult) { + pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_executed"); + } if (receivablesResult?.error) { pushUnique(queryLimitations, receivablesResult.error); pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_error"); @@ -5300,6 +5845,12 @@ export async function executeAssistantMcpDiscoveryPilot( } else if (openContractsResult) { pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_executed"); } + if (dueDateAgingResult?.error) { + pushUnique(queryLimitations, dueDateAgingResult.error); + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_error"); + } else if (dueDateAgingResult) { + pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_executed"); + } if (inventoryOnHandResult?.error) { pushUnique(queryLimitations, inventoryOnHandResult.error); pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_error"); @@ -5411,10 +5962,12 @@ export async function executeAssistantMcpDiscoveryPilot( outgoingResult, lifecycleResult, taxResult, + accountingFinancialResultResult, tradingMarginResult, receivablesResult, payablesResult, openContractsResult, + dueDateAgingResult, documentActivityProfileResult, counterpartyProfileResult, contractUsageProfileResult, @@ -5451,6 +6004,9 @@ export async function executeAssistantMcpDiscoveryPilot( if (derivedBusinessOverview.tax_position) { pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows"); } + if (derivedBusinessOverview.accounting_financial_result) { + pushReason(reasonCodes, "pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows"); + } if (derivedBusinessOverview.trading_margin_proxy) { pushReason(reasonCodes, "pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows"); } @@ -5466,6 +6022,10 @@ export async function executeAssistantMcpDiscoveryPilot( if (derivedBusinessOverview.debt_staleness_risk_proxy) { pushReason(reasonCodes, "pilot_derived_business_overview_debt_staleness_risk_proxy_from_confirmed_rows"); } + if (derivedBusinessOverview.debt_due_date_aging) { + pushReason(reasonCodes, "pilot_derived_business_overview_debt_due_date_aging_from_confirmed_rows"); + pushReason(reasonCodes, `pilot_derived_business_overview_debt_due_date_aging_${derivedBusinessOverview.debt_due_date_aging.evidence_status}`); + } if (derivedBusinessOverview.inventory_position) { pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows"); } @@ -5484,10 +6044,12 @@ export async function executeAssistantMcpDiscoveryPilot( outgoingResult, lifecycleResult, taxResult, + accountingFinancialResultResult, tradingMarginResult, receivablesResult, payablesResult, openContractsResult, + dueDateAgingResult, documentActivityProfileResult, counterpartyProfileResult, contractUsageProfileResult, diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index 183eeab..2929e34 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -108,7 +108,7 @@ export interface AssistantMcpDiscoveryEvidenceContract { const DEFAULT_DISCOVERY_BUDGET: AssistantMcpDiscoveryExecutionBudget = { max_probe_count: 3, - max_rows_per_probe: 100 + max_rows_per_probe: 200 }; const MAX_PROBE_COUNT = 36; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 15c4af0..0f83756 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -1,4 +1,5 @@ import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint"; +import { isLikelyFinancialInstitutionCounterparty } from "./counterpartyRoleHeuristics"; export const ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_discovery_response_candidate_v1" as const; @@ -97,7 +98,27 @@ function userFacingLines(values: string[]): string[] { return uniqueStrings(values).filter((line) => !hasInternalMechanics(line)); } +function sanitizeUserFacingMechanics(value: string): string { + return String(value ?? "").replace(/MCP-срез(?:ом|у|е|а)?/giu, (match) => { + const normalized = match.toLowerCase(); + if (normalized.endsWith("ом")) { + return "срезом 1С"; + } + if (normalized.endsWith("у")) { + return "срезу 1С"; + } + if (normalized.endsWith("е")) { + return "срезе 1С"; + } + if (normalized.endsWith("а")) { + return "среза 1С"; + } + return "срез 1С"; + }); +} + function localizeLine(value: string): string { + const sanitizedValue = sanitizeUserFacingMechanics(value); if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) { return "В 1С найдены строки активности в запрошенном срезе."; } @@ -126,7 +147,7 @@ function localizeLine(value: string): string { value ) ) { - return "Запрошенный период уперся в лимит строк MCP; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды."; + return "Запрошенный период достиг лимита строк; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды."; } const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i); if (counterpartyMatch) { @@ -151,10 +172,10 @@ function localizeLine(value: string): string { } const movementRowsMatch = value.match(/^1C movement rows were found for counterparty\s+(.+)$/i); if (movementRowsMatch) { - return `Р’ 1РЎ найдены строки движений РїРѕ контрагенту ${movementRowsMatch[1]}.`; + return `В 1С найдены строки движений по контрагенту ${movementRowsMatch[1]}.`; } if (/^1C movement rows were found for the requested scope$/i.test(value)) { - return "Р’ 1РЎ найдены строки движений РїРѕ запрошенному контуру."; + return "В 1С найдены строки движений по запрошенному контуру."; } const supplierPayoutMatch = value.match(/^1C supplier-payout rows were found for counterparty\s+(.+)$/i); if (supplierPayoutMatch) { @@ -186,7 +207,7 @@ function localizeLine(value: string): string { return "Срез документов ограничен только подтвержденными строками документов в проверенном окне."; } if (/^Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope$/i.test(value)) { - return "Срез движений ограничен только подтвержденными строками движений РІ проверенном РѕРєРЅРµ."; + return "Срез движений ограничен только подтвержденными строками движений в проверенном окне."; } if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) { return "Сумма входящих поступлений рассчитана только по подтвержденным строкам поступлений в 1С."; @@ -274,10 +295,10 @@ function localizeLine(value: string): string { return "Полный срез документов без явно проверенного периода не подтвержден."; } if (/^Full movement history outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { - return "Полный исторический срез движений РІРЅРµ проверенного периода этим РїРѕРёСЃРєРѕРј РЅРµ подтвержден."; + return "Полный исторический срез движений вне проверенного периода этим поиском не подтвержден."; } if (/^Full movement history is not proven without an explicit checked period$/i.test(value)) { - return "Полный срез движений без СЏРІРЅРѕ проверенного периода РЅРµ подтвержден."; + return "Полный срез движений без явно проверенного периода не подтвержден."; } if (/^Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) { return "Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден."; @@ -296,14 +317,14 @@ function localizeLine(value: string): string { value ) ) { - return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк."; + return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк."; } if ( /^Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit$/i.test( value ) ) { - return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк хотя бы по одной стороне."; + return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк хотя бы по одной стороне."; } if (/^Requested period coverage was recovered through monthly 1C value-flow probes$/i.test(value)) { return "Покрытие запрошенного периода восстановлено помесячными проверками 1С."; @@ -323,7 +344,7 @@ function localizeLine(value: string): string { if (/^Complete requested-period coverage for bidirectional value-flow is not proven by the available checked rows$/i.test(value)) { return "Полное покрытие запрошенного периода по двустороннему денежному потоку не подтверждено доступными проверенными строками."; } - return value; + return sanitizedValue; } function section(title: string, lines: string[]): string | null { @@ -408,7 +429,7 @@ function businessOverviewCoverageLimitLine(overview: Record): s limited.push("исходящие"); } return limited.length > 0 - ? `Важно: ${limited.join(" и ")} уперлись в лимит выборки MCP, поэтому это проверенный срез найденных строк, а не гарантированно полный бухгалтерский оборот.` + ? `Важно: по направлению ${limited.join(" и ")} проверка достигла лимита строк; это расширенный проверенный срез найденных строк, но не гарантия полного бухгалтерского оборота без отдельной полной выгрузки.` : null; } @@ -437,6 +458,34 @@ function firstOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_r return label && amount ? `${label} — ${sentenceAmount(amount) ?? amount}` : null; } +function firstNonFinancialOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_ru"): string | null { + if (!Array.isArray(rows)) { + return null; + } + for (const row of rows) { + const item = toRecordObject(row); + const label = toNonEmptyString(item?.axis_value); + if (!label || isLikelyFinancialInstitutionCounterparty(label)) { + continue; + } + const amount = moneyText(item?.[amountKey]); + if (amount) { + return `${label} — ${sentenceAmount(amount) ?? amount}`; + } + } + return null; +} + +function overviewAxisLooksFinancial(row: Record | null): boolean { + if (!row) { + return false; + } + return ( + row.counterparty_role_hint === "bank_or_financial_institution" || + isLikelyFinancialInstitutionCounterparty(row.axis_value) + ); +} + function businessOverviewTaxLine(overview: Record): string | null { const tax = toRecordObject(overview.tax_position); if (!tax) { @@ -568,7 +617,7 @@ function buildCompactBidirectionalValueFlowReply( lines.push(`Основа: ${basis.join("; ")}.`); } if (flow.coverage_limited_by_probe_limit === true) { - lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); + lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); } lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна."); @@ -721,17 +770,202 @@ function buildCompactBusinessOverviewReply( const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null); const customerName = toNonEmptyString(topCustomer?.axis_value); const customerAmount = moneyText(topCustomer?.total_amount_human_ru); + const topCustomerLooksFinancial = overviewAxisLooksFinancial(topCustomer); + const nonFinancialCustomer = firstNonFinancialOverviewAxisLabel( + topCustomerLooksFinancial ? overview.top_customers : [] + ); const topCustomerLead = customerName && customerAmount - ? `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}` + ? topCustomerLooksFinancial + ? `; крупнейший входящий денежный источник: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount} (похоже на банк/финорганизацию, не называю это клиентской выручкой без назначения платежа)${nonFinancialCustomer ? `; крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}` : ""}` + : `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}` : ""; + const topSupplierRecord = toRecordObject(Array.isArray(overview.top_suppliers) ? overview.top_suppliers[0] : null); const topSupplier = firstOverviewAxisLabel(overview.top_suppliers); - const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${topSupplier}` : ""; + const topSupplierLooksFinancial = overviewAxisLooksFinancial(topSupplierRecord); + const nonFinancialSupplier = firstNonFinancialOverviewAxisLabel( + topSupplierLooksFinancial ? overview.top_suppliers : [] + ); + const topSupplierLead = topSupplier + ? topSupplierLooksFinancial + ? `; крупнейший получатель исходящих денег: ${topSupplier} (похоже на банк/финорганизацию, не называю это обычным поставщиком без назначения платежа/договора)${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}` + : `; крупнейший получатель исходящих денег: ${topSupplier}` + : ""; const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : ""; const graphReasonCodes = toStringList(graph?.reason_codes); const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer"); const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary); const lines: string[] = []; + const actionFamily = toNonEmptyString(turnMeaning?.asked_action_family); + const unsupportedFamily = toNonEmptyString(turnMeaning?.unsupported_but_understood_family); + const profitMarginBoundary = actionFamily === "profit_margin_boundary" || unsupportedFamily === "profit_margin_boundary"; + const debtDueDateBoundary = actionFamily === "debt_due_date_boundary" || unsupportedFamily === "debt_due_date_boundary"; + const vendorRiskBoundary = + actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary"; + const inventoryReserveBoundary = + actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary"; + + if (profitMarginBoundary) { + const accountingFinancialResult = toRecordObject(overview.accounting_financial_result); + if (accountingFinancialResult) { + const direction = toNonEmptyString(accountingFinancialResult.final_result_direction); + const amount = moneyText(accountingFinancialResult.final_result_amount_human_ru); + const periodScope = toNonEmptyString(accountingFinancialResult.period_scope) ?? period; + const marginPct = + typeof accountingFinancialResult.net_margin_to_revenue_pct === "number" && + Number.isFinite(accountingFinancialResult.net_margin_to_revenue_pct) + ? `${accountingFinancialResult.net_margin_to_revenue_pct}%` + : null; + const directionText = + direction === "profit" + ? "учетная прибыль" + : direction === "loss" + ? "учетный убыток" + : "нулевой учетный финрезультат"; + const amountText = amount + ? direction === "loss" + ? `минус ${amount}` + : amount + : "сумма не распознана"; + lines.push( + `Коротко: по бухгалтерскому маршруту 90/91/99 за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к выручке 90.01 ${marginPct}` : "; маржа к выручке 90.01 не рассчитана"}.` + ); + lines.push( + "Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность." + ); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + const headline = toNonEmptyString(draft.headline); + const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim(); + lines.push( + cleanHeadline + ? `Коротко: ${localizeLine(cleanHeadline)}` + : "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финансовый результат." + ); + const boundaryLines = userFacingLines([ + ...toStringList(draft.confirmed_lines), + ...toStringList(draft.inference_lines), + ...toStringList(draft.unknown_lines) + ]) + .filter((line) => /(?:прибыл|марж|финанс|p\s*&\s*l|p&l|расход|себестоим|закрыт|profit|margin|financial)/iu.test(line)) + .slice(0, 2); + if (boundaryLines.length > 0) { + lines.push(...boundaryLines.map(localizeLine)); + } + lines.push( + "Для точного P&L нужны отдельный маршрут по себестоимости, расходам, закрытию периода и финрезультату; текущий proxy нельзя выдавать за подтвержденную чистую прибыль или маржу." + ); + if (limitLine) { + lines.push(limitLine); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + + if (debtDueDateBoundary) { + const dueDateAging = toRecordObject(overview.debt_due_date_aging); + if (dueDateAging) { + const status = toNonEmptyString(dueDateAging.evidence_status); + const asOfDate = toNonEmptyString(dueDateAging.as_of_date) ?? "проверенную дату"; + const overdueAmount = moneyText(dueDateAging.overdue_amount_human_ru); + const grossAmount = moneyText(dueDateAging.gross_open_amount_human_ru); + const rowsWithPaymentTerms = + typeof dueDateAging.rows_with_payment_terms === "number" && Number.isFinite(dueDateAging.rows_with_payment_terms) + ? dueDateAging.rows_with_payment_terms + : null; + const rowsWithAmount = + typeof dueDateAging.rows_with_amount === "number" && Number.isFinite(dueDateAging.rows_with_amount) + ? dueDateAging.rows_with_amount + : null; + if (status === "confirmed_overdue") { + lines.push( + `Коротко: на ${asOfDate} подтвержденная просрочка есть: ${overdueAmount ?? "сумма не распознана"} по ${dueDateAging.overdue_rows ?? "найденным"} строкам.` + ); + lines.push("Основа ответа: открытые расчеты 60/62/76, договорный срок оплаты и дата расчетного документа; это уже due-date route, не старение договора как proxy."); + } else if (status === "no_payment_terms_configured") { + lines.push( + `Коротко: на ${asOfDate} подтвержденной просрочки нет: открытые расчеты проверены${grossAmount ? ` на ${grossAmount}` : ""}, но в найденных договорах срок оплаты не установлен.` + ); + lines.push( + rowsWithAmount !== null + ? `Проверено строк с суммой: ${rowsWithAmount}. Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.` + : "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой." + ); + } else if (status === "insufficient_due_date_basis") { + lines.push( + `Коротко: due-date route запущен на ${asOfDate}, но просрочка не подтверждена: по строкам с установленным сроком оплаты не хватило даты расчетного документа.` + ); + if (rowsWithPaymentTerms !== null) { + lines.push(`Строк с установленным сроком оплаты: ${rowsWithPaymentTerms}; нужен документ-основание с датой для расчета due date.`); + } + } else { + lines.push( + `Коротко: due-date route на ${asOfDate} проверен, подтвержденной просрочки не найдено${rowsWithPaymentTerms !== null ? `; строк с установленным сроком оплаты ${rowsWithPaymentTerms}` : ""}.` + ); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + const headline = toNonEmptyString(draft.headline); + const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim(); + lines.push( + cleanHeadline + ? `Коротко: ${localizeLine(cleanHeadline)}` + : "Коротко: нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута." + ); + lines.push( + "Проверить нужно отдельно: договоры, сроки оплаты, погашение и закрытие задолженности; без этого нельзя доказать overdue/due-date aging." + ); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + + if (vendorRiskBoundary) { + const supplierBasis = topSupplier + ? topSupplierLooksFinancial + ? `крупнейший получатель исходящих денег: ${topSupplier}; по названию это банк/финансовая организация, поэтому это не доказанная зависимость от обычного поставщика${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}` + : `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${topSupplier}` + : outgoingAmount + ? `исходящие платежи/закупочный поток в проверенном срезе: ${outgoingAmount}` + : "есть только ограниченный срез исходящих платежей без полного vendor-risk профиля"; + const proxyLabel = topSupplierLooksFinancial ? "outgoing cash concentration proxy" : "procurement concentration proxy"; + lines.push( + `Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${supplierBasis}.` + ); + lines.push( + "Это сигнал концентрации закупок/исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов." + ); + lines.push( + "Для точного вывода нужен отдельный reviewed vendor-risk route: поставщики, договорные условия, качество поставок, сроки, доля в закупках и полная структура расходов." + ); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + + if (inventoryReserveBoundary) { + const headline = toNonEmptyString(draft.headline); + const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim(); + lines.push( + cleanHeadline + ? `Коротко: ${localizeLine(cleanHeadline)}` + : "Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя." + ); + const boundaryLines = userFacingLines([ + ...toStringList(draft.unknown_lines), + ...toStringList(draft.limitation_lines) + ]) + .filter((line) => /(?:резерв|неликвид|склад|товар|reserve|obsolete|inventory|stock)/iu.test(line)) + .slice(0, 2); + if (boundaryLines.length > 0) { + lines.push(...boundaryLines.map(localizeLine)); + } + lines.push( + "Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; proxy-сигналы нельзя выдавать за доказанный факт резерва." + ); + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) { lines.push( @@ -761,7 +995,7 @@ function buildCompactBusinessOverviewReply( return null; } lines.push( - `Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.` + `Коротко: в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.` ); const netYear = toNonEmptyString(netLeader?.year_bucket); const netYearAmount = moneyText(netLeader?.net_amount_human_ru); @@ -783,7 +1017,11 @@ function buildCompactBusinessOverviewReply( ); lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); if (!directMoneyAnswer && customerName && customerAmount) { - lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); + lines.push( + topCustomerLooksFinancial + ? `Крупнейший входящий денежный источник в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}. По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой.${nonFinancialCustomer ? ` Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}` + : `Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.` + ); } } else { return null; @@ -797,10 +1035,18 @@ function buildCompactBusinessOverviewReply( } if (!directMoneyAnswer && topSupplier) { - lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`); + lines.push( + topSupplierLooksFinancial + ? `Крупнейший получатель исходящих денег: ${topSupplier}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком.${nonFinancialSupplier ? ` Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}` + : `Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.` + ); } if (!directMoneyAnswer && (topCustomer || topSupplier)) { - lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль."); + lines.push( + topCustomerLooksFinancial || topSupplierLooksFinancial + ? "Важно по ролям: текущий денежный срез подтверждает источники и получателей денег, но банковские контрагенты требуют проверки назначения платежа/счетов и не доказывают роль клиента или поставщика." + : "Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль." + ); } if (!directMoneyAnswer) { lines.push( diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts index d013f70..c451ade 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryRuntimeBridge.ts @@ -20,6 +20,8 @@ export const ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_v1" as const; export const ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = "assistant_mcp_discovery_loop_state_v1" as const; +export const ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION = + "assistant_mcp_route_candidate_v1" as const; export type AssistantMcpDiscoveryRuntimeBridgeStatus = | "answer_draft_ready" @@ -31,6 +33,11 @@ export type AssistantMcpDiscoveryLoopStatus = | "awaiting_clarification" | "ready_for_next_hop" | "blocked"; +export type AssistantMcpRouteCandidateStatus = + | "ready_for_reviewed_execution" + | "needs_user_scope" + | "needs_route_enablement" + | "blocked"; export interface AssistantMcpDiscoveryRuntimeBridgeInput { semanticDataNeed?: string | null; @@ -61,6 +68,26 @@ export interface AssistantMcpDiscoveryLoopStateContract { explicit_date_scope: string | null; } +export interface AssistantMcpRouteCandidateContract { + schema_version: typeof ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION; + policy_owner: "assistantMcpDiscoveryRuntimeBridge"; + candidate_status: AssistantMcpRouteCandidateStatus; + selected_chain_id: AssistantMcpDiscoveryChainId; + selected_chain_summary: string; + nearest_catalog_chain_template: AssistantMcpDiscoveryPlannerContract["catalog_chain_template_alignment"]["top_chain_template_match"]; + catalog_alignment_status: AssistantMcpDiscoveryPlannerContract["catalog_chain_template_alignment"]["alignment_status"]; + business_fact_family: string | null; + action_family: string | null; + proof_expectation: string | null; + required_axes: string[]; + provided_axes: string[]; + missing_axes: string[]; + executable_now: boolean; + enablement_reason: string | null; + recommended_next_action: string; + forbidden_overclaim_flags: string[]; +} + export interface AssistantMcpDiscoveryRuntimeBridgeContract { schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION; policy_owner: "assistantMcpDiscoveryRuntimeBridge"; @@ -70,6 +97,7 @@ export interface AssistantMcpDiscoveryRuntimeBridgeContract { pilot: AssistantMcpDiscoveryPilotExecutionContract; answer_draft: AssistantMcpDiscoveryAnswerDraftContract; loop_state: AssistantMcpDiscoveryLoopStateContract; + route_candidate: AssistantMcpRouteCandidateContract; user_facing_response_allowed: boolean; business_fact_answer_allowed: boolean; requires_user_clarification: boolean; @@ -138,6 +166,26 @@ function loopStatusFor( return "ready_for_next_hop"; } +function routeCandidateStatusFor( + bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus, + pilot: AssistantMcpDiscoveryPilotExecutionContract, + missingProofFamily: AssistantMcpDiscoveryBusinessOverviewMissingProofFamily | null +): AssistantMcpRouteCandidateStatus { + if (bridgeStatus === "blocked" || pilot.pilot_status === "blocked") { + return "blocked"; + } + if (bridgeStatus === "needs_clarification" || pilot.pilot_status === "skipped_needs_clarification") { + return "needs_user_scope"; + } + if (bridgeStatus === "unsupported" || pilot.pilot_status === "unsupported") { + return "needs_route_enablement"; + } + if (missingProofFamily) { + return "needs_route_enablement"; + } + return "ready_for_reviewed_execution"; +} + function flattenAxes( pilot: AssistantMcpDiscoveryPilotExecutionContract, source: "provided_axes" | "missing_axis_options" @@ -168,6 +216,144 @@ function entityCandidatesFromPlanner(planner: AssistantMcpDiscoveryPlannerContra return uniqueStrings(values); } +function firstNonEmpty(values: Array): string | null { + for (const value of values) { + const text = String(value ?? "").trim(); + if (text) { + return text; + } + } + return null; +} + +type AssistantMcpDiscoveryBusinessOverviewMissingProofFamily = NonNullable< + AssistantMcpDiscoveryPilotExecutionContract["derived_business_overview"] +>["missing_proof_families"][number]; + +function routeCandidateProofFamiliesFor(actionFamily: string | null, proofExpectation: string | null): string[] { + const combined = `${actionFamily ?? ""} ${proofExpectation ?? ""}`.trim().toLowerCase(); + const result: string[] = []; + const add = (family: string) => { + if (!result.includes(family)) { + result.push(family); + } + }; + if (!combined || combined === "broad_evaluation bounded_inference") { + return result; + } + if (/(?:inventory|stock|warehouse|reserve|liquidation|write[-_ ]?off|obsolete|obsolescence)/iu.test(combined)) { + add("inventory_reserve_liquidation_quality"); + } + if (/(?:debt|due[-_ ]?date|overdue|aging|credit[-_ ]?risk)/iu.test(combined)) { + add("debt_due_date_aging_quality"); + } + if (/(?:vendor|supplier|procurement|sourcing)/iu.test(combined)) { + add("vendor_risk_procurement_quality"); + } + if (/(?:profit|margin|pnl|p&l|financial[-_ ]?result)/iu.test(combined)) { + add("accounting_profit_margin"); + } + return result; +} + +function routeCandidateMissingProofFamily( + planner: AssistantMcpDiscoveryPlannerContract, + pilot: AssistantMcpDiscoveryPilotExecutionContract +): AssistantMcpDiscoveryBusinessOverviewMissingProofFamily | null { + if (planner.data_need_graph?.business_fact_family !== "business_overview") { + return null; + } + const wantedFamilies = routeCandidateProofFamiliesFor( + planner.data_need_graph?.action_family ?? null, + planner.data_need_graph?.proof_expectation ?? null + ); + if (wantedFamilies.length <= 0) { + return null; + } + const missingProofFamilies = pilot.derived_business_overview?.missing_proof_families ?? []; + return missingProofFamilies.find((item) => wantedFamilies.includes(item.family)) ?? null; +} + +function routeCandidateEnablementReason( + status: AssistantMcpRouteCandidateStatus, + pilot: AssistantMcpDiscoveryPilotExecutionContract, + missingAxes: string[], + missingProofFamily: AssistantMcpDiscoveryBusinessOverviewMissingProofFamily | null +): string | null { + if (status === "ready_for_reviewed_execution") { + return null; + } + if (status === "needs_user_scope") { + return missingAxes.length > 0 + ? `Missing scope axes: ${missingAxes.join(", ")}` + : "Selected chain needs user clarification before MCP execution"; + } + if (missingProofFamily) { + return [ + `Missing reviewed proof family: ${missingProofFamily.family}`, + `next_required_evidence=${missingProofFamily.next_required_evidence}`, + missingProofFamily.current_supported_evidence + ? `current_supported_evidence=${missingProofFamily.current_supported_evidence}` + : null, + `must_not_claim=${missingProofFamily.must_not_claim}` + ] + .filter((item): item is string => Boolean(item)) + .join("; "); + } + return firstNonEmpty([ + ...pilot.query_limitations, + ...pilot.evidence.unknown_facts, + "Selected chain is not safely executable by the reviewed MCP runtime yet" + ]); +} + +function routeCandidateNextAction(status: AssistantMcpRouteCandidateStatus): string { + if (status === "ready_for_reviewed_execution") { + return "Execute through the reviewed runtime bridge and truth gate."; + } + if (status === "needs_user_scope") { + return "Ask the user for the missing scope axes before MCP execution."; + } + if (status === "needs_route_enablement") { + return "Create or wire a reviewed exact route for the selected chain before treating the fact as answerable."; + } + return "Do not execute until the blocking reason is resolved."; +} + +function buildRouteCandidate( + planner: AssistantMcpDiscoveryPlannerContract, + pilot: AssistantMcpDiscoveryPilotExecutionContract, + bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus +): AssistantMcpRouteCandidateContract { + const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? []; + const providedAxes = flattenAxes(pilot, "provided_axes"); + const missingAxes = plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options"); + const missingProofFamily = routeCandidateMissingProofFamily(planner, pilot); + const candidateStatus = routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily); + return { + schema_version: ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION, + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + candidate_status: candidateStatus, + selected_chain_id: planner.selected_chain_id, + selected_chain_summary: planner.selected_chain_summary, + nearest_catalog_chain_template: planner.catalog_chain_template_alignment.top_chain_template_match, + catalog_alignment_status: planner.catalog_chain_template_alignment.alignment_status, + business_fact_family: planner.data_need_graph?.business_fact_family ?? null, + action_family: planner.data_need_graph?.action_family ?? null, + proof_expectation: planner.data_need_graph?.proof_expectation ?? null, + required_axes: [...planner.required_axes], + provided_axes: providedAxes, + missing_axes: missingAxes, + executable_now: candidateStatus === "ready_for_reviewed_execution", + enablement_reason: routeCandidateEnablementReason(candidateStatus, pilot, missingAxes, missingProofFamily), + recommended_next_action: routeCandidateNextAction(candidateStatus), + forbidden_overclaim_flags: uniqueStrings([ + ...(planner.data_need_graph?.forbidden_overclaim_flags ?? []), + ...(missingProofFamily ? [missingProofFamily.must_not_claim] : []) + ]) + }; +} + function buildLoopState( planner: AssistantMcpDiscoveryPlannerContract, pilot: AssistantMcpDiscoveryPilotExecutionContract, @@ -215,11 +401,14 @@ export async function runAssistantMcpDiscoveryRuntimeBridge( const answerDraft = buildAssistantMcpDiscoveryAnswerDraft(pilot); const bridgeStatus = bridgeStatusFor(pilot, answerDraft); const loopState = buildLoopState(planner, pilot, bridgeStatus); + const routeCandidate = buildRouteCandidate(planner, pilot, bridgeStatus); const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]); pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`); pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer"); pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`); + pushReason(reasonCodes, "runtime_bridge_route_candidate_built"); + pushReason(reasonCodes, `runtime_bridge_route_candidate_${routeCandidate.candidate_status}`); return { schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION, @@ -230,6 +419,7 @@ export async function runAssistantMcpDiscoveryRuntimeBridge( pilot, answer_draft: answerDraft, loop_state: loopState, + route_candidate: routeCandidate, user_facing_response_allowed: bridgeStatus !== "blocked", business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft), requires_user_clarification: bridgeStatus === "needs_clarification", diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 6e165fc..cce51f4 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -180,6 +180,11 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean { "всему", "всей", "всем", + "год", + "года", + "году", + "годом", + "годы", "выводу", "выводам", "аудиту", @@ -824,6 +829,51 @@ function hasOrganizationLevelEarningsOverviewSignal(text: string): boolean { return hasYearRankingCue || hasCompanyEarningsCue || hasCompanyProfitMarginCue; } +function hasOrganizationLevelProfitMarginBoundaryOverviewSignal(text: string): boolean { + if (!text) { + return false; + } + const hasProfitMarginCue = + /(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit(?:ability)?|margin|financial\s+result)/iu.test( + text + ); + const hasCompanyScopeCue = + /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043c\u044b\b|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\b(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e|\u043e\u0430\u043e)\b|(?:19|20)\d{2}|company|business|organization|our|we|us)/iu.test( + text + ); + return hasProfitMarginCue && hasCompanyScopeCue; +} + +function hasProfitMarginBoundaryFollowupSignal(text: string): boolean { + if (!text) { + return false; + } + const hasProfitOrResultCue = + /(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u0443\u0431\u044b\u0442\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit|loss|margin|financial\s+result)/iu.test( + text + ); + const hasFollowupShape = + /(?:\u044d\u0442\u043e|\u0438\u0442\u043e\u0433|\u0438\u0442\u043e\u0433\u043e|\u043f\u043e\u043b\u0443\u0447\w*|\u043a\u043e\u0440\u043e\u0442\w*|\u0432\s+\u0438\u0442\u043e\u0433\u0435|\u043c\u043e\u0436\u043d\u043e\s+(?:\u043b\u0438\s+)?\u0441\u043a\u0430\u0437\u0430\u0442\u044c|is\s+it|result|short|brief)/iu.test( + text + ); + return hasProfitOrResultCue && hasFollowupShape; +} + +function hasDebtDueDateBoundaryFollowupSignal(text: string): boolean { + if (!text) { + return false; + } + const hasDueDateCue = + /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447\w*|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437\w*|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\w*|due[-\s]?date|overdue|debt\s+aging|aging)/iu.test( + text + ); + const hasFollowupShape = + /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\w*|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|why|short|brief|so)/iu.test( + text + ); + return hasDueDateCue && hasFollowupShape; +} + function hasOrganizationLevelDebtDueDateOverviewSignal(text: string): boolean { if (!text) { return false; @@ -999,6 +1049,13 @@ function hasExplicitVatQuestionSignal(text: string): boolean { ); } +function hasExplicitVatMovementEvidenceSignal(text: string): boolean { + if (!/(?:\u043d\u0434\u0441|vat)/iu.test(text)) { + return false; + } + return hasMovementEvidenceFollowupSignal(text); +} + function hasBusinessOverviewSeparateCounterpartySignal(text: string): boolean { if (!text) { return false; @@ -1534,6 +1591,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ); const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText); const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText); + const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText); const explicitVatSuppressesBusinessOverviewContinuation = Boolean( explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal ); @@ -1552,6 +1610,7 @@ export function buildAssistantMcpDiscoveryTurnInput( const rawMetadataSignal = !rawLifecycleSignal && !rawValueFlowSignal && + !explicitVatMovementEvidenceSignal && !rawReferentialDocumentExclusionSignal && hasMetadataSignal(rawText); const rawEntityResolutionSignal = @@ -1569,7 +1628,8 @@ export function buildAssistantMcpDiscoveryTurnInput( const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(dateScopeSignalText); const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText); const rawDateScope = collectDateScopeFromRawText(dateScopeSignalText); - const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null; + const rawMetadataScopeHint = + rawMetadataSignal || explicitVatMovementEvidenceSignal ? metadataScopeHintFromRawText(rawText) : null; const rawTopicSwitchSignal = hasExplicitTopicSwitchSignal(rawText); const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null; const rawScopedEntityCandidate = @@ -1594,12 +1654,49 @@ export function buildAssistantMcpDiscoveryTurnInput( const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); const broadBusinessEvaluationUnsupported = unsupported === "broad_business_evaluation"; - const businessOverviewSignal = - rawBusinessOverviewSignal || + const seededBusinessOverviewSignal = broadBusinessEvaluationUnsupported || rawDomain === "business_summary" || rawDomain === "business_overview" || rawAction === "broad_evaluation"; + const inventoryReserveBusinessOverviewSignal = + (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(rawText); + const debtDueDateFollowupBusinessOverviewSignal = + businessOverviewContinuationSignal && hasDebtDueDateBoundaryFollowupSignal(rawText); + const debtDueDateBusinessOverviewSignal = + ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) || + debtDueDateFollowupBusinessOverviewSignal; + const supplierQualityBusinessOverviewSignal = + (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText); + const profitMarginFollowupBusinessOverviewSignal = + businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText); + const profitMarginBusinessOverviewSignal = + ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) && + hasOrganizationLevelProfitMarginBoundaryOverviewSignal(rawText)) || + profitMarginFollowupBusinessOverviewSignal; + const businessOverviewActionFamily = inventoryReserveBusinessOverviewSignal + ? "inventory_reserve_boundary" + : debtDueDateBusinessOverviewSignal + ? "debt_due_date_boundary" + : supplierQualityBusinessOverviewSignal + ? "vendor_risk_procurement_boundary" + : profitMarginBusinessOverviewSignal + ? "profit_margin_boundary" + : "broad_evaluation"; + const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal + ? "inventory_reserve_liquidation_boundary" + : debtDueDateBusinessOverviewSignal + ? "debt_due_date_boundary" + : supplierQualityBusinessOverviewSignal + ? "vendor_risk_procurement_boundary" + : profitMarginBusinessOverviewSignal + ? "profit_margin_boundary" + : "broad_business_evaluation"; + const businessOverviewSignal = + rawBusinessOverviewSignal || + seededBusinessOverviewSignal; const businessOverviewSeparateCounterpartySignal = Boolean( businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText) ); @@ -1630,7 +1727,7 @@ export function buildAssistantMcpDiscoveryTurnInput( : rawAssistantTurnMeaningOrganizationScope; const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText); const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText); - const currentTurnFreshOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization; + const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope; const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope; const followupCounterpartyIsMetadataOrganizationScope = Boolean( @@ -2029,9 +2126,21 @@ export function buildAssistantMcpDiscoveryTurnInput( const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable ? "metadata lane clarification" : semanticNeedFor({ - domain: businessOverviewSignal ? "business_overview" : rawDomain ?? seededDomain, - action: businessOverviewSignal ? "broad_evaluation" : rawAction ?? seededAction, - unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported, + domain: explicitVatMovementEvidenceSignal + ? "movements" + : businessOverviewSignal + ? "business_overview" + : rawDomain ?? seededDomain, + action: explicitVatMovementEvidenceSignal + ? "list_movements" + : businessOverviewSignal + ? businessOverviewActionFamily + : rawAction ?? seededAction, + unsupported: explicitVatMovementEvidenceSignal + ? "movement_evidence" + : businessOverviewSignal + ? businessOverviewUnsupportedFamily + : unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, @@ -2065,7 +2174,9 @@ export function buildAssistantMcpDiscoveryTurnInput( followupSeed.metadataSelectedEntitySet ?? null; const metadataScopedLaneWithoutSubject = Boolean( - (metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) && + (metadataGroundedMovementLaneApplicable || + metadataGroundedDocumentLaneApplicable || + explicitVatMovementEvidenceSignal) && !effectiveFollowupCounterparty && metadataLaneCarryoverAvailable ); @@ -2278,11 +2389,13 @@ export function buildAssistantMcpDiscoveryTurnInput( const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { asked_domain_family: businessOverviewSignal - ? "business_overview" - : lifecycleSignal + ? "business_overview" + : lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" + : explicitVatMovementEvidenceSignal + ? "movements" : metadataGroundedMovementLaneApplicable ? "movements" : metadataGroundedDocumentLaneApplicable @@ -2293,7 +2406,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "metadata" : rawDomain ?? seededDomain, asked_action_family: businessOverviewSignal - ? "broad_evaluation" + ? businessOverviewActionFamily : lifecycleSignal ? "activity_duration" : valueFlowSignal @@ -2302,6 +2415,8 @@ export function buildAssistantMcpDiscoveryTurnInput( : payoutSignal ? "payout" : rawAction ?? seededAction ?? "turnover" + : explicitVatMovementEvidenceSignal + ? "list_movements" : metadataGroundedMovementLaneApplicable ? "list_movements" : metadataGroundedDocumentLaneApplicable @@ -2334,7 +2449,7 @@ export function buildAssistantMcpDiscoveryTurnInput( subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined, unsupported_but_understood_family: businessOverviewSignal - ? "broad_business_evaluation" + ? businessOverviewUnsupportedFamily : unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" @@ -2346,8 +2461,10 @@ export function buildAssistantMcpDiscoveryTurnInput( : seededUnsupported ?? "counterparty_value_or_turnover" : metadataGroundedMovementLaneApplicable ? "movement_evidence" - : metadataGroundedDocumentLaneApplicable + : metadataGroundedDocumentLaneApplicable ? "document_evidence" + : explicitVatMovementEvidenceSignal + ? "movement_evidence" : metadataAmbiguityLaneClarificationApplicable ? "metadata_lane_choice_clarification" : entityResolutionSignal @@ -2363,6 +2480,7 @@ export function buildAssistantMcpDiscoveryTurnInput( unsupported || lifecycleSignal || valueFlowSignal || + explicitVatMovementEvidenceSignal || metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable || metadataAmbiguityLaneClarificationApplicable || @@ -2423,13 +2541,17 @@ export function buildAssistantMcpDiscoveryTurnInput( const currentTurnValueFlowExactOverrideApplicable = Boolean( valueFlowSignal && explicitIntentCandidate && - rawValueFlowAggregateQuestionSignal && + (rawValueFlowAggregateQuestionSignal || hasValueRankingSignal(rawText)) && semanticDataNeed && (entityCandidates.length > 0 || explicitOrganizationScope || openScopeValueFlowWithoutResolvedCounterparty) ); const runDiscovery = shouldRunDiscovery({ - unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported, + unsupported: explicitVatMovementEvidenceSignal + ? "movement_evidence" + : businessOverviewSignal + ? "broad_business_evaluation" + : unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable, @@ -2446,6 +2568,7 @@ export function buildAssistantMcpDiscoveryTurnInput( groundedValueFlowFollowupApplicable, forceDiscoveryOverExplicitIntent: businessOverviewSignal || + explicitVatMovementEvidenceSignal || Boolean(entityResolutionClarificationCandidate) || organizationClarificationFollowupApplicable || periodClarificationFollowupApplicable || @@ -2469,6 +2592,8 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "followup_context" : metadataGroundedDocumentLaneApplicable ? "followup_context" + : explicitVatMovementEvidenceSignal + ? "raw_text" : predecomposeContract ? "predecompose_contract" : lifecycleSignal @@ -2490,6 +2615,9 @@ export function buildAssistantMcpDiscoveryTurnInput( if (rawMetadataSignal) { pushReason(reasonCodes, "mcp_discovery_metadata_signal_detected"); } + if (explicitVatMovementEvidenceSignal) { + pushReason(reasonCodes, "mcp_discovery_vat_movement_evidence_signal_detected"); + } if (entityResolutionSignal) { pushReason(reasonCodes, "mcp_discovery_entity_resolution_signal_detected"); } @@ -2613,6 +2741,12 @@ export function buildAssistantMcpDiscoveryTurnInput( if (businessOverviewContinuationSignal) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context"); } + if (profitMarginFollowupBusinessOverviewSignal) { + pushReason(reasonCodes, "mcp_discovery_business_overview_profit_margin_followup_boundary"); + } + if (debtDueDateFollowupBusinessOverviewSignal) { + pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary"); + } if (explicitVatSuppressesBusinessOverviewContinuation) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"); } diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 7366d47..6911794 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -395,6 +395,13 @@ export function createAssistantRoutePolicy(deps) { const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized); return hasRequestCue && hasTemporalCue; } + function hasOrganizationClarificationTextCue(text) { + const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); + if (!normalized) { + return false; + } + return /(? toNonEmptyString(item)).filter(Boolean) + : []; + const currentTurnPredecomposeOrganization = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.entities?.organization) ?? + (toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_kind) === "organization" + ? toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_value) + : null); + const routeCandidateOrganizationClarificationDetected = Boolean(followupContext && + followupLoopStatus === "awaiting_clarification" && + followupLoopSelectedChainId && + followupLoopPendingAxes.includes("organization") && + (currentTurnPredecomposeOrganization || + explicitOrganizationClarificationSelection || + [ + rawUserMessage, + repairedRawUserMessage, + effectiveAddressUserMessage, + repairedEffectiveAddressUserMessage + ].some((message) => hasOrganizationClarificationTextCue(message)))); const protectedInventoryShortFollowup = Boolean(followupContext && (isInventorySelectedObjectIntent(followupPreviousIntent) || (followupPreviousIntent === "inventory_on_hand_as_of_date" && @@ -608,6 +636,8 @@ export function createAssistantRoutePolicy(deps) { "net_value_flow" ].includes(String(toNonEmptyString(assistantTurnMeaning?.asked_action_family) ?? "")) || /(?:нетто|сальдо|сколько\s+мы\s+(?:получили|заплатили)|incoming|outgoing)/iu.test(analyticsSample))); + const effectiveGroundedValueFlowFollowupContextDetected = + groundedValueFlowFollowupContextDetected || routeCandidateOrganizationClarificationDetected; const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane && [ "address_intent_resolver_detected", @@ -617,14 +647,15 @@ export function createAssistantRoutePolicy(deps) { ].includes(String(baseToolGate?.reason ?? ""))) || Boolean(baseToolGate?.runAddressLane && String(baseToolGate?.reason ?? "") === "followup_context_detected" && - groundedValueFlowFollowupContextDetected); + effectiveGroundedValueFlowFollowupContextDetected); const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate && deterministicNonDomainGuard && (llmFirstUnsupportedCandidate || llmContractMode === null) && !baseToolGatePreservesAddressLane && - !groundedValueFlowFollowupContextDetected && + !effectiveGroundedValueFlowFollowupContextDetected && !protectedInventoryShortFollowup && - !organizationClarificationContinuationDetected); + !organizationClarificationContinuationDetected && + !routeCandidateOrganizationClarificationDetected); const lastAddressAssistantDebug = sessionItems ? findLastAddressAssistantItem(sessionItems)?.debug ?? null : null; @@ -668,7 +699,7 @@ export function createAssistantRoutePolicy(deps) { !turnMeaningIntentCandidate && !dataScopeMetaQuery && !dangerOrCoercionSignal && - !groundedValueFlowFollowupContextDetected && + !effectiveGroundedValueFlowFollowupContextDetected && !organizationClarificationContinuationDetected); const hardMetaMode = resolveHardMetaMode({ dataScopeMetaQuery, @@ -834,7 +865,7 @@ export function createAssistantRoutePolicy(deps) { !dataScopeMetaQuery && !capabilityMetaQuery && !dangerOrCoercionSignal && - !groundedValueFlowFollowupContextDetected && + !effectiveGroundedValueFlowFollowupContextDetected && !organizationClarificationContinuationDetected); if (unsupportedCurrentTurnMeaningBoundary) { return { diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 9601a5e..5d0fb33 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2079,6 +2079,9 @@ function isAddressLaneDebugPayload(debug) { if (typeof debug.mcp_call_status === "string" && debug.mcp_call_status.trim().length > 0) { return true; } + if (debug.mcp_discovery_response_applied === true && debug.assistant_mcp_discovery_entry_point_v1) { + return true; + } if (typeof debug.anchor_type === "string" && debug.anchor_type.trim().length > 0) { return true; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index dc19c14..57744d0 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -208,6 +208,34 @@ export function createAssistantTransitionPolicy(deps) { ); } + function hasBusinessOverviewBoundaryFollowupCue(text) { + const normalized = normalizeFollowupText(text); + if (!normalized) { + return false; + } + const hasBoundaryCue = + /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u0443\u0431\u044b\u0442|\u043c\u0430\u0440\u0436|\u0440\u0435\u0437\u0435\u0440\u0432|\u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|due[-\s]?date|overdue|aging|profit|loss|margin|vendor|risk)/iu.test( + normalized + ); + const hasFollowupShape = + /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\u043a|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|\u0438\u0442\u043e\u0433|why|short|brief|so)/iu.test( + normalized + ); + return hasBoundaryCue && hasFollowupShape; + } + + function hasOrganizationClarificationTextCue(text) { + const normalized = deps.compactWhitespace( + deps.repairAddressMojibake(String(text ?? "")).toLowerCase() + ); + if (!normalized) { + return false; + } + return /(? hasOrganizationClarificationTextCue(message))) + ); const hasValueFlowCarryoverSourceHint = sourceIntentHint === "customer_revenue_and_payments" || sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" || sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" || sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1"; + const hasBusinessOverviewCarryoverSourceHint = + sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1"; const navigationSessionState = resolveNavigationSessionContextState( addressNavigationState, deps.toNonEmptyString, @@ -706,21 +770,31 @@ export function createAssistantTransitionPolicy(deps) { hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) ? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) : false; + const businessOverviewBoundaryFollowupPrimary = + hasBusinessOverviewCarryoverSourceHint && hasBusinessOverviewBoundaryFollowupCue(userMessage); + const businessOverviewBoundaryFollowupAlternate = + hasBusinessOverviewCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) + ? hasBusinessOverviewBoundaryFollowupCue(String(alternateMessage ?? "")) + : false; const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage); let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || shortValueFlowRetargetPrimary || + businessOverviewBoundaryFollowupPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || - explicitSummaryBundleReuseSignal; + explicitSummaryBundleReuseSignal || + mcpDiscoveryOrganizationClarificationContinuation; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || - explicitSummaryBundleReuseSignal + explicitSummaryBundleReuseSignal || + mcpDiscoveryOrganizationClarificationContinuation : false; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) @@ -760,6 +834,7 @@ export function createAssistantTransitionPolicy(deps) { hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || + mcpDiscoveryOrganizationClarificationContinuation || hasImplicitContinuationSignal || hasSuggestedIntentPivotSignal || inventoryShortFollowupPrimary || @@ -773,6 +848,8 @@ export function createAssistantTransitionPolicy(deps) { Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupPrimary || + businessOverviewBoundaryFollowupAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -783,6 +860,7 @@ export function createAssistantTransitionPolicy(deps) { hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || + mcpDiscoveryOrganizationClarificationContinuation || inventoryShortFollowupPrimary || inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || @@ -794,6 +872,8 @@ export function createAssistantTransitionPolicy(deps) { Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupPrimary || + businessOverviewBoundaryFollowupAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) @@ -826,6 +906,7 @@ export function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && + !mcpDiscoveryOrganizationClarificationContinuation && !hasIndexReferenceSignal && !explicitSummaryBundleReuseSignal ) { @@ -843,6 +924,7 @@ export function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && + !mcpDiscoveryOrganizationClarificationContinuation && !hasIndexReferenceSignal && !explicitSummaryBundleReuseSignal ) { @@ -884,19 +966,10 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); - const sourceDiscoveryLoopStatus = readAssistantMcpDiscoveryLoopStatus(carryoverSourceDebug, deps.toNonEmptyString); - const sourceDiscoveryLoopSelectedChainId = readAssistantMcpDiscoveryLoopSelectedChainId( - carryoverSourceDebug, - deps.toNonEmptyString - ); - const sourceDiscoveryLoopPendingAxes = readAssistantMcpDiscoveryLoopPendingAxes( - carryoverSourceDebug, - deps.toNonEmptyString - ); - const sourceDiscoveryLoopProvidedAxes = readAssistantMcpDiscoveryLoopProvidedAxes( - carryoverSourceDebug, - deps.toNonEmptyString - ); + const sourceDiscoveryLoopStatus = sourceDiscoveryLoopStatusHint; + const sourceDiscoveryLoopSelectedChainId = sourceDiscoveryLoopSelectedChainIdHint; + const sourceDiscoveryLoopPendingAxes = sourceDiscoveryLoopPendingAxesHint; + const sourceDiscoveryLoopProvidedAxes = sourceDiscoveryLoopProvidedAxesHint; const sourceDiscoveryLoopAskedDomainFamily = readAssistantMcpDiscoveryLoopAskedDomainFamily( carryoverSourceDebug, deps.toNonEmptyString @@ -959,6 +1032,7 @@ export function createAssistantTransitionPolicy(deps) { explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasOrganizationClarificationContinuation && + !mcpDiscoveryOrganizationClarificationContinuation && !hasImplicitContinuationSignal && !hasIndexReferenceSignal && !hasInventoryRootTemporalFollowupPrimary && @@ -967,6 +1041,8 @@ export function createAssistantTransitionPolicy(deps) { !hasInventoryRootRestatementAlternate && !inventoryShortFollowupPrimary && !inventoryShortFollowupAlternate && + !businessOverviewBoundaryFollowupPrimary && + !businessOverviewBoundaryFollowupAlternate && !foreignAccountingPivotOverInventory && !deps.hasFollowupMarker(userMessage) && !deps.hasReferentialPointer(userMessage) && @@ -1027,24 +1103,29 @@ export function createAssistantTransitionPolicy(deps) { hasSuggestedIntentPivotSignal || Boolean(debtRoleSwapPrimary) || shortValueFlowRetargetPrimary || + businessOverviewBoundaryFollowupPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || explicitSummaryBundleReuseSignal || - hasInventoryRootTemporalFollowupPrimary; + hasInventoryRootTemporalFollowupPrimary || + mcpDiscoveryOrganizationClarificationContinuation; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || hasSuggestedIntentPivotSignal || Boolean(debtRoleSwapAlternate) || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || explicitSummaryBundleReuseSignal || - hasInventoryRootTemporalFollowupAlternate + hasInventoryRootTemporalFollowupAlternate || + mcpDiscoveryOrganizationClarificationContinuation : false; hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal || hasOrganizationClarificationContinuation || + mcpDiscoveryOrganizationClarificationContinuation || hasSuggestedIntentPivotSignal || hasImplicitContinuationSignal || inventoryShortFollowupPrimary || @@ -1056,6 +1137,8 @@ export function createAssistantTransitionPolicy(deps) { Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || + businessOverviewBoundaryFollowupPrimary || + businessOverviewBoundaryFollowupAlternate || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || (deps.toNonEmptyString(alternateMessage) diff --git a/llm_normalizer/backend/src/services/counterpartyRoleHeuristics.ts b/llm_normalizer/backend/src/services/counterpartyRoleHeuristics.ts new file mode 100644 index 0000000..bc98cbd --- /dev/null +++ b/llm_normalizer/backend/src/services/counterpartyRoleHeuristics.ts @@ -0,0 +1,47 @@ +export type CounterpartyRoleHint = "ordinary_counterparty" | "bank_or_financial_institution"; + +const FINANCIAL_INSTITUTION_PATTERNS: RegExp[] = [ + /(?:^|[\s"«(,-])банк(?:$|[\s"»),.-])/u, + /сбербанк/u, + /(?:^|[\s"«(,-])сбер(?:$|[\s"»),.-])/u, + /(?:^|[\s"«(,-])втб(?:$|[\s"»),.-])/u, + /альфа[\s-]*банк/u, + /тинькофф/u, + /(?:^|[\s"«(,-])т[\s-]*банк(?:$|[\s"»),.-])/u, + /газпромбанк/u, + /росбанк/u, + /райффайзен/u, + /совкомбанк/u, + /промсвязьбанк/u, + /(?:^|[\s"«(,-])псб(?:$|[\s"»),.-])/u, + /(?:^|[\s"«(,-])мкб(?:$|[\s"»),.-])/u, + /ак[\s-]*барс/u, + /уралсиб/u, + /юникредит/u, + /почта[\s-]*банк/u, + /(?:^|[\s"«(,-])открытие(?:$|[\s"»),.-])/u, + /кредитн(?:ая|ый|ое|ые)\s+организац/u +]; + +function normalizeCounterpartyRoleText(value: unknown): string { + return String(value ?? "") + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[._]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function isLikelyFinancialInstitutionCounterparty(value: unknown): boolean { + const normalized = normalizeCounterpartyRoleText(value); + if (!normalized) { + return false; + } + return FINANCIAL_INSTITUTION_PATTERNS.some((pattern) => pattern.test(normalized)); +} + +export function counterpartyRoleHintForName(value: unknown): CounterpartyRoleHint { + return isLikelyFinancialInstitutionCounterparty(value) + ? "bank_or_financial_institution" + : "ordinary_counterparty"; +} diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index a9eb9bf..c2e3524 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -18,6 +18,8 @@ export type AddressIntent = | "vat_payable_forecast" | "vat_liability_confirmed_for_tax_period" | "vat_payable_confirmed_as_of_date" + | "accounting_financial_result_for_organization" + | "debt_due_date_aging_for_organization" | "open_contracts_confirmed_as_of_date" | "list_contracts_by_counterparty" | "list_open_contracts" @@ -189,6 +191,8 @@ export interface AddressRecipeDefinition { | "vat_payable_forecast_profile" | "vat_liability_confirmed_tax_period_profile" | "vat_payable_confirmed_as_of_balance_profile" + | "accounting_financial_result_profile" + | "debt_due_date_aging_profile" | "open_contracts_confirmed_as_of_balance_profile" | "payables_confirmed_as_of_balance_profile" | "receivables_confirmed_as_of_balance_profile" diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index 5ab1afd..1bb0a13 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -8,6 +8,24 @@ describe("addressIntentResolver regression bridges", () => { expect(result.intent).toBe("vat_liability_confirmed_for_tax_period"); }); + it("detects VAT movement inspection wording with an explicit year", () => { + const result = resolveAddressIntent( + "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f \u043f\u043e \u041d\u0414\u0421 \u0437\u0430 2020 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" + ); + + expect(result.intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(result.reasons).toContain("vat_period_inspection_bridge_signal_detected"); + }); + + it("detects canonical VAT charged-or-paid wording with an explicit year", () => { + const result = resolveAddressIntent( + "\u041a\u0430\u043a\u043e\u0439 \u041d\u0414\u0421 \u0431\u044b\u043b \u043d\u0430\u0447\u0438\u0441\u043b\u0435\u043d \u0438\u043b\u0438 \u0443\u043f\u043b\u0430\u0447\u0435\u043d \u0432 2020 \u0433\u043e\u0434\u0443 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441?" + ); + + expect(result.intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(result.reasons).toContain("vat_liability_explicit_period_bridge_signal_detected"); + }); + it("detects payables snapshot wording in plain human form", () => { const result = resolveAddressIntent("мы должны комуто денег на сегодня?"); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 8d38cdc..cbd1fb5 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -1336,9 +1336,9 @@ describe("address compose stage utf8 headers", () => { { userMessage: "Сколько у нас заказчиков, поставщиков и смешанных контрагентов?" } ); - expect(reply.text).toContain("Роли контрагентов по активности:"); - expect(reply.text).toContain("Заказчики (только customer-роль): 122."); - expect(reply.text).toContain("Поставщики (только supplier-роль): 71."); + expect(reply.text).toContain("Распределение ролей по активности:"); + expect(reply.text).toContain("Заказчики с ролью покупателя: 122."); + expect(reply.text).toContain("Поставщики с ролью поставщика: 71."); expect(reply.text).toContain("Смешанные (и покупатель, и поставщик): 23."); expect(reply.text).not.toContain("Всего уникальных контрагентов в базе"); }); @@ -1391,7 +1391,7 @@ describe("address compose stage utf8 headers", () => { { userMessage: "скока поставщиков в базе" } ); - expect(reply.text).toContain("Поставщиков (только supplier-роль): 71."); + expect(reply.text).toContain("Поставщиков с ролью поставщика: 71."); expect(reply.text).not.toContain("Роли контрагентов по активности:"); expect(reply.text).not.toContain("Всего уникальных контрагентов в базе"); }); @@ -1444,7 +1444,7 @@ describe("address compose stage utf8 headers", () => { { userMessage: "скок клиентов" } ); - expect(reply.text).toContain("Заказчиков (только customer-роль): 122."); + expect(reply.text).toContain("Заказчиков с ролью покупателя: 122."); expect(reply.text).not.toContain("Роли контрагентов по активности:"); expect(reply.text).not.toContain("Всего уникальных контрагентов в базе"); }); @@ -1711,7 +1711,7 @@ describe("address compose stage utf8 headers", () => { expect(reply.responseType).toBe("FACTUAL_LIST"); expect(reply.text).toContain("по максимальной сумме одной входящей операции"); - expect(reply.text).toContain("1. Клиент Б | max single: 1200"); + expect(reply.text).toContain("1. Клиент Б | максимальная разовая сумма: 1.200,00 ₽"); }); it("renders supplier payout list by operations count", () => { @@ -2033,7 +2033,7 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Покрытие VAT-источников через MCP"); + expect(reply.text).toContain("Покрытие VAT-источников в 1С"); expect(reply.text).toContain("Найдено VAT-объектов: 5"); expect(reply.text).toContain("РегистрНакопления.НДСПродажи"); }); @@ -2063,12 +2063,14 @@ describe("address compose stage utf8 headers", () => { userMessage: "сколько платить ндс в налоговую за декабрь 2019", periodFrom: "2019-10-01", periodTo: "2019-12-31", + organizationHint: "ООО Альтернатива Плюс", useRubCurrency: true } ); expect(reply.responseType).toBe("FACTUAL_SUMMARY"); - expect(reply.text).toContain("Коротко: подтвержденный НДС к уплате за налоговый период"); + expect(reply.text).toContain("Коротко: подтвержденный НДС к уплате за налоговый период по организации ООО Альтернатива Плюс"); + expect(reply.text).toContain("- Организация: ООО Альтернатива Плюс."); expect(reply.text).toContain("50.000,00 ₽"); expect(reply.semantics?.result_mode).toBe("confirmed_balance"); expect(reply.semantics?.balance_confirmed).toBe(true); @@ -2214,7 +2216,7 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_LIST"); - expect(reply.text).toContain("Блок 2.1. MCP-проверка VAT-источников"); + expect(reply.text).toContain("Блок 2.1. Проверка VAT-источников в 1С"); expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3"); expect(reply.text).toContain("Источников с движениями до даты среза: 1"); expect(reply.text).toContain("РегистрНакопления.НДСНачисленный"); @@ -2247,7 +2249,7 @@ describe("address compose stage utf8 headers", () => { ); expect(reply.responseType).toBe("FACTUAL_LIST"); - expect(reply.text).toContain("Probe VAT-источников завершился ошибкой"); + expect(reply.text).toContain("Дополнительная проверка VAT-источников завершилась ошибкой"); }); }); @@ -5099,13 +5101,13 @@ describe("address recipe catalog counterparty filtering", () => { expect(aging.extracted_filters.limit).toBeUndefined(); }); - it("selects customer value recipe and keeps top-20 default", () => { + it("selects customer value recipe and keeps expanded top-200 default", () => { const selected = selectAddressRecipe("customer_revenue_and_payments", {}); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, {}); expect(plan.recipe.recipe_id).toBe("address_customer_revenue_and_payments_v1"); - expect(plan.limit).toBe(20); + expect(plan.limit).toBe(200); expect(plan.query).toContain("ПоступлениеНаРасчетныйСчет"); expect(plan.query).toContain("БанкПоступление.ДоговорКонтрагента"); }); @@ -5138,13 +5140,13 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.limit).toBe(1000); }); - it("selects supplier payouts recipe and keeps top-20 default", () => { + it("selects supplier payouts recipe and keeps expanded top-200 default", () => { const selected = selectAddressRecipe("supplier_payouts_profile", {}); expect(selected.selected_recipe).toBeTruthy(); const plan = buildAddressRecipePlan(selected.selected_recipe!, {}); expect(plan.recipe.recipe_id).toBe("address_supplier_payouts_profile_v1"); - expect(plan.limit).toBe(20); + expect(plan.limit).toBe(200); expect(plan.query).toContain("СписаниеСРасчетногоСчета"); expect(plan.query).toContain("БанкСписание.ДоговорКонтрагента"); }); @@ -5343,6 +5345,26 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 2) = \"60\""); }); + it("builds debt due-date aging query without carrying noisy organization suffix", () => { + const selected = selectAddressRecipe("debt_due_date_aging_for_organization", { + as_of_date: "2020-12-31", + organization: "ООО Альтернатива Плюс на конец 2020 можно точно понять" + }); + expect(selected.selected_recipe?.recipe_id).toBe("address_debt_due_date_aging_for_organization_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, { + as_of_date: "2020-12-31", + organization: "ООО Альтернатива Плюс на конец 2020 можно точно понять" + }); + + expect(plan.query).toContain("УстановленСрокОплаты"); + expect(plan.query).toContain('Наименование ПОДОБНО "%Альтернатива%"'); + expect(plan.query).toContain('Наименование ПОДОБНО "%Плюс%"'); + expect(plan.query).not.toContain('Наименование ПОДОБНО "%конец%"'); + expect(plan.query).not.toContain('Наименование ПОДОБНО "%2020%"'); + expect(plan.query).not.toContain('Наименование ПОДОБНО "%можно%"'); + expect(plan.query).not.toContain('Наименование ПОДОБНО "%понять%"'); + }); + it("injects account condition into movements query for account snapshot", () => { const filters = extractAddressFilters( "Какой остаток по счету 60 на дату 2020-07-31", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts index 0dafa5d..9ed7327 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryAnswerAdapter.test.ts @@ -180,6 +180,76 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not present the confirmed movement rows as a complete movement universe."); }); + it("does not present bank-like ranked value-flow leaders as ordinary customers", () => { + const draft = buildAssistantMcpDiscoveryAnswerDraft({ + pilot_status: "executed", + pilot_scope: "counterparty_ranked_value_flow_query_movements_v1", + dry_run: false, + mcp_execution_performed: true, + executed_primitives: ["query_movements"], + skipped_primitives: [], + probe_results: [], + evidence: { + evidence_status: "confirmed", + answer_permission: "confirmed_answer", + confirmed_facts: [], + inferred_facts: [], + unknown_facts: [], + query_limitations: [], + reason_codes: [], + query_plan: {} + }, + source_rows_summary: "2 MCP movement rows fetched, 2 matched ranking scope", + derived_metadata_surface: null, + derived_entity_resolution: null, + derived_activity_period: null, + derived_value_flow: null, + derived_bidirectional_value_flow: null, + derived_business_overview: null, + derived_ranked_value_flow: { + organization_scope: "ООО Альтернатива Плюс", + period_scope: "2020", + value_flow_direction: "incoming_customer_revenue", + ranking_need: "top_desc", + aggregation_axis: "counterparty", + coverage_limited_by_probe_limit: false, + ranked_values: [ + { + axis_value: "СБЕРБАНК, ПАО", + total_amount: 12792194.31, + total_amount_human_ru: "12 792 194,31 руб.", + rows_with_amount: 3, + rows_matched: 3, + first_movement_date: "2020-01-15", + latest_movement_date: "2020-12-20", + counterparty_role_hint: "bank_or_financial_institution" + }, + { + axis_value: "Группа СВК", + total_amount: 12093465, + total_amount_human_ru: "12 093 465 руб.", + rows_with_amount: 2, + rows_matched: 2, + first_movement_date: "2020-02-15", + latest_movement_date: "2020-11-10", + counterparty_role_hint: "ordinary_counterparty" + } + ], + inference_basis: "confirmed_1c_movement_rows_grouped_by_counterparty" + }, + query_limitations: [], + reason_codes: ["pilot_derived_ranked_value_flow_from_confirmed_rows"] + } as any); + + const confirmed = draft.confirmed_lines.join("\n"); + expect(confirmed).toContain("Крупнейший входящий денежный источник СБЕРБАНК, ПАО"); + expect(confirmed).toContain("не называю это клиентской выручкой"); + expect(confirmed).not.toContain("Больше всего денег принёс контрагент СБЕРБАНК"); + expect(draft.must_not_claim).toContain( + "Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof." + ); + }); + it("turns business overview multi-probe evidence into an analyst-safe draft", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { @@ -355,6 +425,7 @@ describe("assistant MCP discovery answer adapter", () => { { rows: [] }, { rows: [] }, { rows: [] }, + { rows: [] }, { rows: [ { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, @@ -429,6 +500,7 @@ describe("assistant MCP discovery answer adapter", () => { { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, { rows: [] }, + { rows: [] }, { rows: [ { Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А" } @@ -528,6 +600,7 @@ describe("assistant MCP discovery answer adapter", () => { { rows: [] }, { rows: [] }, { rows: [] }, + { rows: [] }, { rows: [ { Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" }, @@ -576,6 +649,73 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.must_not_claim).toContain("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value."); }); + it("answers inventory reserve boundary questions direct-first before broad overview detail", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "inventory_reserve_boundary", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: ["collect_scoped_movements", "probe_coverage", "explain_evidence_basis"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "inventory_reserve_boundary", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "inventory_reserve_liquidation_boundary" + } + }); + const pilot = await executeAssistantMcpDiscoveryPilot( + planner, + buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" } + ] + }, + { + rows: [ + { Period: "2020-01-10T00:00:00", Amount: 200000, Quantity: 8, Item: "Товар А" } + ] + }, + { rows: [{ Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }] }, + { + rows: [ + { Period: "2020-03-01T00:00:00", Amount: 600000, Item: "Товар А", Counterparty: "Клиент А", AccountKt: "41.01" }, + { Period: "2020-02-01T00:00:00", Amount: 240000, Item: "Товар А", Counterparty: "Поставщик А", AccountDt: "41.01" } + ] + } + ]) + ); + + const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot); + + expect(draft.headline).toContain( + "\u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432" + ); + expect(draft.headline).toContain("\u043d\u0435\u043b\u044c\u0437\u044f"); + expect(draft.headline).toContain("staleness-risk proxy"); + expect(draft.headline).not.toContain("бизнес-обзор"); + expect(draft.must_not_claim).toContain("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value."); + }); + it("renders metadata-scoped movement all-time follow-up as an all-time bounded answer", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { @@ -796,7 +936,9 @@ describe("assistant MCP discovery answer adapter", () => { expect(draft.answer_mode).toBe("confirmed_with_bounded_inference"); expect(draft.confirmed_lines).toHaveLength(1); - expect(userText).toContain("\u0411\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0451\u0441 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442"); + expect(userText).toContain("Крупнейший входящий денежный источник"); + expect(userText).toContain("не называю это клиентской выручкой"); + expect(userText).not.toContain("\u0411\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0451\u0441 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442 \u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a"); expect(userText).toContain("\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"); expect(userText).not.toContain("1C incoming value-flow"); expect(userText).not.toContain("Full ranking outside"); @@ -1409,7 +1551,7 @@ describe("assistant MCP discovery answer adapter", () => { unsupported_but_understood_family: "counterparty_payouts_or_outflow" } }); - const broadRows = Array.from({ length: 100 }, (_, index) => ({ + const broadRows = Array.from({ length: 200 }, (_, index) => ({ Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, Amount: 10, Counterparty: "SVK" diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDebugAttachment.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDebugAttachment.test.ts index d3241a1..da23d55 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDebugAttachment.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDebugAttachment.test.ts @@ -26,6 +26,25 @@ function entryPointContract(overrides: Record = {}) { selected_chain_matches_top: true } }, + route_candidate: { + schema_version: "assistant_mcp_route_candidate_v1", + policy_owner: "assistantMcpDiscoveryRuntimeBridge", + candidate_status: "ready_for_reviewed_execution", + selected_chain_id: "value_flow_ranking", + selected_chain_summary: "Rank value flow", + nearest_catalog_chain_template: "value_flow_ranking", + catalog_alignment_status: "selected_matches_top", + business_fact_family: "value_flow", + action_family: "turnover", + proof_expectation: "coverage_checked_fact", + required_axes: ["organization", "period"], + provided_axes: ["organization", "period"], + missing_axes: [], + executable_now: true, + enablement_reason: null, + recommended_next_action: "Execute through the reviewed runtime bridge and truth gate.", + forbidden_overclaim_flags: ["no_unchecked_fact_totals"] + }, answer_draft: { answer_mode: "confirmed_with_bounded_inference" } @@ -54,6 +73,15 @@ describe("assistant MCP discovery debug attachment", () => { expect(debug.mcp_discovery_catalog_chain_alignment_status).toBe("selected_matches_top"); expect(debug.mcp_discovery_catalog_chain_top_match).toBe("value_flow_ranking"); expect(debug.mcp_discovery_catalog_chain_selected_matches_top).toBe(true); + expect(debug.mcp_discovery_route_candidate_status).toBe("ready_for_reviewed_execution"); + expect(debug.mcp_discovery_route_candidate_fact_family).toBe("value_flow"); + expect(debug.mcp_discovery_route_candidate_action_family).toBe("turnover"); + expect(debug.mcp_discovery_route_candidate_missing_axes).toEqual([]); + expect(debug.mcp_discovery_route_candidate_provided_axes).toEqual(["organization", "period"]); + expect(debug.mcp_discovery_route_candidate_executable_now).toBe(true); + expect(debug.mcp_discovery_route_candidate_next_action).toBe( + "Execute through the reviewed runtime bridge and truth gate." + ); expect(debug.mcp_discovery_answer_mode).toBe("confirmed_with_bounded_inference"); expect(debug.mcp_discovery_business_fact_answer_allowed).toBe(true); expect(debug.mcp_discovery_user_facing_response_allowed).toBe(true); @@ -76,6 +104,11 @@ describe("assistant MCP discovery debug attachment", () => { expect(debug.mcp_discovery_catalog_chain_alignment_status).toBeNull(); expect(debug.mcp_discovery_catalog_chain_top_match).toBeNull(); expect(debug.mcp_discovery_catalog_chain_selected_matches_top).toBe(false); + expect(debug.mcp_discovery_route_candidate_v1).toBeNull(); + expect(debug.mcp_discovery_route_candidate_status).toBeNull(); + expect(debug.mcp_discovery_route_candidate_missing_axes).toEqual([]); + expect(debug.mcp_discovery_route_candidate_provided_axes).toEqual([]); + expect(debug.mcp_discovery_route_candidate_executable_now).toBe(false); expect(debug.mcp_discovery_answer_mode).toBeNull(); expect(debug.mcp_discovery_business_fact_answer_allowed).toBe(false); expect(debug.mcp_discovery_user_facing_response_allowed).toBe(false); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts index febcbfc..95bbd24 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPilotExecutor.test.ts @@ -293,6 +293,80 @@ describe("assistant MCP discovery pilot executor", () => { expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(6); }); + it("marks bank-like counterparties in business-overview rankings before evidence wording", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "broad_evaluation", + aggregation_need: null, + time_scope_need: "all_time_scope", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс" + } + }); + const deps = buildSequentialDeps([ + { + rows: [ + { Period: "2020-01-15T00:00:00", Amount: 1200000, Counterparty: "СБЕРБАНК, ПАО" }, + { Period: "2020-02-15T00:00:00", Amount: 800000, Counterparty: "Группа СВК" } + ] + }, + { + rows: [ + { Period: "2020-01-20T00:00:00", Amount: 650000, Counterparty: "СБЕРБАНК, ПАО" }, + { Period: "2020-03-20T00:00:00", Amount: 50000, Counterparty: "ООО Поставщик" } + ] + }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.derived_business_overview?.top_customers[0]).toMatchObject({ + axis_value: "СБЕРБАНК, ПАО", + counterparty_role_hint: "bank_or_financial_institution" + }); + expect(result.derived_business_overview?.top_customers[1]).toMatchObject({ + axis_value: "Группа СВК", + counterparty_role_hint: "ordinary_counterparty" + }); + expect(result.derived_business_overview?.top_suppliers[0]).toMatchObject({ + axis_value: "СБЕРБАНК, ПАО", + counterparty_role_hint: "bank_or_financial_institution" + }); + const confirmedFacts = result.evidence.confirmed_facts.join("\n"); + const inferredFacts = result.evidence.inferred_facts.join("\n"); + expect(confirmedFacts).toContain("Крупнейший входящий денежный источник"); + expect(confirmedFacts).toContain("Крупнейший небанковский входящий контрагент"); + expect(confirmedFacts).toContain("Крупнейший получатель исходящих денег"); + expect(confirmedFacts).not.toContain("Самый крупный подтвержденный клиент в проверенном срезе: СБЕРБАНК"); + expect(confirmedFacts).not.toContain("Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: СБЕРБАНК"); + expect(inferredFacts).toContain("outgoing cash concentration proxy"); + }); + it("adds a checked VAT/tax family to business overview only when an explicit period is available", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { @@ -347,6 +421,7 @@ describe("assistant MCP discovery pilot executor", () => { { rows: [] }, { rows: [] }, { rows: [] }, + { rows: [] }, { rows: [ { Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" }, @@ -402,9 +477,9 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.reason_codes).toContain("pilot_derived_business_overview_tax_position_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_business_overview_trading_margin_query_mcp_executed"); expect(result.reason_codes).toContain("pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows"); - expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(13); + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14); const taxCall = deps.executeAddressMcpQuery.mock.calls[2]?.[0]; - const tradingMarginCall = deps.executeAddressMcpQuery.mock.calls[9]?.[0]; + const tradingMarginCall = deps.executeAddressMcpQuery.mock.calls[10]?.[0]; expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПродаж"); expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПокупок"); expect(String(tradingMarginCall?.query ?? "")).toContain("Документ.РеализацияТоваровУслуг.Товары"); @@ -453,6 +528,7 @@ describe("assistant MCP discovery pilot executor", () => { { rows: [] }, { rows: [] }, { rows: [] }, + { rows: [] }, { rows: [ { Period: "2020-03-01T00:00:00", Amount: 100000, Item: "Товар А", Counterparty: "Клиент А", AccountKt: "41.01" }, @@ -510,6 +586,7 @@ describe("assistant MCP discovery pilot executor", () => { { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] }, { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] }, { rows: [] }, + { rows: [] }, { rows: [ { Period: "2020-12-31T00:00:00", Amount: 70000, Counterparty: "Клиент А" }, @@ -602,15 +679,107 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.reason_codes).toContain("pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_age_signal_from_contract_dates"); expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_staleness_risk_proxy_from_confirmed_rows"); - expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(13); - const receivablesCall = deps.executeAddressMcpQuery.mock.calls[3]?.[0]; - const payablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0]; - const openContractsCall = deps.executeAddressMcpQuery.mock.calls[5]?.[0]; + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14); + const receivablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0]; + const payablesCall = deps.executeAddressMcpQuery.mock.calls[5]?.[0]; + const openContractsCall = deps.executeAddressMcpQuery.mock.calls[6]?.[0]; expect(String(receivablesCall?.query ?? "")).toContain("62"); expect(String(payablesCall?.query ?? "")).toContain("60"); expect(String(openContractsCall?.query ?? "")).toContain("СуммаРазвернутыйОстатокКт"); }); + it("checks debt due-date aging with contract payment terms before claiming overdue debt", async () => { + const planner = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "debt_due_date_boundary", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "due_date_aging", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "aggregate_ranked_axis_values", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_overdue_claim"], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }); + const deps = buildSequentialDeps([ + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А от 10.02.2019" } + ] + }, + { + rows: [ + { + Period: "2020-12-31T00:00:00", + Amount: 100000, + Counterparty: "Клиент А", + Contract: "Договор А от 10.02.2019", + ДокументРасчетов: "Реализация товаров от 10.03.2020", + УстановленСрокОплаты: false, + СрокОплаты: 0 + } + ] + }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] } + ]); + + const result = await executeAssistantMcpDiscoveryPilot(planner, deps); + + expect(result.derived_business_overview?.debt_due_date_aging).toMatchObject({ + as_of_date: "2020-12-31", + rows_with_amount: 1, + gross_open_amount: 100000, + rows_with_payment_terms: 0, + rows_without_payment_terms: 1, + overdue_rows: 0, + evidence_status: "no_payment_terms_configured" + }); + expect(result.derived_business_overview?.missing_signal_families).not.toContain("debt_due_date_aging_quality"); + expect(result.derived_business_overview?.missing_proof_families.map((item) => item.family)).not.toContain( + "debt_due_date_aging_quality" + ); + expect(result.evidence.confirmed_facts.join("\n")).toContain("срок оплаты не установлен"); + expect(result.evidence.confirmed_facts.join("\n")).toContain("Подтвержденной просрочки"); + expect(result.evidence.unknown_facts.join("\n")).not.toContain("due-date aging этим бизнес-обзором не подтверждены"); + expect(result.reason_codes).toContain("pilot_business_overview_debt_due_date_aging_query_mcp_executed"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_due_date_aging_from_confirmed_rows"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_due_date_aging_no_payment_terms_configured"); + const dueDateCall = deps.executeAddressMcpQuery.mock.calls[7]?.[0]; + expect(String(dueDateCall?.query ?? "")).toContain("УстановленСрокОплаты"); + expect(String(dueDateCall?.query ?? "")).toContain("СрокОплаты"); + }); + it("adds a checked inventory-position family to business overview only as an as-of-date snapshot", async () => { const planner = planAssistantMcpDiscovery({ dataNeedGraph: { @@ -650,6 +819,7 @@ describe("assistant MCP discovery pilot executor", () => { { rows: [] }, { rows: [] }, { rows: [] }, + { rows: [] }, { rows: [ { Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" }, @@ -729,8 +899,8 @@ describe("assistant MCP discovery pilot executor", () => { expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_position_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_turnover_proxy_from_confirmed_rows"); expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_staleness_risk_proxy_from_confirmed_rows"); - expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(13); - const inventoryCall = deps.executeAddressMcpQuery.mock.calls[6]?.[0]; + expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14); + const inventoryCall = deps.executeAddressMcpQuery.mock.calls[7]?.[0]; expect(inventoryCall?.account_scope).toContain("41.01"); }); @@ -1335,7 +1505,7 @@ describe("assistant MCP discovery pilot executor", () => { unsupported_but_understood_family: "counterparty_payouts_or_outflow" } }); - const rows = Array.from({ length: 100 }, (_, index) => ({ + const rows = Array.from({ length: 200 }, (_, index) => ({ Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, Amount: 10, Counterparty: "SVK" @@ -1359,7 +1529,7 @@ describe("assistant MCP discovery pilot executor", () => { unsupported_but_understood_family: "counterparty_payouts_or_outflow" } }); - const broadRows = Array.from({ length: 100 }, (_, index) => ({ + const broadRows = Array.from({ length: 200 }, (_, index) => ({ Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, Amount: 10, Counterparty: "SVK" @@ -1560,7 +1730,7 @@ describe("assistant MCP discovery pilot executor", () => { unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" } }); - const outgoingBroadRows = Array.from({ length: 100 }, (_, index) => ({ + const outgoingBroadRows = Array.from({ length: 200 }, (_, index) => ({ Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`, Amount: 10, Counterparty: "SVK" diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index ade614a..52a0737 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -44,6 +44,177 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("primitive"); }); + it("keeps inventory reserve boundary answers direct instead of compacting into a money overview", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "inventory_reserve_boundary", + unsupported_but_understood_family: "inventory_reserve_liquidation_boundary" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + period_scope: null, + incoming_customer_revenue: { + total_amount_human_ru: "157 192 981,43 руб.", + coverage_limited_by_probe_limit: true + }, + outgoing_supplier_payout: { + total_amount_human_ru: "35 439 044,74 руб.", + coverage_limited_by_probe_limit: true + }, + net_amount_human_ru: "121 753 936,69 руб.", + net_direction: "net_incoming" + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: + "\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432 \u043f\u043e\u0434 \u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434\u044b \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0438\u043c \u0434\u0430\u043d\u043d\u044b\u043c \u043d\u0435\u043b\u044c\u0437\u044f.", + confirmed_lines: ["Денежный обзор здесь не является ответом на складской резерв."], + inference_lines: [], + unknown_lines: [ + "\u0420\u0435\u0437\u0435\u0440\u0432\u044b, \u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0438 \u043b\u0438\u043a\u0432\u0438\u0434\u0430\u0446\u0438\u043e\u043d\u043d\u0430\u044f \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0441\u043a\u043b\u0430\u0434\u0430 \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u044b." + ], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain( + "\u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432" + ); + expect(candidate.reply_text).toContain("\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0443\u0436\u043d\u043e"); + expect(candidate.reply_text).not.toContain("157 192 981"); + expect(candidate.reply_text).not.toContain("лимит"); + }); + + it("keeps profit/margin boundary answers direct instead of compacting into a money overview", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "profit_margin_boundary", + unsupported_but_understood_family: "profit_margin_boundary" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + incoming_customer_revenue: { + total_amount_human_ru: "47 628 853,03 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "19 568 878,06 руб." + }, + net_amount_human_ru: "28 059 974,97 руб.", + net_direction: "net_incoming" + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: + "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финансовый результат.", + confirmed_lines: ["Денежный обзор здесь не является ответом на чистую прибыль."], + inference_lines: [], + unknown_lines: ["Для точного P&L нужны себестоимость, расходы и закрытие периода."], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("нельзя точно подтвердить чистую прибыль"); + expect(candidate.reply_text).toContain("P&L"); + expect(candidate.reply_text).toContain("себестоимости"); + expect(candidate.reply_text).not.toContain("47 628 853"); + }); + + it("keeps vendor-risk boundary answers direct instead of compacting into a money overview", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "vendor_risk_procurement_boundary", + unsupported_but_understood_family: "vendor_risk_procurement_boundary" + }, + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: ["data_need_graph_family_business_overview"] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + outgoing_supplier_payout: { + total_amount_human_ru: "19 568 878,06 руб." + }, + top_suppliers: [ + { + axis_value: "СБЕРБАНК, ПАО", + total_amount_human_ru: "6 653 022,52 руб." + } + ] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: ["Vendor-risk route не подключен."], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("риск зависимости"); + expect(candidate.reply_text).toContain("outgoing cash concentration proxy"); + expect(candidate.reply_text).toContain("банк/финансовая организация"); + expect(candidate.reply_text).toContain("не доказанная зависимость от обычного поставщика"); + expect(candidate.reply_text).not.toContain("крупнейший подтвержденный поставщик/получатель исходящих платежей: СБЕРБАНК"); + expect(candidate.reply_text).not.toContain("операционное нетто"); + }); + it("uses a compact direct answer for business-overview top year questions", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ @@ -114,13 +285,15 @@ describe("assistant MCP discovery response candidate", () => { }) ); - expect(candidate.reply_text).toContain("в доступном проверенном MCP-срезе"); + expect(candidate.reply_text).toContain("в доступном проверенном срезе 1С"); 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("лимит выборки MCP"); + expect(candidate.reply_text).toContain("проверка достигла лимита строк"); + expect(candidate.reply_text).not.toContain("лимит выборки MCP"); + expect(candidate.reply_text).not.toContain("MCP-срез"); expect(candidate.reply_text).not.toContain("Что подтверждено:"); expect(candidate.reply_text).not.toContain("Профиль операционной активности"); }); @@ -197,6 +370,76 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("Складской срез"); }); + it("does not present bank-like incoming leaders as ordinary client revenue", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + data_need_graph: { + business_fact_family: "business_overview", + ranking_need: null, + reason_codes: [ + "data_need_graph_family_business_overview", + "data_need_graph_business_overview_direct_money_answer" + ] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "business_overview_route_template_v1", + derived_business_overview: { + period_scope: "2020", + incoming_customer_revenue: { + total_amount_human_ru: "24 885 659,31 руб.", + coverage_limited_by_probe_limit: false + }, + outgoing_supplier_payout: { + total_amount_human_ru: "19 568 878,06 руб.", + coverage_limited_by_probe_limit: false + }, + net_amount_human_ru: "5 316 781,25 руб.", + net_direction: "net_incoming", + top_customers: [ + { + axis_value: "СБЕРБАНК, ПАО", + total_amount_human_ru: "12 792 194,31 руб.", + counterparty_role_hint: "bank_or_financial_institution" + }, + { + axis_value: "Группа СВК", + total_amount_human_ru: "12 093 465 руб.", + counterparty_role_hint: "ordinary_counterparty" + } + ], + top_suppliers: [], + yearly_breakdown: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Ограниченный бизнес-обзор.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + const firstLine = candidate.reply_text?.split("\n")[0] ?? ""; + expect(firstLine).toContain("крупнейший входящий денежный источник: СБЕРБАНК, ПАО"); + expect(firstLine).toContain("не называю это клиентской выручкой"); + expect(firstLine).toContain("крупнейший небанковский входящий контрагент: Группа СВК"); + expect(candidate.reply_text).not.toContain("крупнейший источник входящих денег: СБЕРБАНК"); + expect(candidate.reply_text).not.toContain("Самый крупный подтвержденный клиент"); + }); + it("mentions separate counterparty scope in company plus counterparty business summaries", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ @@ -607,7 +850,7 @@ describe("assistant MCP discovery response candidate", () => { requires_user_clarification: false, answer_draft: { answer_mode: "confirmed_with_bounded_inference", - headline: "РџРѕ данным 1РЎ найдены строки исходящих платежей/списаний; СЃСѓРјРјСѓ РјРѕР¶РЅРѕ называть только РІ рамках проверенного периода Рё найденных строк.", + headline: "По данным 1С найдены строки исходящих платежей/списаний; сумму можно называть только в рамках проверенного периода и найденных строк.", confirmed_lines: ["1C supplier-payout rows were found for counterparty SVK"], inference_lines: [ "Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit" @@ -661,7 +904,7 @@ describe("assistant MCP discovery response candidate", () => { requires_user_clarification: false, answer_draft: { answer_mode: "confirmed_with_bounded_inference", - headline: "РџРѕ данным 1РЎ найдены строки движений; ответ ограничен проверенным периодом Рё найденными строками.", + headline: "По данным 1С найдены строки движений; ответ ограничен проверенным периодом и найденными строками.", confirmed_lines: ["1C movement rows were found for counterparty SVK"], inference_lines: ["Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope"], unknown_lines: ["Full movement history outside the checked period is not proven by this MCP discovery pilot"], @@ -672,9 +915,9 @@ describe("assistant MCP discovery response candidate", () => { }) ); - expect(candidate.reply_text).toContain("Р’ 1РЎ найдены строки движений РїРѕ контрагенту SVK."); - expect(candidate.reply_text).toContain("Срез движений ограничен только подтвержденными строками движений"); - expect(candidate.reply_text).toContain("Полный исторический срез движений РІРЅРµ проверенного периода этим РїРѕРёСЃРєРѕРј РЅРµ подтвержден."); + expect(candidate.reply_text).toContain("В 1С найдены строки движений по контрагенту SVK."); + expect(candidate.reply_text).toContain("Срез движений ограничен только подтвержденными строками движений"); + expect(candidate.reply_text).toContain("Полный исторический срез движений вне проверенного периода этим поиском не подтвержден."); expect(candidate.reply_text).not.toContain("1C movement rows were found"); }); @@ -841,9 +1084,8 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).toContain( "\u0412 1\u0421 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u044b \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0432 \u0437\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u043e\u043c \u0441\u0440\u0435\u0437\u0435" ); - expect(candidate.reply_text).toContain( - "\u0417\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0439 \u043f\u0435\u0440\u0438\u043e\u0434 \u0443\u043f\u0435\u0440\u0441\u044f \u0432 \u043b\u0438\u043c\u0438\u0442 \u0441\u0442\u0440\u043e\u043a MCP" - ); + expect(candidate.reply_text).toContain("Запрошенный период достиг лимита строк"); + expect(candidate.reply_text).not.toContain("уперся в лимит строк MCP"); expect(candidate.reply_text).not.toContain( "\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0441\u043a\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0443\u0440\u0443" ); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index e6a3d0c..cf692a7 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -621,7 +621,7 @@ describe("assistant MCP discovery response policy", () => { answer_mode: "confirmed_with_bounded_inference", headline: "Рейтинг по контрагентам построен по подтвержденным строкам 1С.", confirmed_lines: [ - "Больше всего денег принёс контрагент СБЕРБАНК, ПАО по организации ООО Альтернатива Плюс за период 2020: 12 792 194,31 руб. по 9 строкам с суммой." + "Крупнейший входящий денежный источник — СБЕРБАНК, ПАО по организации ООО Альтернатива Плюс за период 2020: 12 792 194,31 руб. по 9 строкам с суммой. Это банк/финансовая организация; без назначения платежа или договора не считаю это обычным клиентом или выручкой." ], inference_lines: [ "Рейтинг по контрагентам по организации ООО Альтернатива Плюс за период 2020 рассчитан только по подтвержденным строкам 1С." @@ -639,6 +639,7 @@ describe("assistant MCP discovery response policy", () => { expect(result.decision).toBe("apply_candidate"); expect(result.reply_text).toContain("ООО Альтернатива Плюс"); expect(result.reply_text).toContain("2020"); + expect(result.reply_text).toContain("не считаю это обычным клиентом"); expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_aligned_factual_address_reply"); expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index 315b268..7784d13 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -37,6 +37,22 @@ function buildBidirectionalDeps( }; } +function buildSequentialDeps(results: Array<{ rows: Array>; error?: string | null }>) { + const executeAddressMcpQuery = vi.fn(async () => { + const next = results.shift() ?? { rows: [] }; + const rows = next.rows; + const error = next.error ?? null; + return { + fetched_rows: rows.length, + matched_rows: error ? 0 : rows.length, + raw_rows: rows, + rows: error ? [] : rows, + error + }; + }); + return { executeAddressMcpQuery }; +} + describe("assistant MCP discovery runtime bridge", () => { it("composes planner, pilot executor, and answer draft without wiring the hot runtime", async () => { const result = await runAssistantMcpDiscoveryRuntimeBridge({ @@ -150,8 +166,24 @@ describe("assistant MCP discovery runtime bridge", () => { expect(result.loop_state.catalog_chain_template_matches[0]).toBe("value_flow_ranking"); expect(result.loop_state.catalog_chain_template_alignment.alignment_status).toBe("selected_matches_top"); expect(result.loop_state.catalog_chain_template_alignment.selected_chain_matches_top).toBe(true); + expect(result.route_candidate).toMatchObject({ + schema_version: "assistant_mcp_route_candidate_v1", + candidate_status: "needs_user_scope", + selected_chain_id: "value_flow_ranking", + nearest_catalog_chain_template: "value_flow_ranking", + catalog_alignment_status: "selected_matches_top", + business_fact_family: "value_flow", + action_family: "turnover", + executable_now: false + }); + expect(result.route_candidate.missing_axes).toContain("organization"); + expect(result.route_candidate.provided_axes).toContain("aggregate_axis"); + expect(result.route_candidate.recommended_next_action).toBe( + "Ask the user for the missing scope axes before MCP execution." + ); expect(result.reason_codes).toContain("planner_selected_chain_matches_catalog_top"); expect(result.reason_codes).toContain("runtime_bridge_loop_state_awaiting_clarification"); + expect(result.reason_codes).toContain("runtime_bridge_route_candidate_needs_user_scope"); }); it("produces a bounded ranked value-flow answer when period and organization are known", async () => { @@ -192,6 +224,12 @@ describe("assistant MCP discovery runtime bridge", () => { axis_value: "СВК-А", total_amount: 2100 }); + expect(result.route_candidate).toMatchObject({ + candidate_status: "ready_for_reviewed_execution", + selected_chain_id: "value_flow_ranking", + executable_now: true, + enablement_reason: null + }); expect(result.answer_draft.confirmed_lines.join("\n")).toContain("СВК-А"); }); @@ -437,6 +475,288 @@ describe("assistant MCP discovery runtime bridge", () => { expect(userFacing).not.toContain("MCP discovery pilot"); }); + it("marks exact business-overview proof gaps as route enablement instead of reviewed execution", async () => { + const deps = buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Client A" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Supplier A" }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Widget A" }, + { Period: "2020-12-31T00:00:00", Amount: 50000, Quantity: 5, Item: "Widget B" } + ] + }, + { + rows: [ + { Period: "2020-01-10T00:00:00", Amount: 200000, Quantity: 8, Item: "Widget A" }, + { Period: "2020-11-01T00:00:00", Amount: 50000, Quantity: 2, Item: "Widget B" } + ] + }, + { + rows: [ + { Period: "2020-01-15T00:00:00", Registrator: "Purchase 1" }, + { Period: "2020-12-15T00:00:00", Registrator: "Purchase 2" } + ] + }, + { + rows: [ + { Period: "2020-03-01T00:00:00", Amount: 600000, Item: "Widget A", Counterparty: "Client A", AccountKt: "41.01" }, + { Period: "2020-02-01T00:00:00", Amount: 240000, Item: "Widget A", Counterparty: "Supplier A", AccountDt: "41.01" } + ] + } + ]); + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "inventory_reserve_boundary", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: [ + "no_raw_model_claims", + "no_unchecked_fact_totals", + "no_unchecked_business_health_claim" + ], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "inventory_reserve_boundary", + explicit_organization_scope: "OOO Alternative Plus", + explicit_date_scope: "2020" + }, + deps + }); + + expect(result.bridge_status).toBe("answer_draft_ready"); + expect(result.business_fact_answer_allowed).toBe(true); + expect(result.route_candidate).toMatchObject({ + candidate_status: "needs_route_enablement", + selected_chain_id: "business_overview", + business_fact_family: "business_overview", + action_family: "inventory_reserve_boundary", + executable_now: false + }); + expect(result.route_candidate.enablement_reason).toContain("inventory_reserve_liquidation_quality"); + expect(result.route_candidate.enablement_reason).toContain( + "reviewed_inventory_quality_route_with_reserves_writeoffs_obsolescence_and_liquidation_value" + ); + expect(result.route_candidate.forbidden_overclaim_flags).toContain( + "confirmed_obsolete_stock_reserve_writeoff_or_liquidation_value" + ); + expect(result.reason_codes).toContain("runtime_bridge_route_candidate_needs_route_enablement"); + }); + + it("promotes profit-margin boundary when accounting 90/91/99 proof is available", async () => { + const deps = buildSequentialDeps([ + { rows: [{ Period: "2020-01-15T00:00:00", Amount: 12012833.72, Counterparty: "Client A" }] }, + { rows: [{ Period: "2020-01-20T00:00:00", Amount: 6430415.34, Counterparty: "Supplier A" }] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Registrator: "ACC90_REVENUE_KT", Amount: 12012833.72 }, + { Period: "2020-12-31T00:00:00", Registrator: "ACC90_COST_DT", Amount: 6430415.34 }, + { Period: "2020-12-31T00:00:00", Registrator: "ACC90_SELLING_DT", Amount: 1715450.75 }, + { Period: "2020-12-31T00:00:00", Registrator: "ACC90_RESULT_TO_99_PROFIT", Amount: 3053486.79 }, + { Period: "2020-12-31T00:00:00", Registrator: "ACC90_RESULT_FROM_99_LOSS", Amount: 1188658.09 }, + { Period: "2020-12-31T00:00:00", Registrator: "ACC91_RESULT_FROM_99_LOSS", Amount: 9001644.55 }, + { Period: "2020-12-31T00:00:00", Registrator: "ACC84_TO99_LOSS_TRANSFER", Amount: 7136815.85 } + ] + }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [{ Period: "2020-01-15T00:00:00", Registrator: "Purchase 1" }] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] } + ]); + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "profit_margin_boundary", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "bounded_inference", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: [ + "no_raw_model_claims", + "no_unchecked_fact_totals", + "no_unchecked_business_health_claim" + ], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "profit_margin_boundary", + explicit_organization_scope: "OOO Alternative Plus", + explicit_date_scope: "2020" + }, + deps + }); + const overview = result.pilot.derived_business_overview; + const missingFamilies = overview?.missing_proof_families.map((item) => item.family) ?? []; + const userFacing = [ + result.answer_draft.headline, + ...result.answer_draft.confirmed_lines, + ...result.answer_draft.inference_lines, + ...result.answer_draft.unknown_lines, + ...result.answer_draft.limitation_lines, + result.answer_draft.next_step_line ?? "" + ].join("\n"); + + expect(result.bridge_status).toBe("answer_draft_ready"); + expect(result.business_fact_answer_allowed).toBe(true); + expect(result.route_candidate).toMatchObject({ + candidate_status: "ready_for_reviewed_execution", + selected_chain_id: "business_overview", + business_fact_family: "business_overview", + action_family: "profit_margin_boundary", + executable_now: true + }); + expect(overview?.accounting_financial_result).toMatchObject({ + period_scope: "2020", + final_result_amount: -7136815.85, + final_result_direction: "loss", + final_transfer_basis: "account_99_to_84_period_close", + period_close_rows_with_amount: 4 + }); + expect(overview?.accounting_financial_result?.net_margin_to_revenue_pct).toBe(-59.41); + expect(missingFamilies).not.toContain("accounting_profit_margin"); + expect(userFacing).toContain("90/91/99"); + expect(userFacing).toContain("90.01"); + expect(userFacing).toContain("7 136 815,85"); + expect(userFacing).not.toContain("operating-flow/trading-margin proxy"); + expect(result.reason_codes).toContain("pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows"); + expect(result.reason_codes).toContain("answer_contains_business_overview_accounting_financial_result"); + expect(result.reason_codes).toContain("runtime_bridge_route_candidate_ready_for_reviewed_execution"); + }); + + it("promotes debt due-date boundary after reviewed payment-term route returns a checked negative", async () => { + const deps = buildSequentialDeps([ + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { + rows: [ + { Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А" } + ] + }, + { + rows: [ + { + Period: "2020-12-31T00:00:00", + Amount: 100000, + Counterparty: "Клиент А", + Contract: "Договор А", + ДокументРасчетов: "Реализация товаров от 10.03.2020", + УстановленСрокОплаты: false, + СрокОплаты: 0 + } + ] + }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] }, + { rows: [] } + ]); + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: [], + business_fact_family: "business_overview", + action_family: "debt_due_date_boundary", + aggregation_need: null, + time_scope_need: "explicit_period", + comparison_need: null, + ranking_need: null, + proof_expectation: "due_date_aging", + clarification_gaps: [], + decomposition_candidates: [ + "collect_scoped_movements", + "aggregate_checked_amounts", + "fetch_supporting_documents", + "probe_coverage", + "explain_evidence_basis" + ], + forbidden_overclaim_flags: [ + "no_raw_model_claims", + "no_unchecked_overdue_claim" + ], + reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"] + }, + turnMeaning: { + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + }, + deps + }); + const userFacing = [ + result.answer_draft.headline, + ...result.answer_draft.confirmed_lines, + ...result.answer_draft.unknown_lines + ].join("\n"); + + expect(result.bridge_status).toBe("answer_draft_ready"); + expect(result.route_candidate).toMatchObject({ + candidate_status: "ready_for_reviewed_execution", + selected_chain_id: "business_overview", + business_fact_family: "business_overview", + action_family: "debt_due_date_boundary", + executable_now: true + }); + expect(result.pilot.derived_business_overview?.debt_due_date_aging).toMatchObject({ + evidence_status: "no_payment_terms_configured", + rows_without_payment_terms: 1, + overdue_rows: 0 + }); + expect(userFacing).toContain("срок оплаты не установлен"); + expect(userFacing).toContain("Подтвержденной просрочки"); + expect(result.reason_codes).toContain("runtime_bridge_route_candidate_ready_for_reviewed_execution"); + expect(result.reason_codes).toContain("answer_contains_business_overview_debt_due_date_aging_no_payment_terms_configured"); + }); + it("bridges selected-item inventory provenance templates through exact document evidence", async () => { const deps = buildDeps([ { @@ -711,5 +1031,15 @@ describe("assistant MCP discovery runtime bridge", () => { }); expect(result.loop_state.pending_axes).toEqual(["organization", "period"]); expect(result.loop_state.explicit_entity_candidates).toEqual([]); + expect(result.route_candidate).toMatchObject({ + candidate_status: "needs_user_scope", + selected_chain_id: "movement_evidence", + business_fact_family: "movement_evidence", + action_family: "list_movements", + proof_expectation: "clarification_required", + executable_now: false + }); + expect(result.route_candidate.missing_axes).toEqual(["organization", "period"]); + expect(result.route_candidate.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals"); }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index c916c1e..cade8de 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -1505,6 +1505,36 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); }); + it("forces discovery over an exact ranking intent when organization scope is missing", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u043a\u0430\u043a\u043e\u0439 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442 \u043f\u0440\u0438\u043d\u0435\u0441 \u0431\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u0437\u0430 2020 \u0433\u043e\u0434?", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + explicit_intent_candidate: "customer_revenue_and_payments", + explicit_entity_candidates: [{ type: "counterparty", value: "\u0433\u043e\u0434\u0443", source: "current_turn_loose_entity_tail" }], + explicit_date_scope: "2020", + stale_replay_forbidden: false + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("counterparty value-flow evidence"); + expect(result.data_need_graph?.business_fact_family).toBe("value_flow"); + expect(result.data_need_graph?.ranking_need).toBe("top_desc"); + expect(result.data_need_graph?.clarification_gaps).toEqual(["organization"]); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "counterparty_value_or_turnover", + explicit_date_scope: "2020", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + it("routes broad business evaluation into business overview discovery without metadata drift", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: @@ -1536,6 +1566,46 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); }); + it("keeps inventory reserve questions as a bounded boundary check instead of generic overview", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u041c\u043e\u0436\u043d\u043e \u043b\u0438 \u043f\u043e \u044d\u0442\u0438\u043c \u0434\u0430\u043d\u043d\u044b\u043c \u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432 \u043f\u043e\u0434 \u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434\u044b \u043d\u0430 \u0441\u043a\u043b\u0430\u0434\u0435?", + assistantTurnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName, + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.subject_candidates).toEqual([]); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "inventory_reserve_boundary", + explicit_organization_scope: orgName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "inventory_reserve_liquidation_boundary", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); + expect(result.reason_codes).toContain("mcp_discovery_data_need_graph_built"); + }); + it("lets raw business-overview wording override stale exact turnover meaning", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: @@ -2170,10 +2240,10 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); expect(result.turn_meaning_ref).toMatchObject({ asked_domain_family: "business_overview", - asked_action_family: "broad_evaluation", + asked_action_family: "profit_margin_boundary", explicit_organization_scope: orgName, explicit_date_scope: "2020", - unsupported_but_understood_family: "broad_business_evaluation", + unsupported_but_understood_family: "profit_margin_boundary", stale_replay_forbidden: true }); expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); @@ -2181,6 +2251,23 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.clarification_gaps).toEqual([]); }); + it("routes legal-entity profit and margin wording to the missing P&L proof-family boundary", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "по ООО Альтернатива Плюс за 2020 можно точно сказать чистую прибыль и маржу?" + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "profit_margin_boundary", + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "profit_margin_boundary" + }); + }); + it("routes organization-level overdue debt wording to business overview instead of exact receivables recipes", () => { const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; const result = buildAssistantMcpDiscoveryTurnInput({ @@ -2201,10 +2288,10 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); expect(result.turn_meaning_ref).toMatchObject({ asked_domain_family: "business_overview", - asked_action_family: "broad_evaluation", + asked_action_family: "debt_due_date_boundary", explicit_organization_scope: orgName, explicit_date_scope: "2020", - unsupported_but_understood_family: "broad_business_evaluation", + unsupported_but_understood_family: "debt_due_date_boundary", stale_replay_forbidden: true }); expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); @@ -2232,10 +2319,10 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); expect(result.turn_meaning_ref).toMatchObject({ asked_domain_family: "business_overview", - asked_action_family: "broad_evaluation", + asked_action_family: "inventory_reserve_boundary", explicit_organization_scope: orgName, explicit_date_scope: "2020", - unsupported_but_understood_family: "broad_business_evaluation", + unsupported_but_understood_family: "inventory_reserve_liquidation_boundary", stale_replay_forbidden: true }); expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); @@ -2263,10 +2350,10 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); expect(result.turn_meaning_ref).toMatchObject({ asked_domain_family: "business_overview", - asked_action_family: "broad_evaluation", + asked_action_family: "vendor_risk_procurement_boundary", explicit_organization_scope: orgName, explicit_date_scope: "2020", - unsupported_but_understood_family: "broad_business_evaluation", + unsupported_but_understood_family: "vendor_risk_procurement_boundary", stale_replay_forbidden: true }); expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate"); @@ -2708,6 +2795,42 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.data_need_graph?.clarification_gaps).toEqual(["period"]); }); + it("routes explicit VAT movement wording to metadata-scoped movement evidence over exact VAT aggregate", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f \u043f\u043e \u041d\u0414\u0421 \u0437\u0430 2020 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + assistantTurnMeaning: { + explicit_intent_candidate: "vat_liability_confirmed_for_tax_period" + }, + predecomposeContract: { + entities: { organization: orgName }, + period: { + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("movement evidence"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "movements", + asked_action_family: "list_movements", + metadata_scope_hint: "\u041d\u0414\u0421", + subject_resolution_optional: true, + explicit_organization_scope: orgName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "movement_evidence", + stale_replay_forbidden: true + }); + expect(result.data_need_graph?.business_fact_family).toBe("movement_evidence"); + expect(result.data_need_graph?.clarification_gaps).toEqual([]); + expect(result.reason_codes).toContain("mcp_discovery_vat_movement_evidence_signal_detected"); + }); + it("pivots a metadata-scoped subjectless movement retrieval into documents without inventing a counterparty", () => { const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; @@ -2867,6 +2990,62 @@ describe("assistant MCP discovery turn input adapter", () => { }); }); + it("prefers semantic organization over noisy raw organization tail for debt due-date overview", () => { + const orgName = "ООО Альтернатива Плюс"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "по ООО Альтернатива Плюс на конец 2020 можно точно понять, какая дебиторка просрочена?", + predecomposeContract: { + entities: { counterparty: orgName, organization: orgName }, + period: { period_from: "2020-01-01", period_to: "2020-12-31" } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + explicit_organization_scope: orgName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "debt_due_date_boundary", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + }); + + it("routes debt due-date boundary follow-up through the business overview lane", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0442\u043e \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0441\u0440\u043e\u0447\u043a\u0443 \u0434\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f, \u043a\u043e\u0440\u043e\u0442\u043a\u043e \u043f\u043e\u0447\u0435\u043c\u0443?", + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName, + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + explicit_organization_scope: orgName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "debt_due_date_boundary", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context"); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_debt_due_date_followup_boundary"); + }); + it("lets raw metadata scope override stale document evidence subject on topic switch", () => { const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; @@ -2946,6 +3125,44 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context"); }); + it("keeps short profit/loss follow-up on the accounting profit-margin boundary", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0430 \u044d\u0442\u043e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u0443\u0431\u044b\u0442\u043e\u043a, \u043a\u043e\u0440\u043e\u0442\u043a\u043e?", + assistantTurnMeaning: { + asked_domain_family: "unknown", + asked_action_family: "unknown" + }, + followupContext: { + previous_discovery_pilot_scope: "business_overview_route_template_v1", + previous_filters: { + organization: orgName, + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.data_need_graph?.subject_candidates).toEqual([]); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "profit_margin_boundary", + explicit_organization_scope: orgName, + explicit_date_scope: "2020", + unsupported_but_understood_family: "profit_margin_boundary", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context"); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_profit_margin_followup_boundary"); + }); + it("keeps detailed money-breakdown follow-up in business overview without pseudo counterparty anchors", () => { const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index 99d1bea..3c51aba 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -557,6 +557,45 @@ describe("assistantRoutePolicy", () => { expect(decision.orchestrationContract?.followup_context_detected).toBe(false); }); + it("keeps plain organization clarification in address lane for pending route-candidate scope", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const policy = buildPolicy({ + resolveAddressToolGateDecision: undefined + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: orgName, + effectiveAddressUserMessage: orgName, + followupContext: { + previous_intent: "customer_revenue_and_payments", + target_intent: "customer_revenue_and_payments", + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_discovery_loop_status: "awaiting_clarification", + previous_discovery_loop_selected_chain_id: "value_flow_ranking", + previous_discovery_loop_pending_axes: ["organization"], + previous_discovery_loop_asked_domain_family: "counterparty_value", + previous_discovery_loop_asked_action_family: "turnover", + previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover" + }, + llmPreDecomposeMeta: { + predecomposeContract: { + mode: "unsupported", + intent: "unknown", + entities: { organization: orgName }, + semantics: { anchor_kind: "organization", anchor_value: orgName } + } + }, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateReason).toBe("followup_context_detected"); + expect(decision.livingMode).toBe("address_data"); + expect(decision.livingReason).toBe("address_lane_triggered"); + expect(decision.orchestrationContract?.hard_meta_mode).toBeNull(); + }); + it("does not turn short entity follow-up into organization switch just because scope already has an active company", () => { const policy = buildPolicy({ resolveAddressToolGateDecision: undefined, diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 8add1cb..9cf81b7 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1206,6 +1206,67 @@ describe("assistantTransitionPolicy", () => { period_to: "2020-12-31" }); }); + + it("carries business overview boundary context through short capability-shaped follow-up", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => null, + shouldHandleAsAssistantCapabilityMetaQuery: () => true, + hasDataRetrievalRequestSignal: () => false, + hasAddressFollowupContextSignal: () => false + }); + const organization = "ООО Альтернатива Плюс"; + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "то есть просрочку доказать нельзя, коротко почему?", + [ + { + role: "assistant", + text: "На 2020-12-31 подтвержденной просрочки нет: в договорах срок оплаты не установлен.", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_active_organization: organization, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "debt_due_date_boundary", + unsupported_but_understood_family: "debt_due_date_boundary", + explicit_organization_scope: organization, + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + pilot: { + pilot_scope: "business_overview_route_template_v1" + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + } + } + ], + null, + null, + null + ); + + expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); + expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe( + "business_overview_route_template_v1" + ); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + organization, + period_from: "2020-01-01", + period_to: "2020-12-31" + }); + }); it("carries resolved entity candidates from grounded entity-resolution discovery into followup context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null, @@ -1506,6 +1567,67 @@ describe("assistantTransitionPolicy", () => { ); }); + it("treats a plain organization reply as continuation of a pending route-candidate organization scope", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + role: "assistant", + text: "\u041d\u0443\u0436\u043d\u043e \u0443\u0442\u043e\u0447\u043d\u0438\u0442\u044c \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e.", + debug: { + execution_lane: "living_chat", + detected_intent: "customer_revenue_and_payments", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + bridge: { + bridge_status: "needs_clarification", + business_fact_answer_allowed: false, + pilot: { + pilot_scope: "counterparty_value_flow_query_movements_v1" + }, + loop_state: { + schema_version: "assistant_mcp_discovery_loop_state_v1", + loop_status: "awaiting_clarification", + selected_chain_id: "value_flow_ranking", + pilot_scope: "counterparty_value_flow_query_movements_v1", + asked_domain_family: "counterparty_value", + asked_action_family: "turnover", + unsupported_but_understood_family: "counterparty_value_or_turnover", + ranking_need: "top_desc", + pending_axes: ["organization"], + provided_axes: ["aggregate_axis", "amount", "coverage_target"], + explicit_date_scope: "2020" + } + } + } + } + }), + hasAddressFollowupContextSignal: () => false + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + orgName, + [], + null, + { + predecomposeContract: { + mode: "unsupported", + intent: "unknown", + entities: { organization: orgName }, + semantics: { anchor_kind: "organization", anchor_value: orgName } + } + }, + null + ); + + expect(carryover?.followupContext?.previous_discovery_loop_status).toBe("awaiting_clarification"); + expect(carryover?.followupContext?.previous_discovery_loop_selected_chain_id).toBe("value_flow_ranking"); + expect(carryover?.followupContext?.previous_discovery_loop_pending_axes).toEqual(["organization"]); + expect(carryover?.followupContext?.target_intent).toBe("customer_revenue_and_payments"); + }); + it("carries grounded metadata downstream route hints into followup context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null, diff --git a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts index a7d700d..772142d 100644 --- a/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts +++ b/llm_normalizer/backend/tests/counterpartyAnalyticsReplyBuilders.test.ts @@ -114,6 +114,40 @@ describe("counterparty analytics reply builders", () => { expect(reply.text).not.toContain("Топ-"); }); + it("does not mistake a year period in top-counterparty wording for a top-year question", () => { + const reply = composeFactualReply( + "customer_revenue_and_payments", + [ + { + period: "2020-03-01T00:00:00Z", + registrator: "Поступление 1", + account_dt: "", + account_kt: "", + amount: 500, + analytics: ["Клиент А", "Договор А-1"] + }, + { + period: "2020-03-02T00:00:00Z", + registrator: "Поступление 2", + account_dt: "", + account_kt: "", + amount: 1200, + analytics: ["Клиент Б", "Договор Б-1"] + } + ], + { + userMessage: "какой контрагент принес больше всего денег за 2020 год?", + periodFrom: "2020-01-01", + periodTo: "2020-12-31" + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Клиент Б"); + expect(reply.text).not.toContain("Клиент А"); + expect(reply.text).not.toContain("1. 2020 |"); + }); + it("explains organization activity age as 1C activity rather than legal age", () => { const reply = composeFactualReply( "counterparty_activity_lifecycle",