diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index f295d39..f0ec680 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -84,6 +84,12 @@ "expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"], "expected_result_modes": ["heuristic_candidates", "confirmed_balance"] }, + { + "intent": "open_items_by_counterparty_or_contract", + "expected_selected_recipes": ["address_open_items_by_party_or_contract_v1"], + "expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"], + "expected_result_modes": ["heuristic_candidates", "confirmed_balance"] + }, { "intent": "open_contracts_confirmed_as_of_date", "expected_selected_recipes": ["address_open_contracts_confirmed_as_of_date_v1"], diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index fdadc79..7be419b 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -356,6 +356,12 @@ function extractMonthPeriod(text) { } return {}; } +function isExactHistoricalPeriodWindow(filters) { + return (typeof filters.period_from === "string" && + filters.period_from.trim().length > 0 && + typeof filters.period_to === "string" && + filters.period_to.trim().length > 0); +} function extractPeriodRange(text) { const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2); if (!directMatch) { @@ -710,6 +716,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue) { if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { return true; } + if (meaningfulNonGenericTokens.length === 0) { + return true; + } const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); return meaningfulTokens.length === 0; } @@ -1407,6 +1416,9 @@ function resolveSemanticDateBasisHint(filters, warnings) { const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0; const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0; const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0; + if (warnings.includes("as_of_date_derived_from_exact_historical_period") && (hasPeriodFrom || hasPeriodTo)) { + return hasPeriodFrom && hasPeriodTo ? "period_range" : "period_end"; + } if (hasPeriodFrom && hasPeriodTo) { return "period_range"; } @@ -1670,6 +1682,14 @@ function extractAddressFilters(userMessage, intent) { warnings.push("period_derived_from_year_phrase"); } } + if (isExactHistoricalPeriodWindow(filters) && !warnings.includes("exact_historical_period_window_requested")) { + const derivedFromHistoricalPhrase = warnings.includes("period_derived_from_month_phrase") || + warnings.includes("period_derived_from_year_range_phrase") || + warnings.includes("period_derived_from_year_phrase"); + if (derivedFromHistoricalPhrase) { + warnings.push("exact_historical_period_window_requested"); + } + } const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate; if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) { const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate); @@ -1685,10 +1705,12 @@ function extractAddressFilters(userMessage, intent) { } } const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase"); + const yearPeriodWasDerived = warnings.includes("period_derived_from_year_phrase") || warnings.includes("period_derived_from_year_range_phrase"); if (intent === "vat_liability_confirmed_for_tax_period" && !periodRange.period_from && !periodRange.period_to && - !monthPeriodWasDerived) { + !monthPeriodWasDerived && + !yearPeriodWasDerived) { const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; if (periodToForQuarter) { const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); @@ -1711,7 +1733,12 @@ 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 = intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; + 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")) { + warnings.push("exact_historical_period_window_requested"); + } if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) { delete filters.period_from; delete filters.period_to; @@ -1738,6 +1765,14 @@ function extractAddressFilters(userMessage, intent) { if (filters.period_to) { filters.as_of_date = filters.period_to; warnings.push("as_of_date_derived_from_period_to"); + if (warnings.includes("period_derived_from_month_phrase") || + warnings.includes("period_derived_from_year_range_phrase") || + warnings.includes("period_derived_from_year_phrase")) { + warnings.push("as_of_date_derived_from_exact_historical_period"); + if (!warnings.includes("exact_historical_period_window_requested")) { + warnings.push("exact_historical_period_window_requested"); + } + } } else if (shouldDefaultAsOfDateToToday(intent)) { filters.as_of_date = new Date().toISOString().slice(0, 10); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 3550950..5c10bf8 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1724,6 +1724,10 @@ function resolveUnicodeAddressIntentBridge(text) { ]).has(byAnchorToken); const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized); const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized); + const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized); + if (hasInventoryPurchaseToSaleDocumentChainCue) { + return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"); + } const hasSelectedObjectProfitabilityCue = /(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test(normalized) && (/(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|profit|margin)/iu.test(normalized) || (/(?:\u043f\u0440\u043e\u0434\u0430\u0436|sale)/iu.test(normalized) && @@ -1731,10 +1735,6 @@ function resolveUnicodeAddressIntentBridge(text) { if (hasSelectedObjectProfitabilityCue) { return unicodeBridgeResolution("inventory_profitability_for_item", "high", "unicode_selected_object_profitability_bridge_signal_detected"); } - const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized); - if (hasInventoryPurchaseToSaleDocumentChainCue) { - return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"); - } const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) && /(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized); if (hasOpenItemsAccountCue) { diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 4cb9c1f..49c54bd 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1769,7 +1769,7 @@ function enforceStrictAccountScopeForIntent(plan, intent) { account_scope_mode: "strict" }; } -function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) { +function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate, warnings = []) { const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date); const periodTo = normalizeAnalysisDateHint(filters.period_to); const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null; @@ -1779,8 +1779,10 @@ function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) { if (derivedAsOf) { executionFilters.as_of_date = derivedAsOf; } - delete executionFilters.period_from; - delete executionFilters.period_to; + if (!warnings.includes("as_of_date_derived_from_exact_historical_period")) { + delete executionFilters.period_from; + delete executionFilters.period_to; + } const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit) ? Math.max(1, Math.trunc(executionFilters.limit)) : null; @@ -1952,6 +1954,9 @@ function asksForUnresolvedInventorySupplierLink(userMessage) { return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? "")); } function canAutoBroadenPeriodWindow(intent, filters) { + if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) { + return false; + } const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) && typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 && @@ -3001,16 +3006,16 @@ class AddressQueryService { const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const confirmedBalanceInventoryIntent = intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance"; const payablesConfirmedExecution = confirmedBalancePayablesIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; const inventoryConfirmedExecution = confirmedBalanceInventoryIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; let executionFilters = inventoryConfirmedExecution?.executionFilters ?? payablesConfirmedExecution?.executionFilters ?? @@ -4219,6 +4224,7 @@ class AddressQueryService { !counterpartyItemFlowQuery && isDocumentOrBankAnchorIntent(intent.intent) && !hasExplicitPeriodWindow(filters.extracted_filters) && + !filters.warnings.some((warning) => warning.startsWith("period_derived_from_")) && (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) { const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) ? Math.max(1, Math.trunc(filters.extracted_filters.limit)) diff --git a/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js b/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js index 991cdda..51f0a79 100644 --- a/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js +++ b/llm_normalizer/backend/dist/services/addressTruthGatePolicy.js @@ -119,6 +119,10 @@ function truthGateStatusFrom(input) { return input.truthGateStatusHint; } const missingRequiredFilters = input.missingRequiredFilters ?? []; + const reasonCodes = input.reasons ?? []; + const heuristicOpenItemsFallback = Boolean(input.intent === "open_items_by_counterparty_or_contract" && + (reasonCodes.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates") || + reasonCodes.includes("open_items_account_query_override_to_movements"))); if (input.routeExpectationStatus === "mismatch") { return "blocked_route_expectation_failure"; } @@ -134,6 +138,9 @@ function truthGateStatusFrom(input) { if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") { return "full_confirmed"; } + if (heuristicOpenItemsFallback) { + return "partial_supported"; + } if (input.limitedReasonCategory === "empty_match" || input.limitedReasonCategory === "recipe_visibility_gap" || input.limitedReasonCategory === "unsupported" || diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index cc048c4..5ccce18 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -3281,11 +3281,22 @@ function composeFactualReplyBody(intent, rows, options = {}) { } if (intent === "open_items_by_counterparty_or_contract") { const counterparties = buildCounterpartyRiskAggregate(rows); - const accountLead = typeof options.accountHint === "string" && options.accountHint.trim().length > 0 - ? `Проверил хвосты по счету ${options.accountHint.trim()}.` - : "Собраны открытые позиции по взаиморасчетам."; + const accountLabel = typeof options.accountHint === "string" && options.accountHint.trim().length > 0 + ? `по счету ${options.accountHint.trim()}` + : "по взаиморасчетам"; + const exactBalanceRequested = options.requestedResultMode === "confirmed_balance"; + const periodLabel = options.asOfDate + ? `на ${formatDateRu(options.asOfDate)}` + : options.periodFrom || options.periodTo + ? `за период ${formatDateRu(options.periodFrom ?? "...")}..${formatDateRu(options.periodTo ?? "...")}` + : null; const lines = [ - accountLead, + exactBalanceRequested + ? `Коротко: точный открытый остаток ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} не подтвержден; ниже только предварительные сигналы по движениям: ${formatNumberWithDots(rows.length)} строк, контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.` + : `Коротко: ${accountLabel} найдено ${formatNumberWithDots(rows.length)} строк хвостов/открытых расчетов; контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`, + exactBalanceRequested + ? "Это не подтвержденное сальдо и не финальный реестр открытых расчетов: текущий контур видит движения-кандидаты, но не доказывает остаток закрытия." + : "Это shortlist для проверки, а не финальный подтвержденный реестр открытых расчетов.", `Строк отобрано: ${rows.length}.`, `Контрагентов с сигналом: ${counterparties.length}.` ]; @@ -3301,7 +3312,12 @@ function composeFactualReplyBody(intent, rows, options = {}) { } return { responseType: "FACTUAL_LIST", - text: lines.join("\n") + text: lines.join("\n"), + semantics: { + result_mode: "heuristic_candidates", + evidence_strength: counterparties.length > 0 || rows.length > 0 ? "medium" : "weak", + balance_confirmed: false + } }; } if (intent === "list_contracts_by_counterparty") { @@ -3366,7 +3382,7 @@ function composeFactualReplyBody(intent, rows, options = {}) { ? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.` : `Найдено документов по контрагенту: ${rows.length}.`); } - if (counterpartyLabel) { + if (counterpartyLabel && itemFlowQuestion) { lines.push(`Контрагент: ${counterpartyLabel}`); } if (itemFlowQuestion) { @@ -3388,7 +3404,11 @@ function composeFactualReplyBody(intent, rows, options = {}) { } } else { - lines.push(...formatTopRows(rows, rows.length)); + const visibleRows = rows.slice(0, 5); + lines.push(...formatTopRows(visibleRows, visibleRows.length)); + if (rows.length > visibleRows.length) { + lines.push(`Показаны первые ${visibleRows.length} из ${rows.length} документов; полный список остается в подтвержденном срезе.`); + } } return { responseType: "FACTUAL_LIST", diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 9bc7f77..ddeb6a8 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -165,11 +165,18 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([ "сейчас", "этому", "этомуже", + "этой", + "этойже", "тому", "томуже", + "той", + "тойже", "нему", "ней", "ним", + "цепочка", + "цепочке", + "цепочку", "неуказанному", "неуказанный", "неуказанная", diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index de477e4..35986ba 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -93,11 +93,16 @@ function composeInventoryReply(intent, rows, options, deps) { : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; const lines = [directAnswerLine]; if (positions.length > 0) { - (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", positions.slice(0, 20).map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, { + const visiblePositionsLimit = 6; + const visiblePositions = positions.slice(0, visiblePositionsLimit); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", visiblePositions.map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, { formatDateRu: deps.formatDateRu, formatNumberWithDots: deps.formatNumberWithDots, formatMoneyRub: deps.formatMoneyRub }))); + if (positions.length > visiblePositions.length) { + lines.push(`Показаны первые ${deps.formatNumberWithDots(visiblePositions.length)} из ${deps.formatNumberWithDots(positions.length)} позиций по сумме; полный список можно раскрыть отдельным запросом.`); + } } else { (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", [ diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index 6154782..c040a77 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -190,6 +190,7 @@ async function runAssistantLivingChatRuntime(input) { organization: scopedOrganization, addressDebug: lastMemoryAddressDebug, sessionItems: input.sessionItems, + userMessage, toNonEmptyString: input.toNonEmptyString }); activeOrganization = scopedOrganization ?? activeOrganization; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js index 5877889..fb377c6 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryDataNeedGraph.js @@ -121,7 +121,7 @@ function timeScopeNeedFor(input) { if (input.explicitDateScope) { return "explicit_period"; } - if (input.allTimeScopeHint && + if ((input.allTimeScopeHint || input.subjectScopedBidirectionalAllTime) && (input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence")) { return "all_time_scope"; } @@ -396,6 +396,10 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const comparisonNeed = comparisonNeedFor(action); const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); + const subjectScopedBidirectionalAllTime = businessFactFamily === "value_flow" && + comparisonNeed === "incoming_vs_outgoing" && + subjectCandidates.length > 0 && + !explicitDateScope; const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({ family: businessFactFamily, rawUtterance, @@ -449,7 +453,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { const timeScopeNeed = timeScopeNeedFor({ family: businessFactFamily, explicitDateScope, - allTimeScopeHint + allTimeScopeHint, + subjectScopedBidirectionalAllTime }); if (timeScopeNeed === "period_required" && !explicitDateScope) { pushUnique(clarificationGaps, "period"); @@ -492,6 +497,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) { if (allTimeScopeHint) { pushReason(reasonCodes, "data_need_graph_all_time_scope_hint"); } + if (subjectScopedBidirectionalAllTime) { + pushReason(reasonCodes, "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"); + } if (businessFactFamily === "business_overview" && !explicitDateScope) { pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js index 00dd209..5e4fadd 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryPolicy.js @@ -80,6 +80,7 @@ function normalizeTurnMeaning(value) { const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); const entities = toStringList(value.explicit_entity_candidates); + const businessOverviewSeparateEntities = toStringList(value.business_overview_separate_entity_candidates); const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets); if (domain) { result.asked_domain_family = domain; @@ -96,6 +97,9 @@ function normalizeTurnMeaning(value) { if (entities.length > 0) { result.explicit_entity_candidates = entities; } + if (businessOverviewSeparateEntities.length > 0) { + result.business_overview_separate_entity_candidates = businessOverviewSeparateEntities; + } if (metadataAmbiguityEntitySets.length > 0) { result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets; } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 6b5b1ae..3054d06 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -365,18 +365,230 @@ function businessOverviewYearRowsLine(overview) { const joined = values.join("; "); return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null; } +function firstOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") { + const first = toRecordObject(Array.isArray(rows) ? rows[0] : null); + const label = toNonEmptyString(first?.axis_value); + const amount = moneyText(first?.[amountKey]); + return label && amount ? `${label} — ${sentenceAmount(amount) ?? amount}` : null; +} +function businessOverviewTaxLine(overview) { + const tax = toRecordObject(overview.tax_position); + if (!tax) { + return null; + } + const salesVat = moneyText(tax.sales_vat_amount_human_ru); + const purchaseVat = moneyText(tax.purchase_vat_amount_human_ru); + const netVat = moneyText(tax.net_vat_amount_human_ru); + if (!salesVat && !purchaseVat && !netVat) { + return null; + } + const direction = tax.net_vat_direction === "vat_to_pay" + ? "НДС к уплате" + : tax.net_vat_direction === "vat_to_recover_or_offset" + ? "НДС к возмещению/зачету" + : "чистая НДС-позиция"; + return `НДС: продажи ${salesVat ?? "0 руб."}, покупки ${purchaseVat ?? "0 руб."}, ${direction} ${sentenceAmount(netVat) ?? netVat ?? "0 руб."}.`; +} +function businessOverviewDebtLine(overview) { + const debt = toRecordObject(overview.debt_position); + if (!debt) { + return null; + } + const receivables = moneyText(toRecordObject(debt.receivables)?.total_amount_human_ru); + const payables = moneyText(toRecordObject(debt.payables)?.total_amount_human_ru); + const net = moneyText(debt.net_debt_position_amount_human_ru); + if (!receivables && !payables && !net) { + return null; + } + const direction = debt.net_debt_position_direction === "net_payable" ? "кредиторка больше дебиторки" : "дебиторка больше кредиторки"; + return `Долги: дебиторка ${receivables ?? "0 руб."}, кредиторка ${payables ?? "0 руб."}, нетто ${sentenceAmount(net) ?? net ?? "0 руб."} (${direction}).`; +} +function businessOverviewInventoryLine(overview) { + const inventory = toRecordObject(overview.inventory_position); + if (!inventory) { + return null; + } + const amount = moneyText(inventory.total_amount_human_ru); + const rows = Number(inventory.rows_matched); + const quantity = Number(inventory.total_quantity); + if (!amount && !Number.isFinite(rows)) { + return null; + } + const pieces = [ + Number.isFinite(rows) ? `${rows} позиций` : null, + amount ? `на ${sentenceAmount(amount) ?? amount}` : null, + Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null + ].filter((item) => Boolean(item)); + return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null; +} +function rowCountText(value) { + const count = Number(value); + return Number.isFinite(count) ? String(count) : null; +} +function sideRowsText(side) { + const rowsWithAmount = rowCountText(side?.rows_with_amount); + const rowsMatched = rowCountText(side?.rows_matched); + if (rowsWithAmount && rowsMatched) { + return `${rowsWithAmount} из ${rowsMatched}`; + } + return rowsWithAmount ?? rowsMatched; +} +function sideDateText(side) { + const first = toNonEmptyString(side?.first_movement_date); + const latest = toNonEmptyString(side?.latest_movement_date); + if (first && latest) { + return first === latest ? `дата ${first}` : `даты ${first}..${latest}`; + } + return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null; +} +function bidirectionalNetLabel(direction) { + if (direction === "net_outgoing") { + return "нетто в сторону контрагента"; + } + if (direction === "balanced") { + return "нетто около нуля"; + } + return "нетто в нашу сторону"; +} +function buildCompactBidirectionalValueFlowReply(entryPoint, draft) { + const bridge = toRecordObject(entryPoint.bridge); + const pilot = toRecordObject(bridge?.pilot); + const flow = toRecordObject(pilot?.derived_bidirectional_value_flow); + if (!flow) { + return null; + } + const incoming = toRecordObject(flow.incoming_customer_revenue); + const outgoing = toRecordObject(flow.outgoing_supplier_payout); + const incomingAmount = moneyText(incoming?.total_amount_human_ru); + const outgoingAmount = moneyText(outgoing?.total_amount_human_ru); + const netAmount = moneyText(flow.net_amount_human_ru); + if (!incomingAmount && !outgoingAmount && !netAmount) { + return null; + } + const counterparty = toNonEmptyString(flow.counterparty) ?? "запрошенному контрагенту"; + const period = toNonEmptyString(flow.period_scope); + const periodText = period ? ` за период ${period}` : " в проверенном окне"; + const incomingRows = sideRowsText(incoming); + const outgoingRows = sideRowsText(outgoing); + const incomingDates = sideDateText(incoming); + const outgoingDates = sideDateText(outgoing); + const netLabel = bidirectionalNetLabel(flow.net_direction); + const lines = [ + `Коротко: по контрагенту ${counterparty}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` + ]; + const basis = []; + if (incomingRows) { + basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`); + } + if (outgoingRows) { + basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`); + } + if (basis.length > 0) { + lines.push(`Основа: ${basis.join("; ")}.`); + } + if (flow.coverage_limited_by_probe_limit === true) { + lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); + } + lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна."); + const fallbackNextStep = toNonEmptyString(draft.next_step_line); + if (fallbackNextStep) { + lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; +} +function compactComparable(value) { + return String(value ?? "") + .toLowerCase() + .replace(/[«»"']/g, "") + .replace(/\s+/g, " ") + .trim(); +} +function businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope) { + const candidates = uniqueStrings([ + ...toStringList(turnMeaning?.business_overview_separate_entity_candidates), + ...toStringList(graph?.subject_candidates), + ...toStringList(turnMeaning?.explicit_entity_candidates) + ]); + const organizationComparable = compactComparable(organizationScope); + for (const candidate of candidates) { + const text = toNonEmptyString(candidate); + if (!text) { + continue; + } + const comparable = compactComparable(text); + if (organizationComparable && comparable === organizationComparable) { + continue; + } + return text; + } + return null; +} +function sameBusinessSubject(left, right) { + const leftComparable = compactComparable(left); + const rightComparable = compactComparable(right); + return Boolean(leftComparable && rightComparable && leftComparable === rightComparable); +} +function previousDocumentSummaryLine(bundle, separateSubject) { + if (!bundle || !sameBusinessSubject(toNonEmptyString(bundle.counterparty), separateSubject)) { + return null; + } + const count = Number(bundle.document_count); + if (!Number.isFinite(count) || count <= 0) { + return null; + } + return `документы по цепочке: найдено ${count}`; +} +function buildPreviousCounterpartyValueFlowSummary(flow, separateSubject, documentBundle) { + if (!flow || !separateSubject || !sameBusinessSubject(toNonEmptyString(flow.counterparty), separateSubject)) { + return null; + } + const incoming = toRecordObject(flow.incoming_customer_revenue); + const outgoing = toRecordObject(flow.outgoing_supplier_payout); + const incomingAmount = moneyText(incoming?.total_amount_human_ru); + const outgoingAmount = moneyText(outgoing?.total_amount_human_ru); + const netAmount = moneyText(flow.net_amount_human_ru); + if (!incomingAmount && !outgoingAmount && !netAmount) { + return null; + } + const counterparty = toNonEmptyString(flow.counterparty) ?? separateSubject; + const netLabel = bidirectionalNetLabel(flow.net_direction); + const lead = `; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` + + `${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`; + const basis = []; + const incomingRows = sideRowsText(incoming); + const outgoingRows = sideRowsText(outgoing); + const incomingDates = sideDateText(incoming); + const outgoingDates = sideDateText(outgoing); + if (incomingRows) { + basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`); + } + if (outgoingRows) { + basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`); + } + const documents = previousDocumentSummaryLine(documentBundle, counterparty); + if (documents) { + basis.push(documents); + } + const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : ""; + return { + lead, + line: `Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` + + `заплатили ${outgoingAmount ?? "0 руб."}, расчетное ${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` + + `${basisText} Это не перенос сумм компании на контрагента, а отдельный ранее подтвержденный контрагентский срез.` + }; +} function buildCompactBusinessOverviewReply(entryPoint, draft) { const turnInput = toRecordObject(entryPoint.turn_input); + const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); const graph = toRecordObject(turnInput?.data_need_graph); const bridge = toRecordObject(entryPoint.bridge); const pilot = toRecordObject(bridge?.pilot); const overview = toRecordObject(pilot?.derived_business_overview); - const graphReasons = readStringArray(graph?.reason_codes); const isBusinessOverview = toNonEmptyString(graph?.business_fact_family) === "business_overview" || toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1"; const rankingNeed = toNonEmptyString(graph?.ranking_need); - const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer"); - if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) { + if (!isBusinessOverview || !overview) { return null; } const incoming = toRecordObject(overview.incoming_customer_revenue); @@ -387,7 +599,38 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто"; const period = businessOverviewPeriodText(overview); const limitLine = businessOverviewCoverageLimitLine(overview); + const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); + const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope); + const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle), separateSubject, toRecordObject(turnMeaning?.previous_counterparty_document_bundle)); + const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : ""; + const separateSubjectLead = separateSubject + ? previousCounterpartySummary?.lead ?? + `; по контрагенту ${separateSubject} суммы компании не переношу, это отдельный контур без подтвержденного итога в этой строке` + : ""; + 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 topCustomerLead = customerName && customerAmount + ? `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}` + : ""; + const topSupplier = firstOverviewAxisLabel(overview.top_suppliers); + const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${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 = []; + 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); + lines.push(`Можно утверждать: по компании подтвержден operating-flow proxy по найденным строкам 1С; по ${separateSubject} отдельно подтверждены входящие/исходящие строки, расчетное нетто и документы из предыдущего контрагентского среза.`); + lines.push(`Нельзя утверждать: это не чистая прибыль, не полный бухгалтерский оборот вне проверенного окна и не доказательство, что ${separateSubject} является главным клиентом или поставщиком как бизнес-роль.`); + if (limitLine) { + lines.push(limitLine); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } if (rankingNeed) { const incomingLeader = strongestIncomingYear(overview); const netLeader = strongestNetYear(overview); @@ -397,7 +640,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { if (!leaderYear || !leaderAmount) { return null; } - lines.push(`Коротко: самый доходный год в доступном денежном контуре 1С — ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.`); + lines.push(`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${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) { @@ -414,18 +657,54 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { } } else if (incomingAmount || outgoingAmount || netAmount) { - lines.push(`Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.`); + lines.push(`Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`); lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); - 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); - if (customerName && customerAmount) { + if (!directMoneyAnswer && customerName && customerAmount) { lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); } } else { return null; } + if (separateSubject) { + lines.push(previousCounterpartySummary?.line ?? + `Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`); + } + if (!directMoneyAnswer && topSupplier) { + lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`); + } + if (!directMoneyAnswer && (topCustomer || topSupplier)) { + lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль."); + } + if (!directMoneyAnswer) { + lines.push(`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`); + const taxLine = businessOverviewTaxLine(overview); + if (taxLine) { + lines.push(taxLine); + } + const debtLine = businessOverviewDebtLine(overview); + if (debtLine) { + lines.push(debtLine); + } + const inventoryLine = businessOverviewInventoryLine(overview); + if (inventoryLine) { + lines.push(inventoryLine); + } + const missingOverviewFamilies = []; + if (!taxLine) { + missingOverviewFamilies.push("общая НДС/налоговая позиция без отдельного точного расчета"); + } + if (!debtLine) { + missingOverviewFamilies.push("долги без даты среза"); + } + if (!inventoryLine) { + missingOverviewFamilies.push("склад без даты среза"); + } + if (missingOverviewFamilies.length > 0) { + lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`); + } + lines.push("Что нельзя утверждать: чистую прибыль, полноценный финрезультат, юридические бизнес-роли клиентов/поставщиков и общую налоговую позицию без отдельного точного расчета."); + } if (limitLine) { lines.push(limitLine); } @@ -476,6 +755,10 @@ function buildReplyText(entryPoint, status) { } return null; } + const compactBidirectionalValueFlowReply = buildCompactBidirectionalValueFlowReply(entryPoint, draft); + if (compactBidirectionalValueFlowReply) { + return compactBidirectionalValueFlowReply; + } const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft); if (compactBusinessOverviewReply) { return compactBusinessOverviewReply; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index 878db2a..6e80bf2 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -233,6 +233,18 @@ function readStateTransitionReasonCodes(input) { .map((item) => toNonEmptyString(item)) .filter((item) => Boolean(item)); } +function hasFullConfirmedTruth(input) { + const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); + if (truthGateStatus === "full_confirmed") { + return true; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); + const coverageStatus = toNonEmptyString(truthGate?.coverage_status); + const groundingStatus = toNonEmptyString(truthGate?.grounding_status); + return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded"); +} function readStringArray(value) { return Array.isArray(value) ? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)) @@ -299,6 +311,12 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) { if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) { + return false; + } + } const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); @@ -335,16 +353,7 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) { if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } - const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); - const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); - const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); - const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); - const coverageStatus = toNonEmptyString(truthGate?.coverage_status); - const groundingStatus = toNonEmptyString(truthGate?.grounding_status); - const hasFullConfirmedTruth = truthGateStatus === "full_confirmed" || - sourceTruthGateStatus === "full_confirmed" || - (coverageStatus === "full" && groundingStatus === "grounded"); - if (!hasFullConfirmedTruth) { + if (!hasFullConfirmedTruth(input)) { return false; } const truthAnswerShape = readTruthAnswerShape(input); @@ -354,6 +363,26 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) { } return readStateTransitionReasonCodes(input).some((reason) => /^intent_adjusted_to_.+_followup_context$/i.test(reason)); } +function hasRuntimeMatchedExactReply(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } + if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + return false; + } + if (!hasFullConfirmedTruth(input)) { + return false; + } + const reasonCodes = readStateTransitionReasonCodes(input); + return (reasonCodes.some((reason) => reason === "route_expectation_matched") && + reasonCodes.some((reason) => /(?:confirmed_balance_exact|exact_.+_intent|vat_period_inspection_bridge_signal_detected)/iu.test(reason))); +} function hasAlignedFactualAddressReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -380,6 +409,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (hasRuntimeAdjustedExactReply(input, entryPoint)) { return false; } + if (hasRuntimeMatchedExactReply(input, entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const turnMeaning = readDiscoveryTurnMeaning(entryPoint); const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); @@ -453,16 +485,7 @@ function hasFullConfirmedFactualAddressReply(input, entryPoint) { if (hasMetadataDiscoveryPriority(input, entryPoint)) { return false; } - const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); - if (truthGateStatus === "full_confirmed") { - return true; - } - const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); - const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); - const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); - const coverageStatus = toNonEmptyString(truthGate?.coverage_status); - const groundingStatus = toNonEmptyString(truthGate?.grounding_status); - return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded"); + return hasFullConfirmedTruth(input); } function applyAssistantMcpDiscoveryResponsePolicy(input) { const currentReply = String(input.currentReply ?? ""); @@ -482,6 +505,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); + const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); @@ -534,6 +558,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (runtimeAdjustedExactReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning"); } + if (runtimeMatchedExactReply) { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning"); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); } @@ -557,6 +584,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !fullConfirmedFactualAddressReply && !exactMatchedFactualAddressReply && !runtimeAdjustedExactReply && + !runtimeMatchedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 3e6f21c..74293e7 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -193,6 +193,9 @@ function pushScopedEntityCandidate(target, value, groundedFollowupEntity) { isValueFlowPredicateEntityCandidate(text)) { return; } + if (target.some((existing) => sameScopedName(existing, text))) { + return; + } pushUnique(target, text); } function canonicalizeEntityResolutionCandidate(value) { @@ -220,6 +223,19 @@ function compactLower(value) { function sameScopedName(left, right) { return Boolean(left && right && compactLower(left) === compactLower(right)); } +function preferredScopedDisplayName(value, candidates) { + const anchor = toNonEmptyString(value); + if (!anchor) { + return null; + } + for (const candidate of candidates) { + const text = candidateValue(candidate); + if (sameScopedName(text, anchor)) { + return text; + } + } + return anchor; +} function candidateValue(value) { const direct = toNonEmptyString(value); if (direct && direct !== "[object Object]") { @@ -553,7 +569,9 @@ function collectFollowupDiscoverySeed(followupContext) { metadataSelectedSurfaceObjects: collectEntityCandidates(followupContext?.previous_discovery_metadata_selected_surface_objects), metadataRecommendedNextPrimitive: normalizeMetadataRecommendedPrimitive(followupContext?.previous_discovery_metadata_recommended_next_primitive), metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true, - metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets) + metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets), + previousBidirectionalValueFlow: toRecordObject(followupContext?.previous_discovery_bidirectional_value_flow), + previousDocumentSummary: toRecordObject(followupContext?.previous_discovery_document_summary) }; } function buildMetadataSurfaceRef(followupSeed) { @@ -652,8 +670,15 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text) { const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u043e\u0431\u0449\w*|\u043a\u0430\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0441\u0440\u0435\u0437|\u0430\u043d\u0430\u043b\u0438\u0437|(?:19|20)\d{2}|company|business|organization|overall|our|we|us|show|give|analysis)/iu.test(text); return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue; } +function hasCrossScopeExecutiveSummarySignal(text) { + return (/(?:\u0441\u043e\u0431\u0435\u0440\p{L}*\s+(?:\u043a\u043e\u0440\u043e\u0442\u043a\p{L}*\s+)?\u0438\u0442\u043e\u0433|\u044d\u043a\u0437\u0435\u043a\u044c\u044e\u0442\u0438\u0432\p{L}*\s+\u0441\u0430\u043c\u043c\u0430\u0440\u0438|executive\s+summary|final\s+summary)/iu.test(text) && + /(?:\u0447\u0442\u043e\s+(?:\u043c\u044b\s+)?\u043f\u043e\u0434\u0442\u0432\u0435\u0440\p{L}*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043f\u043e\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|confirmed|company|organization)/iu.test(text) && + /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test(text) && + /(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test(text)); +} function hasBusinessOverviewSignal(text) { - if (hasOrganizationLevelEarningsOverviewSignal(text) || + if (hasCrossScopeExecutiveSummarySignal(text) || + hasOrganizationLevelEarningsOverviewSignal(text) || hasOrganizationLevelDebtPositionOverviewSignal(text) || hasOrganizationLevelDebtDueDateOverviewSignal(text) || hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) || @@ -679,6 +704,34 @@ function hasBusinessOverviewContinuationSignal(text) { hasFinalSummaryCue || hasMoneyBreakdownCue); } +function hasExplicitVatQuestionSignal(text) { + if (!text) { + return false; + } + 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 hasBusinessOverviewSeparateCounterpartySignal(text) { + if (!text) { + return false; + } + return (/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|counterpart(?:y|ies)?)/iu.test(text) && + /(?:\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|company|organization|\u0438\u0442\u043e\u0433|summary|\u0432\u044b\u0432\u043e\u0434\p{L}*)/iu.test(text)); +} +function businessOverviewSeparateCounterpartyCandidateFromText(text) { + const source = (0, addressTextRepair_1.repairAddressMojibakeText)(String(text ?? "")); + const patterns = [ + /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*)\s+(.+?)(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu, + /(?:\u0434\u043b\u044f|for)\s+([\p{L}\d._-]+(?:\s+[\p{L}\d._-]+){0,3})(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu + ]; + for (const pattern of patterns) { + const candidate = normalizeFollowupCounterpartyCandidate(source.match(pattern)?.[1]); + if (candidate && !isInvalidEntityCandidate(candidate)) { + return candidate; + } + } + return null; +} function hasExplicitTopicSwitchSignal(text) { return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(text); } @@ -1047,8 +1100,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) { const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText; const rawText = compactLower(rawSignalSourceText); const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? ""); - const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText); - const rawBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) || businessOverviewContinuationSignal; + const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText); + const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText); + const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal); + const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) && + hasBusinessOverviewContinuationSignal(rawText) && + !explicitVatSuppressesBusinessOverviewContinuation; + const rawBusinessOverviewSignal = rawPrimaryBusinessOverviewSignal || businessOverviewContinuationSignal; const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText); const rawBidirectionalValueFlowSignal = !rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText); const rawValueFlowSignal = !rawBusinessOverviewSignal && @@ -1094,6 +1152,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) { rawDomain === "business_summary" || rawDomain === "business_overview" || rawAction === "broad_evaluation"; + const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)); + const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal + ? businessOverviewSeparateCounterpartyCandidateFromText(rawText) + : null; const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const currentTurnDocumentLaneSignal = rawAction === "list_documents"; const currentTurnMovementLaneSignal = rawAction === "list_movements"; @@ -1125,6 +1187,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { sameScopedName(followupSeed.counterparty, followupSeed.organization) || sameScopedName(followupSeed.counterparty, currentTurnOrganizationScope))); const businessOverviewSuppressesFollowupCounterparty = Boolean(businessOverviewSignal && + !businessOverviewSeparateCounterpartySignal && (rawBusinessOverviewSignal || businessOverviewContinuationSignal || broadBusinessEvaluationUnsupported || @@ -1161,7 +1224,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? null : normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty); const predecomposeDateScope = collectDateScope(predecomposeContract); + const suppressFollowupBusinessOverviewSeed = Boolean(explicitVatSuppressesBusinessOverviewContinuation && hasBusinessOverviewFollowupSeed(followupSeed)); const periodClarificationFollowupApplicable = Boolean(followupSeed.domain && + !suppressFollowupBusinessOverviewSeed && followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period") && !rawLifecycleSignal && @@ -1172,6 +1237,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { relativeCurrentDateHintDetected || (predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope)))); const followupDiscoverySeedApplicable = Boolean(followupSeed.domain && + !suppressFollowupBusinessOverviewSeed && !rawLifecycleSignal && !rawMetadataSignal && (periodClarificationFollowupApplicable || @@ -1499,6 +1565,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) { for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity); } + pushScopedEntityCandidate(entityCandidates, businessOverviewSeparateCounterpartyCandidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity); if (!groundedFollowupEntity) { @@ -1511,6 +1578,20 @@ function buildAssistantMcpDiscoveryTurnInput(input) { } pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); } + const businessOverviewSeparateCounterpartyDisplayCandidate = businessOverviewSeparateCounterpartySignal + ? preferredScopedDisplayName(businessOverviewSeparateCounterpartyCandidate, [ + groundedFollowupEntity, + effectiveFollowupCounterparty, + followupSeed.discoveryEntity, + normalizedPredecomposeCounterparty, + rawScopedEntityCandidate, + rawEntityCandidate, + ...entityCandidates + ]) + : null; + const businessOverviewSeparateEntityCandidates = businessOverviewSeparateCounterpartyDisplayCandidate + ? [businessOverviewSeparateCounterpartyDisplayCandidate] + : []; if ((rawMetadataSignal || metadataFollowupSeedApplicable) && !groundedFollowupEntity && !metadataScopedLaneWithoutSubject) { @@ -1579,6 +1660,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) { (clarificationLoopStillNeedsPeriod || businessOverviewSignal || openScopeValueFlowWithoutResolvedCounterparty || + valueFlowGroundedDocumentFollowupApplicable || + valueFlowGroundedMovementFollowupApplicable || (valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))); const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal); const topicSwitchSuppressesFollowupScope = Boolean(rawTopicSwitchSignal && @@ -1604,10 +1687,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { (suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)) ? null : followupSeed.dateScope; + const businessOverviewRawYearOverridesPredecomposeAsOf = Boolean(businessOverviewSignal && + rawDateScope && + /^\d{4}$/.test(rawDateScope) && + normalizedPredecomposeDateScope && + normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)); const explicitDateScope = rawAllTimeScopeSignal ? null : normalizedAssistantTurnMeaningDateScope ?? - normalizedPredecomposeDateScope ?? + (businessOverviewRawYearOverridesPredecomposeAsOf ? rawDateScope : normalizedPredecomposeDateScope) ?? rawDateScope ?? normalizedFollowupDateScope; const followupDateScopeApplied = Boolean(!rawAllTimeScopeSignal && @@ -1656,6 +1744,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? followupSeed.rankingNeed : undefined, explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates, + business_overview_separate_entity_candidates: businessOverviewSeparateEntityCandidates, + previous_counterparty_value_flow_bundle: businessOverviewSignal && followupSeed.previousBidirectionalValueFlow + ? followupSeed.previousBidirectionalValueFlow + : undefined, + previous_counterparty_document_bundle: businessOverviewSignal && followupSeed.previousDocumentSummary ? followupSeed.previousDocumentSummary : undefined, metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 ? followupSeed.metadataAmbiguityEntitySets : undefined, @@ -1716,6 +1809,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } + if ((turnMeaning.business_overview_separate_entity_candidates?.length ?? 0) > 0) { + cleanTurnMeaning.business_overview_separate_entity_candidates = turnMeaning.business_overview_separate_entity_candidates; + } + if (toRecordObject(turnMeaning.previous_counterparty_value_flow_bundle)) { + cleanTurnMeaning.previous_counterparty_value_flow_bundle = turnMeaning.previous_counterparty_value_flow_bundle; + } + if (toRecordObject(turnMeaning.previous_counterparty_document_bundle)) { + cleanTurnMeaning.previous_counterparty_document_bundle = turnMeaning.previous_counterparty_document_bundle; + } if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; } @@ -1924,9 +2026,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (businessOverviewContinuationSignal) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context"); } + if (explicitVatSuppressesBusinessOverviewContinuation) { + pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"); + } if (businessOverviewSuppressesFollowupCounterparty) { pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty"); } + if (businessOverviewSeparateCounterpartySignal) { + pushReason(reasonCodes, "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope"); + } + if (businessOverviewSeparateCounterpartyCandidate) { + pushReason(reasonCodes, "mcp_discovery_business_overview_counterparty_from_summary_text"); + } + if (businessOverviewRawYearOverridesPredecomposeAsOf) { + pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope"); + } if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) && normalizedPredecomposeCounterparty) { pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose"); @@ -1957,11 +2071,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (runDiscovery && !hasTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing"); } + const dataNeedGraphTurnMeaning = businessOverviewSeparateCounterpartySignal && cleanTurnMeaning.explicit_entity_candidates + ? { + ...cleanTurnMeaning, + explicit_entity_candidates: [] + } + : cleanTurnMeaning; const dataNeedGraph = runDiscovery && hasTurnMeaning ? (0, assistantMcpDiscoveryDataNeedGraph_1.buildAssistantMcpDiscoveryDataNeedGraph)({ semanticDataNeed, rawUtterance: rawSignalSourceText, - turnMeaning: cleanTurnMeaning + turnMeaning: dataNeedGraphTurnMeaning }) : null; if (dataNeedGraph) { diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index e28d0cc..1ad1275 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -171,6 +171,30 @@ function hasSignalAcrossSamples(samples, detector) { function hasExplicitRecapPromptSignal(samples) { return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample)); } +function normalizeMemoryCheckpointSample(value) { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[«»"'`]/g, "") + .replace(/\s+/g, " "); +} +function hasMemoryCheckpointPromptSignal(samples) { + return samples.some((sample) => { + const text = normalizeMemoryCheckpointSample(sample); + if (!text) { + return false; + } + if (/(?:стартов\w*\s+чек\s+контекст|чек\s+контекста|context\s+check|memory\s+check)/iu.test(text)) { + return true; + } + const hasSelectedStateCue = /(?:выбранн\w*\s+(?:компан|организац|контрагент|объект)|активн\w*\s+(?:компан|организац|контрагент|объект)|selected\s+(?:company|organization|counterparty|object)|active\s+(?:company|organization|counterparty|object))/iu.test(text); + const hasDialogStateCue = /(?:в\s+текущ\w*\s+диалог|в\s+этом\s+диалог|в\s+сессии|контекст(?:е|а)?\s+диалог|current\s+(?:dialog|session|conversation))/iu.test(text); + const hasHonestyCue = /(?:не\s+выдумывай\s+памят|не\s+придумывай\s+памят|скажи\s+честно|если\s+нет|no\s+fabricat|do\s+not\s+invent\s+memory)/iu.test(text); + const asksCurrentSelection = /(?:есть\s+ли\s+уже|есть\s+ли\s+сейчас|что\s+выбрано|кто\s+выбран|какая\s+компан\w*\s+выбран)/iu.test(text); + return (hasSelectedStateCue && hasDialogStateCue) || (hasDialogStateCue && hasHonestyCue) || (asksCurrentSelection && hasHonestyCue); + }); +} function buildInventoryHistoryCapabilityFollowupReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const organization = input.organization ?? contextFacts.organization; @@ -545,6 +569,26 @@ function extractBuyerFromSaleTraceAnswer(answerText, itemLabel) { } return null; } +function extractRequestedMemorySubject(userMessage) { + const text = String(userMessage ?? "").trim(); + if (!text) { + return null; + } + const patterns = [ + /памят[ьи]\s+про\s+([^.;!?]+)/iu, + /memory\s+about\s+([^.;!?]+)/iu + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + const subject = match?.[1] + ? match[1].replace(/[«»"'`]/g, "").replace(/\s+/g, " ").trim() + : ""; + if (subject.length >= 2 && subject.length <= 80) { + return subject; + } + } + return null; +} function buildAddressMemoryRecapReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const item = contextFacts.item; @@ -604,7 +648,14 @@ function buildAddressMemoryRecapReply(input) { "Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию." ].join(" "); } - return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; + const requestedMemorySubject = extractRequestedMemorySubject(input.userMessage); + const subjectLine = requestedMemorySubject + ? ` Память про «${requestedMemorySubject}» в этом диалоге не подтверждена.` + : " Память про конкретную компанию или контрагента в этом диалоге не подтверждена."; + return [ + `Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции.${subjectLine}`, + "Чтобы продолжить без выдуманной памяти, назови компанию, контрагента или объект, и я начну новый проверенный контур." + ].join(" "); } function buildBroadBusinessEvaluationReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); @@ -820,6 +871,7 @@ function createAssistantMemoryRecapPolicy(deps) { const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal); const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal); const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples); + const memoryCheckpointPromptSignal = hasMemoryCheckpointPromptSignal(samples); return { contextualHistoricalCapabilityFollowupDetected: Boolean(input.capabilityMetaQuery && !input.dataScopeMetaQuery && @@ -829,9 +881,10 @@ function createAssistantMemoryRecapPolicy(deps) { contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery && !input.capabilityMetaQuery && !input.aggregateBusinessAnalyticsSignal && - memoryRecapSignal && - (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && - continuity.hasGroundedAddressContext) + (memoryCheckpointPromptSignal || + (memoryRecapSignal && + (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && + continuity.hasGroundedAddressContext))) }; } return { diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 2313587..2fc0fbd 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -116,6 +116,10 @@ function createAssistantTransitionPolicy(deps) { } return null; } + function hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage = null) { + 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 parseDmyDateToIso(value) { const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (!match) { @@ -244,6 +248,57 @@ function createAssistantTransitionPolicy(deps) { } return null; } + function readMcpDiscoveryBidirectionalValueFlow(debug) { + const entryPoint = debug?.assistant_mcp_discovery_entry_point_v1; + const flow = entryPoint?.bridge?.pilot?.derived_bidirectional_value_flow; + if (!flow || typeof flow !== "object" || Array.isArray(flow)) { + return null; + } + return flow; + } + function readCounterpartyDocumentSummaryFromItem(item) { + const text = deps.toNonEmptyString(item?.text); + if (!text) { + return null; + } + const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? ""; + const match = firstLine.match(/Контрагент:\s*([^.\n]+)\.\s*Найдено документов:\s*(\d+)/iu); + if (!match?.[1] || !match?.[2]) { + return null; + } + return { + counterparty: deps.toNonEmptyString(match[1]), + document_count: Number(match[2]), + direct_answer: firstLine + }; + } + function findRecentDiscoveryValueFlowBundle(items) { + for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) { + const item = items[index]; + const debug = item?.debug; + if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") { + continue; + } + const flow = readMcpDiscoveryBidirectionalValueFlow(debug); + if (flow) { + return flow; + } + } + return null; + } + function findRecentCounterpartyDocumentBundle(items) { + for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const summary = readCounterpartyDocumentSummaryFromItem(item); + if (summary) { + return summary; + } + } + return null; + } function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) { if (sourceIntentHint !== "inventory_purchase_provenance_for_item" && !hasInventoryItemFocusHint && @@ -388,7 +443,8 @@ function createAssistantTransitionPolicy(deps) { llmPreDecomposeMeta }) : null; - if (assistantTurnMeaning?.stale_replay_forbidden === true) { + if (assistantTurnMeaning?.stale_replay_forbidden === true && + !hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)) { return null; } const latestAddressItem = deps.findLastAddressAssistantItem(items); @@ -465,17 +521,20 @@ function createAssistantTransitionPolicy(deps) { const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) ? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) : false; + const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage); let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || - inventoryPurchaseDateVatBridge; + inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || - inventoryPurchaseDateVatBridge + inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal : false; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) @@ -507,6 +566,7 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || @@ -526,6 +586,7 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || @@ -556,7 +617,8 @@ function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && - !hasIndexReferenceSignal) { + !hasIndexReferenceSignal && + !explicitSummaryBundleReuseSignal) { return null; } if (!hasPrimaryFollowupSignal && @@ -570,7 +632,8 @@ function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && - !hasIndexReferenceSignal) { + !hasIndexReferenceSignal && + !explicitSummaryBundleReuseSignal) { return null; } if (!carryoverSourceDebug) { @@ -598,6 +661,8 @@ function createAssistantTransitionPolicy(deps) { const sourceDiscoveryLoopSubjectResolutionOptional = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSubjectResolutionOptional)(carryoverSourceDebug); const sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityAmbiguityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityAmbiguityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryBidirectionalValueFlow = readMcpDiscoveryBidirectionalValueFlow(carryoverSourceDebug) ?? findRecentDiscoveryValueFlowBundle(items); + const sourceDiscoveryDocumentSummary = findRecentCounterpartyDocumentBundle(items); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; @@ -690,6 +755,7 @@ function createAssistantTransitionPolicy(deps) { shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || hasInventoryRootTemporalFollowupPrimary; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || @@ -698,6 +764,7 @@ function createAssistantTransitionPolicy(deps) { shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || hasInventoryRootTemporalFollowupAlternate : false; hasStrongFollowupReference = @@ -711,6 +778,7 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || @@ -859,6 +927,8 @@ function createAssistantTransitionPolicy(deps) { previous_discovery_metadata_recommended_next_primitive: sourceDiscoveryMetadataRecommendedNextPrimitive ?? undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, previous_discovery_metadata_ambiguity_entity_sets: sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined, + previous_discovery_bidirectional_value_flow: sourceDiscoveryBidirectionalValueFlow ?? undefined, + previous_discovery_document_summary: sourceDiscoveryDocumentSummary ?? undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, root_context_only: rootScopedPivot || undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, diff --git a/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js index d7af741..ea37f86 100644 --- a/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantTruthAnswerPolicyRuntimeAdapter.js @@ -80,21 +80,24 @@ function groundingStatusFrom(debug, input, truthGateStatus) { } function coverageStatusFrom(debug, input, truthGateStatus, groundingStatus) { const explicitCoverageEvidence = (0, addressCoverageEvidencePolicy_1.toAddressCoverageEvidenceContract)(debug.address_coverage_evidence_v1); - if (truthGateStatus === "full_confirmed") { - return "full"; - } - if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { - return "partial"; - } if (truthGateStatus.startsWith("blocked")) { return "blocked"; } if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") { return "blocked"; } + if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { + return "partial"; + } if (explicitCoverageEvidence) { return explicitCoverageEvidence.coverage_status; } + if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") { + return "partial"; + } + if (truthGateStatus === "full_confirmed") { + return "full"; + } const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); if (coverageReport) { const total = asNumber(coverageReport.requirements_total); @@ -123,10 +126,16 @@ function truthModeFrom(input) { if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) { return "clarification_required"; } - if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) { + if (input.coverageStatus === "partial") { + return "limited"; + } + if (input.truthGateStatus === "full_confirmed" && input.coverageStatus === "full") { return "confirmed"; } - if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") { + if (input.coverageStatus === "full" && input.groundingStatus === "grounded") { + return "confirmed"; + } + if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual") { return "limited"; } return "unsupported"; @@ -140,6 +149,9 @@ function evidenceGradeFrom(debug, coverageStatus, groundingStatus, truthGateStat if (isEvidenceGrade(explicit)) { return explicit; } + if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") { + return coverageStatus === "partial" ? "medium" : "weak"; + } if (coverageStatus === "blocked") { return "none"; } diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 0bc0a97..14a16bd 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -384,6 +384,15 @@ function extractMonthPeriod(text: string): { period_from?: string; period_to?: s return {}; } +function isExactHistoricalPeriodWindow(filters: AddressFilterSet): boolean { + return ( + typeof filters.period_from === "string" && + filters.period_from.trim().length > 0 && + typeof filters.period_to === "string" && + filters.period_to.trim().length > 0 + ); +} + function extractPeriodRange(text: string): { period_from?: string; period_to?: string } { const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2); if (!directMatch) { @@ -810,6 +819,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean { if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { return true; } + if (meaningfulNonGenericTokens.length === 0) { + return true; + } const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); return meaningfulTokens.length === 0; } @@ -1634,6 +1646,9 @@ function resolveSemanticDateBasisHint(filters: AddressFilterSet, warnings: strin const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0; const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0; const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0; + if (warnings.includes("as_of_date_derived_from_exact_historical_period") && (hasPeriodFrom || hasPeriodTo)) { + return hasPeriodFrom && hasPeriodTo ? "period_range" : "period_end"; + } if (hasPeriodFrom && hasPeriodTo) { return "period_range"; } @@ -1938,6 +1953,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent } } + if (isExactHistoricalPeriodWindow(filters) && !warnings.includes("exact_historical_period_window_requested")) { + const derivedFromHistoricalPhrase = + warnings.includes("period_derived_from_month_phrase") || + warnings.includes("period_derived_from_year_range_phrase") || + warnings.includes("period_derived_from_year_phrase"); + if (derivedFromHistoricalPhrase) { + warnings.push("exact_historical_period_window_requested"); + } + } + const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate; if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) { const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate); @@ -1954,11 +1979,14 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent } } const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase"); + const yearPeriodWasDerived = + warnings.includes("period_derived_from_year_phrase") || warnings.includes("period_derived_from_year_range_phrase"); if ( intent === "vat_liability_confirmed_for_tax_period" && !periodRange.period_from && !periodRange.period_to && - !monthPeriodWasDerived + !monthPeriodWasDerived && + !yearPeriodWasDerived ) { const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; if (periodToForQuarter) { @@ -1986,7 +2014,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_phrase"); const preserveDerivedPeriodWindow = - intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; + 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")) { + warnings.push("exact_historical_period_window_requested"); + } if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) { delete filters.period_from; delete filters.period_to; @@ -2016,6 +2049,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent if (filters.period_to) { filters.as_of_date = filters.period_to; warnings.push("as_of_date_derived_from_period_to"); + if ( + warnings.includes("period_derived_from_month_phrase") || + warnings.includes("period_derived_from_year_range_phrase") || + warnings.includes("period_derived_from_year_phrase") + ) { + warnings.push("as_of_date_derived_from_exact_historical_period"); + if (!warnings.includes("exact_historical_period_window_requested")) { + warnings.push("exact_historical_period_window_requested"); + } + } } else if (shouldDefaultAsOfDateToToday(intent)) { filters.as_of_date = new Date().toISOString().slice(0, 10); warnings.push("as_of_date_defaulted_today"); diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 623c55a..b1d9dac 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2232,6 +2232,18 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test( normalized ); + const hasInventoryPurchaseToSaleDocumentChainCue = + /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test( + normalized + ) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized); + if (hasInventoryPurchaseToSaleDocumentChainCue) { + return unicodeBridgeResolution( + "inventory_purchase_to_sale_chain", + "high", + "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected" + ); + } + const hasSelectedObjectProfitabilityCue = /(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test( normalized @@ -2247,18 +2259,6 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio ); } - const hasInventoryPurchaseToSaleDocumentChainCue = - /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test( - normalized - ) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized); - if (hasInventoryPurchaseToSaleDocumentChainCue) { - return unicodeBridgeResolution( - "inventory_purchase_to_sale_chain", - "high", - "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected" - ); - } - const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) && /(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test( diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index bd1972c..993bef8 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -2194,7 +2194,8 @@ function enforceStrictAccountScopeForIntent( function resolveExecutionFiltersForConfirmedBalance( filters: AddressFilterSet, - analysisDate: string | null + analysisDate: string | null, + warnings: string[] = [] ): { executionFilters: AddressFilterSet; asOfDerived: string | null; @@ -2208,8 +2209,10 @@ function resolveExecutionFiltersForConfirmedBalance( if (derivedAsOf) { executionFilters.as_of_date = derivedAsOf; } - delete executionFilters.period_from; - delete executionFilters.period_to; + if (!warnings.includes("as_of_date_derived_from_exact_historical_period")) { + delete executionFilters.period_from; + delete executionFilters.period_to; + } const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit) ? Math.max(1, Math.trunc(executionFilters.limit)) @@ -2415,6 +2418,9 @@ function asksForUnresolvedInventorySupplierLink(userMessage: string | null | und } function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean { + if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) { + return false; + } const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) && typeof filters.as_of_date === "string" && @@ -3713,16 +3719,16 @@ export class AddressQueryService { intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance"; const payablesConfirmedExecution = confirmedBalancePayablesIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; const inventoryConfirmedExecution = confirmedBalanceInventoryIntent - ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) + ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings) : null; let executionFilters = inventoryConfirmedExecution?.executionFilters ?? @@ -5145,6 +5151,7 @@ export class AddressQueryService { !counterpartyItemFlowQuery && isDocumentOrBankAnchorIntent(intent.intent) && !hasExplicitPeriodWindow(filters.extracted_filters) && + !filters.warnings.some((warning) => warning.startsWith("period_derived_from_")) && (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") ) { const currentLimit = diff --git a/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts b/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts index bc35cc5..8bbffd2 100644 --- a/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts +++ b/llm_normalizer/backend/src/services/addressTruthGatePolicy.ts @@ -175,6 +175,12 @@ function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTrut return input.truthGateStatusHint; } const missingRequiredFilters = input.missingRequiredFilters ?? []; + const reasonCodes = input.reasons ?? []; + const heuristicOpenItemsFallback = Boolean( + input.intent === "open_items_by_counterparty_or_contract" && + (reasonCodes.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates") || + reasonCodes.includes("open_items_account_query_override_to_movements")) + ); if (input.routeExpectationStatus === "mismatch") { return "blocked_route_expectation_failure"; } @@ -190,6 +196,9 @@ function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTrut if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") { return "full_confirmed"; } + if (heuristicOpenItemsFallback) { + return "partial_supported"; + } if ( input.limitedReasonCategory === "empty_match" || input.limitedReasonCategory === "recipe_visibility_gap" || diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index 0516994..b52806a 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -4198,12 +4198,23 @@ function composeFactualReplyBody( if (intent === "open_items_by_counterparty_or_contract") { const counterparties = buildCounterpartyRiskAggregate(rows); - const accountLead = + const accountLabel = typeof options.accountHint === "string" && options.accountHint.trim().length > 0 - ? `Проверил хвосты по счету ${options.accountHint.trim()}.` - : "Собраны открытые позиции по взаиморасчетам."; + ? `по счету ${options.accountHint.trim()}` + : "по взаиморасчетам"; + const exactBalanceRequested = options.requestedResultMode === "confirmed_balance"; + const periodLabel = options.asOfDate + ? `на ${formatDateRu(options.asOfDate)}` + : options.periodFrom || options.periodTo + ? `за период ${formatDateRu(options.periodFrom ?? "...")}..${formatDateRu(options.periodTo ?? "...")}` + : null; const lines = [ - accountLead, + exactBalanceRequested + ? `Коротко: точный открытый остаток ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} не подтвержден; ниже только предварительные сигналы по движениям: ${formatNumberWithDots(rows.length)} строк, контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.` + : `Коротко: ${accountLabel} найдено ${formatNumberWithDots(rows.length)} строк хвостов/открытых расчетов; контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`, + exactBalanceRequested + ? "Это не подтвержденное сальдо и не финальный реестр открытых расчетов: текущий контур видит движения-кандидаты, но не доказывает остаток закрытия." + : "Это shortlist для проверки, а не финальный подтвержденный реестр открытых расчетов.", `Строк отобрано: ${rows.length}.`, `Контрагентов с сигналом: ${counterparties.length}.` ]; @@ -4223,7 +4234,12 @@ function composeFactualReplyBody( } return { responseType: "FACTUAL_LIST", - text: lines.join("\n") + text: lines.join("\n"), + semantics: { + result_mode: "heuristic_candidates", + evidence_strength: counterparties.length > 0 || rows.length > 0 ? "medium" : "weak", + balance_confirmed: false + } }; } @@ -4310,7 +4326,7 @@ function composeFactualReplyBody( : `Найдено документов по контрагенту: ${rows.length}.` ); } - if (counterpartyLabel) { + if (counterpartyLabel && itemFlowQuestion) { lines.push(`Контрагент: ${counterpartyLabel}`); } if (itemFlowQuestion) { @@ -4330,7 +4346,11 @@ function composeFactualReplyBody( lines.push(`Показаны первые 12 из ${rows.length} поставок.`); } } else { - lines.push(...formatTopRows(rows, rows.length)); + const visibleRows = rows.slice(0, 5); + lines.push(...formatTopRows(visibleRows, visibleRows.length)); + if (rows.length > visibleRows.length) { + lines.push(`Показаны первые ${visibleRows.length} из ${rows.length} документов; полный список остается в подтвержденном срезе.`); + } } return { responseType: "FACTUAL_LIST", diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index d0466bb..faf2304 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -259,11 +259,18 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([ "сейчас", "этому", "этомуже", + "этой", + "этойже", "тому", "томуже", + "той", + "тойже", "нему", "ней", "ним", + "цепочка", + "цепочке", + "цепочку", "неуказанному", "неуказанный", "неуказанная", diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 6f18ea3..85830d8 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -182,10 +182,12 @@ export function composeInventoryReply( const lines: string[] = [directAnswerLine]; if (positions.length > 0) { + const visiblePositionsLimit = 6; + const visiblePositions = positions.slice(0, visiblePositionsLimit); appendInventorySection( lines, "Позиции:", - positions.slice(0, 20).map((item, index) => + visiblePositions.map((item, index) => formatInventorySnapshotPositionLine(item, index, { formatDateRu: deps.formatDateRu, formatNumberWithDots: deps.formatNumberWithDots, @@ -193,6 +195,11 @@ export function composeInventoryReply( }) ) ); + if (positions.length > visiblePositions.length) { + lines.push( + `Показаны первые ${deps.formatNumberWithDots(visiblePositions.length)} из ${deps.formatNumberWithDots(positions.length)} позиций по сумме; полный список можно раскрыть отдельным запросом.` + ); + } } else { appendInventorySection(lines, "Позиции:", [ "- На дату среза товары с ненулевым остатком не найдены." diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 9cf0dc0..5f4dca4 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -271,6 +271,7 @@ export async function runAssistantLivingChatRuntime( organization: scopedOrganization, addressDebug: lastMemoryAddressDebug, sessionItems: input.sessionItems, + userMessage, toNonEmptyString: input.toNonEmptyString }); activeOrganization = scopedOrganization ?? activeOrganization; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts index 60a01d0..e3f8498 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryDataNeedGraph.ts @@ -185,12 +185,13 @@ function timeScopeNeedFor(input: { family: string | null; explicitDateScope: string | null; allTimeScopeHint: boolean; + subjectScopedBidirectionalAllTime: boolean; }): string | null { if (input.explicitDateScope) { return "explicit_period"; } if ( - input.allTimeScopeHint && + (input.allTimeScopeHint || input.subjectScopedBidirectionalAllTime) && (input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence") ) { return "all_time_scope"; @@ -515,6 +516,11 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const comparisonNeed = comparisonNeedFor(action); const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); + const subjectScopedBidirectionalAllTime = + businessFactFamily === "value_flow" && + comparisonNeed === "incoming_vs_outgoing" && + subjectCandidates.length > 0 && + !explicitDateScope; const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({ family: businessFactFamily, rawUtterance, @@ -576,7 +582,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( const timeScopeNeed = timeScopeNeedFor({ family: businessFactFamily, explicitDateScope, - allTimeScopeHint + allTimeScopeHint, + subjectScopedBidirectionalAllTime }); if (timeScopeNeed === "period_required" && !explicitDateScope) { pushUnique(clarificationGaps, "period"); @@ -618,6 +625,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph( if (allTimeScopeHint) { pushReason(reasonCodes, "data_need_graph_all_time_scope_hint"); } + if (subjectScopedBidirectionalAllTime) { + pushReason(reasonCodes, "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"); + } if (businessFactFamily === "business_overview" && !explicitDateScope) { pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts index c102a20..183eeab 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryPolicy.ts @@ -26,6 +26,9 @@ export interface AssistantMcpDiscoveryTurnMeaningRef { asked_aggregation_axis?: string | null; seeded_ranking_need?: string | null; explicit_entity_candidates?: string[]; + business_overview_separate_entity_candidates?: string[]; + previous_counterparty_value_flow_bundle?: Record | null; + previous_counterparty_document_bundle?: Record | null; metadata_ambiguity_entity_sets?: string[]; metadata_scope_hint?: string | null; explicit_organization_scope?: string | null; @@ -177,6 +180,7 @@ function normalizeTurnMeaning( const dateScope = toNonEmptyString(value.explicit_date_scope); const unsupported = toNonEmptyString(value.unsupported_but_understood_family); const entities = toStringList(value.explicit_entity_candidates); + const businessOverviewSeparateEntities = toStringList(value.business_overview_separate_entity_candidates); const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets); if (domain) { result.asked_domain_family = domain; @@ -193,6 +197,9 @@ function normalizeTurnMeaning( if (entities.length > 0) { result.explicit_entity_candidates = entities; } + if (businessOverviewSeparateEntities.length > 0) { + result.business_overview_separate_entity_candidates = businessOverviewSeparateEntities; + } if (metadataAmbiguityEntitySets.length > 0) { result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index 40b4dda..15c4af0 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -430,22 +430,271 @@ function businessOverviewYearRowsLine(overview: Record): string return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null; } +function firstOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_ru"): string | null { + const first = toRecordObject(Array.isArray(rows) ? rows[0] : null); + const label = toNonEmptyString(first?.axis_value); + const amount = moneyText(first?.[amountKey]); + return label && amount ? `${label} — ${sentenceAmount(amount) ?? amount}` : null; +} + +function businessOverviewTaxLine(overview: Record): string | null { + const tax = toRecordObject(overview.tax_position); + if (!tax) { + return null; + } + const salesVat = moneyText(tax.sales_vat_amount_human_ru); + const purchaseVat = moneyText(tax.purchase_vat_amount_human_ru); + const netVat = moneyText(tax.net_vat_amount_human_ru); + if (!salesVat && !purchaseVat && !netVat) { + return null; + } + const direction = + tax.net_vat_direction === "vat_to_pay" + ? "НДС к уплате" + : tax.net_vat_direction === "vat_to_recover_or_offset" + ? "НДС к возмещению/зачету" + : "чистая НДС-позиция"; + return `НДС: продажи ${salesVat ?? "0 руб."}, покупки ${purchaseVat ?? "0 руб."}, ${direction} ${sentenceAmount(netVat) ?? netVat ?? "0 руб."}.`; +} + +function businessOverviewDebtLine(overview: Record): string | null { + const debt = toRecordObject(overview.debt_position); + if (!debt) { + return null; + } + const receivables = moneyText(toRecordObject(debt.receivables)?.total_amount_human_ru); + const payables = moneyText(toRecordObject(debt.payables)?.total_amount_human_ru); + const net = moneyText(debt.net_debt_position_amount_human_ru); + if (!receivables && !payables && !net) { + return null; + } + const direction = + debt.net_debt_position_direction === "net_payable" ? "кредиторка больше дебиторки" : "дебиторка больше кредиторки"; + return `Долги: дебиторка ${receivables ?? "0 руб."}, кредиторка ${payables ?? "0 руб."}, нетто ${sentenceAmount(net) ?? net ?? "0 руб."} (${direction}).`; +} + +function businessOverviewInventoryLine(overview: Record): string | null { + const inventory = toRecordObject(overview.inventory_position); + if (!inventory) { + return null; + } + const amount = moneyText(inventory.total_amount_human_ru); + const rows = Number(inventory.rows_matched); + const quantity = Number(inventory.total_quantity); + if (!amount && !Number.isFinite(rows)) { + return null; + } + const pieces = [ + Number.isFinite(rows) ? `${rows} позиций` : null, + amount ? `на ${sentenceAmount(amount) ?? amount}` : null, + Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null + ].filter((item): item is string => Boolean(item)); + return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null; +} + +function rowCountText(value: unknown): string | null { + const count = Number(value); + return Number.isFinite(count) ? String(count) : null; +} + +function sideRowsText(side: Record | null): string | null { + const rowsWithAmount = rowCountText(side?.rows_with_amount); + const rowsMatched = rowCountText(side?.rows_matched); + if (rowsWithAmount && rowsMatched) { + return `${rowsWithAmount} из ${rowsMatched}`; + } + return rowsWithAmount ?? rowsMatched; +} + +function sideDateText(side: Record | null): string | null { + const first = toNonEmptyString(side?.first_movement_date); + const latest = toNonEmptyString(side?.latest_movement_date); + if (first && latest) { + return first === latest ? `дата ${first}` : `даты ${first}..${latest}`; + } + return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null; +} + +function bidirectionalNetLabel(direction: unknown): string { + if (direction === "net_outgoing") { + return "нетто в сторону контрагента"; + } + if (direction === "balanced") { + return "нетто около нуля"; + } + return "нетто в нашу сторону"; +} + +function buildCompactBidirectionalValueFlowReply( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract, + draft: Record +): string | null { + const bridge = toRecordObject(entryPoint.bridge); + const pilot = toRecordObject(bridge?.pilot); + const flow = toRecordObject(pilot?.derived_bidirectional_value_flow); + if (!flow) { + return null; + } + + const incoming = toRecordObject(flow.incoming_customer_revenue); + const outgoing = toRecordObject(flow.outgoing_supplier_payout); + const incomingAmount = moneyText(incoming?.total_amount_human_ru); + const outgoingAmount = moneyText(outgoing?.total_amount_human_ru); + const netAmount = moneyText(flow.net_amount_human_ru); + if (!incomingAmount && !outgoingAmount && !netAmount) { + return null; + } + + const counterparty = toNonEmptyString(flow.counterparty) ?? "запрошенному контрагенту"; + const period = toNonEmptyString(flow.period_scope); + const periodText = period ? ` за период ${period}` : " в проверенном окне"; + const incomingRows = sideRowsText(incoming); + const outgoingRows = sideRowsText(outgoing); + const incomingDates = sideDateText(incoming); + const outgoingDates = sideDateText(outgoing); + const netLabel = bidirectionalNetLabel(flow.net_direction); + const lines = [ + `Коротко: по контрагенту ${counterparty}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` + ]; + + const basis: string[] = []; + if (incomingRows) { + basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`); + } + if (outgoingRows) { + basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`); + } + if (basis.length > 0) { + lines.push(`Основа: ${basis.join("; ")}.`); + } + if (flow.coverage_limited_by_probe_limit === true) { + lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода."); + } + lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна."); + + const fallbackNextStep = toNonEmptyString(draft.next_step_line); + if (fallbackNextStep) { + lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`); + } + + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; +} + +function compactComparable(value: string | null): string { + return String(value ?? "") + .toLowerCase() + .replace(/[«»"']/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +function businessOverviewSeparateSubjectLabel( + graph: Record | null, + turnMeaning: Record | null, + organizationScope: string | null +): string | null { + const candidates = uniqueStrings([ + ...toStringList(turnMeaning?.business_overview_separate_entity_candidates), + ...toStringList(graph?.subject_candidates), + ...toStringList(turnMeaning?.explicit_entity_candidates) + ]); + const organizationComparable = compactComparable(organizationScope); + for (const candidate of candidates) { + const text = toNonEmptyString(candidate); + if (!text) { + continue; + } + const comparable = compactComparable(text); + if (organizationComparable && comparable === organizationComparable) { + continue; + } + return text; + } + return null; +} + +function sameBusinessSubject(left: string | null, right: string | null): boolean { + const leftComparable = compactComparable(left); + const rightComparable = compactComparable(right); + return Boolean(leftComparable && rightComparable && leftComparable === rightComparable); +} + +function previousDocumentSummaryLine( + bundle: Record | null, + separateSubject: string | null +): string | null { + if (!bundle || !sameBusinessSubject(toNonEmptyString(bundle.counterparty), separateSubject)) { + return null; + } + const count = Number(bundle.document_count); + if (!Number.isFinite(count) || count <= 0) { + return null; + } + return `документы по цепочке: найдено ${count}`; +} + +function buildPreviousCounterpartyValueFlowSummary( + flow: Record | null, + separateSubject: string | null, + documentBundle: Record | null +): { lead: string; line: string } | null { + if (!flow || !separateSubject || !sameBusinessSubject(toNonEmptyString(flow.counterparty), separateSubject)) { + return null; + } + const incoming = toRecordObject(flow.incoming_customer_revenue); + const outgoing = toRecordObject(flow.outgoing_supplier_payout); + const incomingAmount = moneyText(incoming?.total_amount_human_ru); + const outgoingAmount = moneyText(outgoing?.total_amount_human_ru); + const netAmount = moneyText(flow.net_amount_human_ru); + if (!incomingAmount && !outgoingAmount && !netAmount) { + return null; + } + const counterparty = toNonEmptyString(flow.counterparty) ?? separateSubject; + const netLabel = bidirectionalNetLabel(flow.net_direction); + const lead = + `; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` + + `${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`; + const basis: string[] = []; + const incomingRows = sideRowsText(incoming); + const outgoingRows = sideRowsText(outgoing); + const incomingDates = sideDateText(incoming); + const outgoingDates = sideDateText(outgoing); + if (incomingRows) { + basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`); + } + if (outgoingRows) { + basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`); + } + const documents = previousDocumentSummaryLine(documentBundle, counterparty); + if (documents) { + basis.push(documents); + } + const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : ""; + return { + lead, + line: + `Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` + + `заплатили ${outgoingAmount ?? "0 руб."}, расчетное ${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` + + `${basisText} Это не перенос сумм компании на контрагента, а отдельный ранее подтвержденный контрагентский срез.` + }; +} + function buildCompactBusinessOverviewReply( entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract, draft: Record ): string | null { const turnInput = toRecordObject(entryPoint.turn_input); + const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref); const graph = toRecordObject(turnInput?.data_need_graph); const bridge = toRecordObject(entryPoint.bridge); const pilot = toRecordObject(bridge?.pilot); const overview = toRecordObject(pilot?.derived_business_overview); - const graphReasons = readStringArray(graph?.reason_codes); const isBusinessOverview = toNonEmptyString(graph?.business_fact_family) === "business_overview" || toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1"; const rankingNeed = toNonEmptyString(graph?.ranking_need); - const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer"); - if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) { + if (!isBusinessOverview || !overview) { return null; } @@ -457,8 +706,51 @@ function buildCompactBusinessOverviewReply( const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто"; const period = businessOverviewPeriodText(overview); const limitLine = businessOverviewCoverageLimitLine(overview); + const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope); + const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope); + const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary( + toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle), + separateSubject, + toRecordObject(turnMeaning?.previous_counterparty_document_bundle) + ); + const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : ""; + const separateSubjectLead = separateSubject + ? previousCounterpartySummary?.lead ?? + `; по контрагенту ${separateSubject} суммы компании не переношу, это отдельный контур без подтвержденного итога в этой строке` + : ""; + 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 topCustomerLead = + customerName && customerAmount + ? `; крупнейший источник входящих денег: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}` + : ""; + const topSupplier = firstOverviewAxisLabel(overview.top_suppliers); + const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${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[] = []; + 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); + lines.push( + `Можно утверждать: по компании подтвержден operating-flow proxy по найденным строкам 1С; по ${separateSubject} отдельно подтверждены входящие/исходящие строки, расчетное нетто и документы из предыдущего контрагентского среза.` + ); + lines.push( + `Нельзя утверждать: это не чистая прибыль, не полный бухгалтерский оборот вне проверенного окна и не доказательство, что ${separateSubject} является главным клиентом или поставщиком как бизнес-роль.` + ); + if (limitLine) { + lines.push(limitLine); + } + const reply = lines.join("\n").trim(); + return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null; + } + if (rankingNeed) { const incomingLeader = strongestIncomingYear(overview); const netLeader = strongestNetYear(overview); @@ -469,7 +761,7 @@ function buildCompactBusinessOverviewReply( return null; } lines.push( - `Коротко: самый доходный год в доступном денежном контуре 1С — ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.` + `Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.` ); const netYear = toNonEmptyString(netLeader?.year_bucket); const netYearAmount = moneyText(netLeader?.net_amount_human_ru); @@ -487,19 +779,62 @@ function buildCompactBusinessOverviewReply( } } else if (incomingAmount || outgoingAmount || netAmount) { lines.push( - `Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.` + `Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.` ); lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); - 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); - if (customerName && customerAmount) { + if (!directMoneyAnswer && customerName && customerAmount) { lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`); } } else { return null; } + if (separateSubject) { + lines.push( + previousCounterpartySummary?.line ?? + `Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.` + ); + } + + if (!directMoneyAnswer && topSupplier) { + lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`); + } + if (!directMoneyAnswer && (topCustomer || topSupplier)) { + lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль."); + } + if (!directMoneyAnswer) { + lines.push( + `Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.` + ); + const taxLine = businessOverviewTaxLine(overview); + if (taxLine) { + lines.push(taxLine); + } + const debtLine = businessOverviewDebtLine(overview); + if (debtLine) { + lines.push(debtLine); + } + const inventoryLine = businessOverviewInventoryLine(overview); + if (inventoryLine) { + lines.push(inventoryLine); + } + const missingOverviewFamilies: string[] = []; + if (!taxLine) { + missingOverviewFamilies.push("общая НДС/налоговая позиция без отдельного точного расчета"); + } + if (!debtLine) { + missingOverviewFamilies.push("долги без даты среза"); + } + if (!inventoryLine) { + missingOverviewFamilies.push("склад без даты среза"); + } + if (missingOverviewFamilies.length > 0) { + lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`); + } + lines.push( + "Что нельзя утверждать: чистую прибыль, полноценный финрезультат, юридические бизнес-роли клиентов/поставщиков и общую налоговую позицию без отдельного точного расчета." + ); + } if (limitLine) { lines.push(limitLine); } @@ -556,6 +891,11 @@ function buildReplyText(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContra return null; } + const compactBidirectionalValueFlowReply = buildCompactBidirectionalValueFlowReply(entryPoint, draft); + if (compactBidirectionalValueFlowReply) { + return compactBidirectionalValueFlowReply; + } + const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft); if (compactBusinessOverviewReply) { return compactBusinessOverviewReply; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index f6e8db7..56a6d2c 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -344,6 +344,19 @@ function readStateTransitionReasonCodes(input: ApplyAssistantMcpDiscoveryRespons .filter((item): item is string => Boolean(item)); } +function hasFullConfirmedTruth(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); + if (truthGateStatus === "full_confirmed") { + return true; + } + const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); + const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); + const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); + const coverageStatus = toNonEmptyString(truthGate?.coverage_status); + const groundingStatus = toNonEmptyString(truthGate?.grounding_status); + return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded"); +} + function readStringArray(value: unknown): string[] { return Array.isArray(value) ? value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)) @@ -424,6 +437,12 @@ function hasExactMatchedFactualAddressReply( if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) { + return false; + } + } const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); @@ -472,17 +491,7 @@ function hasRuntimeAdjustedExactReply( if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } - const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); - const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); - const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); - const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); - const coverageStatus = toNonEmptyString(truthGate?.coverage_status); - const groundingStatus = toNonEmptyString(truthGate?.grounding_status); - const hasFullConfirmedTruth = - truthGateStatus === "full_confirmed" || - sourceTruthGateStatus === "full_confirmed" || - (coverageStatus === "full" && groundingStatus === "grounded"); - if (!hasFullConfirmedTruth) { + if (!hasFullConfirmedTruth(input)) { return false; } const truthAnswerShape = readTruthAnswerShape(input); @@ -495,6 +504,32 @@ function hasRuntimeAdjustedExactReply( ); } +function hasRuntimeMatchedExactReply( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } + if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + return false; + } + if (!hasFullConfirmedTruth(input)) { + return false; + } + const reasonCodes = readStateTransitionReasonCodes(input); + return ( + reasonCodes.some((reason) => reason === "route_expectation_matched") && + reasonCodes.some((reason) => /(?:confirmed_balance_exact|exact_.+_intent|vat_period_inspection_bridge_signal_detected)/iu.test(reason)) + ); +} + function hasAlignedFactualAddressReply( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -528,6 +563,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning( if (hasRuntimeAdjustedExactReply(input, entryPoint)) { return false; } + if (hasRuntimeMatchedExactReply(input, entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const turnMeaning = readDiscoveryTurnMeaning(entryPoint); const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); @@ -619,16 +657,7 @@ function hasFullConfirmedFactualAddressReply( if (hasMetadataDiscoveryPriority(input, entryPoint)) { return false; } - const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); - if (truthGateStatus === "full_confirmed") { - return true; - } - const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1); - const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate); - const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status); - const coverageStatus = toNonEmptyString(truthGate?.coverage_status); - const groundingStatus = toNonEmptyString(truthGate?.grounding_status); - return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded"); + return hasFullConfirmedTruth(input); } export function applyAssistantMcpDiscoveryResponsePolicy( @@ -652,6 +681,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); + const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning( @@ -714,6 +744,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy( "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning" ); } + if (runtimeMatchedExactReply) { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning" + ); + } if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { pushReason( reasonCodes, @@ -742,6 +778,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !fullConfirmedFactualAddressReply && !exactMatchedFactualAddressReply && !runtimeAdjustedExactReply && + !runtimeMatchedExactReply && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index 702a8db..6e165fc 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -259,6 +259,9 @@ function pushScopedEntityCandidate( ) { return; } + if (target.some((existing) => sameScopedName(existing, text))) { + return; + } pushUnique(target, text); } @@ -291,6 +294,20 @@ function sameScopedName(left: string | null, right: string | null): boolean { return Boolean(left && right && compactLower(left) === compactLower(right)); } +function preferredScopedDisplayName(value: string | null, candidates: unknown[]): string | null { + const anchor = toNonEmptyString(value); + if (!anchor) { + return null; + } + for (const candidate of candidates) { + const text = candidateValue(candidate); + if (sameScopedName(text, anchor)) { + return text; + } + } + return anchor; +} + function candidateValue(value: unknown): string | null { const direct = toNonEmptyString(value); if (direct && direct !== "[object Object]") { @@ -612,6 +629,8 @@ function collectFollowupDiscoverySeed(followupContext: Record | metadataRecommendedNextPrimitive: AssistantMcpDiscoveryMetadataRecommendedPrimitive | null; metadataAmbiguityDetected: boolean; metadataAmbiguityEntitySets: string[]; + previousBidirectionalValueFlow: Record | null; + previousDocumentSummary: Record | null; } { const previousFilters = toRecordObject(followupContext?.previous_filters); const rootFilters = toRecordObject(followupContext?.root_filters); @@ -717,7 +736,9 @@ function collectFollowupDiscoverySeed(followupContext: Record | followupContext?.previous_discovery_metadata_recommended_next_primitive ), metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true, - metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets) + metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets), + previousBidirectionalValueFlow: toRecordObject(followupContext?.previous_discovery_bidirectional_value_flow), + previousDocumentSummary: toRecordObject(followupContext?.previous_discovery_document_summary) }; } @@ -896,8 +917,26 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text: string): boolea return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue; } +function hasCrossScopeExecutiveSummarySignal(text: string): boolean { + return ( + /(?:\u0441\u043e\u0431\u0435\u0440\p{L}*\s+(?:\u043a\u043e\u0440\u043e\u0442\u043a\p{L}*\s+)?\u0438\u0442\u043e\u0433|\u044d\u043a\u0437\u0435\u043a\u044c\u044e\u0442\u0438\u0432\p{L}*\s+\u0441\u0430\u043c\u043c\u0430\u0440\u0438|executive\s+summary|final\s+summary)/iu.test( + text + ) && + /(?:\u0447\u0442\u043e\s+(?:\u043c\u044b\s+)?\u043f\u043e\u0434\u0442\u0432\u0435\u0440\p{L}*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043f\u043e\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|confirmed|company|organization)/iu.test( + text + ) && + /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test( + text + ) && + /(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test( + text + ) + ); +} + function hasBusinessOverviewSignal(text: string): boolean { if ( + hasCrossScopeExecutiveSummarySignal(text) || hasOrganizationLevelEarningsOverviewSignal(text) || hasOrganizationLevelDebtPositionOverviewSignal(text) || hasOrganizationLevelDebtDueDateOverviewSignal(text) || @@ -948,6 +987,43 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean { ); } +function hasExplicitVatQuestionSignal(text: string): boolean { + if (!text) { + return false; + } + 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 hasBusinessOverviewSeparateCounterpartySignal(text: string): boolean { + if (!text) { + return false; + } + return ( + /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|counterpart(?:y|ies)?)/iu.test(text) && + /(?:\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|company|organization|\u0438\u0442\u043e\u0433|summary|\u0432\u044b\u0432\u043e\u0434\p{L}*)/iu.test(text) + ); +} + +function businessOverviewSeparateCounterpartyCandidateFromText(text: string): string | null { + const source = repairAddressMojibakeText(String(text ?? "")); + const patterns = [ + /(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*)\s+(.+?)(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu, + /(?:\u0434\u043b\u044f|for)\s+([\p{L}\d._-]+(?:\s+[\p{L}\d._-]+){0,3})(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu + ]; + for (const pattern of patterns) { + const candidate = normalizeFollowupCounterpartyCandidate(source.match(pattern)?.[1]); + if (candidate && !isInvalidEntityCandidate(candidate)) { + return candidate; + } + } + return null; +} + function hasExplicitTopicSwitchSignal(text: string): boolean { return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test( text @@ -1456,9 +1532,16 @@ export function buildAssistantMcpDiscoveryTurnInput( const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal( repairedUserText ?? rawUserText ?? "" ); + const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText); + const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText); + const explicitVatSuppressesBusinessOverviewContinuation = Boolean( + explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal + ); const businessOverviewContinuationSignal = - hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText); - const rawBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) || businessOverviewContinuationSignal; + hasBusinessOverviewFollowupSeed(followupSeed) && + hasBusinessOverviewContinuationSignal(rawText) && + !explicitVatSuppressesBusinessOverviewContinuation; + const rawBusinessOverviewSignal = rawPrimaryBusinessOverviewSignal || businessOverviewContinuationSignal; const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText); const rawBidirectionalValueFlowSignal = !rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText); @@ -1517,6 +1600,12 @@ export function buildAssistantMcpDiscoveryTurnInput( rawDomain === "business_summary" || rawDomain === "business_overview" || rawAction === "broad_evaluation"; + const businessOverviewSeparateCounterpartySignal = Boolean( + businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText) + ); + const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal + ? businessOverviewSeparateCounterpartyCandidateFromText(rawText) + : null; const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const currentTurnDocumentLaneSignal = rawAction === "list_documents"; const currentTurnMovementLaneSignal = rawAction === "list_movements"; @@ -1556,6 +1645,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ); const businessOverviewSuppressesFollowupCounterparty = Boolean( businessOverviewSignal && + !businessOverviewSeparateCounterpartySignal && (rawBusinessOverviewSignal || businessOverviewContinuationSignal || broadBusinessEvaluationUnsupported || @@ -1604,8 +1694,12 @@ export function buildAssistantMcpDiscoveryTurnInput( ? null : normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty); const predecomposeDateScope = collectDateScope(predecomposeContract); + const suppressFollowupBusinessOverviewSeed = Boolean( + explicitVatSuppressesBusinessOverviewContinuation && hasBusinessOverviewFollowupSeed(followupSeed) + ); const periodClarificationFollowupApplicable = Boolean( followupSeed.domain && + !suppressFollowupBusinessOverviewSeed && followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopPendingAxes.includes("period") && !rawLifecycleSignal && @@ -1618,6 +1712,7 @@ export function buildAssistantMcpDiscoveryTurnInput( ); const followupDiscoverySeedApplicable = Boolean( followupSeed.domain && + !suppressFollowupBusinessOverviewSeed && !rawLifecycleSignal && !rawMetadataSignal && (periodClarificationFollowupApplicable || @@ -2005,6 +2100,7 @@ export function buildAssistantMcpDiscoveryTurnInput( for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity); } + pushScopedEntityCandidate(entityCandidates, businessOverviewSeparateCounterpartyCandidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity); if (!groundedFollowupEntity) { @@ -2017,6 +2113,20 @@ export function buildAssistantMcpDiscoveryTurnInput( } pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); } + const businessOverviewSeparateCounterpartyDisplayCandidate = businessOverviewSeparateCounterpartySignal + ? preferredScopedDisplayName(businessOverviewSeparateCounterpartyCandidate, [ + groundedFollowupEntity, + effectiveFollowupCounterparty, + followupSeed.discoveryEntity, + normalizedPredecomposeCounterparty, + rawScopedEntityCandidate, + rawEntityCandidate, + ...entityCandidates + ]) + : null; + const businessOverviewSeparateEntityCandidates = businessOverviewSeparateCounterpartyDisplayCandidate + ? [businessOverviewSeparateCounterpartyDisplayCandidate] + : []; if ( (rawMetadataSignal || metadataFollowupSeedApplicable) && !groundedFollowupEntity && @@ -2107,6 +2217,8 @@ export function buildAssistantMcpDiscoveryTurnInput( (clarificationLoopStillNeedsPeriod || businessOverviewSignal || openScopeValueFlowWithoutResolvedCounterparty || + valueFlowGroundedDocumentFollowupApplicable || + valueFlowGroundedMovementFollowupApplicable || (valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))) ); const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal); @@ -2138,11 +2250,18 @@ export function buildAssistantMcpDiscoveryTurnInput( (suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)) ? null : followupSeed.dateScope; + const businessOverviewRawYearOverridesPredecomposeAsOf = Boolean( + businessOverviewSignal && + rawDateScope && + /^\d{4}$/.test(rawDateScope) && + normalizedPredecomposeDateScope && + normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`) + ); const explicitDateScope = rawAllTimeScopeSignal ? null : normalizedAssistantTurnMeaningDateScope ?? - normalizedPredecomposeDateScope ?? + (businessOverviewRawYearOverridesPredecomposeAsOf ? rawDateScope : normalizedPredecomposeDateScope) ?? rawDateScope ?? normalizedFollowupDateScope; const followupDateScopeApplied = Boolean( @@ -2198,6 +2317,13 @@ export function buildAssistantMcpDiscoveryTurnInput( ? followupSeed.rankingNeed : undefined, explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates, + business_overview_separate_entity_candidates: businessOverviewSeparateEntityCandidates, + previous_counterparty_value_flow_bundle: + businessOverviewSignal && followupSeed.previousBidirectionalValueFlow + ? followupSeed.previousBidirectionalValueFlow + : undefined, + previous_counterparty_document_bundle: + businessOverviewSignal && followupSeed.previousDocumentSummary ? followupSeed.previousDocumentSummary : undefined, metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 ? followupSeed.metadataAmbiguityEntitySets @@ -2263,6 +2389,15 @@ export function buildAssistantMcpDiscoveryTurnInput( if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; } + if ((turnMeaning.business_overview_separate_entity_candidates?.length ?? 0) > 0) { + cleanTurnMeaning.business_overview_separate_entity_candidates = turnMeaning.business_overview_separate_entity_candidates; + } + if (toRecordObject(turnMeaning.previous_counterparty_value_flow_bundle)) { + cleanTurnMeaning.previous_counterparty_value_flow_bundle = turnMeaning.previous_counterparty_value_flow_bundle; + } + if (toRecordObject(turnMeaning.previous_counterparty_document_bundle)) { + cleanTurnMeaning.previous_counterparty_document_bundle = turnMeaning.previous_counterparty_document_bundle; + } if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; } @@ -2478,9 +2613,21 @@ export function buildAssistantMcpDiscoveryTurnInput( if (businessOverviewContinuationSignal) { pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context"); } + if (explicitVatSuppressesBusinessOverviewContinuation) { + pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"); + } if (businessOverviewSuppressesFollowupCounterparty) { pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty"); } + if (businessOverviewSeparateCounterpartySignal) { + pushReason(reasonCodes, "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope"); + } + if (businessOverviewSeparateCounterpartyCandidate) { + pushReason(reasonCodes, "mcp_discovery_business_overview_counterparty_from_summary_text"); + } + if (businessOverviewRawYearOverridesPredecomposeAsOf) { + pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope"); + } if ( !(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) && normalizedPredecomposeCounterparty @@ -2515,12 +2662,19 @@ export function buildAssistantMcpDiscoveryTurnInput( if (runDiscovery && !hasTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing"); } + const dataNeedGraphTurnMeaning = + businessOverviewSeparateCounterpartySignal && cleanTurnMeaning.explicit_entity_candidates + ? { + ...cleanTurnMeaning, + explicit_entity_candidates: [] + } + : cleanTurnMeaning; const dataNeedGraph = runDiscovery && hasTurnMeaning ? buildAssistantMcpDiscoveryDataNeedGraph({ semanticDataNeed, rawUtterance: rawSignalSourceText, - turnMeaning: cleanTurnMeaning + turnMeaning: dataNeedGraphTurnMeaning }) : null; if (dataNeedGraph) { diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index 6a62ba3..d829984 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -236,6 +236,36 @@ function hasExplicitRecapPromptSignal(samples: string[]): boolean { ); } +function normalizeMemoryCheckpointSample(value: unknown): string { + return String(value ?? "") + .trim() + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[«»"'`]/g, "") + .replace(/\s+/g, " "); +} + +function hasMemoryCheckpointPromptSignal(samples: string[]): boolean { + return samples.some((sample) => { + const text = normalizeMemoryCheckpointSample(sample); + if (!text) { + return false; + } + if (/(?:стартов\w*\s+чек\s+контекст|чек\s+контекста|context\s+check|memory\s+check)/iu.test(text)) { + return true; + } + const hasSelectedStateCue = + /(?:выбранн\w*\s+(?:компан|организац|контрагент|объект)|активн\w*\s+(?:компан|организац|контрагент|объект)|selected\s+(?:company|organization|counterparty|object)|active\s+(?:company|organization|counterparty|object))/iu.test(text); + const hasDialogStateCue = + /(?:в\s+текущ\w*\s+диалог|в\s+этом\s+диалог|в\s+сессии|контекст(?:е|а)?\s+диалог|current\s+(?:dialog|session|conversation))/iu.test(text); + const hasHonestyCue = + /(?:не\s+выдумывай\s+памят|не\s+придумывай\s+памят|скажи\s+честно|если\s+нет|no\s+fabricat|do\s+not\s+invent\s+memory)/iu.test(text); + const asksCurrentSelection = + /(?:есть\s+ли\s+уже|есть\s+ли\s+сейчас|что\s+выбрано|кто\s+выбран|какая\s+компан\w*\s+выбран)/iu.test(text); + return (hasSelectedStateCue && hasDialogStateCue) || (hasDialogStateCue && hasHonestyCue) || (asksCurrentSelection && hasHonestyCue); + }); +} + export function buildInventoryHistoryCapabilityFollowupReply(input: { organization: string | null; addressDebug: Record | null; @@ -713,10 +743,32 @@ function extractBuyerFromSaleTraceAnswer( return null; } +function extractRequestedMemorySubject(userMessage: unknown): string | null { + const text = String(userMessage ?? "").trim(); + if (!text) { + return null; + } + const patterns = [ + /памят[ьи]\s+про\s+([^.;!?]+)/iu, + /memory\s+about\s+([^.;!?]+)/iu + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + const subject = match?.[1] + ? match[1].replace(/[«»"'`]/g, "").replace(/\s+/g, " ").trim() + : ""; + if (subject.length >= 2 && subject.length <= 80) { + return subject; + } + } + return null; +} + export function buildAddressMemoryRecapReply(input: { organization: string | null; addressDebug: Record | null; sessionItems?: unknown[]; + userMessage?: unknown; toNonEmptyString: (value: unknown) => string | null; }): string { const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString); @@ -782,7 +834,14 @@ export function buildAddressMemoryRecapReply(input: { ].join(" "); } - return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; + const requestedMemorySubject = extractRequestedMemorySubject(input.userMessage); + const subjectLine = requestedMemorySubject + ? ` Память про «${requestedMemorySubject}» в этом диалоге не подтверждена.` + : " Память про конкретную компанию или контрагента в этом диалоге не подтверждена."; + return [ + `Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции.${subjectLine}`, + "Чтобы продолжить без выдуманной памяти, назови компанию, контрагента или объект, и я начну новый проверенный контур." + ].join(" "); } export function buildBroadBusinessEvaluationReply(input: { @@ -1055,6 +1114,7 @@ export function createAssistantMemoryRecapPolicy( deps.hasConversationMemoryRecallFollowupSignal ); const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples); + const memoryCheckpointPromptSignal = hasMemoryCheckpointPromptSignal(samples); return { contextualHistoricalCapabilityFollowupDetected: Boolean( input.capabilityMetaQuery && @@ -1067,9 +1127,10 @@ export function createAssistantMemoryRecapPolicy( !input.dataScopeMetaQuery && !input.capabilityMetaQuery && !input.aggregateBusinessAnalyticsSignal && - memoryRecapSignal && - (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && - continuity.hasGroundedAddressContext + (memoryCheckpointPromptSignal || + (memoryRecapSignal && + (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && + continuity.hasGroundedAddressContext)) ) }; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 1711523..dc19c14 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -198,6 +198,16 @@ export function createAssistantTransitionPolicy(deps) { return null; } + function hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage = null) { + 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 parseDmyDateToIso(value) { const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/); if (!match) { @@ -341,6 +351,61 @@ export function createAssistantTransitionPolicy(deps) { return null; } + function readMcpDiscoveryBidirectionalValueFlow(debug) { + const entryPoint = debug?.assistant_mcp_discovery_entry_point_v1; + const flow = entryPoint?.bridge?.pilot?.derived_bidirectional_value_flow; + if (!flow || typeof flow !== "object" || Array.isArray(flow)) { + return null; + } + return flow; + } + + function readCounterpartyDocumentSummaryFromItem(item) { + const text = deps.toNonEmptyString(item?.text); + if (!text) { + return null; + } + const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? ""; + const match = firstLine.match(/Контрагент:\s*([^.\n]+)\.\s*Найдено документов:\s*(\d+)/iu); + if (!match?.[1] || !match?.[2]) { + return null; + } + return { + counterparty: deps.toNonEmptyString(match[1]), + document_count: Number(match[2]), + direct_answer: firstLine + }; + } + + function findRecentDiscoveryValueFlowBundle(items) { + for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) { + const item = items[index]; + const debug = item?.debug; + if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") { + continue; + } + const flow = readMcpDiscoveryBidirectionalValueFlow(debug); + if (flow) { + return flow; + } + } + return null; + } + + function findRecentCounterpartyDocumentBundle(items) { + for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") { + continue; + } + const summary = readCounterpartyDocumentSummaryFromItem(item); + if (summary) { + return summary; + } + } + return null; + } + function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) { if ( sourceIntentHint !== "inventory_purchase_provenance_for_item" && @@ -530,7 +595,10 @@ export function createAssistantTransitionPolicy(deps) { llmPreDecomposeMeta }) : null; - if (assistantTurnMeaning?.stale_replay_forbidden === true) { + if ( + assistantTurnMeaning?.stale_replay_forbidden === true && + !hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage) + ) { return null; } const latestAddressItem = deps.findLastAddressAssistantItem(items); @@ -638,18 +706,21 @@ export function createAssistantTransitionPolicy(deps) { hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) ? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) : false; + const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage); let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || - inventoryPurchaseDateVatBridge; + inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || - inventoryPurchaseDateVatBridge + inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal : false; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) @@ -698,6 +769,7 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || @@ -718,6 +790,7 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || @@ -753,7 +826,8 @@ export function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && - !hasIndexReferenceSignal + !hasIndexReferenceSignal && + !explicitSummaryBundleReuseSignal ) { return null; } @@ -769,7 +843,8 @@ export function createAssistantTransitionPolicy(deps) { !hasImplicitContinuationSignal && !hasSuggestedIntentPivotSignal && !hasOrganizationClarificationContinuation && - !hasIndexReferenceSignal + !hasIndexReferenceSignal && + !explicitSummaryBundleReuseSignal ) { return null; } @@ -848,6 +923,9 @@ export function createAssistantTransitionPolicy(deps) { carryoverSourceDebug, deps.toNonEmptyString ); + const sourceDiscoveryBidirectionalValueFlow = + readMcpDiscoveryBidirectionalValueFlow(carryoverSourceDebug) ?? findRecentDiscoveryValueFlowBundle(items); + const sourceDiscoveryDocumentSummary = findRecentCounterpartyDocumentBundle(items); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; @@ -951,6 +1029,7 @@ export function createAssistantTransitionPolicy(deps) { shortValueFlowRetargetPrimary || inventoryShortFollowupPrimary || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || hasInventoryRootTemporalFollowupPrimary; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || @@ -959,6 +1038,7 @@ export function createAssistantTransitionPolicy(deps) { shortValueFlowRetargetAlternate || inventoryShortFollowupAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || hasInventoryRootTemporalFollowupAlternate : false; hasStrongFollowupReference = @@ -972,6 +1052,7 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || inventoryPurchaseDateVatBridge || + explicitSummaryBundleReuseSignal || Boolean(debtRoleSwapIntent) || shortValueFlowRetargetPrimary || shortValueFlowRetargetAlternate || @@ -1220,6 +1301,8 @@ export function createAssistantTransitionPolicy(deps) { previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, previous_discovery_metadata_ambiguity_entity_sets: sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined, + previous_discovery_bidirectional_value_flow: sourceDiscoveryBidirectionalValueFlow ?? undefined, + previous_discovery_document_summary: sourceDiscoveryDocumentSummary ?? undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, root_context_only: rootScopedPivot || undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, diff --git a/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts index 8ae508e..0b9e725 100644 --- a/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantTruthAnswerPolicyRuntimeAdapter.ts @@ -125,21 +125,24 @@ function coverageStatusFrom( groundingStatus: AssistantGroundingStatus ): AssistantCoverageStatus { const explicitCoverageEvidence = toAddressCoverageEvidenceContract(debug.address_coverage_evidence_v1); - if (truthGateStatus === "full_confirmed") { - return "full"; - } - if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { - return "partial"; - } if (truthGateStatus.startsWith("blocked")) { return "blocked"; } if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") { return "blocked"; } + if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") { + return "partial"; + } if (explicitCoverageEvidence) { return explicitCoverageEvidence.coverage_status; } + if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") { + return "partial"; + } + if (truthGateStatus === "full_confirmed") { + return "full"; + } const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); if (coverageReport) { @@ -176,10 +179,16 @@ function truthModeFrom(input: { if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) { return "clarification_required"; } - if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) { + if (input.coverageStatus === "partial") { + return "limited"; + } + if (input.truthGateStatus === "full_confirmed" && input.coverageStatus === "full") { return "confirmed"; } - if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") { + if (input.coverageStatus === "full" && input.groundingStatus === "grounded") { + return "confirmed"; + } + if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual") { return "limited"; } return "unsupported"; @@ -199,6 +208,9 @@ function evidenceGradeFrom( if (isEvidenceGrade(explicit)) { return explicit; } + if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") { + return coverageStatus === "partial" ? "medium" : "weak"; + } if (coverageStatus === "blocked") { return "none"; } diff --git a/llm_normalizer/backend/tests/addressCounterpartyItemFlowAndOpenItemsRoute.test.ts b/llm_normalizer/backend/tests/addressCounterpartyItemFlowAndOpenItemsRoute.test.ts index c8e77e2..a9506c6 100644 --- a/llm_normalizer/backend/tests/addressCounterpartyItemFlowAndOpenItemsRoute.test.ts +++ b/llm_normalizer/backend/tests/addressCounterpartyItemFlowAndOpenItemsRoute.test.ts @@ -255,6 +255,28 @@ describe("counterparty shipment item flow and open-items routing", () => { expect(reply.text).not.toContain("Контрагент: Группа. Найдено документов"); }); + it("keeps document follow-up answer compact for larger counterparty lists", () => { + const rows = Array.from({ length: 7 }, (_, index) => ({ + period: `2021-11-${String(index + 1).padStart(2, "0")}T12:00:00Z`, + registrator: `Документ ${index + 1}`, + account_dt: "0", + account_kt: "0", + amount: 1000 + index, + analytics: ["Группа СВК", "Договор № 1-ПМ/2020"], + organization: "ООО Альтернатива Плюс" + })); + + const reply = composeFactualReply("list_documents_by_counterparty", rows, { + counterpartyHint: "Группа СВК" + }); + + expect(reply.text).toContain("Контрагент: Группа СВК. Найдено документов: 7."); + expect(reply.text).toContain("Показаны первые 5 из 7 документов"); + expect(reply.text).toContain("Документ 5"); + expect(reply.text).not.toContain("Документ 6"); + expect(reply.text.split("\n").filter((line) => line.startsWith("Контрагент:")).length).toBe(1); + }); + it("keeps current resolved counterparty label over stale follow-up anchor during short retarget", async () => { executeAddressMcpQueryMock .mockResolvedValueOnce({ @@ -427,6 +449,14 @@ describe("counterparty shipment item flow and open-items routing", () => { expect(result?.response_type).toBe("FACTUAL_LIST"); expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract"); expect(String(result?.reply_text ?? "")).toContain("счету 60"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("Коротко:"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("точный открытый остаток"); + expect(String(result?.reply_text ?? "")).toContain("не подтвержден"); + expect(String(result?.reply_text ?? "")).toContain("предварительные сигналы"); + expect(result?.debug.address_coverage_evidence_v1?.requested_result_mode).toBe("confirmed_balance"); + expect(result?.debug.address_coverage_evidence_v1?.result_mode).toBe("heuristic_candidates"); + expect(result?.debug.address_coverage_evidence_v1?.coverage_status).toBe("partial"); + expect(result?.debug.address_coverage_evidence_v1?.balance_confirmed).toBe(false); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто"); diff --git a/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts b/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts index e6570e7..389db6d 100644 --- a/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts @@ -14,6 +14,29 @@ describe("address filter extractor regressions", () => { expect(extracted.warnings).toContain("period_derived_from_month_phrase"); expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); }); + + it("keeps explicit year window for confirmed VAT tax-period intent", () => { + const extracted = extractAddressFilters( + "\u0447\u0442\u043e \u0441 \u043d\u0434\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441", + "vat_liability_confirmed_for_tax_period" + ); + + expect(extracted.extracted_filters.period_from).toBe("2020-01-01"); + expect(extracted.extracted_filters.period_to).toBe("2020-12-31"); + expect(extracted.warnings).toContain("period_derived_from_year_phrase"); + expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + }); + + it("drops pronoun-only counterparty anchors for chain follow-ups", () => { + const extracted = extractAddressFilters( + "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u044d\u0442\u043e\u0439 \u0446\u0435\u043f\u043e\u0447\u043a\u0435", + "list_documents_by_counterparty" + ); + + expect(extracted.extracted_filters.counterparty).toBeUndefined(); + expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality"); + }); + it("extracts a compact counterparty tail for customer revenue profile", () => { const extracted = extractAddressFilters( "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a", diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index 0963689..3321e9d 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -291,4 +291,27 @@ describe("address follow-up temporal regressions", () => { expect(movements?.filters.extracted_filters.counterparty).toBe("Группа СВК"); expect(movements?.baseReasons).toContain("counterparty_from_followup_context"); }); + + it("replaces pronoun chain anchors from counterparty follow-up context", () => { + const followupContext = { + previous_intent: "customer_revenue_and_payments" as const, + target_intent: "list_documents_by_counterparty" as const, + previous_filters: { + organization: "ООО Альтернатива Плюс", + counterparty: "Группа СВК" + }, + previous_anchor_type: "counterparty" as const, + previous_anchor_value: "Группа СВК", + resolved_counterparty_from_display: true + }; + + const documents = runAddressDecomposeStage( + "покажи документы по этой цепочке и не смешивай Группа СВК с организацией ООО Альтернатива Плюс", + followupContext + ); + + expect(documents?.intent.intent).toBe("list_documents_by_counterparty"); + expect(documents?.filters.extracted_filters.counterparty).toBe("Группа СВК"); + expect(documents?.baseReasons).toContain("counterparty_from_followup_context"); + }); }); diff --git a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts index 24aa727..80b75ea 100644 --- a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts +++ b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts @@ -67,6 +67,17 @@ describe("address route expectations contract", () => { expect(audit.reason).toBe("route_expectation_matched"); }); + it("matches open-items route as a supported factual route", () => { + const audit = evaluateAddressRouteExpectation({ + intent: "open_items_by_counterparty_or_contract", + selectedRecipe: "address_open_items_by_party_or_contract_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(audit.status).toBe("matched"); + expect(audit.reason).toBe("route_expectation_matched"); + }); + it("detects selected recipe mismatch", () => { const audit = evaluateAddressRouteExpectation({ intent: "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts b/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts index 132f54a..e8fd2d8 100644 --- a/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts +++ b/llm_normalizer/backend/tests/addressTruthGatePolicy.test.ts @@ -25,6 +25,32 @@ describe("address truth gate policy", () => { expect(gate.reason_codes).toContain("limited_category_empty_match"); }); + it("keeps open-items movement fallback partial even when heuristic rows are found", () => { + const gate = resolveAddressTruthGate({ + intent: "open_items_by_counterparty_or_contract", + filters: { + account: "60", + period_from: "2020-08-01", + period_to: "2020-08-31", + organization: "ООО Альтернатива Плюс" + }, + selectedRecipe: "address_open_items_by_party_or_contract_v1", + rowsMatched: 8, + runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS", + reasons: [ + "confirmed_balance_unavailable_fallback_to_heuristic_candidates", + "open_items_account_query_override_to_movements" + ], + routeExpectationStatus: "matched", + replyType: "factual" + }); + + expect(gate.truth_gate_status).toBe("partial_supported"); + expect(gate.carryover_eligibility).toBe("root_only"); + expect(gate.blocked_or_limited_explanation).toBe("evidence_or_coverage_is_partial"); + expect(gate.reason_codes).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); + }); + it("keeps selected-item limited answers object-scoped", () => { const gate = resolveAddressTruthGate({ intent: "inventory_sale_trace_for_item", diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index 209cdd9..b38a2d7 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -468,6 +468,26 @@ describe("assistant living chat runtime adapter", () => { expect(executeLlmChat).not.toHaveBeenCalled(); }); + it("builds honest memory checkpoint reply when there is no selected context", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: + "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.", + modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" }, + sessionItems: [], + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("не вижу выбранной компании"); + expect(output.chatText).toContain("Группа СВК"); + expect(output.chatText).toContain("не подтверждена"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); + it("builds deterministic memory recap for prior grounded MCP discovery counterparty context", async () => { const executeLlmChat = vi.fn(async () => "raw-llm"); const input = buildRuntimeInput({ diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 6f5df43..bd548e6 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -1007,6 +1007,39 @@ describe("assistant orchestration contract", () => { expect(decision.livingReason).toBe("memory_recap_followup_detected"); }); + it("routes startup memory checkpoint without selected context to deterministic chat", () => { + const question = + "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК."; + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: question, + effectiveAddressUserMessage: question, + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "customer_revenue_and_payments", + intent_confidence: "high" + }, + semanticExtractionContract: { + valid: true, + apply_canonical_recommended: true, + reason_codes: ["unsupported_low_confidence_contract"] + } + } as any, + sessionItems: [], + useMock: false + } as any); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("memory_recap_followup_detected"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("memory_recap_followup_detected"); + }); + it("keeps documentary inventory chain verification in address lane for supported exact intent", () => { const question = "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"; diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts index 150042b..731844c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryDataNeedGraph.test.ts @@ -32,6 +32,34 @@ describe("assistant MCP discovery data need graph", () => { expect(result.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals"); }); + it("defaults explicit-counterparty bidirectional value-flow without period to bounded all-time scope", () => { + const result = buildAssistantMcpDiscoveryDataNeedGraph({ + semanticDataNeed: "counterparty value-flow evidence", + rawUtterance: "how much money passed with SVK, incoming and outgoing?", + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["SVK"] + } + }); + + expect(result.business_fact_family).toBe("value_flow"); + expect(result.comparison_need).toBe("incoming_vs_outgoing"); + expect(result.time_scope_need).toBe("all_time_scope"); + expect(result.clarification_gaps).toEqual([]); + expect(result.proof_expectation).toBe("coverage_checked_fact"); + expect(result.decomposition_candidates).toEqual([ + "resolve_entity_reference", + "collect_incoming_movements", + "collect_outgoing_movements", + "aggregate_checked_amounts", + "probe_coverage" + ]); + expect(result.reason_codes).toContain( + "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope" + ); + }); + it("marks metadata lane choice as a clarification-required graph", () => { const result = buildAssistantMcpDiscoveryDataNeedGraph({ semanticDataNeed: "metadata lane clarification", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts index b1dc983..6290129 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryPlanner.test.ts @@ -1011,6 +1011,45 @@ describe("assistant MCP discovery planner", () => { expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_comparison"); }); + it("keeps explicit-counterparty bidirectional comparison executable over bounded all-time scope", () => { + const result = planAssistantMcpDiscovery({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: ["SVK"], + business_fact_family: "value_flow", + action_family: "net_value_flow", + aggregation_need: null, + time_scope_need: "all_time_scope", + comparison_need: "incoming_vs_outgoing", + ranking_need: null, + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: ["resolve_entity_reference", "collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_comparison_incoming_vs_outgoing", + "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope" + ] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["SVK"], + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + } + }); + + expect(result.planner_status).toBe("ready_for_execution"); + expect(result.selected_chain_id).toBe("value_flow_comparison"); + expect(result.proposed_primitives).toEqual(["resolve_entity_reference", "query_movements", "probe_coverage"]); + expect(result.required_axes).toEqual(["counterparty", "all_time_scope", "amount", "coverage_target"]); + expect(result.catalog_review.review_status).toBe("catalog_compatible"); + expect(result.discovery_plan.clarification_gaps).toEqual([]); + expect(result.reason_codes).toContain("planner_ready_for_guarded_mcp_execution"); + }); + it("builds an inference-safe lifecycle plan with evidence explanation", () => { const result = planAssistantMcpDiscovery({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index ce0f6ac..ade614a 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -114,9 +114,11 @@ describe("assistant MCP discovery response candidate", () => { }) ); - expect(candidate.reply_text).toContain("самый доходный год"); + expect(candidate.reply_text).toContain("в доступном проверенном MCP-срезе"); + 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).not.toContain("Что подтверждено:"); @@ -162,6 +164,12 @@ describe("assistant MCP discovery response candidate", () => { total_amount_human_ru: "11 536 836,23 руб." } ], + top_suppliers: [ + { + axis_value: "ООО Поставщик", + total_amount_human_ru: "2 200 000 руб." + } + ], yearly_breakdown: [] } }, @@ -181,12 +189,219 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).toContain("за 2017"); expect(candidate.reply_text).toContain("получили 16 932 063,96 руб."); expect(candidate.reply_text).toContain("исходящие платежи/списания 4 458 027,05 руб."); - expect(candidate.reply_text).toContain("12 474 036,91 руб."); + expect(candidate.reply_text).toContain("12 474 036,91 руб"); + expect(candidate.reply_text?.split("\n")[0]).toContain("крупнейший источник входящих денег: ГКУ УКРиС"); + expect(candidate.reply_text?.split("\n")[0]).toContain("крупнейший получатель исходящих денег: ООО Поставщик"); expect(candidate.reply_text).toContain("денежный operating-flow proxy"); 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({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + business_overview_separate_entity_candidates: ["Группа СВК"] + }, + data_need_graph: { + business_fact_family: "business_overview", + subject_candidates: [], + 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", + top_customers: [], + yearly_breakdown: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Company summary.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("по компании ООО Альтернатива Плюс"); + expect(candidate.reply_text).toContain("Группа СВК"); + expect(candidate.reply_text?.split("\n")[0]).toContain("суммы компании не переношу"); + expect(candidate.reply_text).toContain("нельзя делать вывод о выручке, долге или прибыльности"); + expect(candidate.reply_text).toContain("без отдельного контрагентского среза"); + }); + + it("adds missing proof boundaries for broad all-time business overviews", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс" + }, + data_need_graph: { + business_fact_family: "business_overview", + subject_candidates: [], + 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", + top_customers: [], + top_suppliers: [], + yearly_breakdown: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Company summary.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + expect(candidate.reply_text).toContain("Что не подтверждено в этом срезе"); + expect(candidate.reply_text).toContain("НДС"); + expect(candidate.reply_text).toContain("долги"); + expect(candidate.reply_text).toContain("склад"); + expect(candidate.reply_text).not.toContain("capability_id"); + }); + + it("reuses previous counterparty value-flow bundle in company plus counterparty summaries", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + turn_input: { + adapter_status: "ready", + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_organization_scope: "ООО Альтернатива Плюс", + business_overview_separate_entity_candidates: ["Группа СВК"], + previous_counterparty_value_flow_bundle: { + counterparty: "Группа СВК", + incoming_customer_revenue: { + total_amount_human_ru: "20 653 490 руб.", + rows_with_amount: 26, + rows_matched: 26, + first_movement_date: "2020-07-27", + latest_movement_date: "2021-11-10" + }, + outgoing_supplier_payout: { + total_amount_human_ru: "2 129 651 руб.", + rows_with_amount: 1, + rows_matched: 1, + first_movement_date: "2022-01-20", + latest_movement_date: "2022-01-20" + }, + net_amount_human_ru: "18 523 839 руб.", + net_direction: "net_incoming" + }, + previous_counterparty_document_bundle: { + counterparty: "Группа СВК", + document_count: 19 + } + }, + data_need_graph: { + business_fact_family: "business_overview", + subject_candidates: [], + 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 руб." }, + outgoing_supplier_payout: { total_amount_human_ru: "35 439 044,74 руб." }, + net_amount_human_ru: "121 753 936,69 руб.", + net_direction: "net_incoming", + top_customers: [], + yearly_breakdown: [] + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Company summary.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + const firstLine = candidate.reply_text?.split("\n")[0] ?? ""; + expect(firstLine).toContain("отдельно по Группа СВК: получили 20 653 490 руб."); + expect(firstLine).toContain("можно утверждать только эти подтвержденные срезы"); + expect(firstLine).toContain("нельзя называть это чистой прибылью"); + expect(candidate.reply_text).toContain("Отдельно по контрагенту Группа СВК: подтверждено получили 20 653 490 руб."); + expect(candidate.reply_text).toContain("заплатили 2 129 651 руб."); + expect(candidate.reply_text).toContain("Можно утверждать:"); + expect(candidate.reply_text).toContain("Нельзя утверждать:"); + expect(candidate.reply_text).toContain("документы по цепочке: найдено 19"); + expect(candidate.reply_text).toContain("ранее подтвержденный контрагентский срез"); + }); + it("localizes value-flow evidence without leaking pilot mechanics", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ @@ -294,6 +509,63 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).not.toContain("query_movements"); }); + it("uses a compact direct first line for derived bidirectional value-flow totals", () => { + const candidate = buildAssistantMcpDiscoveryResponseCandidate( + entryPoint({ + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + derived_bidirectional_value_flow: { + counterparty: "SVK", + period_scope: "2020", + net_amount_human_ru: "8 500,50 руб.", + net_direction: "net_incoming", + coverage_limited_by_probe_limit: false, + incoming_customer_revenue: { + rows_matched: 2, + rows_with_amount: 2, + total_amount_human_ru: "12 500,50 руб.", + first_movement_date: "2020-01-15", + latest_movement_date: "2020-02-20" + }, + outgoing_supplier_payout: { + rows_matched: 1, + rows_with_amount: 1, + total_amount_human_ru: "4 000 руб.", + first_movement_date: "2020-03-10", + latest_movement_date: "2020-03-10" + } + } + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "По данным 1С найдены строки входящих и исходящих денежных движений.", + confirmed_lines: ["1C bidirectional value-flow rows were checked for counterparty SVK: incoming=found, outgoing=found"], + inference_lines: ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + ); + + const firstLine = candidate.reply_text?.split("\n")[0] ?? ""; + expect(firstLine).toContain("Коротко:"); + expect(firstLine).toContain("SVK"); + expect(firstLine).toContain("получили 12 500,50 руб."); + expect(firstLine).toContain("заплатили 4 000 руб."); + expect(firstLine).toContain("нетто в нашу сторону: 8 500,50 руб."); + expect(candidate.reply_text).toContain("Основа:"); + expect(candidate.reply_text).not.toContain("Что подтверждено"); + expect(candidate.reply_text).not.toContain("pilot_"); + expect(candidate.reply_text).not.toContain("query_movements"); + }); + it("keeps monthly breakdown lines user-facing and localizes monthly inference basis", () => { const candidate = buildAssistantMcpDiscoveryResponseCandidate( entryPoint({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index fa2635d..e6a3d0c 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -135,6 +135,69 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate"); }); + it("lets a grounded business overview candidate override a semantically wrong exact address recipe", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Supplier and stock overlap was confirmed for 2020.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "inventory_supplier_stock_overlap_as_of_date", + selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + answer_shape_contract: { + reply_type: "factual", + capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + explicit_organization_scope: "OOO Alternative Plus", + explicit_date_scope: "2020" + }, + data_need_graph: { + business_fact_family: "business_overview", + clarification_gaps: [] + } + }, + bridge: { + bridge_status: "answer_draft_ready", + user_facing_response_allowed: true, + business_fact_answer_allowed: true, + requires_user_clarification: false, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference", + headline: "Business overview was assembled from confirmed 1C evidence.", + confirmed_lines: [ + "Incoming customer money flow: 200000.00 RUB.", + "Outgoing supplier payouts: 150000.00 RUB." + ], + inference_lines: ["Net confirmed cash-flow spread is +50000.00 RUB; this is not profit."], + unknown_lines: ["Profit and formal margin are not confirmed by this overview."], + limitation_lines: ["The overview is limited to checked 1C rows."], + next_step_line: "Check profit, VAT, debt quality, and inventory liquidity next." + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded"); + expect(result.reply_text).toContain("Business overview"); + expect(result.reply_text).toContain("Incoming customer money flow"); + 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_exact_matched_factual_address_reply"); + }); + it("overrides exact inbound value-flow replies when the discovery turn meaning asks for payouts", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "Incoming turnover by SVK: 12 224 925.00 rub.", @@ -744,6 +807,65 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); }); + it("keeps runtime-matched exact VAT replies over a stale business overview discovery seed", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Short: confirmed VAT for 2020 is based on checked VAT rows.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "vat_liability_confirmed_for_tax_period", + selected_recipe: "address_vat_liability_confirmed_tax_period_v1", + mcp_call_status: "matched_non_empty", + truth_mode: "confirmed", + capability_binding_status: "bound", + capability_binding_violations: [], + truth_gate_contract_status: "full_confirmed", + assistant_truth_answer_policy_v1: { + truth_gate: { + coverage_status: "full", + grounding_status: "grounded", + source_truth_gate_status: "full_confirmed" + }, + answer_shape: { + reply_type: "factual", + capability_contract_id: "confirmed_vat_liability_for_tax_period" + } + }, + assistant_state_transition_v1: { + reason_codes: [ + "root_followup_continue_previous", + "route_expectation_matched", + "vat_period_inspection_bridge_signal_detected", + "confirmed_balance_exact_vat_tax_period_intent" + ] + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation" + }, + data_need_graph: { + business_fact_family: "business_overview", + clarification_gaps: [] + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_text).toContain("confirmed VAT"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + }); + it("keeps address lane answers when discovery was not requested for the current turn", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "supported exact route answer", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index a861d7d..315b268 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -279,6 +279,66 @@ describe("assistant MCP discovery runtime bridge", () => { expect(result.answer_draft.confirmed_lines.join("\n")).toContain("заплатили"); }); + it("executes explicit-counterparty bidirectional comparison without period as bounded all-time scope", async () => { + const result = await runAssistantMcpDiscoveryRuntimeBridge({ + dataNeedGraph: { + schema_version: "assistant_data_need_graph_v1", + policy_owner: "assistantMcpDiscoveryDataNeedGraph", + subject_candidates: ["SVK"], + business_fact_family: "value_flow", + action_family: "net_value_flow", + aggregation_need: null, + time_scope_need: "all_time_scope", + comparison_need: "incoming_vs_outgoing", + ranking_need: null, + proof_expectation: "coverage_checked_fact", + clarification_gaps: [], + decomposition_candidates: [ + "resolve_entity_reference", + "collect_incoming_movements", + "collect_outgoing_movements", + "probe_coverage" + ], + forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"], + reason_codes: [ + "data_need_graph_built", + "data_need_graph_comparison_incoming_vs_outgoing", + "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope" + ] + }, + turnMeaning: { + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["SVK"], + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting" + }, + deps: buildBidirectionalDeps( + [ + { Period: "2020-01-10T00:00:00", Amount: 3200, Counterparty: "SVK" }, + { Period: "2021-04-11T00:00:00", Amount: 1800, Counterparty: "SVK" } + ], + [{ Period: "2022-02-12T00:00:00", Amount: 1400, Counterparty: "SVK" }] + ) + }); + + expect(result.bridge_status).toBe("answer_draft_ready"); + expect(result.requires_user_clarification).toBe(false); + expect(result.business_fact_answer_allowed).toBe(true); + expect(result.planner.selected_chain_id).toBe("value_flow_comparison"); + expect(result.planner.required_axes).toContain("all_time_scope"); + expect(result.pilot.mcp_execution_performed).toBe(true); + expect(result.pilot.derived_bidirectional_value_flow).toMatchObject({ + period_scope: null, + incoming_customer_revenue: { + total_amount: 5000 + }, + outgoing_supplier_payout: { + total_amount: 1400 + } + }); + expect(result.answer_draft.confirmed_lines.join("\n")).toContain("SVK"); + }); + it("keeps document-ready plans bounded when the pilot finds no confirmed rows", async () => { const result = await runAssistantMcpDiscoveryRuntimeBridge({ turnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index b6d56b8..c916c1e 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -133,6 +133,39 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected"); }); + it("keeps explicit counterparty money-flow basis questions executable without requiring a period", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "How much money passed with SVK, incoming and outgoing, and what documents or movements prove it?", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "list_documents", + explicit_intent_candidate: "list_documents_by_counterparty", + explicit_entity_candidates: [{ value: "SVK" }] + }, + predecomposeContract: { + entities: { counterparty: "SVK" }, + period: { scope: "unspecified", period_from: null, period_to: null } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + explicit_entity_candidates: ["SVK"], + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined(); + expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope"); + expect(result.data_need_graph?.clarification_gaps).toEqual([]); + expect(result.data_need_graph?.reason_codes).toContain( + "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope" + ); + expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected"); + }); + it("extracts compact scoped counterparty from net follow-up wording when LLM entities are empty", () => { const orgName = "ООО Альтернатива Плюс"; const result = buildAssistantMcpDiscoveryTurnInput({ @@ -938,6 +971,39 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context"); }); + it("does not leak implicit current date into document follow-up after all-time bidirectional value-flow", () => { + const orgName = "ООО Альтернатива Плюс"; + const counterpartyName = "Группа СВК"; + const today = new Date().toISOString().slice(0, 10); + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а покажи документы по этой цепочке", + followupContext: { + previous_discovery_pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + previous_filters: { + counterparty: counterpartyName, + organization: orgName, + as_of_date: today + }, + previous_anchor_type: "counterparty", + previous_anchor_value: counterpartyName + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "documents", + asked_action_family: "list_documents", + explicit_entity_candidates: [counterpartyName], + explicit_organization_scope: orgName, + unsupported_but_understood_family: "document_evidence", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined(); + expect(result.reason_codes).toContain("mcp_discovery_value_flow_grounded_document_followup"); + expect(result.reason_codes).not.toContain("mcp_discovery_date_scope_from_followup_context"); + }); + it("seeds short metadata follow-up from prior metadata discovery context", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: "а по регистрам?", @@ -1531,6 +1597,40 @@ describe("assistant MCP discovery turn input adapter", () => { }); }); + it("keeps a raw business-overview year over a predecompose as-of date derived from that year", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434: \u0434\u0435\u043d\u044c\u0433\u0438, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434.", + assistantTurnMeaning: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }, + predecomposeContract: { + entities: { + organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441" + }, + period: { + scope: "as_of", + period_from: "2020-01-01", + period_to: "2020-12-31", + as_of_date: "2020-12-31", + has_explicit_period: true + } + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "business_overview", + asked_action_family: "broad_evaluation", + explicit_date_scope: "2020" + }); + expect(result.reason_codes).toContain("mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope"); + }); + it("keeps all-time business overview from reusing a negated VAT period as active scope", () => { const result = buildAssistantMcpDiscoveryTurnInput({ userMessage: @@ -2937,7 +3037,7 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context"); }); - it("keeps VAT-position follow-up inside business overview instead of stale inventory position", () => { + it("lets an explicit VAT follow-up stay on the exact VAT route instead of stale business 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({ @@ -2957,21 +3057,16 @@ describe("assistant MCP discovery turn input adapter", () => { } }); - 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: "broad_evaluation", - explicit_organization_scope: orgName, - explicit_date_scope: "2020", - unsupported_but_understood_family: "broad_business_evaluation", - 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.adapter_status).toBe("not_applicable"); + expect(result.should_run_discovery).toBe(false); + expect(result.semantic_data_need).toBeNull(); + expect(result.data_need_graph).toBeNull(); + expect(result.turn_meaning_ref).toBeNull(); + expect(result.reason_codes).toContain( + "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question" + ); + expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_continuation_from_followup_context"); }); it("routes business overview final-summary wording to the overview lane without document pseudo subject", () => { @@ -3012,4 +3107,108 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context"); }); + + it("routes cross-scope executive summary over stale document carryover and keeps prior bundles", () => { + const orgName = + "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; + const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a"; + const valueFlowBundle = { + counterparty: counterpartyName, + incoming_total: 20653490, + outgoing_total: 2129651, + net_amount: 18523839 + }; + const documentBundle = { + counterparty: counterpartyName, + document_count: 19, + direct_answer: + "\u041a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442: \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a. \u041d\u0430\u0439\u0434\u0435\u043d\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432: 19." + }; + + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "\u0421\u043e\u0431\u0435\u0440\u0438 \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u0438\u0442\u043e\u0433: \u0447\u0442\u043e \u043c\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438, \u0447\u0442\u043e \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a, \u043a\u0430\u043a\u0438\u0435 \u0432\u044b\u0432\u043e\u0434\u044b \u043c\u043e\u0436\u043d\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0438 \u043a\u0430\u043a\u0438\u0435 \u043d\u0435\u043b\u044c\u0437\u044f.", + assistantTurnMeaning: { + asked_domain_family: "counterparty", + asked_action_family: "list_documents", + explicit_intent_candidate: "list_documents_by_counterparty" + }, + followupContext: { + previous_discovery_pilot_scope: "counterparty_document_evidence_query_documents_v1", + previous_intent: "list_documents_by_counterparty", + target_intent: "list_documents_by_counterparty", + previous_filters: { + organization: orgName, + counterparty: counterpartyName, + as_of_date: "2026-05-09" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: counterpartyName, + previous_discovery_bidirectional_value_flow: valueFlowBundle, + previous_discovery_document_summary: documentBundle + } + }); + + 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: "broad_evaluation", + business_overview_separate_entity_candidates: [counterpartyName], + previous_counterparty_value_flow_bundle: valueFlowBundle, + previous_counterparty_document_bundle: documentBundle, + explicit_organization_scope: orgName, + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain( + "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn"); + }); + + it("preserves explicit counterparty scope for company plus counterparty business summaries", () => { + const orgName = "ООО Альтернатива Плюс"; + const counterpartyName = "Группа СВК"; + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: + "Собери короткий итог: что мы подтвердили по компании, что отдельно по Группа СВК, какие выводы можно делать и какие нельзя.", + 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: "counterparty_document_evidence_query_documents_v1", + previous_filters: { + organization: orgName, + counterparty: counterpartyName + }, + previous_anchor_type: "counterparty", + previous_anchor_value: counterpartyName + } + }); + + expect(result.adapter_status).toBe("ready"); + 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: "broad_evaluation", + business_overview_separate_entity_candidates: [counterpartyName], + explicit_organization_scope: orgName, + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + }); + expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); + expect(result.reason_codes).toContain( + "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope" + ); + expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index 23900bb..5f993d0 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -69,6 +69,27 @@ describe("assistantMemoryRecapPolicy", () => { expect(signals.contextualMemoryRecapFollowupDetected).toBe(true); }); + it("detects startup memory checkpoint without prior grounded context", () => { + const signals = policy.resolveRouteMemorySignals({ + rawUserMessage: + "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "", + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + dataRetrievalSignal: false, + strongDataSignal: true, + aggregateBusinessAnalyticsSignal: false, + lastGroundedAddressDebug: null, + hasPriorAddressDebug: false, + sessionItems: [] + }); + + expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false); + expect(signals.contextualMemoryRecapFollowupDetected).toBe(true); + }); + it("treats explicit recap wording over selected-object phrasing as memory follow-up even when data cues are present", () => { const signals = policy.resolveRouteMemorySignals({ rawUserMessage: "а ты помнишь, что мы по этой позиции уже выяснили?", @@ -324,6 +345,25 @@ describe("assistantMemoryRecapPolicy", () => { expect(reply).toContain("подняли документы закупки"); }); + it("honestly reports empty memory when startup checkpoint has no selected context", () => { + const reply = buildAddressMemoryRecapReply({ + organization: null, + addressDebug: null, + sessionItems: [], + userMessage: + "Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.", + toNonEmptyString: (value: unknown) => { + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + } + }); + + expect(reply).toContain("Коротко: в текущем диалоге я не вижу выбранной компании"); + expect(reply).toContain("Группа СВК"); + expect(reply).toContain("не подтверждена"); + expect(reply).not.toContain("Да, помню предыдущий адресный контур"); + }); + it("resolves grounded answer inspection from shared memory context", () => { const context = resolveAssistantLivingChatMemoryContext({ modeDecisionReason: "answer_inspection_followup_detected", diff --git a/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts index 9c2f3ee..acee75d 100644 --- a/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantTruthAnswerPolicyRuntimeAdapter.test.ts @@ -131,6 +131,85 @@ describe("assistant truth answer policy runtime adapter", () => { expect(policy.answer_shape.may_power_followup).toBe(true); }); + it("downgrades stale full-confirmed truth gates when coverage evidence is only heuristic", () => { + const policy = resolveAssistantTruthAnswerPolicyRuntime({ + addressDebug: { + capability_id: "address_open_items_by_counterparty_or_contract", + rows_matched: 8, + answer_grounding_check: { + status: "grounded", + reasons: ["confirmed_balance_unavailable_fallback_to_heuristic_candidates"] + }, + address_coverage_evidence_v1: { + schema_version: "address_coverage_evidence_v1", + policy_owner: "addressCoverageEvidencePolicy", + requested_result_mode: "confirmed_balance", + result_mode: "heuristic_candidates", + evidence_strength: "medium", + balance_confirmed: false, + as_of_date_basis: "period_range", + coverage_status: "partial", + evidence_basis: "heuristic_candidates", + reason_codes: [ + "coverage_status_partial", + "result_mode_heuristic_candidates", + "balance_confirmed_false" + ] + }, + address_truth_gate_v1: { + schema_version: "address_truth_gate_v1", + policy_owner: "addressTruthGatePolicy", + truth_gate_status: "full_confirmed", + carryover_eligibility: "none", + limited_reason_category: null, + runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", + reason_codes: ["stale_full_confirmed_shadow"], + blocked_or_limited_explanation: null + } + }, + replyType: "factual" + }); + + expect(policy.truth_gate.coverage_status).toBe("partial"); + expect(policy.truth_gate.truth_mode).toBe("limited"); + expect(policy.truth_gate.evidence_grade).toBe("medium"); + expect(policy.truth_gate.reason_codes).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates"); + expect(policy.answer_shape.answer_shape).toBe("limited_with_reason"); + expect(policy.answer_shape.must_include_limitation).toBe(true); + }); + + it("downgrades stale full-confirmed truth gates from top-level heuristic result metadata", () => { + const policy = resolveAssistantTruthAnswerPolicyRuntime({ + addressDebug: { + capability_id: "address_open_items_by_counterparty_or_contract", + rows_matched: 8, + result_mode: "heuristic_candidates", + evidence_strength: "medium", + balance_confirmed: false, + answer_grounding_check: { + status: "grounded", + reasons: ["confirmed_balance_unavailable_fallback_to_heuristic_candidates"] + }, + address_truth_gate_v1: { + schema_version: "address_truth_gate_v1", + policy_owner: "addressTruthGatePolicy", + truth_gate_status: "full_confirmed", + carryover_eligibility: "none", + limited_reason_category: null, + runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS", + reason_codes: [], + blocked_or_limited_explanation: null + } + }, + replyType: "factual" + }); + + expect(policy.truth_gate.coverage_status).toBe("partial"); + expect(policy.truth_gate.truth_mode).toBe("limited"); + expect(policy.truth_gate.evidence_grade).toBe("medium"); + expect(policy.answer_shape.answer_shape).toBe("limited_with_reason"); + }); + it("keeps explicit temporal-limited factual answers limited in the truth contract", () => { const policy = resolveAssistantTruthAnswerPolicyRuntime({ addressDebug: {