From a15cbb3fcb8ecfa26a51360e8618e58b3705c887 Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 8 May 2026 21:57:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=BA=D1=80=D0=B5=D0=BF=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=81=D0=B5=D0=BC=D0=B0=D0=BD=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D1=83=D1=8E=20=D0=B0=D1=80=D0=B1=D0=B8=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8E=20follow-up=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=20=D0=B6=D0=B8=D1=80=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B3=D0=BE=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressIntentResolver.js | 12 ++ .../address_runtime/decomposeStage.js | 31 ++++- .../assistantMcpDiscoveryResponsePolicy.js | 92 +++++++++++++- .../assistantMcpDiscoveryTurnInputAdapter.js | 17 +++ .../services/assistantMetaFollowupPolicy.js | 52 +++++++- .../services/assistantTurnMeaningPolicy.js | 89 +++++++++++-- .../src/services/addressIntentResolver.ts | 30 +++++ .../address_runtime/decomposeStage.ts | 45 ++++++- .../assistantMcpDiscoveryResponsePolicy.ts | 117 +++++++++++++++++- .../assistantMcpDiscoveryTurnInputAdapter.ts | 17 +++ .../services/assistantMetaFollowupPolicy.ts | 84 +++++++++++-- .../services/assistantTurnMeaningPolicy.ts | 90 +++++++++++++- ...tyFollowupInventoryStaleRegression.test.ts | 31 +++++ .../addressIntentResolverRegression.test.ts | 9 ++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 95 ++++++++++++++ ...nputAdapterGarbageEntityRegression.test.ts | 33 +++++ .../tests/assistantMetaFollowupPolicy.test.ts | 45 +++++++ .../tests/assistantTurnMeaningPolicy.test.ts | 46 +++++++ 18 files changed, 898 insertions(+), 37 deletions(-) create mode 100644 llm_normalizer/backend/tests/addressCounterpartyFollowupInventoryStaleRegression.test.ts create mode 100644 llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapterGarbageEntityRegression.test.ts diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 2137dec..3550950 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1724,6 +1724,13 @@ function resolveUnicodeAddressIntentBridge(text) { ]).has(byAnchorToken); const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized); const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized); + 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) && + /(?:\u0437\u0430\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|purchase|document)/iu.test(normalized))); + 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"); @@ -1745,6 +1752,11 @@ function resolveUnicodeAddressIntentBridge(text) { if (!hasContractCue && hasHighestValueCustomerCue) { return unicodeBridgeResolution("customer_revenue_and_payments", "high", "unicode_customer_revenue_bridge_signal_detected"); } + const hasCustomerConcentrationCue = /(?:\u043a\u0440\u0443\u043f\u043d\w*|\u043e\u0441\u043d\u043e\u0432\u043d\w*|\u0433\u043b\u0430\u0432\u043d\w*|\u0442\u043e\u043f|top|largest|main|key|concentration|\u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\w*|\u0437\u0430\u0432\u0438\u0441\w*|\u043e\u0434\u043d(?:\u043e\u0433\u043e|\u043e\u043c\u0443)\s+(?:\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043b\u0438\u0435\u043d\u0442))/iu.test(normalized) && + /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|customer|client|buyer|counterparty)/iu.test(normalized); + if (!hasContractCue && hasCustomerConcentrationCue) { + return unicodeBridgeResolution("customer_revenue_and_payments", "high", "unicode_customer_concentration_bridge_signal_detected"); + } if (hasOrganizationLevelEarningsOverviewBridgeSignal(normalized)) { return unicodeBridgeResolution("unknown", "high", "unicode_business_overview_earnings_deferred_to_discovery"); } diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 78ebb86..9bc7f77 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -522,6 +522,9 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, return hasTemporalPatch; } function hasSelectedObjectInventorySignal(text) { + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) { + return true; + } return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? "")); } function hasSelectedObjectInlineSnapshotMetadata(text) { @@ -590,6 +593,12 @@ function hasAddressFollowupContextSignal(text) { if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(normalized)) { return true; } + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(normalized)) { + return true; + } + if (/(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|а\s+теперь|теперь|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that|then|now)/iu.test(normalized)) { + return true; + } if (hasAllTimeHint(normalized)) { return true; } @@ -1201,7 +1210,17 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo followupContext.current_frame_kind === "inventory_drilldown"; const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal)); + const previousCounterpartyLaneActive = hasPreviousCounterparty && + (followupContext.previous_anchor_type === "counterparty" || + sourceIntent === "list_documents_by_counterparty" || + sourceIntent === "list_contracts_by_counterparty" || + sourceIntent === "bank_operations_by_counterparty" || + sourceIntent === "customer_revenue_and_payments" || + sourceIntent === "supplier_payouts_profile" || + sourceIntent === "counterparty_activity_lifecycle" || + sourceIntent === "open_items_by_counterparty_or_contract"); const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test(normalizedMessage) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage); + const staleInventoryLineageCanYieldToCounterparty = previousCounterpartyLaneActive && !hasExplicitInventoryItemReference; const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage); if (inventoryPurchaseDateVatBridge && (detectedIntent.intent === "unknown" || @@ -1236,14 +1255,20 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"] }; } - if (!inventoryLineageActive && + if ((!inventoryLineageActive || staleInventoryLineageCanYieldToCounterparty) && hasAnyPartyAnchor && !hasExplicitInventoryItemReference && - detectedIntent.intent === "inventory_purchase_documents_for_item") { + (detectedIntent.intent === "inventory_purchase_documents_for_item" || + (staleInventoryLineageCanYieldToCounterparty && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage)))) { return { intent: hasPreviousContract && !hasPreviousCounterparty ? "list_documents_by_contract" : "list_documents_by_counterparty", confidence: "low", - reasons: [...detectedIntent.reasons, "intent_adjusted_from_non_inventory_followup_context"] + reasons: [ + ...detectedIntent.reasons, + staleInventoryLineageCanYieldToCounterparty + ? "intent_adjusted_from_stale_inventory_context_to_counterparty_documents" + : "intent_adjusted_from_non_inventory_followup_context" + ] }; } const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index b56923d..878db2a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -111,10 +111,11 @@ function isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning) { askedAction === "age_or_activity_duration"); } if (normalizedIntent === "supplier_payouts_profile") { - return askedDomain === "counterparty_value" && askedAction === "payout"; + return (askedDomain === "counterparty_value" || askedDomain === "counterparty") && askedAction === "payout"; } if (normalizedIntent === "customer_revenue_and_payments") { - return askedDomain === "counterparty_value" && (askedAction === "turnover" || askedAction === "counterparty_value_or_turnover"); + return ((askedDomain === "counterparty_value" || askedDomain === "counterparty") && + (askedAction === "turnover" || askedAction === "counterparty_value_or_turnover")); } if (normalizedIntent === "receivables_confirmed_as_of_date") { return askedDomain === "receivables" || askedAction === "confirmed_snapshot"; @@ -137,6 +138,13 @@ function isDetectedIntentAlignedWithTurnMeaning(detectedIntent, turnMeaning) { if (normalizedIntent === "inventory_on_hand_as_of_date" || normalizedIntent === "inventory_aging_by_purchase_date") { return askedDomain === "inventory" && askedAction === "confirmed_snapshot"; } + if (normalizedIntent === "inventory_purchase_provenance_for_item" || + normalizedIntent === "inventory_purchase_documents_for_item" || + normalizedIntent === "inventory_sale_trace_for_item" || + normalizedIntent === "inventory_profitability_for_item" || + normalizedIntent === "inventory_purchase_to_sale_chain") { + return askedDomain === "inventory"; + } return false; } function readDiscoveryTurnMeaning(entryPoint) { @@ -147,6 +155,34 @@ function readDiscoveryDataNeedGraph(entryPoint) { const turnInput = toRecordObject(entryPoint?.turn_input); return toRecordObject(turnInput?.data_need_graph); } +function isMetadataDiscoveryTurn(entryPoint) { + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const graph = readDiscoveryDataNeedGraph(entryPoint); + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const reasonCodes = Array.isArray(entryPoint?.reason_codes) ? entryPoint.reason_codes : []; + return Boolean(toNonEmptyString(turnMeaning?.asked_domain_family) === "metadata" || + toNonEmptyString(turnMeaning?.unsupported_but_understood_family) === "1c_metadata_surface" || + toNonEmptyString(graph?.business_fact_family) === "schema_surface" || + toNonEmptyString(pilot?.pilot_scope) === "metadata_inspection_v1" || + reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected")); +} +function isInventoryExactAddressIntent(intent) { + return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test(String(intent ?? "")); +} +function hasMetadataDiscoveryPriority(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (!isMetadataDiscoveryTurn(entryPoint)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + return !isInventoryExactAddressIntent(detectedIntent); +} function isOpenScopeValueFlowWithoutSubject(entryPoint) { const graph = readDiscoveryDataNeedGraph(entryPoint); const businessFactFamily = toNonEmptyString(graph?.business_fact_family); @@ -224,6 +260,26 @@ function hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint) { } return false; } +function hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint) { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + if (!detectedIntent) { + return false; + } + const asksForMovements = askedDomain === "movements" || askedAction === "list_movements"; + const asksForDocuments = askedDomain === "documents" || askedAction === "list_documents"; + const detectedDocumentsLane = detectedIntent === "list_documents_by_counterparty" || detectedIntent === "list_documents_by_contract"; + const detectedCashLane = detectedIntent === "bank_operations_by_counterparty" || detectedIntent === "bank_operations_by_contract"; + return (asksForMovements && detectedDocumentsLane) || (asksForDocuments && detectedCashLane); +} function hasExactMatchedFactualAddressReply(input, entryPoint) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { return false; @@ -234,9 +290,15 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) { if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } if (hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + return false; + } const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); @@ -267,6 +329,12 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) { if (!hasEffectivelyFactualAddressReply(input)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } + 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); @@ -293,6 +361,9 @@ function hasAlignedFactualAddressReply(input, entryPoint) { if (!hasEffectivelyFactualAddressReply(input)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } @@ -323,6 +394,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) { if (needsOpenScopeValueFlowOrganizationClarification(entryPoint)) { return true; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return true; + } if (detectedIntent === "customer_revenue_and_payments" && isOpenScopeValueFlowWithoutSubject(entryPoint)) { return true; @@ -336,6 +410,9 @@ function hasMatchedFactualAddressContinuationTarget(input, entryPoint) { if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const dialogContinuationContract = toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract) ?? toRecordObject(input.addressRuntimeMeta?.dialog_continuation_contract_v2); @@ -373,6 +450,9 @@ function hasFullConfirmedFactualAddressReply(input, entryPoint) { if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); if (truthGateStatus === "full_confirmed") { return true; @@ -403,7 +483,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); + const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); + const evidenceLaneConflictWithDiscoveryTurnMeaning = hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); } @@ -428,9 +510,15 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (valueFlowActionConflictWithDiscoveryTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_response_policy_value_flow_action_conflict_allows_candidate_override"); } + if (evidenceLaneConflictWithDiscoveryTurnMeaning) { + pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); + } if (openScopeValueFlowDiscoveryPriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); } + if (metadataDiscoveryPriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_metadata_candidate_priority"); + } if (matchedFactualAddressContinuationTarget) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_address_continuation_target"); } diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index 4754284..3e6f21c 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -105,6 +105,23 @@ function isGarbageSemanticAnchorCandidate(value) { } const compact = (0, addressTextRepair_1.normalizeRussianComparableText)(text); if (new Set([ + "для", + "по", + "ему", + "нему", + "ней", + "этой", + "этому", + "этим", + "этими", + "данным", + "данными", + "документам", + "документами", + "движение", + "движения", + "движениям", + "операциям", "данным", "этим", "этими", diff --git a/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js b/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js index 4444ca3..77dedcb 100644 --- a/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js @@ -20,6 +20,45 @@ function hasImplicitHistoricalCapabilityMetaSignal(samples) { return samples.some((sample) => /(?:историческ|история|архив|раньше|ретро|старые\s+данные)/iu.test(sample) && /(?:мож(?:ешь|ем|но)|уме(?:ешь|ете))/iu.test(sample)); } +function normalizedSampleText(value) { + return String(value ?? "") + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/\u0451/gu, "\u0435") + .trim(); +} +function hasSchemaDataScopeMetaSignal(samples) { + return samples.some((sample) => { + const text = normalizedSampleText(sample); + if (!text) { + return false; + } + const hasOneCOrSchemaCue = /(?:\b1\s*[cс]\b|1с|metadata|schema|схем|структур|метаданн)/iu.test(text); + const hasSchemaObjectCue = /(?:справочник|справочники|регистр|регистры|объект(?:ы|ов)?|пол[ея]|связ[ьи]|реквизит|таблиц|раздел(?:ы|ов)?|catalog|register|object|field|relation|link)/iu.test(text); + const hasDirectSchemaQuestion = /(?:какие\s+(?:справочник|справочники|регистры|объекты|поля|связи|реквизиты)|где\s+(?:лежат|хранятся).*(?:данные|поля)|что\s+есть\s+в\s+1с\s+по)/iu.test(text); + const hasDocumentSchemaQuestion = /(?:какие\s+(?:поля|связи|реквизиты).{0,80}(?:документ|реализац|поступлен)|(?:документ|реализац|поступлен).{0,80}(?:поля|связи|реквизиты))/iu.test(text); + return (hasOneCOrSchemaCue && hasSchemaObjectCue) || hasDirectSchemaQuestion || hasDocumentSchemaQuestion; + }); +} +function hasBusinessBoundaryQuestionSignal(samples) { + return samples.some((sample) => { + const text = normalizedSampleText(sample); + if (!text) { + return false; + } + const hasClassificationCue = /(?:можно\s+ли\s+считать|это\s+можно\s+считать|как\s+считать|классифиц|объясни\s+аккуратно|это\s+уже|это\s+пока|can\s+this\s+be\s+treated)/iu.test(text); + const hasBusinessObjectCue = /(?:просроч|открыт\w*\s+задолж|долг|дебитор|кредитор|ликвид|резерв|неликвид|склад|прибыл|марж|оборот|контрагент|покупател|поставщик|overdue|debt|receivable|payable|stock|profit|margin)/iu.test(text); + return hasClassificationCue && hasBusinessObjectCue; + }); +} +function hasContextIntegrityInspectionSignal(samples) { + return samples.some((sample) => { + const text = normalizedSampleText(sample); + return Boolean(text && + /(?:проверь\s+себя|не\s+смешал|не\s+перепутал|правильно\s+ли\s+контур|объясни\s+контур|self[-\s]?check)/iu.test(text) && + /(?:контрагент|организац|компан|контур|scope|counterparty|organization|company)/iu.test(text)); + }); +} function createAssistantMetaFollowupPolicy(deps) { function resolveMetaSignalSet(input) { const samples = collectMessageSamples(input); @@ -31,12 +70,17 @@ function createAssistantMetaFollowupPolicy(deps) { answerInspectionFollowupSignal: false }; } + const dataScopeMetaQuery = hasSignalAcrossSamples(samples, deps.hasAssistantDataScopeMetaQuestionSignal) || + hasSchemaDataScopeMetaSignal(samples); + const businessBoundaryQuestion = hasBusinessBoundaryQuestionSignal(samples); return { - dataScopeMetaQuery: hasSignalAcrossSamples(samples, deps.hasAssistantDataScopeMetaQuestionSignal), - capabilityMetaQuery: hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || - hasImplicitHistoricalCapabilityMetaSignal(samples), + dataScopeMetaQuery, + capabilityMetaQuery: !businessBoundaryQuestion && + (hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || + hasImplicitHistoricalCapabilityMetaSignal(samples)), metaAnswerFollowupSignal: hasSignalAcrossSamples(samples, deps.hasMetaAnswerFollowupSignal), - answerInspectionFollowupSignal: hasSignalAcrossSamples(samples, deps.hasAnswerInspectionFollowupSignal) + answerInspectionFollowupSignal: hasSignalAcrossSamples(samples, deps.hasAnswerInspectionFollowupSignal) || + hasContextIntegrityInspectionSignal(samples) }; } function resolveHardMetaMode(input) { diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index cda9eb2..2182271 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -3,11 +3,32 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.createAssistantTurnMeaningPolicy = createAssistantTurnMeaningPolicy; const SUPPORTED_ADDRESS_INTENTS = new Set([ + "period_coverage_profile", + "document_type_and_account_section_profile", + "counterparty_population_and_roles", + "counterparty_activity_lifecycle", "receivables_confirmed_as_of_date", "payables_confirmed_as_of_date", "list_documents_by_counterparty", + "bank_operations_by_counterparty", + "list_contracts_by_counterparty", "customer_revenue_and_payments", + "supplier_payouts_profile", + "open_contracts_confirmed_as_of_date", + "list_open_contracts", + "open_items_by_counterparty_or_contract", + "list_payables_counterparties", + "list_receivables_counterparties", "inventory_on_hand_as_of_date", + "inventory_purchase_provenance_for_item", + "inventory_purchase_documents_for_item", + "inventory_supplier_stock_overlap_as_of_date", + "inventory_sale_trace_for_item", + "inventory_profitability_for_item", + "inventory_purchase_to_sale_chain", + "inventory_aging_by_purchase_date", + "contract_usage_overview", + "contract_usage_and_value", "vat_liability_confirmed_for_tax_period", "vat_payable_confirmed_as_of_date", "vat_payable_forecast" @@ -115,6 +136,17 @@ function detectCounterpartyTurnoverFamily(text) { function hasExplicitCounterpartyValueObject(text) { return /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u0441\u0434\u0435\u043b\u043a|customer|client|counterparty|supplier|vendor|contract|item|product|deal)/iu.test(text); } +function hasSelectedObjectInventoryExactSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasSelectedObjectCue = /(?:\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|\u043f\u043e\s+\u044d\u0442(?:\u043e\u0439|\u043e\u043c\u0443)\s+(?:\u043f\u043e\u0437\u0438\u0446\u0438\u0438|\u0442\u043e\u0432\u0430\u0440\u0443))/iu.test(normalized); + if (!hasSelectedObjectCue) { + return false; + } + return /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u0440\u043e\u0434\u0430\u043b|\u0437\u0430\u043a\u0443\u043f|\u043f\u043e\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0446\u0435\u043f\u043e\u0447|profit|margin|sale|purchase|document)/iu.test(normalized); +} function hasOrganizationLevelEarningsOverviewSignal(text) { const normalized = String(text ?? ""); if (!normalized || hasExplicitCounterpartyValueObject(normalized)) { @@ -138,6 +170,25 @@ function hasOrganizationLevelDebtDueDateOverviewSignal(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(normalized); return hasDueDateDebtCue && hasCompanyScopeCue; } +function hasOrganizationLevelDebtPositionOverviewSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasReceivablesCue = /(?:\u0434\u0435\u0431\u0438\u0442\u043e\u0440\w*|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\w*|receivables?|accounts\s+receivable)/iu.test(normalized); + const hasPayablesCue = /(?:\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440\w*|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436|\u043c\u044b\s+\u0434\u043e\u043b\u0436\w*|payables?|accounts\s+payable)/iu.test(normalized); + const hasOverviewCue = /(?:\u0441\u0440\u0435\u0437|\u043f\u043e\u0437\u0438\u0446\w*|\u0431\u0430\u043b\u0430\u043d\u0441|\u0441\u0430\u043b\u044c\u0434\u043e|\u043d\u0435\u0442\u0442\u043e|\u043d\u0430\s+\u0441\u0435\u0433\u043e\u0434\u043d|\u043d\u0430\s+\u0434\u0430\u0442\u0443|\u0443\s+\u043d\u0430\u0441|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0431\u0437\u043e\u0440|\u0430\u043d\u0430\u043b\u0438\u0437|as\s+of|overview|balance|net|company|business)/iu.test(normalized); + return hasReceivablesCue && hasPayablesCue && hasOverviewCue; +} +function hasDebtClassificationFollowupSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasClassificationCue = /(?:\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438\s+\u0441\u0447\u0438\u0442\u0430\u0442\u044c|\u044d\u0442\u043e\s+\u043c\u043e\u0436\u043d\u043e\s+\u0441\u0447\u0438\u0442\u0430\u0442\u044c|\u044d\u0442\u043e\s+\u0441\u0447\u0438\u0442\u0430\u0442\u044c|\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u0446|\u0447\u0442\u043e\s+\u044d\u0442\u043e|can\s+this\s+be\s+treated)/iu.test(normalized); + const hasDebtCue = /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447|\u043e\u0442\u043a\u0440\u044b\u0442\w*\s+\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440|overdue|open\s+debt|receivable|payable)/iu.test(normalized); + return hasClassificationCue && hasDebtCue; +} function hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) { const normalized = String(text ?? ""); if (!normalized) { @@ -201,6 +252,16 @@ function detectBroadBusinessEvaluation(text) { family: "broad_business_evaluation" }; } + if (hasOrganizationLevelDebtPositionOverviewSignal(normalized)) { + return { + family: "broad_business_evaluation" + }; + } + if (hasDebtClassificationFollowupSignal(normalized)) { + return { + family: "broad_business_evaluation" + }; + } if (hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(normalized)) { return { family: "broad_business_evaluation" @@ -239,7 +300,8 @@ function createAssistantTurnMeaningPolicy(deps = {}) { const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`); const supportedIntent = detectSupportedIntent(joinedText, deps); const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText); - const broadBusinessEvaluation = detectBroadBusinessEvaluation(joinedText); + const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText); + const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText); const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); const explicitIntentCandidate = broadBusinessEvaluation?.family ? null @@ -287,17 +349,20 @@ function createAssistantTurnMeaningPolicy(deps = {}) { ? "confirmed_snapshot" : broadBusinessEvaluation?.family ? "broad_evaluation" - : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" - ? "confirmed_tax_period" - : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" - ? "confirmed_snapshot" - : explicitIntentCandidate === "vat_payable_forecast" - ? "forecast" - : explicitIntentCandidate === "list_documents_by_counterparty" - ? "list_documents" - : counterpartyTurnover?.family - ? "counterparty_value_or_turnover" - : null; + : explicitIntentCandidate === "customer_revenue_and_payments" || + explicitIntentCandidate === "supplier_payouts_profile" + ? "counterparty_value_or_turnover" + : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" + ? "confirmed_tax_period" + : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" + ? "confirmed_snapshot" + : explicitIntentCandidate === "vat_payable_forecast" + ? "forecast" + : explicitIntentCandidate === "list_documents_by_counterparty" + ? "list_documents" + : counterpartyTurnover?.family + ? "counterparty_value_or_turnover" + : null; const staleReplayForbidden = Boolean(unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate)); return { schema_version: "assistant_turn_meaning_v1", diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 7602101..623c55a 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -2232,6 +2232,21 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test( normalized ); + 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) && + /(?:\u0437\u0430\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|purchase|document)/iu.test(normalized))); + 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 @@ -2281,6 +2296,21 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio ); } + const hasCustomerConcentrationCue = + /(?:\u043a\u0440\u0443\u043f\u043d\w*|\u043e\u0441\u043d\u043e\u0432\u043d\w*|\u0433\u043b\u0430\u0432\u043d\w*|\u0442\u043e\u043f|top|largest|main|key|concentration|\u043a\u043e\u043d\u0446\u0435\u043d\u0442\u0440\u0430\u0446\w*|\u0437\u0430\u0432\u0438\u0441\w*|\u043e\u0434\u043d(?:\u043e\u0433\u043e|\u043e\u043c\u0443)\s+(?:\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043b\u0438\u0435\u043d\u0442))/iu.test( + normalized + ) && + /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|customer|client|buyer|counterparty)/iu.test( + normalized + ); + if (!hasContractCue && hasCustomerConcentrationCue) { + return unicodeBridgeResolution( + "customer_revenue_and_payments", + "high", + "unicode_customer_concentration_bridge_signal_detected" + ); + } + if (hasOrganizationLevelEarningsOverviewBridgeSignal(normalized)) { return unicodeBridgeResolution("unknown", "high", "unicode_business_overview_earnings_deferred_to_discovery"); } diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 484ead9..d0466bb 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -660,6 +660,13 @@ function shouldRestoreInventoryRootFrame( } function hasSelectedObjectInventorySignal(text: string): boolean { + if ( + /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + String(text ?? "") + ) + ) { + return true; + } return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( String(text ?? "") ); @@ -763,6 +770,20 @@ export function hasAddressFollowupContextSignal(text: string): boolean { ) { return true; } + if ( + /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + normalized + ) + ) { + return true; + } + if ( + /(?:^|\s)(?:и|а\s+еще|а\s+ещё|еще|ещё|также|а\s+теперь|теперь|по\s+этому|по\s+тому|это\s+же|в\s+этом|тот\s+же|also|same|that|then|now)/iu.test( + normalized + ) + ) { + return true; + } if (hasAllTimeHint(normalized)) { return true; } @@ -1499,10 +1520,22 @@ function deriveIntentWithFollowupContext( const inventorySelectedObjectFollowup = inventoryLineageActive && (hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal)); + const previousCounterpartyLaneActive = + hasPreviousCounterparty && + (followupContext.previous_anchor_type === "counterparty" || + sourceIntent === "list_documents_by_counterparty" || + sourceIntent === "list_contracts_by_counterparty" || + sourceIntent === "bank_operations_by_counterparty" || + sourceIntent === "customer_revenue_and_payments" || + sourceIntent === "supplier_payouts_profile" || + sourceIntent === "counterparty_activity_lifecycle" || + sourceIntent === "open_items_by_counterparty_or_contract"); const hasExplicitInventoryItemReference = /(?:товар|номенклатур|позици|склад|остат|sku|item|product|товар|номенклатур|позици|склад|остат)/iu.test( normalizedMessage ) || hasSelectedObjectInlineSnapshotMetadata(normalizedMessage); + const staleInventoryLineageCanYieldToCounterparty = + previousCounterpartyLaneActive && !hasExplicitInventoryItemReference; const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage); @@ -1547,15 +1580,21 @@ function deriveIntentWithFollowupContext( } if ( - !inventoryLineageActive && + (!inventoryLineageActive || staleInventoryLineageCanYieldToCounterparty) && hasAnyPartyAnchor && !hasExplicitInventoryItemReference && - detectedIntent.intent === "inventory_purchase_documents_for_item" + (detectedIntent.intent === "inventory_purchase_documents_for_item" || + (staleInventoryLineageCanYieldToCounterparty && hasInventoryPurchaseDocumentsFollowupCue(normalizedMessage))) ) { return { intent: hasPreviousContract && !hasPreviousCounterparty ? "list_documents_by_contract" : "list_documents_by_counterparty", confidence: "low", - reasons: [...detectedIntent.reasons, "intent_adjusted_from_non_inventory_followup_context"] + reasons: [ + ...detectedIntent.reasons, + staleInventoryLineageCanYieldToCounterparty + ? "intent_adjusted_from_stale_inventory_context_to_counterparty_documents" + : "intent_adjusted_from_non_inventory_followup_context" + ] }; } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 6ccdbc2..f6e8db7 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -183,10 +183,13 @@ function isDetectedIntentAlignedWithTurnMeaning( ); } if (normalizedIntent === "supplier_payouts_profile") { - return askedDomain === "counterparty_value" && askedAction === "payout"; + return (askedDomain === "counterparty_value" || askedDomain === "counterparty") && askedAction === "payout"; } if (normalizedIntent === "customer_revenue_and_payments") { - return askedDomain === "counterparty_value" && (askedAction === "turnover" || askedAction === "counterparty_value_or_turnover"); + return ( + (askedDomain === "counterparty_value" || askedDomain === "counterparty") && + (askedAction === "turnover" || askedAction === "counterparty_value_or_turnover") + ); } if (normalizedIntent === "receivables_confirmed_as_of_date") { return askedDomain === "receivables" || askedAction === "confirmed_snapshot"; @@ -209,6 +212,15 @@ function isDetectedIntentAlignedWithTurnMeaning( if (normalizedIntent === "inventory_on_hand_as_of_date" || normalizedIntent === "inventory_aging_by_purchase_date") { return askedDomain === "inventory" && askedAction === "confirmed_snapshot"; } + if ( + normalizedIntent === "inventory_purchase_provenance_for_item" || + normalizedIntent === "inventory_purchase_documents_for_item" || + normalizedIntent === "inventory_sale_trace_for_item" || + normalizedIntent === "inventory_profitability_for_item" || + normalizedIntent === "inventory_purchase_to_sale_chain" + ) { + return askedDomain === "inventory"; + } return false; } @@ -226,6 +238,46 @@ function readDiscoveryDataNeedGraph( return toRecordObject(turnInput?.data_need_graph); } +function isMetadataDiscoveryTurn( + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const graph = readDiscoveryDataNeedGraph(entryPoint); + const bridge = toRecordObject(entryPoint?.bridge); + const pilot = toRecordObject(bridge?.pilot); + const reasonCodes = Array.isArray(entryPoint?.reason_codes) ? entryPoint.reason_codes : []; + return Boolean( + toNonEmptyString(turnMeaning?.asked_domain_family) === "metadata" || + toNonEmptyString(turnMeaning?.unsupported_but_understood_family) === "1c_metadata_surface" || + toNonEmptyString(graph?.business_fact_family) === "schema_surface" || + toNonEmptyString(pilot?.pilot_scope) === "metadata_inspection_v1" || + reasonCodes.some((reason) => toNonEmptyString(reason) === "mcp_discovery_metadata_signal_detected") + ); +} + +function isInventoryExactAddressIntent(intent: string | null): boolean { + return /^(?:inventory_purchase_provenance_for_item|inventory_purchase_documents_for_item|inventory_sale_trace_for_item|inventory_profitability_for_item|inventory_purchase_to_sale_chain|inventory_aging_by_purchase_date|inventory_on_hand_as_of_date)$/u.test( + String(intent ?? "") + ); +} + +function hasMetadataDiscoveryPriority( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + if (!isMetadataDiscoveryTurn(entryPoint)) { + return false; + } + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + return !isInventoryExactAddressIntent(detectedIntent); +} + function isOpenScopeValueFlowWithoutSubject( entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null ): boolean { @@ -324,6 +376,32 @@ function hasValueFlowActionConflictWithDiscoveryTurnMeaning( return false; } +function hasEvidenceLaneConflictWithDiscoveryTurnMeaning( + input: ApplyAssistantMcpDiscoveryResponsePolicyInput, + entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null +): boolean { + if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { + return false; + } + if (!hasEffectivelyFactualAddressReply(input)) { + return false; + } + const turnMeaning = readDiscoveryTurnMeaning(entryPoint); + const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); + const askedAction = toNonEmptyString(turnMeaning?.asked_action_family); + const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); + if (!detectedIntent) { + return false; + } + const asksForMovements = askedDomain === "movements" || askedAction === "list_movements"; + const asksForDocuments = askedDomain === "documents" || askedAction === "list_documents"; + const detectedDocumentsLane = + detectedIntent === "list_documents_by_counterparty" || detectedIntent === "list_documents_by_contract"; + const detectedCashLane = + detectedIntent === "bank_operations_by_counterparty" || detectedIntent === "bank_operations_by_contract"; + return (asksForMovements && detectedDocumentsLane) || (asksForDocuments && detectedCashLane); +} + function hasExactMatchedFactualAddressReply( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -337,9 +415,15 @@ function hasExactMatchedFactualAddressReply( if (hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } if (hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { + return false; + } const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); @@ -382,6 +466,12 @@ function hasRuntimeAdjustedExactReply( if (!hasEffectivelyFactualAddressReply(input)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } + 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); @@ -415,6 +505,9 @@ function hasAlignedFactualAddressReply( if (!hasEffectivelyFactualAddressReply(input)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } @@ -449,6 +542,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning( if (needsOpenScopeValueFlowOrganizationClarification(entryPoint)) { return true; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return true; + } if ( detectedIntent === "customer_revenue_and_payments" && isOpenScopeValueFlowWithoutSubject(entryPoint) @@ -468,6 +564,9 @@ function hasMatchedFactualAddressContinuationTarget( if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const dialogContinuationContract = toRecordObject(input.addressRuntimeMeta?.dialogContinuationContract) ?? @@ -517,6 +616,9 @@ function hasFullConfirmedFactualAddressReply( if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) { return false; } + if (hasMetadataDiscoveryPriority(input, entryPoint)) { + return false; + } const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); if (truthGateStatus === "full_confirmed") { return true; @@ -551,10 +653,15 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); + const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning( input, entryPoint ); + const evidenceLaneConflictWithDiscoveryTurnMeaning = hasEvidenceLaneConflictWithDiscoveryTurnMeaning( + input, + entryPoint + ); if (!entryPoint) { pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point"); @@ -580,9 +687,15 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (valueFlowActionConflictWithDiscoveryTurnMeaning) { pushReason(reasonCodes, "mcp_discovery_response_policy_value_flow_action_conflict_allows_candidate_override"); } + if (evidenceLaneConflictWithDiscoveryTurnMeaning) { + pushReason(reasonCodes, "mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); + } if (openScopeValueFlowDiscoveryPriority) { pushReason(reasonCodes, "mcp_discovery_response_policy_open_scope_value_flow_candidate_priority"); } + if (metadataDiscoveryPriority) { + pushReason(reasonCodes, "mcp_discovery_response_policy_metadata_candidate_priority"); + } if (matchedFactualAddressContinuationTarget) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_factual_address_continuation_target"); } diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index f0c2e35..702a8db 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -155,6 +155,23 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean { const compact = normalizeRussianComparableText(text); if ( new Set([ + "для", + "по", + "ему", + "нему", + "ней", + "этой", + "этому", + "этим", + "этими", + "данным", + "данными", + "документам", + "документами", + "движение", + "движения", + "движениям", + "операциям", "данным", "этим", "этими", diff --git a/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts b/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts index 3819fa7..7d9dd0e 100644 --- a/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts @@ -75,6 +75,69 @@ function hasImplicitHistoricalCapabilityMetaSignal(samples: string[]): boolean { ); } +function normalizedSampleText(value: string): string { + return String(value ?? "") + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/\u0451/gu, "\u0435") + .trim(); +} + +function hasSchemaDataScopeMetaSignal(samples: string[]): boolean { + return samples.some((sample) => { + const text = normalizedSampleText(sample); + if (!text) { + return false; + } + const hasOneCOrSchemaCue = + /(?:\b1\s*[cс]\b|1с|metadata|schema|схем|структур|метаданн)/iu.test(text); + const hasSchemaObjectCue = + /(?:справочник|справочники|регистр|регистры|объект(?:ы|ов)?|пол[ея]|связ[ьи]|реквизит|таблиц|раздел(?:ы|ов)?|catalog|register|object|field|relation|link)/iu.test( + text + ); + const hasDirectSchemaQuestion = + /(?:какие\s+(?:справочник|справочники|регистры|объекты|поля|связи|реквизиты)|где\s+(?:лежат|хранятся).*(?:данные|поля)|что\s+есть\s+в\s+1с\s+по)/iu.test( + text + ); + const hasDocumentSchemaQuestion = + /(?:какие\s+(?:поля|связи|реквизиты).{0,80}(?:документ|реализац|поступлен)|(?:документ|реализац|поступлен).{0,80}(?:поля|связи|реквизиты))/iu.test( + text + ); + return (hasOneCOrSchemaCue && hasSchemaObjectCue) || hasDirectSchemaQuestion || hasDocumentSchemaQuestion; + }); +} + +function hasBusinessBoundaryQuestionSignal(samples: string[]): boolean { + return samples.some((sample) => { + const text = normalizedSampleText(sample); + if (!text) { + return false; + } + const hasClassificationCue = + /(?:можно\s+ли\s+считать|это\s+можно\s+считать|как\s+считать|классифиц|объясни\s+аккуратно|это\s+уже|это\s+пока|can\s+this\s+be\s+treated)/iu.test( + text + ); + const hasBusinessObjectCue = + /(?:просроч|открыт\w*\s+задолж|долг|дебитор|кредитор|ликвид|резерв|неликвид|склад|прибыл|марж|оборот|контрагент|покупател|поставщик|overdue|debt|receivable|payable|stock|profit|margin)/iu.test( + text + ); + return hasClassificationCue && hasBusinessObjectCue; + }); +} + +function hasContextIntegrityInspectionSignal(samples: string[]): boolean { + return samples.some((sample) => { + const text = normalizedSampleText(sample); + return Boolean( + text && + /(?:проверь\s+себя|не\s+смешал|не\s+перепутал|правильно\s+ли\s+контур|объясни\s+контур|self[-\s]?check)/iu.test( + text + ) && + /(?:контрагент|организац|компан|контур|scope|counterparty|organization|company)/iu.test(text) + ); + }); +} + export function createAssistantMetaFollowupPolicy( deps: AssistantMetaFollowupPolicyDeps ) { @@ -90,22 +153,23 @@ export function createAssistantMetaFollowupPolicy( answerInspectionFollowupSignal: false }; } + const dataScopeMetaQuery = + hasSignalAcrossSamples(samples, deps.hasAssistantDataScopeMetaQuestionSignal) || + hasSchemaDataScopeMetaSignal(samples); + const businessBoundaryQuestion = hasBusinessBoundaryQuestionSignal(samples); return { - dataScopeMetaQuery: hasSignalAcrossSamples( - samples, - deps.hasAssistantDataScopeMetaQuestionSignal - ), + dataScopeMetaQuery, capabilityMetaQuery: - hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || - hasImplicitHistoricalCapabilityMetaSignal(samples), + !businessBoundaryQuestion && + (hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || + hasImplicitHistoricalCapabilityMetaSignal(samples)), metaAnswerFollowupSignal: hasSignalAcrossSamples( samples, deps.hasMetaAnswerFollowupSignal ), - answerInspectionFollowupSignal: hasSignalAcrossSamples( - samples, - deps.hasAnswerInspectionFollowupSignal - ) + answerInspectionFollowupSignal: + hasSignalAcrossSamples(samples, deps.hasAnswerInspectionFollowupSignal) || + hasContextIntegrityInspectionSignal(samples) }; } diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 8149662..710c3bd 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -1,11 +1,32 @@ // @ts-nocheck const SUPPORTED_ADDRESS_INTENTS = new Set([ + "period_coverage_profile", + "document_type_and_account_section_profile", + "counterparty_population_and_roles", + "counterparty_activity_lifecycle", "receivables_confirmed_as_of_date", "payables_confirmed_as_of_date", "list_documents_by_counterparty", + "bank_operations_by_counterparty", + "list_contracts_by_counterparty", "customer_revenue_and_payments", + "supplier_payouts_profile", + "open_contracts_confirmed_as_of_date", + "list_open_contracts", + "open_items_by_counterparty_or_contract", + "list_payables_counterparties", + "list_receivables_counterparties", "inventory_on_hand_as_of_date", + "inventory_purchase_provenance_for_item", + "inventory_purchase_documents_for_item", + "inventory_supplier_stock_overlap_as_of_date", + "inventory_sale_trace_for_item", + "inventory_profitability_for_item", + "inventory_purchase_to_sale_chain", + "inventory_aging_by_purchase_date", + "contract_usage_overview", + "contract_usage_and_value", "vat_liability_confirmed_for_tax_period", "vat_payable_confirmed_as_of_date", "vat_payable_forecast" @@ -123,6 +144,23 @@ function hasExplicitCounterpartyValueObject(text) { ); } +function hasSelectedObjectInventoryExactSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasSelectedObjectCue = + /(?:\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|\u043f\u043e\s+\u044d\u0442(?:\u043e\u0439|\u043e\u043c\u0443)\s+(?:\u043f\u043e\u0437\u0438\u0446\u0438\u0438|\u0442\u043e\u0432\u0430\u0440\u0443))/iu.test( + normalized + ); + if (!hasSelectedObjectCue) { + return false; + } + return /(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u0440\u043e\u0434\u0430\u043b|\u0437\u0430\u043a\u0443\u043f|\u043f\u043e\u043a\u0443\u043f|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0446\u0435\u043f\u043e\u0447|profit|margin|sale|purchase|document)/iu.test( + normalized + ); +} + function hasOrganizationLevelEarningsOverviewSignal(text) { const normalized = String(text ?? ""); if (!normalized || hasExplicitCounterpartyValueObject(normalized)) { @@ -169,6 +207,42 @@ function hasOrganizationLevelDebtDueDateOverviewSignal(text) { return hasDueDateDebtCue && hasCompanyScopeCue; } +function hasOrganizationLevelDebtPositionOverviewSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasReceivablesCue = + /(?:\u0434\u0435\u0431\u0438\u0442\u043e\u0440\w*|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\w*|receivables?|accounts\s+receivable)/iu.test( + normalized + ); + const hasPayablesCue = + /(?:\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440\w*|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436|\u043c\u044b\s+\u0434\u043e\u043b\u0436\w*|payables?|accounts\s+payable)/iu.test( + normalized + ); + const hasOverviewCue = + /(?:\u0441\u0440\u0435\u0437|\u043f\u043e\u0437\u0438\u0446\w*|\u0431\u0430\u043b\u0430\u043d\u0441|\u0441\u0430\u043b\u044c\u0434\u043e|\u043d\u0435\u0442\u0442\u043e|\u043d\u0430\s+\u0441\u0435\u0433\u043e\u0434\u043d|\u043d\u0430\s+\u0434\u0430\u0442\u0443|\u0443\s+\u043d\u0430\u0441|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0431\u0437\u043e\u0440|\u0430\u043d\u0430\u043b\u0438\u0437|as\s+of|overview|balance|net|company|business)/iu.test( + normalized + ); + return hasReceivablesCue && hasPayablesCue && hasOverviewCue; +} + +function hasDebtClassificationFollowupSignal(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return false; + } + const hasClassificationCue = + /(?:\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438\s+\u0441\u0447\u0438\u0442\u0430\u0442\u044c|\u044d\u0442\u043e\s+\u043c\u043e\u0436\u043d\u043e\s+\u0441\u0447\u0438\u0442\u0430\u0442\u044c|\u044d\u0442\u043e\s+\u0441\u0447\u0438\u0442\u0430\u0442\u044c|\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u0446|\u0447\u0442\u043e\s+\u044d\u0442\u043e|can\s+this\s+be\s+treated)/iu.test( + normalized + ); + const hasDebtCue = + /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447|\u043e\u0442\u043a\u0440\u044b\u0442\w*\s+\u0437\u0430\u0434\u043e\u043b\u0436|\u0434\u043e\u043b\u0433|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440|overdue|open\s+debt|receivable|payable)/iu.test( + normalized + ); + return hasClassificationCue && hasDebtCue; +} + function hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) { const normalized = String(text ?? ""); if (!normalized) { @@ -277,6 +351,16 @@ function detectBroadBusinessEvaluation(text) { family: "broad_business_evaluation" }; } + if (hasOrganizationLevelDebtPositionOverviewSignal(normalized)) { + return { + family: "broad_business_evaluation" + }; + } + if (hasDebtClassificationFollowupSignal(normalized)) { + return { + family: "broad_business_evaluation" + }; + } if (hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(normalized)) { return { family: "broad_business_evaluation" @@ -317,7 +401,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`); const supportedIntent = detectSupportedIntent(joinedText, deps); const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText); - const broadBusinessEvaluation = detectBroadBusinessEvaluation(joinedText); + const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText); + const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText); const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); const explicitIntentCandidate = broadBusinessEvaluation?.family @@ -370,6 +455,9 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { ? "confirmed_snapshot" : broadBusinessEvaluation?.family ? "broad_evaluation" + : explicitIntentCandidate === "customer_revenue_and_payments" || + explicitIntentCandidate === "supplier_payouts_profile" + ? "counterparty_value_or_turnover" : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" ? "confirmed_tax_period" : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" diff --git a/llm_normalizer/backend/tests/addressCounterpartyFollowupInventoryStaleRegression.test.ts b/llm_normalizer/backend/tests/addressCounterpartyFollowupInventoryStaleRegression.test.ts new file mode 100644 index 0000000..e41789e --- /dev/null +++ b/llm_normalizer/backend/tests/addressCounterpartyFollowupInventoryStaleRegression.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage"; + +describe("counterparty follow-up over stale inventory frame", () => { + it("keeps bare pronoun document follow-up on the active counterparty lane", () => { + const result = runAddressDecomposeStage("а по нему документы?", { + previous_intent: "list_contracts_by_counterparty", + target_intent: "list_documents_by_counterparty", + previous_filters: { + counterparty: 'ТСЖ "Жуковка 51"', + organization: "ООО Альтернатива Плюс", + as_of_date: "2026-04-16" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: 'ТСЖ "Жуковка 51"', + root_intent: "inventory_on_hand_as_of_date", + root_filters: { + organization: "ООО Альтернатива Плюс", + as_of_date: "2026-04-16" + }, + root_anchor_type: "organization", + root_anchor_value: "ООО Альтернатива Плюс", + current_frame_kind: "inventory_root" + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("list_documents_by_counterparty"); + expect(result?.filters.extracted_filters.counterparty).toBe('ТСЖ "Жуковка 51"'); + expect(result?.baseReasons).not.toContain("intent_adjusted_to_inventory_followup_context"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index 6f18d21..5ab1afd 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -29,6 +29,15 @@ describe("addressIntentResolver regression bridges", () => { expect(result.intent).toBe("customer_revenue_and_payments"); }); + it("detects customer concentration wording as customer revenue ranking", () => { + const result = resolveAddressIntent( + "\u041a\u0442\u043e \u043a\u0440\u0443\u043f\u043d\u0435\u0439\u0448\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u044b \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u044b \u041f\u043b\u044e\u0441 \u0438 \u043d\u0430\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0431\u0438\u0437\u043d\u0435\u0441 \u0437\u0430\u0432\u0438\u0441\u0438\u0442 \u043e\u0442 \u043e\u0434\u043d\u043e\u0433\u043e \u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b\u044f?" + ); + + expect(result.intent).toBe("customer_revenue_and_payments"); + expect(result.reasons).toContain("unicode_customer_concentration_bridge_signal_detected"); + }); + it("defers top-year company revenue wording to business overview discovery", () => { const result = resolveAddressIntent("какой у нас самый доходный год"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index 3a65ed5..fa2635d 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -172,6 +172,45 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); }); + it("overrides exact document replies when the discovery turn asks for movements", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Documents for SVK: invoice 1, act 2.", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "list_documents_by_counterparty", + selected_recipe: "address_list_documents_by_counterparty_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: "address_list_documents_by_counterparty" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "movements", + asked_action_family: "list_movements", + explicit_entity_candidates: ["SVK"], + explicit_date_scope: "2020" + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("Confirmed fact"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_evidence_lane_conflict_allows_candidate_override"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); + }); + it("keeps exact matched inventory address replies over stale metadata discovery candidates", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "По товару Шкаф картотечный 1000*400*2100 цепочка поставки и продажи подтверждена.", @@ -209,6 +248,62 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); }); + it("lets metadata discovery override a stale exact address answer outside protected inventory chains", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "ИП Калинин Рќ.Рњ. | СЃСѓРјРјР°: 216600", + currentReplySource: "address_query_runtime_v1", + currentReplyType: "factual", + addressRuntimeMeta: { + detected_intent: "customer_revenue_and_payments", + selected_recipe: "address_customer_revenue_and_payments_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: "address_customer_revenue_and_payments" + }, + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "metadata", + asked_action_family: "inspect_surface", + unsupported_but_understood_family: "1c_metadata_surface" + }, + data_need_graph: { + business_fact_family: "schema_surface", + decomposition_candidates: ["inspect_metadata_surface"] + } + }, + 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: "Metadata surface confirmed.", + confirmed_lines: ["Available metadata object sets: Catalog.Counterparties"], + inference_lines: [], + unknown_lines: [], + limitation_lines: [], + next_step_line: null + } + } + }) + } + }); + + expect(result.applied).toBe(true); + expect(result.decision).toBe("apply_candidate"); + expect(result.reply_text).toContain("Catalog.Counterparties"); + expect(result.reason_codes).toContain("mcp_discovery_response_policy_metadata_candidate_priority"); + expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_exact_matched_factual_address_reply"); + }); + it("keeps aligned factual address lane answers when the exact lane already matched the same semantic intent", () => { const result = applyAssistantMcpDiscoveryResponsePolicy({ currentReply: "ИП Калинин Н.М. | сумма: 216600 | операций: 2", diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapterGarbageEntityRegression.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapterGarbageEntityRegression.test.ts new file mode 100644 index 0000000..610ba75 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapterGarbageEntityRegression.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildAssistantMcpDiscoveryTurnInput } from "../src/services/assistantMcpDiscoveryTurnInputAdapter"; + +describe("assistant MCP discovery turn input adapter garbage entity regressions", () => { + it("does not treat document and movement lane words as counterparty candidates", () => { + const followupContext = { + previous_intent: "customer_revenue_and_payments" as const, + previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1", + previous_filters: { + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty" as const, + previous_anchor_value: "Группа СВК" + }; + + const documents = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а по документам?", + followupContext + }); + const movements = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а по движениям?", + followupContext + }); + + expect(documents.turn_meaning_ref?.explicit_entity_candidates).toEqual(["Группа СВК"]); + expect(movements.turn_meaning_ref?.explicit_entity_candidates).toEqual(["Группа СВК"]); + expect(documents.turn_meaning_ref?.explicit_entity_candidates).not.toContain("документам"); + expect(movements.turn_meaning_ref?.explicit_entity_candidates).not.toContain("движениям"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts b/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts index 534d3ef..dfdaea7 100644 --- a/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts @@ -56,6 +56,51 @@ describe("assistantMetaFollowupPolicy", () => { ).toBeNull(); }); + it("detects 1C schema/catalog questions as data-scope meta even when base detector misses them", () => { + const signals = policy.resolveMetaSignalSet({ + rawUserMessage: + "\u041a\u0430\u043a\u0438\u0435 \u0441\u043f\u0440\u0430\u0432\u043e\u0447\u043d\u0438\u043a\u0438 1\u0421 \u0435\u0441\u0442\u044c \u043f\u043e \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "" + }); + + expect(signals.dataScopeMetaQuery).toBe(true); + expect(signals.capabilityMetaQuery).toBe(false); + }); + + it("detects document field/link questions as schema meta rather than fake counterparty documents", () => { + const signals = policy.resolveMetaSignalSet({ + rawUserMessage: + "\u041a\u0430\u043a\u0438\u0435 \u043f\u043e\u043b\u044f \u0438 \u0441\u0432\u044f\u0437\u0438 \u0441\u0442\u043e\u0438\u0442 \u0441\u043c\u043e\u0442\u0440\u0435\u0442\u044c \u0443 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432 \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 \u0438 \u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u044f?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "" + }); + + expect(signals.dataScopeMetaQuery).toBe(true); + expect(signals.capabilityMetaQuery).toBe(false); + }); + + it("does not turn debt classification wording into capability meta help", () => { + const noisyPolicy = createAssistantMetaFollowupPolicy({ + hasAssistantDataScopeMetaQuestionSignal: () => false, + shouldHandleAsAssistantCapabilityMetaQuery: () => true, + hasMetaAnswerFollowupSignal: () => false, + hasAnswerInspectionFollowupSignal: () => false + }); + + const signals = noisyPolicy.resolveMetaSignalSet({ + rawUserMessage: + "\u042d\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043f\u0440\u043e\u0441\u0440\u043e\u0447\u043a\u043e\u0439 \u0438\u043b\u0438 \u043f\u043e\u043a\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u0439 \u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u044e?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "" + }); + + expect(signals.capabilityMetaQuery).toBe(false); + }); + it("detects evaluative meta follow-up over grounded answer", () => { const detected = policy.isMetaFollowupOverGroundedAnswer({ followupContext: { previous_intent: "vat_payable_forecast" }, diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index 3af422f..22831f5 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -227,4 +227,50 @@ describe("assistantTurnMeaningPolicy", () => { expect(supplierQuality.unsupported_but_understood_family).toBe("broad_business_evaluation"); expect(supplierQuality.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); }); + + it("keeps selected-object profitability as an inventory exact intent instead of company overview", () => { + const policy = buildPolicy(); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + '\u041f\u043e \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u043c\u0443 \u043e\u0431\u044a\u0435\u043a\u0442\u0443 "\u0427\u0435\u0442\u043a\u0438 \u041f\u043e\u0441\u0442 (84*117)": \u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438 \u043d\u0430 \u043f\u0440\u043e\u0434\u0430\u0436\u0435, \u043a\u0430\u043a\u0438\u0435 \u0437\u0430\u043a\u0443\u043f\u043e\u0447\u043d\u044b\u0435 \u0438 \u043f\u0440\u043e\u0434\u0430\u0436\u043d\u044b\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u044d\u0442\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0430\u044e\u0442?' + }); + + expect(meaning.explicit_intent_candidate).toBe("inventory_profitability_for_item"); + expect(meaning.asked_domain_family).toBe("inventory"); + expect(meaning.unsupported_but_understood_family).toBeNull(); + expect(meaning.stale_replay_forbidden).toBe(false); + expect(meaning.reason_codes).not.toContain("broad_business_evaluation_current_turn_signal"); + }); + + it("treats paired receivables/payables position as business overview instead of one debt side", () => { + const policy = buildPolicy(); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u041a\u0430\u043a\u0430\u044f \u0434\u0435\u0431\u0438\u0442\u043e\u0440\u043a\u0430 \u0438 \u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440\u043a\u0430 \u043d\u0430 30.09.2020 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438?" + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("business_summary"); + expect(meaning.asked_action_family).toBe("broad_evaluation"); + expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation"); + expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); + }); + + it("treats debt overdue classification follow-up as business interpretation, not capability help", () => { + const policy = buildPolicy({ + resolveAddressIntent: () => ({ intent: "unknown", confidence: "low" }) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u042d\u0442\u043e \u043c\u043e\u0436\u043d\u043e \u0441\u0447\u0438\u0442\u0430\u0442\u044c \u043f\u0440\u043e\u0441\u0440\u043e\u0447\u043a\u043e\u0439 \u0438\u043b\u0438 \u043f\u043e\u043a\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u0439 \u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\u044e?" + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("business_summary"); + expect(meaning.asked_action_family).toBe("broad_evaluation"); + expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation"); + }); });