From 153de1af7f0c5c32b9c990f8ef4dd7362fe41593 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 19 Apr 2026 21:02:10 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20runtime=20authority=20=D1=81=D0=BC=D1=8B=D1=81?= =?UTF-8?q?=D0=BB=D0=B0=20=D1=82=D0=B5=D0=BA=D1=83=D1=89=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BB=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dist/services/addressIntentResolver.js | 16 +- .../dist/services/assistantRoutePolicy.js | 27 ++- .../backend/dist/services/assistantService.js | 13 +- .../services/assistantTransitionPolicy.js | 15 +- .../services/assistantTurnMeaningPolicy.js | 180 ++++++++++++++++ .../src/services/addressIntentResolver.ts | 19 +- .../src/services/assistantRoutePolicy.ts | 30 ++- .../backend/src/services/assistantService.ts | 13 +- .../src/services/assistantTransitionPolicy.ts | 16 +- .../services/assistantTurnMeaningPolicy.ts | 192 ++++++++++++++++++ .../addressIntentResolverRegression.test.ts | 9 + .../tests/assistantRoutePolicy.test.ts | 30 +++ .../tests/assistantTransitionPolicy.test.ts | 44 ++++ .../tests/assistantTurnMeaningPolicy.test.ts | 57 ++++++ 14 files changed, 643 insertions(+), 18 deletions(-) create mode 100644 llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js create mode 100644 llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts create mode 100644 llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 4a7d234..7ce0e70 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1522,6 +1522,10 @@ function resolveAddressIntent(userMessage) { const text = String(userMessage ?? "").trim().toLowerCase(); const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase(); const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text; + const turnNoiseNormalizedBridgeText = bridgeText + .replace(/(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/giu, "$1\u043d\u0430\u043c") + .replace(/(^|[^\p{L}0-9_])\u043a\u0430\u043a\u0438\u0435\u043a(?=$|[^\p{L}0-9_])/giu, "$1\u043a\u0430\u043a\u0438\u0435"); + const currentTurnBridgeText = turnNoiseNormalizedBridgeText !== bridgeText ? `${bridgeText} ${turnNoiseNormalizedBridgeText}` : bridgeText; const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && /(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) && /(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text); @@ -1532,20 +1536,24 @@ function resolveAddressIntent(userMessage) { reasons: ["vat_liability_colloquial_bridge_signal_detected"] }; } - const hasExplicitReceivablesSnapshotBridge = /(?:\u043d\u0430\u043c\s+\u043a\u0442\u043e-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\w+\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043d\u0430\u043c|receivables?)/iu.test(text); + const hasExplicitReceivablesSnapshotBridge = /(?:\u043d\u0430\u043c\s+\u043a\u0442\u043e-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\w+\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043d\u0430\u043c|receivables?)/iu.test(currentTurnBridgeText); if (hasExplicitReceivablesSnapshotBridge) { return { intent: "receivables_confirmed_as_of_date", confidence: "high", - reasons: ["receivables_snapshot_bridge_signal_detected"] + reasons: currentTurnBridgeText !== bridgeText + ? ["receivables_snapshot_bridge_signal_detected", "current_turn_noise_normalized"] + : ["receivables_snapshot_bridge_signal_detected"] }; } - const hasExplicitPayablesSnapshotBridge = /(?:\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|payables?)/iu.test(text); + const hasExplicitPayablesSnapshotBridge = /(?:\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|payables?)/iu.test(currentTurnBridgeText); if (hasExplicitPayablesSnapshotBridge) { return { intent: "payables_confirmed_as_of_date", confidence: "high", - reasons: ["payables_snapshot_bridge_signal_detected"] + reasons: currentTurnBridgeText !== bridgeText + ? ["payables_snapshot_bridge_signal_detected", "current_turn_noise_normalized"] + : ["payables_snapshot_bridge_signal_detected"] }; } const hasDirectInventoryAgingBridge = /(?:\u043e\u0447\u0435\u043d\u044c\s+\u0434\u0430\u0432\u043d\u043e|\u0434\u0430\u0432\u043d\u043e\s+\u043a\u0443\u043f\u043b|\u0434\u0430\u0432\u043d\u043e\s+\u043f\u0440\u0438\u043e\u0431\u0440\u0435\u0442|\u0441\u0442\u0430\u0440(?:\u044b\u0435|\u044b\u043c|\u044b\u0445)?\s+\u0437\u0430\u043a\u0443\u043f|\u0441\u0442\u0430\u0440(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0442\u043e\u0432\u0430\u0440|old\s+stock|old\s+purchase|very\s+old\s+stock|aging\s+by\s+purchase\s+date)/iu.test(bridgeText); diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index cfa2525..850d997 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -143,7 +143,7 @@ function resolveAddressLaneProtectionArbitration(input) { }; } function createAssistantRoutePolicy(deps) { - const { repairAddressMojibake, findLastGroundedAddressAnswerDebug, findLastOrganizationClarificationAddressDebug, mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, resolveMetaSignalSet, resolveHardMetaMode, isMetaFollowupOverGroundedAnswer, isAnswerInspectionFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, hasOpenContractsAddressSignal, detectAddressQuestionMode, resolveAddressIntent, toNonEmptyString, hasStrictDeepInvestigationCue, hasStrongDataIntentSignal, hasAccountingSignal, hasDangerOrCoercionSignal, hasAddressFollowupContextSignal, hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, isGroundedInventoryContextDebug, resolveRouteMemorySignals, findLastAddressAssistantItem, resolveAddressToolGateDecision: resolveAddressToolGateDecisionOverride, hasAddressLlmPreDecomposeCandidate, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, shouldEmitOrganizationSelectionReply, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, resolveProviderExecutionState } = deps; + const { repairAddressMojibake, findLastGroundedAddressAnswerDebug, findLastOrganizationClarificationAddressDebug, mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, resolveMetaSignalSet, resolveHardMetaMode, isMetaFollowupOverGroundedAnswer, isAnswerInspectionFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, hasOpenContractsAddressSignal, detectAddressQuestionMode, resolveAddressIntent, toNonEmptyString, hasStrictDeepInvestigationCue, hasStrongDataIntentSignal, hasAccountingSignal, hasDangerOrCoercionSignal, hasAddressFollowupContextSignal, hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, isGroundedInventoryContextDebug, resolveRouteMemorySignals, findLastAddressAssistantItem, resolveAddressToolGateDecision: resolveAddressToolGateDecisionOverride, hasAddressLlmPreDecomposeCandidate, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, shouldEmitOrganizationSelectionReply, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, resolveProviderExecutionState, resolveAssistantTurnMeaning } = deps; function resolveBaseAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) { const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? "")); const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? ""); @@ -375,8 +375,30 @@ function createAssistantRoutePolicy(deps) { const resolvedModeDetection = modeDetection.mode === "address_query" ? modeDetection : modeDetectionRaw; const intentResolution = resolveAddressIntent(modeSample); const intentResolutionRaw = resolveAddressIntent(repairedRawUserMessage || rawUserMessage); - const resolvedIntentResolution = intentResolution.intent !== "unknown" ? intentResolution : intentResolutionRaw; const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const assistantTurnMeaning = typeof resolveAssistantTurnMeaning === "function" + ? resolveAssistantTurnMeaning({ + rawUserMessage, + effectiveAddressUserMessage, + repairedRawUserMessage, + repairedEffectiveAddressUserMessage, + llmPreDecomposeMeta + }) + : null; + const turnMeaningIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); + const baseResolvedIntentResolution = intentResolution.intent !== "unknown" ? intentResolution : intentResolutionRaw; + const resolvedIntentResolution = turnMeaningIntentCandidate && + baseResolvedIntentResolution.intent === "unknown" && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(turnMeaningIntentCandidate) + ? { + intent: turnMeaningIntentCandidate, + confidence: assistantTurnMeaning?.meaning_confidence === "high" ? "high" : "medium", + reasons: [ + "assistant_turn_meaning_intent_recovery", + ...(Array.isArray(assistantTurnMeaning?.reason_codes) ? assistantTurnMeaning.reason_codes : []) + ] + } + : baseResolvedIntentResolution; const llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason); const providerExecution = resolveProviderExecutionState({ useMock, @@ -1098,6 +1120,7 @@ function createAssistantRoutePolicy(deps) { schema_version: "assistant_orchestration_contract_v1", hard_meta_mode: null, provider_execution: providerExecution, + assistant_turn_meaning: assistantTurnMeaning, address_mode: resolvedModeDetection.mode, address_mode_confidence: resolvedModeDetection.confidence, address_intent: resolvedIntentResolution.intent, diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index b1550b7..d39e3a6 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -74,6 +74,7 @@ const assistantMetaFollowupPolicy_1 = __importStar(require("./assistantMetaFollo const assistantMemoryRecapPolicy_1 = __importStar(require("./assistantMemoryRecapPolicy")); const assistantContinuityPolicy_1 = __importStar(require("./assistantContinuityPolicy")); const assistantProviderExecutionPolicy_1 = __importStar(require("./assistantProviderExecutionPolicy")); +const assistantTurnMeaningPolicy_1 = __importStar(require("./assistantTurnMeaningPolicy")); const assistantRoutePolicy_1 = __importStar(require("./assistantRoutePolicy")); const assistantTransitionPolicy_1 = __importStar(require("./assistantTransitionPolicy")); const assistantOrganizationScopeRuntimeAdapter_1 = __importStar(require("./assistantOrganizationScopeRuntimeAdapter")); @@ -4057,6 +4058,12 @@ const assistantMemoryRecapPolicy = (0, assistantMemoryRecapPolicy_1.createAssist hasConversationMemoryRecallFollowupSignal: assistantLivingModePolicy.hasConversationMemoryRecallFollowupSignal, isGroundedInventoryContextDebug }); +const assistantTurnMeaningPolicy = (0, assistantTurnMeaningPolicy_1.createAssistantTurnMeaningPolicy)({ + compactWhitespace, + repairAddressMojibake, + resolveAddressIntent: addressIntentResolver_1.resolveAddressIntent, + toNonEmptyString +}); const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePolicy)({ repairAddressMojibake, findLastGroundedAddressAnswerDebug, @@ -4096,7 +4103,8 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision: assistantLivingModePolicy.resolveLivingAssistantModeDecision, - resolveProviderExecutionState: assistantProviderExecutionPolicy.resolveProviderExecutionState + resolveProviderExecutionState: assistantProviderExecutionPolicy.resolveProviderExecutionState, + resolveAssistantTurnMeaning: assistantTurnMeaningPolicy.resolveAssistantTurnMeaning }); const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistantTransitionPolicy)({ compactWhitespace, @@ -4132,7 +4140,8 @@ const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistan buildRootScopedCarryoverFilters, inferDisplayedEntityTypeFromIntent, extractDisplayedAddressEntityCandidates, - resolveDisplayedAddressEntityMention + resolveDisplayedAddressEntityMention, + resolveAssistantTurnMeaning: assistantTurnMeaningPolicy.resolveAssistantTurnMeaning }); const assistantDataScopePolicy = (0, assistantDataScopePolicy_1.createAssistantDataScopePolicy)({ activeMcpChannel: config_1.ASSISTANT_MCP_CHANNEL, diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index e596a5a..5774561 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -288,6 +288,16 @@ function createAssistantTransitionPolicy(deps) { if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) { return null; } + const assistantTurnMeaning = typeof deps.resolveAssistantTurnMeaning === "function" + ? deps.resolveAssistantTurnMeaning({ + rawUserMessage: userMessage, + effectiveAddressUserMessage: alternateMessage ?? userMessage, + llmPreDecomposeMeta + }) + : null; + if (assistantTurnMeaning?.stale_replay_forbidden === true) { + return null; + } const latestAddressItem = deps.findLastAddressAssistantItem(items); const previousAddressItem = (latestAddressItem && isUsableFollowupSourceDebug(latestAddressItem?.debug) ? latestAddressItem @@ -425,13 +435,16 @@ function createAssistantTransitionPolicy(deps) { const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage) ? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent : null; + const assistantTurnMeaningIntent = deps.toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const explicitIntent = llmExplicitIntent && llmExplicitIntent !== "unknown" ? llmExplicitIntent : resolvedPrimaryIntent && resolvedPrimaryIntent !== "unknown" ? resolvedPrimaryIntent : resolvedAlternateIntent && resolvedAlternateIntent !== "unknown" ? resolvedAlternateIntent - : null; + : assistantTurnMeaningIntent && assistantTurnMeaningIntent !== "unknown" + ? assistantTurnMeaningIntent + : null; const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent); const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent) ?? inferStandaloneAddressTopicFamily(userMessage) ?? diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js new file mode 100644 index 0000000..49be55a --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -0,0 +1,180 @@ +"use strict"; +// @ts-nocheck +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAssistantTurnMeaningPolicy = createAssistantTurnMeaningPolicy; +const SUPPORTED_ADDRESS_INTENTS = new Set([ + "receivables_confirmed_as_of_date", + "payables_confirmed_as_of_date", + "list_documents_by_counterparty", + "inventory_on_hand_as_of_date" +]); +function fallbackCompactWhitespace(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} +function normalizeTurnText(value, deps) { + const compactWhitespace = typeof deps?.compactWhitespace === "function" ? deps.compactWhitespace : fallbackCompactWhitespace; + const repaired = typeof deps?.repairAddressMojibake === "function" + ? deps.repairAddressMojibake(String(value ?? "")) + : String(value ?? ""); + return compactWhitespace(repaired.toLowerCase()) + .replace(/\u0451/gu, "\u0435") + .replace(/(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/giu, "$1\u043d\u0430\u043c") + .replace(/(^|[^\p{L}0-9_])\u043a\u0430\u043a\u0438\u0435\u043a(?=$|[^\p{L}0-9_])/giu, "$1\u043a\u0430\u043a\u0438\u0435"); +} +function toNonEmptyString(value, deps) { + if (typeof deps?.toNonEmptyString === "function") { + return deps.toNonEmptyString(value); + } + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} +function detectSupportedIntent(text, deps) { + const resolved = typeof deps?.resolveAddressIntent === "function" ? deps.resolveAddressIntent(text) : null; + const resolverIntent = toNonEmptyString(resolved?.intent, deps); + if (resolverIntent && resolverIntent !== "unknown" && SUPPORTED_ADDRESS_INTENTS.has(resolverIntent)) { + return { + intent: resolverIntent, + confidence: toNonEmptyString(resolved?.confidence, deps) ?? "medium", + reason: "address_intent_resolver_current_turn_signal" + }; + } + if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) { + return { + intent: "receivables_confirmed_as_of_date", + confidence: "high", + reason: "receivables_current_turn_meaning_signal" + }; + } + if (/(?:\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436|\u043c\u044b\s+\u043a\u043e\u043c\u0443\s+\u0434\u043e\u043b\u0436|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440|\bpayables?\b)/iu.test(text)) { + return { + intent: "payables_confirmed_as_of_date", + confidence: "high", + reason: "payables_current_turn_meaning_signal" + }; + } + if (/(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0434\u043e\u043a\u0438|docs?|documents?)/iu.test(text) && /(?:\u043f\u043e|by)\s+[\p{L}0-9._-]{2,}/iu.test(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reason: "counterparty_documents_current_turn_signal" + }; + } + if (/(?:\u043e\u0441\u0442\u0430\u0442|\u0441\u043a\u043b\u0430\u0434|inventory|stock)/iu.test(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "medium", + reason: "inventory_snapshot_current_turn_signal" + }; + } + return null; +} +function detectCounterpartyTurnoverFamily(text) { + const hasTurnoverCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue)/iu.test(text); + if (!hasTurnoverCue) { + return null; + } + const explicitEntityMatch = text.match(/(?:\u043f\u043e|by|for)?\s*([\p{L}0-9._-]{2,})\s*$/iu); + const rawEntity = explicitEntityMatch?.[1] ?? null; + const ignored = new Set([ + "\u043e\u0431\u043e\u0440\u043e\u0442", + "\u0432\u044b\u0440\u0443\u0447\u043a\u0430", + "\u0434\u043e\u0445\u043e\u0434", + "\u0431\u044b\u043b", + "\u0431\u044b\u043b\u0430", + "turnover", + "revenue" + ]); + const entity = rawEntity && !ignored.has(rawEntity) ? rawEntity : null; + return { + family: "counterparty_value_or_turnover", + entity + }; +} +function buildEntityCandidates(counterpartyTurnover) { + if (!counterpartyTurnover?.entity) { + return []; + } + return [ + { + type: "counterparty", + value: counterpartyTurnover.entity, + source: "current_turn_loose_entity_tail" + } + ]; +} +function createAssistantTurnMeaningPolicy(deps = {}) { + function resolveAssistantTurnMeaning(input = {}) { + const rawMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); + const effectiveMessage = String(input?.effectiveAddressUserMessage ?? rawMessage); + const rawText = normalizeTurnText(rawMessage, deps); + const effectiveText = normalizeTurnText(effectiveMessage, deps); + const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`); + const supportedIntent = detectSupportedIntent(joinedText, deps); + const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText); + const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); + const explicitIntentCandidate = supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); + const unsupportedFamily = !explicitIntentCandidate && counterpartyTurnover?.family ? counterpartyTurnover.family : null; + const reasonCodes = []; + if (supportedIntent?.reason) { + reasonCodes.push(supportedIntent.reason); + } + if (counterpartyTurnover?.family) { + reasonCodes.push("counterparty_turnover_current_turn_signal"); + } + if (rawText !== normalizeTurnText(rawMessage, { ...deps, repairAddressMojibake: (value) => String(value ?? "") })) { + reasonCodes.push("mojibake_repair_applied"); + } + if (rawText.includes("\u043d\u0430\u043c") && + /(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/iu.test(String(rawMessage ?? ""))) { + reasonCodes.push("known_turn_typo_normalized"); + } + const askedDomainFamily = explicitIntentCandidate?.startsWith("receivables_") + ? "receivables" + : explicitIntentCandidate?.startsWith("payables_") + ? "payables" + : explicitIntentCandidate?.startsWith("inventory_") + ? "inventory" + : explicitIntentCandidate?.includes("counterparty") + ? "counterparty" + : counterpartyTurnover?.family + ? "counterparty" + : null; + const askedActionFamily = explicitIntentCandidate === "receivables_confirmed_as_of_date" || + explicitIntentCandidate === "payables_confirmed_as_of_date" || + explicitIntentCandidate === "inventory_on_hand_as_of_date" + ? "confirmed_snapshot" + : explicitIntentCandidate === "list_documents_by_counterparty" + ? "list_documents" + : counterpartyTurnover?.family + ? "counterparty_value_or_turnover" + : null; + const staleReplayForbidden = Boolean(unsupportedFamily || (counterpartyTurnover?.entity && !explicitIntentCandidate)); + return { + schema_version: "assistant_turn_meaning_v1", + raw_message: rawMessage, + effective_message: effectiveMessage, + normalized_raw_message: rawText, + normalized_effective_message: effectiveText, + asked_domain_family: askedDomainFamily, + asked_action_family: askedActionFamily, + explicit_intent_candidate: explicitIntentCandidate, + explicit_entity_candidates: buildEntityCandidates(counterpartyTurnover), + meaning_confidence: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), + intent_override_strength: explicitIntentCandidate + ? "explicit_current_turn_intent" + : staleReplayForbidden + ? "explicit_new_action_or_entity" + : "none", + carryover_budget: staleReplayForbidden ? "none" : explicitIntentCandidate ? "matching_family_only" : "normal", + unsupported_but_understood_family: unsupportedFamily, + stale_replay_forbidden: staleReplayForbidden, + reason_codes: reasonCodes + }; + } + return { + resolveAssistantTurnMeaning + }; +} diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 50f93f3..6c76894 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -1904,6 +1904,11 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti const text = String(userMessage ?? "").trim().toLowerCase(); const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase(); const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text; + const turnNoiseNormalizedBridgeText = bridgeText + .replace(/(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/giu, "$1\u043d\u0430\u043c") + .replace(/(^|[^\p{L}0-9_])\u043a\u0430\u043a\u0438\u0435\u043a(?=$|[^\p{L}0-9_])/giu, "$1\u043a\u0430\u043a\u0438\u0435"); + const currentTurnBridgeText = + turnNoiseNormalizedBridgeText !== bridgeText ? `${bridgeText} ${turnNoiseNormalizedBridgeText}` : bridgeText; const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && @@ -1923,25 +1928,31 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti const hasExplicitReceivablesSnapshotBridge = /(?:\u043d\u0430\u043c\s+\u043a\u0442\u043e-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\w+\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043d\u0430\u043c|receivables?)/iu.test( - text + currentTurnBridgeText ); if (hasExplicitReceivablesSnapshotBridge) { return { intent: "receivables_confirmed_as_of_date", confidence: "high", - reasons: ["receivables_snapshot_bridge_signal_detected"] + reasons: + currentTurnBridgeText !== bridgeText + ? ["receivables_snapshot_bridge_signal_detected", "current_turn_noise_normalized"] + : ["receivables_snapshot_bridge_signal_detected"] }; } const hasExplicitPayablesSnapshotBridge = /(?:\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|payables?)/iu.test( - text + currentTurnBridgeText ); if (hasExplicitPayablesSnapshotBridge) { return { intent: "payables_confirmed_as_of_date", confidence: "high", - reasons: ["payables_snapshot_bridge_signal_detected"] + reasons: + currentTurnBridgeText !== bridgeText + ? ["payables_snapshot_bridge_signal_detected", "current_turn_noise_normalized"] + : ["payables_snapshot_bridge_signal_detected"] }; } diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 9286177..5a5af53 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -222,7 +222,8 @@ export function createAssistantRoutePolicy(deps) { compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, - resolveProviderExecutionState + resolveProviderExecutionState, + resolveAssistantTurnMeaning } = deps; function resolveBaseAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) { const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? "")); @@ -455,8 +456,32 @@ export function createAssistantRoutePolicy(deps) { const resolvedModeDetection = modeDetection.mode === "address_query" ? modeDetection : modeDetectionRaw; const intentResolution = resolveAddressIntent(modeSample); const intentResolutionRaw = resolveAddressIntent(repairedRawUserMessage || rawUserMessage); - const resolvedIntentResolution = intentResolution.intent !== "unknown" ? intentResolution : intentResolutionRaw; const llmContractIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const assistantTurnMeaning = + typeof resolveAssistantTurnMeaning === "function" + ? resolveAssistantTurnMeaning({ + rawUserMessage, + effectiveAddressUserMessage, + repairedRawUserMessage, + repairedEffectiveAddressUserMessage, + llmPreDecomposeMeta + }) + : null; + const turnMeaningIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); + const baseResolvedIntentResolution = intentResolution.intent !== "unknown" ? intentResolution : intentResolutionRaw; + const resolvedIntentResolution = + turnMeaningIntentCandidate && + baseResolvedIntentResolution.intent === "unknown" && + ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(turnMeaningIntentCandidate) + ? { + intent: turnMeaningIntentCandidate, + confidence: assistantTurnMeaning?.meaning_confidence === "high" ? "high" : "medium", + reasons: [ + "assistant_turn_meaning_intent_recovery", + ...(Array.isArray(assistantTurnMeaning?.reason_codes) ? assistantTurnMeaning.reason_codes : []) + ] + } + : baseResolvedIntentResolution; const llmPreDecomposeReason = toNonEmptyString(llmPreDecomposeMeta?.reason); const providerExecution = resolveProviderExecutionState({ useMock, @@ -1178,6 +1203,7 @@ export function createAssistantRoutePolicy(deps) { schema_version: "assistant_orchestration_contract_v1", hard_meta_mode: null, provider_execution: providerExecution, + assistant_turn_meaning: assistantTurnMeaning, address_mode: resolvedModeDetection.mode, address_mode_confidence: resolvedModeDetection.confidence, address_intent: resolvedIntentResolution.intent, diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 5a55ef4..3151578 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -28,6 +28,7 @@ import * as assistantMetaFollowupPolicy_1 from "./assistantMetaFollowupPolicy"; import * as assistantMemoryRecapPolicy_1 from "./assistantMemoryRecapPolicy"; import * as assistantContinuityPolicy_1 from "./assistantContinuityPolicy"; import * as assistantProviderExecutionPolicy_1 from "./assistantProviderExecutionPolicy"; +import * as assistantTurnMeaningPolicy_1 from "./assistantTurnMeaningPolicy"; import * as assistantRoutePolicy_1 from "./assistantRoutePolicy"; import * as assistantTransitionPolicy_1 from "./assistantTransitionPolicy"; import * as assistantOrganizationScopeRuntimeAdapter_1 from "./assistantOrganizationScopeRuntimeAdapter"; @@ -4014,6 +4015,12 @@ const assistantMemoryRecapPolicy = (0, assistantMemoryRecapPolicy_1.createAssist hasConversationMemoryRecallFollowupSignal: assistantLivingModePolicy.hasConversationMemoryRecallFollowupSignal, isGroundedInventoryContextDebug }); +const assistantTurnMeaningPolicy = (0, assistantTurnMeaningPolicy_1.createAssistantTurnMeaningPolicy)({ + compactWhitespace, + repairAddressMojibake, + resolveAddressIntent: addressIntentResolver_1.resolveAddressIntent, + toNonEmptyString +}); const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePolicy)({ repairAddressMojibake, findLastGroundedAddressAnswerDebug, @@ -4053,7 +4060,8 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision: assistantLivingModePolicy.resolveLivingAssistantModeDecision, - resolveProviderExecutionState: assistantProviderExecutionPolicy.resolveProviderExecutionState + resolveProviderExecutionState: assistantProviderExecutionPolicy.resolveProviderExecutionState, + resolveAssistantTurnMeaning: assistantTurnMeaningPolicy.resolveAssistantTurnMeaning }); const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistantTransitionPolicy)({ compactWhitespace, @@ -4089,7 +4097,8 @@ const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistan buildRootScopedCarryoverFilters, inferDisplayedEntityTypeFromIntent, extractDisplayedAddressEntityCandidates, - resolveDisplayedAddressEntityMention + resolveDisplayedAddressEntityMention, + resolveAssistantTurnMeaning: assistantTurnMeaningPolicy.resolveAssistantTurnMeaning }); const assistantDataScopePolicy = (0, assistantDataScopePolicy_1.createAssistantDataScopePolicy)({ activeMcpChannel: config_1.ASSISTANT_MCP_CHANNEL, diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 400057b..9b6df1b 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -381,6 +381,17 @@ export function createAssistantTransitionPolicy(deps) { if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) { return null; } + const assistantTurnMeaning = + typeof deps.resolveAssistantTurnMeaning === "function" + ? deps.resolveAssistantTurnMeaning({ + rawUserMessage: userMessage, + effectiveAddressUserMessage: alternateMessage ?? userMessage, + llmPreDecomposeMeta + }) + : null; + if (assistantTurnMeaning?.stale_replay_forbidden === true) { + return null; + } const latestAddressItem = deps.findLastAddressAssistantItem(items); const previousAddressItem = (latestAddressItem && isUsableFollowupSourceDebug(latestAddressItem?.debug) @@ -566,6 +577,7 @@ export function createAssistantTransitionPolicy(deps) { const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage) ? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent : null; + const assistantTurnMeaningIntent = deps.toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const explicitIntent = llmExplicitIntent && llmExplicitIntent !== "unknown" ? llmExplicitIntent @@ -573,7 +585,9 @@ export function createAssistantTransitionPolicy(deps) { ? resolvedPrimaryIntent : resolvedAlternateIntent && resolvedAlternateIntent !== "unknown" ? resolvedAlternateIntent - : null; + : assistantTurnMeaningIntent && assistantTurnMeaningIntent !== "unknown" + ? assistantTurnMeaningIntent + : null; const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent); const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent) ?? diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts new file mode 100644 index 0000000..70527fe --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -0,0 +1,192 @@ +// @ts-nocheck + +const SUPPORTED_ADDRESS_INTENTS = new Set([ + "receivables_confirmed_as_of_date", + "payables_confirmed_as_of_date", + "list_documents_by_counterparty", + "inventory_on_hand_as_of_date" +]); + +function fallbackCompactWhitespace(value) { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function normalizeTurnText(value, deps) { + const compactWhitespace = typeof deps?.compactWhitespace === "function" ? deps.compactWhitespace : fallbackCompactWhitespace; + const repaired = + typeof deps?.repairAddressMojibake === "function" + ? deps.repairAddressMojibake(String(value ?? "")) + : String(value ?? ""); + return compactWhitespace(repaired.toLowerCase()) + .replace(/\u0451/gu, "\u0435") + .replace(/(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/giu, "$1\u043d\u0430\u043c") + .replace(/(^|[^\p{L}0-9_])\u043a\u0430\u043a\u0438\u0435\u043a(?=$|[^\p{L}0-9_])/giu, "$1\u043a\u0430\u043a\u0438\u0435"); +} + +function toNonEmptyString(value, deps) { + if (typeof deps?.toNonEmptyString === "function") { + return deps.toNonEmptyString(value); + } + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function detectSupportedIntent(text, deps) { + const resolved = typeof deps?.resolveAddressIntent === "function" ? deps.resolveAddressIntent(text) : null; + const resolverIntent = toNonEmptyString(resolved?.intent, deps); + if (resolverIntent && resolverIntent !== "unknown" && SUPPORTED_ADDRESS_INTENTS.has(resolverIntent)) { + return { + intent: resolverIntent, + confidence: toNonEmptyString(resolved?.confidence, deps) ?? "medium", + reason: "address_intent_resolver_current_turn_signal" + }; + } + if (/(?:\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436|\u0434\u0435\u0431\u0438\u0442\u043e\u0440|\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a|\breceivables?\b)/iu.test(text)) { + return { + intent: "receivables_confirmed_as_of_date", + confidence: "high", + reason: "receivables_current_turn_meaning_signal" + }; + } + if (/(?:\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436|\u043c\u044b\s+\u043a\u043e\u043c\u0443\s+\u0434\u043e\u043b\u0436|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d|\u043a\u0440\u0435\u0434\u0438\u0442\u043e\u0440|\bpayables?\b)/iu.test(text)) { + return { + intent: "payables_confirmed_as_of_date", + confidence: "high", + reason: "payables_current_turn_meaning_signal" + }; + } + if (/(?:\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442|\u0434\u043e\u043a\u0438|docs?|documents?)/iu.test(text) && /(?:\u043f\u043e|by)\s+[\p{L}0-9._-]{2,}/iu.test(text)) { + return { + intent: "list_documents_by_counterparty", + confidence: "medium", + reason: "counterparty_documents_current_turn_signal" + }; + } + if (/(?:\u043e\u0441\u0442\u0430\u0442|\u0441\u043a\u043b\u0430\u0434|inventory|stock)/iu.test(text)) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "medium", + reason: "inventory_snapshot_current_turn_signal" + }; + } + return null; +} + +function detectCounterpartyTurnoverFamily(text) { + const hasTurnoverCue = /(?:\u043e\u0431\u043e\u0440\u043e\u0442|\u0432\u044b\u0440\u0443\u0447\u043a|\u0434\u043e\u0445\u043e\u0434|turnover|revenue)/iu.test(text); + if (!hasTurnoverCue) { + return null; + } + const explicitEntityMatch = text.match(/(?:\u043f\u043e|by|for)?\s*([\p{L}0-9._-]{2,})\s*$/iu); + const rawEntity = explicitEntityMatch?.[1] ?? null; + const ignored = new Set([ + "\u043e\u0431\u043e\u0440\u043e\u0442", + "\u0432\u044b\u0440\u0443\u0447\u043a\u0430", + "\u0434\u043e\u0445\u043e\u0434", + "\u0431\u044b\u043b", + "\u0431\u044b\u043b\u0430", + "turnover", + "revenue" + ]); + const entity = rawEntity && !ignored.has(rawEntity) ? rawEntity : null; + return { + family: "counterparty_value_or_turnover", + entity + }; +} + +function buildEntityCandidates(counterpartyTurnover) { + if (!counterpartyTurnover?.entity) { + return []; + } + return [ + { + type: "counterparty", + value: counterpartyTurnover.entity, + source: "current_turn_loose_entity_tail" + } + ]; +} + +export function createAssistantTurnMeaningPolicy(deps = {}) { + function resolveAssistantTurnMeaning(input = {}) { + const rawMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); + const effectiveMessage = String(input?.effectiveAddressUserMessage ?? rawMessage); + const rawText = normalizeTurnText(rawMessage, deps); + const effectiveText = normalizeTurnText(effectiveMessage, deps); + const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`); + const supportedIntent = detectSupportedIntent(joinedText, deps); + const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText); + const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); + const explicitIntentCandidate = + supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); + const unsupportedFamily = !explicitIntentCandidate && counterpartyTurnover?.family ? counterpartyTurnover.family : null; + const reasonCodes = []; + if (supportedIntent?.reason) { + reasonCodes.push(supportedIntent.reason); + } + if (counterpartyTurnover?.family) { + reasonCodes.push("counterparty_turnover_current_turn_signal"); + } + if (rawText !== normalizeTurnText(rawMessage, { ...deps, repairAddressMojibake: (value) => String(value ?? "") })) { + reasonCodes.push("mojibake_repair_applied"); + } + if ( + rawText.includes("\u043d\u0430\u043c") && + /(^|[^\p{L}0-9_])\u043d\u0430\u043c\u0441(?=$|[^\p{L}0-9_])/iu.test(String(rawMessage ?? "")) + ) { + reasonCodes.push("known_turn_typo_normalized"); + } + const askedDomainFamily = + explicitIntentCandidate?.startsWith("receivables_") + ? "receivables" + : explicitIntentCandidate?.startsWith("payables_") + ? "payables" + : explicitIntentCandidate?.startsWith("inventory_") + ? "inventory" + : explicitIntentCandidate?.includes("counterparty") + ? "counterparty" + : counterpartyTurnover?.family + ? "counterparty" + : null; + const askedActionFamily = + explicitIntentCandidate === "receivables_confirmed_as_of_date" || + explicitIntentCandidate === "payables_confirmed_as_of_date" || + explicitIntentCandidate === "inventory_on_hand_as_of_date" + ? "confirmed_snapshot" + : explicitIntentCandidate === "list_documents_by_counterparty" + ? "list_documents" + : counterpartyTurnover?.family + ? "counterparty_value_or_turnover" + : null; + const staleReplayForbidden = Boolean(unsupportedFamily || (counterpartyTurnover?.entity && !explicitIntentCandidate)); + return { + schema_version: "assistant_turn_meaning_v1", + raw_message: rawMessage, + effective_message: effectiveMessage, + normalized_raw_message: rawText, + normalized_effective_message: effectiveText, + asked_domain_family: askedDomainFamily, + asked_action_family: askedActionFamily, + explicit_intent_candidate: explicitIntentCandidate, + explicit_entity_candidates: buildEntityCandidates(counterpartyTurnover), + meaning_confidence: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), + intent_override_strength: explicitIntentCandidate + ? "explicit_current_turn_intent" + : staleReplayForbidden + ? "explicit_new_action_or_entity" + : "none", + carryover_budget: staleReplayForbidden ? "none" : explicitIntentCandidate ? "matching_family_only" : "normal", + unsupported_but_understood_family: unsupportedFamily, + stale_replay_forbidden: staleReplayForbidden, + reason_codes: reasonCodes + }; + } + + return { + resolveAssistantTurnMeaning + }; +} diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts index e51b673..fa1fd64 100644 --- a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -14,6 +14,15 @@ describe("addressIntentResolver regression bridges", () => { expect(result.intent).toBe("payables_confirmed_as_of_date"); }); + it("detects receivables snapshot wording through light current-turn typo noise", () => { + const result = resolveAddressIntent( + "\u043a\u0442\u043e \u043d\u0430\u043c\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0434\u0435\u043d\u0435\u0433 \u043d\u0430 \u0441\u0435\u0433\u043e\u0434\u043d\u044f" + ); + + expect(result.intent).toBe("receivables_confirmed_as_of_date"); + expect(result.reasons).toContain("current_turn_noise_normalized"); + }); + it("detects top customer all-time revenue wording", () => { const result = resolveAddressIntent("кто у нас самый доходный клиент за все время"); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index e9a024e..c897692 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -670,4 +670,34 @@ describe("assistantRoutePolicy", () => { expect(decision.toolGateDecision).toBe("run_address_lane"); expect(decision.livingMode).toBe("address_data"); }); + + it("recovers an address route from current-turn meaning when L0 resolver is noisy", () => { + const policy = buildPolicy({ + resolveAddressToolGateDecision: undefined, + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + explicit_intent_candidate: "receivables_confirmed_as_of_date", + meaning_confidence: "high", + reason_codes: ["receivables_current_turn_meaning_signal"], + stale_replay_forbidden: false + }) + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: + "\u043a\u0442\u043e \u043d\u0430\u043c\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0434\u0435\u043d\u0435\u0433 \u043d\u0430 \u0441\u0435\u0433\u043e\u0434\u043d\u044f", + effectiveAddressUserMessage: + "\u043a\u0442\u043e \u043d\u0430\u043c\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0434\u0435\u043d\u0435\u0433 \u043d\u0430 \u0441\u0435\u0433\u043e\u0434\u043d\u044f", + followupContext: null, + llmPreDecomposeMeta: null, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.livingMode).toBe("address_data"); + expect(decision.orchestrationContract?.address_intent).toBe("receivables_confirmed_as_of_date"); + expect(decision.orchestrationContract?.assistant_turn_meaning?.schema_version).toBe( + "assistant_turn_meaning_v1" + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 0ea6666..5001278 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -921,4 +921,48 @@ describe("assistantTransitionPolicy", () => { }); expect(carryover?.followupContext?.previous_filters?.as_of_date).not.toBe("2021-04-15"); }); + + it("drops carryover when current-turn meaning forbids stale replay", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Documents by previous counterparty", + debug: { + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + detected_intent: "list_documents_by_counterparty", + extracted_filters: { + counterparty: "Previous Counterparty", + organization: "Org Alt" + }, + anchor_type: "counterparty", + anchor_value_resolved: "Previous Counterparty" + } + }), + hasAddressFollowupContextSignal: () => true, + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "counterparty", + asked_action_family: "counterparty_value_or_turnover", + unsupported_but_understood_family: "counterparty_value_or_turnover", + explicit_entity_candidates: [ + { + type: "counterparty", + value: "svk", + source: "current_turn_loose_entity_tail" + } + ], + stale_replay_forbidden: true + }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a", + [], + null, + null, + null + ); + + expect(carryover).toBeNull(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts new file mode 100644 index 0000000..b60a172 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { createAssistantTurnMeaningPolicy } from "../src/services/assistantTurnMeaningPolicy"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; + +function buildPolicy() { + return createAssistantTurnMeaningPolicy({ + compactWhitespace: (value: string) => String(value ?? "").replace(/\s+/g, " ").trim(), + repairAddressMojibake: (value: string) => value, + resolveAddressIntent, + toNonEmptyString: (value: unknown) => { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; + } + }); +} + +describe("assistantTurnMeaningPolicy", () => { + it("recovers a supported receivables intent from light current-turn typo noise", () => { + const policy = buildPolicy(); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u043a\u0442\u043e \u043d\u0430\u043c\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u0434\u0435\u043d\u0435\u0433 \u043d\u0430 \u0441\u0435\u0433\u043e\u0434\u043d\u044f" + }); + + expect(meaning.schema_version).toBe("assistant_turn_meaning_v1"); + expect(meaning.explicit_intent_candidate).toBe("receivables_confirmed_as_of_date"); + expect(meaning.asked_domain_family).toBe("receivables"); + expect(meaning.carryover_budget).toBe("matching_family_only"); + expect(meaning.stale_replay_forbidden).toBe(false); + }); + + it("marks unsupported counterparty turnover as understood and forbids stale replay", () => { + const policy = buildPolicy(); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: + "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a" + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("counterparty"); + expect(meaning.asked_action_family).toBe("counterparty_value_or_turnover"); + expect(meaning.unsupported_but_understood_family).toBe("counterparty_value_or_turnover"); + expect(meaning.stale_replay_forbidden).toBe(true); + expect(meaning.explicit_entity_candidates).toEqual([ + { + type: "counterparty", + value: "\u0441\u0432\u043a", + source: "current_turn_loose_entity_tail" + } + ]); + }); +});