From 6b14946f7e0972599b34abed7fc84b5befcc8e6b Mon Sep 17 00:00:00 2001 From: dctouch Date: Fri, 17 Apr 2026 09:25:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20=D0=90=D0=9F11=20-=20?= =?UTF-8?q?=D0=92=D1=8B=D0=BD=D0=B5=D1=81=D1=82=D0=B8=20transition=20carry?= =?UTF-8?q?over=20policy=20=D0=B8=D0=B7=20assistantService=20=D0=B2=20?= =?UTF-8?q?=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D1=83=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/dist/services/assistantService.js | 39 +- .../services/assistantTransitionPolicy.js | 487 +++++++++++++++ .../backend/src/services/assistantService.ts | 39 +- .../src/services/assistantTransitionPolicy.ts | 567 ++++++++++++++++++ .../tests/assistantTransitionPolicy.test.ts | 149 +++++ 5 files changed, 1277 insertions(+), 4 deletions(-) create mode 100644 llm_normalizer/backend/dist/services/assistantTransitionPolicy.js create mode 100644 llm_normalizer/backend/src/services/assistantTransitionPolicy.ts create mode 100644 llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index cc6de63..44b869b 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -69,6 +69,7 @@ const assistantDeepTurnAttemptRuntimeAdapter_1 = __importStar(require("./assista const assistantBoundaryPolicy_1 = __importStar(require("./assistantBoundaryPolicy")); const assistantLivingModePolicy_1 = __importStar(require("./assistantLivingModePolicy")); const assistantRoutePolicy_1 = __importStar(require("./assistantRoutePolicy")); +const assistantTransitionPolicy_1 = __importStar(require("./assistantTransitionPolicy")); const assistantOrganizationScopeRuntimeAdapter_1 = __importStar(require("./assistantOrganizationScopeRuntimeAdapter")); const assistantTurnAttemptRuntimeAdapter_1 = __importStar(require("./assistantTurnAttemptRuntimeAdapter")); const assistantTurnRuntimeDepsAdapter_1 = __importStar(require("./assistantTurnRuntimeDepsAdapter")); @@ -4790,6 +4791,40 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision: assistantLivingModePolicy.resolveLivingAssistantModeDecision }); +const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistantTransitionPolicy)({ + compactWhitespace, + repairAddressMojibake, + countTokens, + findLastAddressAssistantItem, + findLastOrganizationClarificationAddressDebug, + mergeKnownOrganizations, + resolveOrganizationSelectionFromMessage, + toNonEmptyString, + buildAddressFollowupOffer, + isImplicitAddressContinuationByLlm, + isInventorySelectedObjectIntent, + hasShortInventoryObjectFollowupSignal, + resolveDebtRoleSwapFollowupIntent, + hasAddressFollowupContextSignal, + extractDisplayedEntityIndexMention, + findRecentInventoryRootFrame, + hasInventoryRootTemporalFollowupSignal, + hasFollowupMarker, + hasReferentialPointer, + hasStandaloneAddressTopicSignal, + resolveAddressIntent: addressIntentResolver_1.resolveAddressIntent, + resolveAddressIntentFamily, + readAddressFilterString, + normalizeOrganizationScopeValue, + isInventoryDrilldownFrameIntent, + isInventoryRootFrameIntent, + findRecentAddressFilterValue, + hasForeignAccountingPivotOverInventoryMessage, + buildRootScopedCarryoverFilters, + inferDisplayedEntityTypeFromIntent, + extractDisplayedAddressEntityCandidates, + resolveDisplayedAddressEntityMention +}); function normalizeOrganizationScopeSearchText(value) { const source = normalizeScopeKey(value); return source @@ -5643,9 +5678,9 @@ class AssistantService { buildAddressLlmPredecomposeContractV1: predecomposeContract_1.buildAddressLlmPredecomposeContractV1, sanitizeAddressMessageForFallback, toNonEmptyString, - resolveAddressFollowupCarryoverContext, + resolveAddressFollowupCarryoverContext: assistantTransitionPolicy.resolveAddressFollowupCarryoverContext, resolveAssistantOrchestrationDecision, - buildAddressDialogContinuationContractV2, + buildAddressDialogContinuationContractV2: assistantTransitionPolicy.buildAddressDialogContinuationContractV2, mergeFollowupContextWithOrganizationScope, isRetryableAddressLimitedResult, mergeKnownOrganizations, diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js new file mode 100644 index 0000000..66f7329 --- /dev/null +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -0,0 +1,487 @@ +"use strict"; +// @ts-nocheck +Object.defineProperty(exports, "__esModule", { value: true }); +exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy; +function createAssistantTransitionPolicy(deps) { + function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) { + const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + if (!normalized || deps.countTokens(normalized) > 4) { + return false; + } + if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") { + return false; + } + if (/(?:банк|выписк|плат[её]Р¶|оплат|списан|поступлен|bank|payment|wire|statement)/iu.test(normalized)) { + return false; + } + return /^(?:Р°|Рё|РЅСѓ)?\s*РїРѕ\s+[a-zР°-СЏС‘0-9._-]{2,}(?:\s+[a-zР°-СЏС‘0-9._-]{2,})?$/iu.test(normalized); + } + function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null, addressNavigationState = null) { + const previousAddressItem = deps.findLastAddressAssistantItem(items); + const previousAddressDebug = previousAddressItem?.debug ?? null; + const lastOrganizationClarificationDebug = deps.findLastOrganizationClarificationAddressDebug(items); + const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates) + ? deps.mergeKnownOrganizations(lastOrganizationClarificationDebug.organization_candidates) + : []; + const organizationClarificationSelection = deps.resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? + (deps.toNonEmptyString(alternateMessage) + ? deps.resolveOrganizationSelectionFromMessage(String(alternateMessage ?? ""), organizationClarificationCandidates) + : null); + const hasOrganizationClarificationContinuation = Boolean(lastOrganizationClarificationDebug && organizationClarificationSelection); + const followupOffer = previousAddressDebug ? deps.buildAddressFollowupOffer(previousAddressDebug) : null; + const hasImplicitContinuationSignal = Boolean(previousAddressDebug) && + Boolean(followupOffer?.enabled) && + (deps.isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || + (deps.toNonEmptyString(alternateMessage) + ? deps.isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) + : false)); + const sourceIntentHint = deps.toNonEmptyString(previousAddressDebug?.detected_intent); + const navigationFocusObjectHint = addressNavigationState && + typeof addressNavigationState === "object" && + addressNavigationState.session_context && + typeof addressNavigationState.session_context === "object" && + addressNavigationState.session_context.active_focus_object && + typeof addressNavigationState.session_context.active_focus_object === "object" + ? addressNavigationState.session_context.active_focus_object + : null; + const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) && + deps.toNonEmptyString(navigationFocusObjectHint?.object_type) === "item" && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || + deps.isInventorySelectedObjectIntent(sourceIntentHint))); + let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && + deps.hasShortInventoryObjectFollowupSignal(userMessage); + let inventoryShortFollowupAlternate = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && + deps.toNonEmptyString(alternateMessage) + ? deps.hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? "")) + : false; + const debtRoleSwapPrimary = sourceIntentHint + ? deps.resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) + : null; + const debtRoleSwapAlternate = sourceIntentHint && deps.toNonEmptyString(alternateMessage) + ? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) + : null; + const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; + let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; + let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) + ? deps.hasAddressFollowupContextSignal(alternateMessage) || + Boolean(debtRoleSwapAlternate) || + inventoryShortFollowupAlternate + : false; + const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; + const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) + ? deps.extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null + : false; + const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; + const recentInventoryRootFrame = deps.findRecentInventoryRootFrame(items); + const hasInventoryRootTemporalFollowupPrimary = deps.hasInventoryRootTemporalFollowupSignal(userMessage, sourceIntentHint, Boolean(recentInventoryRootFrame)); + const hasInventoryRootTemporalFollowupAlternate = deps.toNonEmptyString(alternateMessage) + ? deps.hasInventoryRootTemporalFollowupSignal(String(alternateMessage ?? ""), sourceIntentHint, Boolean(recentInventoryRootFrame)) + : false; + let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + hasImplicitContinuationSignal || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + Boolean(debtRoleSwapIntent) || + deps.hasFollowupMarker(userMessage) || + deps.hasReferentialPointer(userMessage) || + (deps.toNonEmptyString(alternateMessage) + ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || + deps.hasReferentialPointer(String(alternateMessage ?? "")) + : false); + const hasStandaloneAddressTopic = deps.hasStandaloneAddressTopicSignal(userMessage) || + (deps.toNonEmptyString(alternateMessage) ? deps.hasStandaloneAddressTopicSignal(alternateMessage) : false); + if (hasStandaloneAddressTopic && + !hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasInventoryRootTemporalFollowupPrimary && + !hasInventoryRootTemporalFollowupAlternate && + !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && + !hasIndexReferenceSignal) { + return null; + } + if (!hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasInventoryRootTemporalFollowupPrimary && + !hasInventoryRootTemporalFollowupAlternate && + !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && + !hasIndexReferenceSignal) { + return null; + } + if (!previousAddressDebug) { + return null; + } + const sourceIntent = deps.toNonEmptyString(previousAddressDebug.detected_intent); + const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; + const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage) + ? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent + : null; + const explicitIntent = llmExplicitIntent && llmExplicitIntent !== "unknown" + ? llmExplicitIntent + : resolvedPrimaryIntent && resolvedPrimaryIntent !== "unknown" + ? resolvedPrimaryIntent + : resolvedAlternateIntent && resolvedAlternateIntent !== "unknown" + ? resolvedAlternateIntent + : null; + const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent); + const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent); + if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) { + return null; + } + let previousIntent = sourceIntent; + let followupSelectionMode = "carry_previous_intent"; + if (debtRoleSwapIntent) { + previousIntent = debtRoleSwapIntent; + } + if (hasImplicitContinuationSignal) { + const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) + ? deps.toNonEmptyString(followupOffer.suggested_intents[0]) + : null; + const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent); + if (suggestedIntent && !keepPreviousIntent) { + previousIntent = suggestedIntent; + followupSelectionMode = "switch_to_suggested_intent"; + } + } + let previousAnchorType = deps.toNonEmptyString(previousAddressDebug.anchor_type); + let previousAnchor = deps.toNonEmptyString(previousAddressDebug.anchor_value_resolved) ?? + deps.toNonEmptyString(previousAddressDebug.anchor_value_raw) ?? + deps.readAddressFilterString(previousAddressDebug, "item") ?? + deps.readAddressFilterString(previousAddressDebug, "counterparty") ?? + deps.readAddressFilterString(previousAddressDebug, "account") ?? + deps.readAddressFilterString(previousAddressDebug, "contract"); + const navigationSessionContext = addressNavigationState && typeof addressNavigationState === "object" + ? addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null + : null; + const navigationDateScope = navigationSessionContext && typeof navigationSessionContext.date_scope === "object" + ? navigationSessionContext.date_scope + : null; + const navigationOrganization = deps.normalizeOrganizationScopeValue(navigationSessionContext?.organization_scope); + const navigationFocusObject = navigationSessionContext && typeof navigationSessionContext.active_focus_object === "object" + ? navigationSessionContext.active_focus_object + : null; + const navigationFocusObjectType = deps.toNonEmptyString(navigationFocusObject?.object_type); + const navigationFocusObjectLabel = deps.toNonEmptyString(navigationFocusObject?.label); + const hasInventoryItemFocusCarryover = navigationFocusObjectType === "item" && + navigationFocusObjectLabel && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || + deps.isInventorySelectedObjectIntent(sourceIntentHint)); + if (!inventoryShortFollowupPrimary && hasInventoryItemFocusCarryover) { + inventoryShortFollowupPrimary = deps.hasShortInventoryObjectFollowupSignal(userMessage); + } + if (!inventoryShortFollowupAlternate && hasInventoryItemFocusCarryover && deps.toNonEmptyString(alternateMessage)) { + inventoryShortFollowupAlternate = deps.hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? "")); + } + hasPrimaryFollowupSignal = + deps.hasAddressFollowupContextSignal(userMessage) || + Boolean(debtRoleSwapPrimary) || + inventoryShortFollowupPrimary || + hasInventoryRootTemporalFollowupPrimary; + hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) + ? deps.hasAddressFollowupContextSignal(alternateMessage) || + Boolean(debtRoleSwapAlternate) || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupAlternate + : false; + hasStrongFollowupReference = + hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + hasImplicitContinuationSignal || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + Boolean(debtRoleSwapIntent) || + deps.hasFollowupMarker(userMessage) || + deps.hasReferentialPointer(userMessage) || + (deps.toNonEmptyString(alternateMessage) + ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || + deps.hasReferentialPointer(String(alternateMessage ?? "")) + : false); + const hasSelectedObjectInventorySignalPrimary = /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|selected\s+object)/iu.test(String(userMessage ?? "")); + const hasSelectedObjectInventorySignalAlternate = deps.toNonEmptyString(alternateMessage) + ? /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|selected\s+object)/iu.test(String(alternateMessage ?? "")) + : false; + let inventoryRootFrame = deps.findRecentInventoryRootFrame(items); + if (inventoryRootFrame && navigationOrganization && !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: navigationOrganization + } + }; + } + if (inventoryRootFrame && navigationDateScope) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + as_of_date: deps.toNonEmptyString(inventoryRootFrame.filters?.as_of_date) ?? + deps.toNonEmptyString(navigationDateScope.as_of_date) ?? + undefined, + period_from: deps.toNonEmptyString(inventoryRootFrame.filters?.period_from) ?? + deps.toNonEmptyString(navigationDateScope.period_from) ?? + undefined, + period_to: deps.toNonEmptyString(inventoryRootFrame.filters?.period_to) ?? + deps.toNonEmptyString(navigationDateScope.period_to) ?? + undefined + } + }; + } + let currentFrameKind = inventoryRootFrame + ? deps.isInventoryDrilldownFrameIntent(sourceIntent) + ? "inventory_drilldown" + : deps.isInventoryRootFrameIntent(sourceIntent) + ? "inventory_root" + : "generic" + : null; + let resolvedCounterpartyFromDisplay = false; + const previousFiltersRaw = previousAddressDebug.extracted_filters; + let previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; + const shouldBackfillHistoricalPartyAnchors = sourceIntentHint === "list_contracts_by_counterparty" || + sourceIntentHint === "list_documents_by_counterparty" || + sourceIntentHint === "bank_operations_by_counterparty" || + sourceIntentHint === "list_documents_by_contract" || + sourceIntentHint === "bank_operations_by_contract" || + sourceIntentHint === "open_items_by_counterparty_or_contract"; + if (shouldBackfillHistoricalPartyAnchors && !deps.toNonEmptyString(previousFilters.contract)) { + const historicalContract = deps.findRecentAddressFilterValue(items, "contract"); + if (historicalContract) { + previousFilters.contract = historicalContract; + } + } + if (shouldBackfillHistoricalPartyAnchors && !deps.toNonEmptyString(previousFilters.counterparty)) { + const historicalCounterparty = deps.findRecentAddressFilterValue(items, "counterparty"); + if (historicalCounterparty) { + previousFilters.counterparty = historicalCounterparty; + } + } + if (!deps.toNonEmptyString(previousFilters.organization)) { + const historicalOrganization = deps.findRecentAddressFilterValue(items, "organization"); + if (historicalOrganization) { + previousFilters.organization = historicalOrganization; + } + } + if (!deps.toNonEmptyString(previousFilters.organization) && navigationOrganization) { + previousFilters.organization = navigationOrganization; + } + if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { + previousFilters.organization = organizationClarificationSelection; + } + const shouldBackfillPreviousDateScopeFromNavigation = sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || + sourceIntentHint === "inventory_purchase_provenance_for_item" || + sourceIntentHint === "inventory_purchase_documents_for_item" || + sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_profitability_for_item" || + sourceIntentHint === "inventory_purchase_to_sale_chain" || + sourceIntentHint === "inventory_aging_by_purchase_date" || + sourceIntentHint === "account_balance_snapshot" || + sourceIntentHint === "documents_forming_balance"; + if (shouldBackfillPreviousDateScopeFromNavigation && + !deps.toNonEmptyString(previousFilters.as_of_date) && + deps.toNonEmptyString(navigationDateScope?.as_of_date)) { + previousFilters.as_of_date = deps.toNonEmptyString(navigationDateScope?.as_of_date); + } + if (shouldBackfillPreviousDateScopeFromNavigation && + !deps.toNonEmptyString(previousFilters.period_from) && + deps.toNonEmptyString(navigationDateScope?.period_from)) { + previousFilters.period_from = deps.toNonEmptyString(navigationDateScope?.period_from); + } + if (shouldBackfillPreviousDateScopeFromNavigation && + !deps.toNonEmptyString(previousFilters.period_to) && + deps.toNonEmptyString(navigationDateScope?.period_to)) { + previousFilters.period_to = deps.toNonEmptyString(navigationDateScope?.period_to); + } + const rootContextOnlyPivot = Boolean((deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && + deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); + const inventoryRootTemporalPivot = Boolean(inventoryRootFrame && + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || + deps.isInventoryRootFrameIntent(sourceIntentHint) || + currentFrameKind === "inventory_drilldown" || + currentFrameKind === "inventory_root") && + (hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) && + !deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); + const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot; + if (rootScopedPivot) { + previousIntent = null; + previousAnchorType = null; + previousAnchor = null; + previousFilters = deps.buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame); + currentFrameKind = inventoryRootFrame ? "inventory_root" : currentFrameKind; + followupSelectionMode = "carry_root_context"; + } + const displayedEntityType = deps.inferDisplayedEntityTypeFromIntent(sourceIntent); + const displayedEntities = deps.extractDisplayedAddressEntityCandidates(deps.toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType); + const resolvedEntityFromFollowup = deps.resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ?? + (deps.toNonEmptyString(alternateMessage) + ? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities) + : null); + if (resolvedEntityFromFollowup && !rootScopedPivot) { + if (resolvedEntityFromFollowup.entityType === "counterparty") { + previousFilters.counterparty = resolvedEntityFromFollowup.value; + previousAnchorType = "counterparty"; + previousAnchor = resolvedEntityFromFollowup.value; + resolvedCounterpartyFromDisplay = true; + } + else if (resolvedEntityFromFollowup.entityType === "contract") { + previousFilters.contract = resolvedEntityFromFollowup.value; + previousAnchorType = "contract"; + previousAnchor = resolvedEntityFromFollowup.value; + } + else if (resolvedEntityFromFollowup.entityType === "item") { + previousFilters.item = resolvedEntityFromFollowup.value; + previousAnchorType = "item"; + previousAnchor = resolvedEntityFromFollowup.value; + } + if (followupSelectionMode !== "switch_to_suggested_intent") { + followupSelectionMode = "carry_referenced_entity"; + } + } + if (!rootScopedPivot && + !deps.toNonEmptyString(previousFilters.item) && + navigationFocusObjectType === "item" && + navigationFocusObjectLabel && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_purchase_provenance_for_item" || + sourceIntentHint === "inventory_purchase_documents_for_item" || + sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_profitability_for_item" || + sourceIntentHint === "inventory_purchase_to_sale_chain" || + sourceIntentHint === "inventory_aging_by_purchase_date" || + hasSelectedObjectInventorySignalPrimary || + hasSelectedObjectInventorySignalAlternate)) { + previousFilters.item = navigationFocusObjectLabel; + if (!previousAnchor) { + previousAnchorType = "item"; + previousAnchor = navigationFocusObjectLabel; + } + } + if (organizationClarificationSelection && !previousAnchor) { + previousAnchorType = "organization"; + previousAnchor = organizationClarificationSelection; + } + if (inventoryRootFrame && + organizationClarificationSelection && + !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: organizationClarificationSelection + } + }; + } + if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { + return null; + } + const shouldAttachInventoryRootFrame = Boolean(inventoryRootFrame && + (rootScopedPivot || + deps.isInventoryRootFrameIntent(sourceIntentHint) || + deps.isInventorySelectedObjectIntent(sourceIntentHint) || + hasNavigationInventoryItemFocusHint || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + hasSelectedObjectInventorySignalPrimary || + hasSelectedObjectInventorySignalAlternate)); + const carryoverTargetIntent = followupSelectionMode === "carry_root_context" + ? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined + : explicitIntent ?? previousIntent ?? undefined; + return { + followupContext: { + previous_intent: previousIntent ?? undefined, + target_intent: carryoverTargetIntent, + previous_filters: previousFilters, + previous_anchor_type: previousAnchorType ?? undefined, + previous_anchor_value: previousAnchor, + resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, + root_context_only: rootScopedPivot || undefined, + root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, + root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined, + root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined, + root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined, + current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined + }, + previousAddressIntent: previousIntent, + previousAddressAnchor: previousAnchor, + previousSourceIntent: sourceIntent, + followupSelectionMode, + hasImplicitContinuationSignal + }; + } + function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage, carryoverMeta, llmPreDecomposeMeta) { + const sourceMessage = String(userMessage ?? ""); + const canonicalMessage = String(effectiveMessage ?? sourceMessage); + const hasFollowupContext = Boolean(carryoverMeta?.followupContext); + const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null; + const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null; + const rootContextOnly = selectionMode === "carry_root_context"; + const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw; + const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null; + const targetIntent = selectionMode === "switch_to_suggested_intent" + ? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null + : rootContextOnly + ? rootIntent ?? explicitIntent ?? null + : explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; + const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); + const rewrittenByPredecompose = deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase()); + const hasExplicitIntent = Boolean(explicitIntent); + const decision = !hasFollowupContext + ? "new_topic" + : selectionMode === "switch_to_suggested_intent" + ? "switch_to_suggested" + : "continue_previous"; + const reasons = []; + if (hasFollowupContext) { + reasons.push("followup_context_detected"); + } + if (hasImplicitContinuationSignal) { + reasons.push("implicit_continuation_by_llm"); + } + if (rewrittenByPredecompose) { + reasons.push("effective_message_rewritten_by_predecompose"); + } + if (hasExplicitIntent) { + reasons.push("llm_contract_intent_available"); + } + if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) { + reasons.push("operation_intent_from_current_message"); + } + if (rootContextOnly) { + reasons.push("root_context_only_carryover"); + } + return { + schema_version: "address_dialog_continuation_contract_v2", + source_message: sourceMessage, + effective_message: canonicalMessage, + decision, + decision_reasons: reasons, + followup_context_applied: hasFollowupContext, + previous_intent: previousIntent, + target_intent: targetIntent, + intent_selection_mode: selectionMode, + anchor_type: carryoverMeta?.followupContext?.previous_anchor_type ?? null, + anchor_value: carryoverMeta?.followupContext?.previous_anchor_value ?? null, + implicit_continuation_signal: hasImplicitContinuationSignal + }; + } + return { + resolveAddressFollowupCarryoverContext, + buildAddressDialogContinuationContractV2 + }; +} diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index c6c2f29..9c4488d 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -23,6 +23,7 @@ import * as assistantDeepTurnAttemptRuntimeAdapter_1 from "./assistantDeepTurnAt import * as assistantBoundaryPolicy_1 from "./assistantBoundaryPolicy"; import * as assistantLivingModePolicy_1 from "./assistantLivingModePolicy"; import * as assistantRoutePolicy_1 from "./assistantRoutePolicy"; +import * as assistantTransitionPolicy_1 from "./assistantTransitionPolicy"; import * as assistantOrganizationScopeRuntimeAdapter_1 from "./assistantOrganizationScopeRuntimeAdapter"; import * as assistantOrganizationMatcher_1 from "./assistantOrganizationMatcher"; import * as assistantTurnAttemptRuntimeAdapter_1 from "./assistantTurnAttemptRuntimeAdapter"; @@ -4751,6 +4752,40 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision: assistantLivingModePolicy.resolveLivingAssistantModeDecision }); +const assistantTransitionPolicy = (0, assistantTransitionPolicy_1.createAssistantTransitionPolicy)({ + compactWhitespace, + repairAddressMojibake, + countTokens, + findLastAddressAssistantItem, + findLastOrganizationClarificationAddressDebug, + mergeKnownOrganizations, + resolveOrganizationSelectionFromMessage, + toNonEmptyString, + buildAddressFollowupOffer, + isImplicitAddressContinuationByLlm, + isInventorySelectedObjectIntent, + hasShortInventoryObjectFollowupSignal, + resolveDebtRoleSwapFollowupIntent, + hasAddressFollowupContextSignal, + extractDisplayedEntityIndexMention, + findRecentInventoryRootFrame, + hasInventoryRootTemporalFollowupSignal, + hasFollowupMarker, + hasReferentialPointer, + hasStandaloneAddressTopicSignal, + resolveAddressIntent: addressIntentResolver_1.resolveAddressIntent, + resolveAddressIntentFamily, + readAddressFilterString, + normalizeOrganizationScopeValue, + isInventoryDrilldownFrameIntent, + isInventoryRootFrameIntent, + findRecentAddressFilterValue, + hasForeignAccountingPivotOverInventoryMessage, + buildRootScopedCarryoverFilters, + inferDisplayedEntityTypeFromIntent, + extractDisplayedAddressEntityCandidates, + resolveDisplayedAddressEntityMention +}); function normalizeOrganizationScopeSearchText(value) { const source = normalizeScopeKey(value); return source @@ -5603,9 +5638,9 @@ export class AssistantService { buildAddressLlmPredecomposeContractV1: predecomposeContract_1.buildAddressLlmPredecomposeContractV1, sanitizeAddressMessageForFallback, toNonEmptyString, - resolveAddressFollowupCarryoverContext, + resolveAddressFollowupCarryoverContext: assistantTransitionPolicy.resolveAddressFollowupCarryoverContext, resolveAssistantOrchestrationDecision, - buildAddressDialogContinuationContractV2, + buildAddressDialogContinuationContractV2: assistantTransitionPolicy.buildAddressDialogContinuationContractV2, mergeFollowupContextWithOrganizationScope, isRetryableAddressLimitedResult, mergeKnownOrganizations, diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts new file mode 100644 index 0000000..ea32af7 --- /dev/null +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -0,0 +1,567 @@ +// @ts-nocheck + +export function createAssistantTransitionPolicy(deps) { + function shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent) { + const normalized = deps.compactWhitespace( + deps.repairAddressMojibake(String(userMessage ?? "")).toLowerCase() + ); + if (!normalized || deps.countTokens(normalized) > 4) { + return false; + } + if (sourceIntent !== "list_documents_by_counterparty" && sourceIntent !== "list_documents_by_contract") { + return false; + } + if ( + /(?:банк|выписк|плат[её]Р¶|оплат|списан|поступлен|bank|payment|wire|statement)/iu.test( + normalized + ) + ) { + return false; + } + return /^(?:Р°|Рё|РЅСѓ)?\s*РїРѕ\s+[a-zР°-СЏС‘0-9._-]{2,}(?:\s+[a-zР°-СЏС‘0-9._-]{2,})?$/iu.test(normalized); + } + + function resolveAddressFollowupCarryoverContext( + userMessage, + items, + alternateMessage = null, + llmPreDecomposeMeta = null, + addressNavigationState = null + ) { + const previousAddressItem = deps.findLastAddressAssistantItem(items); + const previousAddressDebug = previousAddressItem?.debug ?? null; + const lastOrganizationClarificationDebug = deps.findLastOrganizationClarificationAddressDebug(items); + const organizationClarificationCandidates = Array.isArray(lastOrganizationClarificationDebug?.organization_candidates) + ? deps.mergeKnownOrganizations(lastOrganizationClarificationDebug.organization_candidates) + : []; + const organizationClarificationSelection = + deps.resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? + (deps.toNonEmptyString(alternateMessage) + ? deps.resolveOrganizationSelectionFromMessage(String(alternateMessage ?? ""), organizationClarificationCandidates) + : null); + const hasOrganizationClarificationContinuation = Boolean( + lastOrganizationClarificationDebug && organizationClarificationSelection + ); + const followupOffer = previousAddressDebug ? deps.buildAddressFollowupOffer(previousAddressDebug) : null; + const hasImplicitContinuationSignal = + Boolean(previousAddressDebug) && + Boolean(followupOffer?.enabled) && + (deps.isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || + (deps.toNonEmptyString(alternateMessage) + ? deps.isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) + : false)); + const sourceIntentHint = deps.toNonEmptyString(previousAddressDebug?.detected_intent); + const navigationFocusObjectHint = + addressNavigationState && + typeof addressNavigationState === "object" && + addressNavigationState.session_context && + typeof addressNavigationState.session_context === "object" && + addressNavigationState.session_context.active_focus_object && + typeof addressNavigationState.session_context.active_focus_object === "object" + ? addressNavigationState.session_context.active_focus_object + : null; + const hasNavigationInventoryItemFocusHint = Boolean( + deps.toNonEmptyString(navigationFocusObjectHint?.label) && + deps.toNonEmptyString(navigationFocusObjectHint?.object_type) === "item" && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || + deps.isInventorySelectedObjectIntent(sourceIntentHint)) + ); + let inventoryShortFollowupPrimary = + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && + deps.hasShortInventoryObjectFollowupSignal(userMessage); + let inventoryShortFollowupAlternate = + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && + deps.toNonEmptyString(alternateMessage) + ? deps.hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? "")) + : false; + const debtRoleSwapPrimary = sourceIntentHint + ? deps.resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) + : null; + const debtRoleSwapAlternate = + sourceIntentHint && deps.toNonEmptyString(alternateMessage) + ? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) + : null; + const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; + let hasPrimaryFollowupSignal = + deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; + let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) + ? deps.hasAddressFollowupContextSignal(alternateMessage) || + Boolean(debtRoleSwapAlternate) || + inventoryShortFollowupAlternate + : false; + const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; + const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) + ? deps.extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null + : false; + const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; + const recentInventoryRootFrame = deps.findRecentInventoryRootFrame(items); + const hasInventoryRootTemporalFollowupPrimary = deps.hasInventoryRootTemporalFollowupSignal( + userMessage, + sourceIntentHint, + Boolean(recentInventoryRootFrame) + ); + const hasInventoryRootTemporalFollowupAlternate = deps.toNonEmptyString(alternateMessage) + ? deps.hasInventoryRootTemporalFollowupSignal( + String(alternateMessage ?? ""), + sourceIntentHint, + Boolean(recentInventoryRootFrame) + ) + : false; + let hasStrongFollowupReference = + hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + hasImplicitContinuationSignal || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + Boolean(debtRoleSwapIntent) || + deps.hasFollowupMarker(userMessage) || + deps.hasReferentialPointer(userMessage) || + (deps.toNonEmptyString(alternateMessage) + ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || + deps.hasReferentialPointer(String(alternateMessage ?? "")) + : false); + const hasStandaloneAddressTopic = + deps.hasStandaloneAddressTopicSignal(userMessage) || + (deps.toNonEmptyString(alternateMessage) ? deps.hasStandaloneAddressTopicSignal(alternateMessage) : false); + if ( + hasStandaloneAddressTopic && + !hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasInventoryRootTemporalFollowupPrimary && + !hasInventoryRootTemporalFollowupAlternate && + !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && + !hasIndexReferenceSignal + ) { + return null; + } + if ( + !hasPrimaryFollowupSignal && + !hasAlternateFollowupSignal && + !hasInventoryRootTemporalFollowupPrimary && + !hasInventoryRootTemporalFollowupAlternate && + !hasImplicitContinuationSignal && + !hasOrganizationClarificationContinuation && + !hasIndexReferenceSignal + ) { + return null; + } + if (!previousAddressDebug) { + return null; + } + const sourceIntent = deps.toNonEmptyString(previousAddressDebug.detected_intent); + const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; + const resolvedAlternateIntent = deps.toNonEmptyString(alternateMessage) + ? deps.resolveAddressIntent(deps.repairAddressMojibake(String(alternateMessage ?? ""))).intent + : null; + const explicitIntent = + llmExplicitIntent && llmExplicitIntent !== "unknown" + ? llmExplicitIntent + : resolvedPrimaryIntent && resolvedPrimaryIntent !== "unknown" + ? resolvedPrimaryIntent + : resolvedAlternateIntent && resolvedAlternateIntent !== "unknown" + ? resolvedAlternateIntent + : null; + const sourceIntentFamily = deps.resolveAddressIntentFamily(sourceIntent); + const explicitIntentFamily = deps.resolveAddressIntentFamily(explicitIntent); + if (sourceIntentFamily && explicitIntentFamily && sourceIntentFamily !== explicitIntentFamily && !hasStrongFollowupReference) { + return null; + } + let previousIntent = sourceIntent; + let followupSelectionMode = "carry_previous_intent"; + if (debtRoleSwapIntent) { + previousIntent = debtRoleSwapIntent; + } + if (hasImplicitContinuationSignal) { + const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) + ? deps.toNonEmptyString(followupOffer.suggested_intents[0]) + : null; + const keepPreviousIntent = shouldKeepPreviousIntentForShortCounterpartyRetarget(userMessage, sourceIntent); + if (suggestedIntent && !keepPreviousIntent) { + previousIntent = suggestedIntent; + followupSelectionMode = "switch_to_suggested_intent"; + } + } + let previousAnchorType = deps.toNonEmptyString(previousAddressDebug.anchor_type); + let previousAnchor = + deps.toNonEmptyString(previousAddressDebug.anchor_value_resolved) ?? + deps.toNonEmptyString(previousAddressDebug.anchor_value_raw) ?? + deps.readAddressFilterString(previousAddressDebug, "item") ?? + deps.readAddressFilterString(previousAddressDebug, "counterparty") ?? + deps.readAddressFilterString(previousAddressDebug, "account") ?? + deps.readAddressFilterString(previousAddressDebug, "contract"); + const navigationSessionContext = + addressNavigationState && typeof addressNavigationState === "object" + ? addressNavigationState.session_context && typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null + : null; + const navigationDateScope = + navigationSessionContext && typeof navigationSessionContext.date_scope === "object" + ? navigationSessionContext.date_scope + : null; + const navigationOrganization = deps.normalizeOrganizationScopeValue(navigationSessionContext?.organization_scope); + const navigationFocusObject = + navigationSessionContext && typeof navigationSessionContext.active_focus_object === "object" + ? navigationSessionContext.active_focus_object + : null; + const navigationFocusObjectType = deps.toNonEmptyString(navigationFocusObject?.object_type); + const navigationFocusObjectLabel = deps.toNonEmptyString(navigationFocusObject?.label); + const hasInventoryItemFocusCarryover = + navigationFocusObjectType === "item" && + navigationFocusObjectLabel && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || + deps.isInventorySelectedObjectIntent(sourceIntentHint)); + if (!inventoryShortFollowupPrimary && hasInventoryItemFocusCarryover) { + inventoryShortFollowupPrimary = deps.hasShortInventoryObjectFollowupSignal(userMessage); + } + if (!inventoryShortFollowupAlternate && hasInventoryItemFocusCarryover && deps.toNonEmptyString(alternateMessage)) { + inventoryShortFollowupAlternate = deps.hasShortInventoryObjectFollowupSignal(String(alternateMessage ?? "")); + } + hasPrimaryFollowupSignal = + deps.hasAddressFollowupContextSignal(userMessage) || + Boolean(debtRoleSwapPrimary) || + inventoryShortFollowupPrimary || + hasInventoryRootTemporalFollowupPrimary; + hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) + ? deps.hasAddressFollowupContextSignal(alternateMessage) || + Boolean(debtRoleSwapAlternate) || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupAlternate + : false; + hasStrongFollowupReference = + hasPrimaryIndexReferenceSignal || + hasAlternateIndexReferenceSignal || + hasOrganizationClarificationContinuation || + hasImplicitContinuationSignal || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + Boolean(debtRoleSwapIntent) || + deps.hasFollowupMarker(userMessage) || + deps.hasReferentialPointer(userMessage) || + (deps.toNonEmptyString(alternateMessage) + ? deps.hasFollowupMarker(String(alternateMessage ?? "")) || + deps.hasReferentialPointer(String(alternateMessage ?? "")) + : false); + const hasSelectedObjectInventorySignalPrimary = /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|selected\s+object)/iu.test( + String(userMessage ?? "") + ); + const hasSelectedObjectInventorySignalAlternate = deps.toNonEmptyString(alternateMessage) + ? /(?:РїРѕ\s+выбранному\s+объекту|РїРѕ\s+этой\s+позиции|РїРѕ\s+этому\s+товару|selected\s+object)/iu.test( + String(alternateMessage ?? "") + ) + : false; + let inventoryRootFrame = deps.findRecentInventoryRootFrame(items); + if (inventoryRootFrame && navigationOrganization && !deps.toNonEmptyString(inventoryRootFrame.filters?.organization)) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: navigationOrganization + } + }; + } + if (inventoryRootFrame && navigationDateScope) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + as_of_date: + deps.toNonEmptyString(inventoryRootFrame.filters?.as_of_date) ?? + deps.toNonEmptyString(navigationDateScope.as_of_date) ?? + undefined, + period_from: + deps.toNonEmptyString(inventoryRootFrame.filters?.period_from) ?? + deps.toNonEmptyString(navigationDateScope.period_from) ?? + undefined, + period_to: + deps.toNonEmptyString(inventoryRootFrame.filters?.period_to) ?? + deps.toNonEmptyString(navigationDateScope.period_to) ?? + undefined + } + }; + } + let currentFrameKind = inventoryRootFrame + ? deps.isInventoryDrilldownFrameIntent(sourceIntent) + ? "inventory_drilldown" + : deps.isInventoryRootFrameIntent(sourceIntent) + ? "inventory_root" + : "generic" + : null; + let resolvedCounterpartyFromDisplay = false; + const previousFiltersRaw = previousAddressDebug.extracted_filters; + let previousFilters = + previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; + const shouldBackfillHistoricalPartyAnchors = + sourceIntentHint === "list_contracts_by_counterparty" || + sourceIntentHint === "list_documents_by_counterparty" || + sourceIntentHint === "bank_operations_by_counterparty" || + sourceIntentHint === "list_documents_by_contract" || + sourceIntentHint === "bank_operations_by_contract" || + sourceIntentHint === "open_items_by_counterparty_or_contract"; + if (shouldBackfillHistoricalPartyAnchors && !deps.toNonEmptyString(previousFilters.contract)) { + const historicalContract = deps.findRecentAddressFilterValue(items, "contract"); + if (historicalContract) { + previousFilters.contract = historicalContract; + } + } + if (shouldBackfillHistoricalPartyAnchors && !deps.toNonEmptyString(previousFilters.counterparty)) { + const historicalCounterparty = deps.findRecentAddressFilterValue(items, "counterparty"); + if (historicalCounterparty) { + previousFilters.counterparty = historicalCounterparty; + } + } + if (!deps.toNonEmptyString(previousFilters.organization)) { + const historicalOrganization = deps.findRecentAddressFilterValue(items, "organization"); + if (historicalOrganization) { + previousFilters.organization = historicalOrganization; + } + } + if (!deps.toNonEmptyString(previousFilters.organization) && navigationOrganization) { + previousFilters.organization = navigationOrganization; + } + if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { + previousFilters.organization = organizationClarificationSelection; + } + const shouldBackfillPreviousDateScopeFromNavigation = + sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || + sourceIntentHint === "inventory_purchase_provenance_for_item" || + sourceIntentHint === "inventory_purchase_documents_for_item" || + sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_profitability_for_item" || + sourceIntentHint === "inventory_purchase_to_sale_chain" || + sourceIntentHint === "inventory_aging_by_purchase_date" || + sourceIntentHint === "account_balance_snapshot" || + sourceIntentHint === "documents_forming_balance"; + if ( + shouldBackfillPreviousDateScopeFromNavigation && + !deps.toNonEmptyString(previousFilters.as_of_date) && + deps.toNonEmptyString(navigationDateScope?.as_of_date) + ) { + previousFilters.as_of_date = deps.toNonEmptyString(navigationDateScope?.as_of_date); + } + if ( + shouldBackfillPreviousDateScopeFromNavigation && + !deps.toNonEmptyString(previousFilters.period_from) && + deps.toNonEmptyString(navigationDateScope?.period_from) + ) { + previousFilters.period_from = deps.toNonEmptyString(navigationDateScope?.period_from); + } + if ( + shouldBackfillPreviousDateScopeFromNavigation && + !deps.toNonEmptyString(previousFilters.period_to) && + deps.toNonEmptyString(navigationDateScope?.period_to) + ) { + previousFilters.period_to = deps.toNonEmptyString(navigationDateScope?.period_to); + } + const rootContextOnlyPivot = Boolean( + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && + deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) + ); + const inventoryRootTemporalPivot = Boolean( + inventoryRootFrame && + (deps.isInventorySelectedObjectIntent(sourceIntentHint) || + deps.isInventoryRootFrameIntent(sourceIntentHint) || + currentFrameKind === "inventory_drilldown" || + currentFrameKind === "inventory_root") && + (hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate) && + !deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) + ); + const rootScopedPivot = rootContextOnlyPivot || inventoryRootTemporalPivot; + if (rootScopedPivot) { + previousIntent = null; + previousAnchorType = null; + previousAnchor = null; + previousFilters = deps.buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame); + currentFrameKind = inventoryRootFrame ? "inventory_root" : currentFrameKind; + followupSelectionMode = "carry_root_context"; + } + const displayedEntityType = deps.inferDisplayedEntityTypeFromIntent(sourceIntent); + const displayedEntities = deps.extractDisplayedAddressEntityCandidates( + deps.toNonEmptyString(previousAddressItem?.text) ?? "", + displayedEntityType + ); + const resolvedEntityFromFollowup = + deps.resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ?? + (deps.toNonEmptyString(alternateMessage) + ? deps.resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities) + : null); + if (resolvedEntityFromFollowup && !rootScopedPivot) { + if (resolvedEntityFromFollowup.entityType === "counterparty") { + previousFilters.counterparty = resolvedEntityFromFollowup.value; + previousAnchorType = "counterparty"; + previousAnchor = resolvedEntityFromFollowup.value; + resolvedCounterpartyFromDisplay = true; + } else if (resolvedEntityFromFollowup.entityType === "contract") { + previousFilters.contract = resolvedEntityFromFollowup.value; + previousAnchorType = "contract"; + previousAnchor = resolvedEntityFromFollowup.value; + } else if (resolvedEntityFromFollowup.entityType === "item") { + previousFilters.item = resolvedEntityFromFollowup.value; + previousAnchorType = "item"; + previousAnchor = resolvedEntityFromFollowup.value; + } + if (followupSelectionMode !== "switch_to_suggested_intent") { + followupSelectionMode = "carry_referenced_entity"; + } + } + if ( + !rootScopedPivot && + !deps.toNonEmptyString(previousFilters.item) && + navigationFocusObjectType === "item" && + navigationFocusObjectLabel && + (sourceIntentHint === "inventory_on_hand_as_of_date" || + sourceIntentHint === "inventory_purchase_provenance_for_item" || + sourceIntentHint === "inventory_purchase_documents_for_item" || + sourceIntentHint === "inventory_sale_trace_for_item" || + sourceIntentHint === "inventory_profitability_for_item" || + sourceIntentHint === "inventory_purchase_to_sale_chain" || + sourceIntentHint === "inventory_aging_by_purchase_date" || + hasSelectedObjectInventorySignalPrimary || + hasSelectedObjectInventorySignalAlternate) + ) { + previousFilters.item = navigationFocusObjectLabel; + if (!previousAnchor) { + previousAnchorType = "item"; + previousAnchor = navigationFocusObjectLabel; + } + } + if (organizationClarificationSelection && !previousAnchor) { + previousAnchorType = "organization"; + previousAnchor = organizationClarificationSelection; + } + if ( + inventoryRootFrame && + organizationClarificationSelection && + !deps.toNonEmptyString(inventoryRootFrame.filters?.organization) + ) { + inventoryRootFrame = { + ...inventoryRootFrame, + filters: { + ...(inventoryRootFrame.filters ?? {}), + organization: organizationClarificationSelection + } + }; + } + if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) { + return null; + } + const shouldAttachInventoryRootFrame = Boolean( + inventoryRootFrame && + (rootScopedPivot || + deps.isInventoryRootFrameIntent(sourceIntentHint) || + deps.isInventorySelectedObjectIntent(sourceIntentHint) || + hasNavigationInventoryItemFocusHint || + inventoryShortFollowupPrimary || + inventoryShortFollowupAlternate || + hasInventoryRootTemporalFollowupPrimary || + hasInventoryRootTemporalFollowupAlternate || + hasSelectedObjectInventorySignalPrimary || + hasSelectedObjectInventorySignalAlternate) + ); + const carryoverTargetIntent = + followupSelectionMode === "carry_root_context" + ? inventoryRootFrame?.intent ?? explicitIntent ?? previousIntent ?? undefined + : explicitIntent ?? previousIntent ?? undefined; + return { + followupContext: { + previous_intent: previousIntent ?? undefined, + target_intent: carryoverTargetIntent, + previous_filters: previousFilters, + previous_anchor_type: previousAnchorType ?? undefined, + previous_anchor_value: previousAnchor, + resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, + root_context_only: rootScopedPivot || undefined, + root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, + root_filters: shouldAttachInventoryRootFrame ? inventoryRootFrame?.filters ?? undefined : undefined, + root_anchor_type: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorType ?? undefined : undefined, + root_anchor_value: shouldAttachInventoryRootFrame ? inventoryRootFrame?.anchorValue ?? undefined : undefined, + current_frame_kind: shouldAttachInventoryRootFrame ? currentFrameKind ?? undefined : undefined + }, + previousAddressIntent: previousIntent, + previousAddressAnchor: previousAnchor, + previousSourceIntent: sourceIntent, + followupSelectionMode, + hasImplicitContinuationSignal + }; + } + + function buildAddressDialogContinuationContractV2( + userMessage, + effectiveMessage, + carryoverMeta, + llmPreDecomposeMeta + ) { + const sourceMessage = String(userMessage ?? ""); + const canonicalMessage = String(effectiveMessage ?? sourceMessage); + const hasFollowupContext = Boolean(carryoverMeta?.followupContext); + const previousIntent = deps.toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null; + const selectionMode = deps.toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null; + const rootContextOnly = selectionMode === "carry_root_context"; + const explicitIntentRaw = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const explicitIntent = explicitIntentRaw === "unknown" ? null : explicitIntentRaw; + const rootIntent = deps.toNonEmptyString(carryoverMeta?.followupContext?.root_intent) ?? null; + const targetIntent = + selectionMode === "switch_to_suggested_intent" + ? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null + : rootContextOnly + ? rootIntent ?? explicitIntent ?? null + : explicitIntent ?? deps.toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null; + const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal); + const rewrittenByPredecompose = + deps.compactWhitespace(sourceMessage.toLowerCase()) !== deps.compactWhitespace(canonicalMessage.toLowerCase()); + const hasExplicitIntent = Boolean(explicitIntent); + const decision = !hasFollowupContext + ? "new_topic" + : selectionMode === "switch_to_suggested_intent" + ? "switch_to_suggested" + : "continue_previous"; + const reasons = []; + if (hasFollowupContext) { + reasons.push("followup_context_detected"); + } + if (hasImplicitContinuationSignal) { + reasons.push("implicit_continuation_by_llm"); + } + if (rewrittenByPredecompose) { + reasons.push("effective_message_rewritten_by_predecompose"); + } + if (hasExplicitIntent) { + reasons.push("llm_contract_intent_available"); + } + if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) { + reasons.push("operation_intent_from_current_message"); + } + if (rootContextOnly) { + reasons.push("root_context_only_carryover"); + } + return { + schema_version: "address_dialog_continuation_contract_v2", + source_message: sourceMessage, + effective_message: canonicalMessage, + decision, + decision_reasons: reasons, + followup_context_applied: hasFollowupContext, + previous_intent: previousIntent, + target_intent: targetIntent, + intent_selection_mode: selectionMode, + anchor_type: carryoverMeta?.followupContext?.previous_anchor_type ?? null, + anchor_value: carryoverMeta?.followupContext?.previous_anchor_value ?? null, + implicit_continuation_signal: hasImplicitContinuationSignal + }; + } + + return { + resolveAddressFollowupCarryoverContext, + buildAddressDialogContinuationContractV2 + }; +} diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts new file mode 100644 index 0000000..21cae14 --- /dev/null +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { createAssistantTransitionPolicy } from "../src/services/assistantTransitionPolicy"; + +function toNonEmptyString(value: unknown): string | null { + if (value === null || value === undefined) { + return null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + +function buildPolicy(overrides: Record = {}) { + return createAssistantTransitionPolicy({ + compactWhitespace: (value: string) => String(value ?? "").replace(/\s+/g, " ").trim(), + repairAddressMojibake: (value: string) => value, + countTokens: (value: string) => String(value ?? "").split(/\s+/).filter(Boolean).length, + findLastAddressAssistantItem: () => ({ + text: "1. Рабочая станция", + debug: { + detected_intent: "inventory_purchase_documents_for_item", + extracted_filters: { + item: "Рабочая станция" + }, + anchor_type: "item", + anchor_value_resolved: "Рабочая станция" + } + }), + findLastOrganizationClarificationAddressDebug: () => null, + mergeKnownOrganizations: (values: unknown[]) => values, + resolveOrganizationSelectionFromMessage: () => null, + toNonEmptyString, + buildAddressFollowupOffer: () => null, + isImplicitAddressContinuationByLlm: () => false, + isInventorySelectedObjectIntent: (intent: unknown) => + [ + "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" + ].includes(String(intent ?? "")), + hasShortInventoryObjectFollowupSignal: () => false, + resolveDebtRoleSwapFollowupIntent: () => null, + hasAddressFollowupContextSignal: () => false, + extractDisplayedEntityIndexMention: () => null, + findRecentInventoryRootFrame: () => ({ + intent: "inventory_on_hand_as_of_date", + filters: { + as_of_date: "2020-03-31", + organization: 'ООО "Альтернатива Плюс"' + }, + anchorType: "organization", + anchorValue: 'ООО "Альтернатива Плюс"' + }), + hasInventoryRootTemporalFollowupSignal: (message: string) => /март 2020/i.test(message), + hasFollowupMarker: () => false, + hasReferentialPointer: () => false, + hasStandaloneAddressTopicSignal: () => false, + resolveAddressIntent: () => ({ intent: "unknown" }), + resolveAddressIntentFamily: (intent: unknown) => (intent ? String(intent) : null), + readAddressFilterString: (debug: Record, key: string) => + debug?.extracted_filters && typeof debug.extracted_filters === "object" + ? toNonEmptyString((debug.extracted_filters as Record)[key]) + : null, + normalizeOrganizationScopeValue: (value: unknown) => toNonEmptyString(value), + isInventoryDrilldownFrameIntent: (intent: unknown) => + [ + "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" + ].includes(String(intent ?? "")), + isInventoryRootFrameIntent: (intent: unknown) => String(intent ?? "") === "inventory_on_hand_as_of_date", + findRecentAddressFilterValue: () => null, + hasForeignAccountingPivotOverInventoryMessage: () => false, + buildRootScopedCarryoverFilters: (_previousFilters: Record, inventoryRootFrame: Record) => ({ + ...(inventoryRootFrame?.filters ?? {}) + }), + inferDisplayedEntityTypeFromIntent: () => "item", + extractDisplayedAddressEntityCandidates: () => [], + resolveDisplayedAddressEntityMention: () => null, + ...overrides + }); +} + +describe("assistantTransitionPolicy", () => { + it("promotes inventory temporal follow-up into root-scoped carryover", () => { + const policy = buildPolicy(); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "остатки на март 2020", + [], + null, + null, + { + session_context: { + active_focus_object: { + object_type: "item", + label: "Рабочая станция" + } + } + } + ); + + expect(carryover?.followupSelectionMode).toBe("carry_root_context"); + expect(carryover?.followupContext?.root_context_only).toBe(true); + expect(carryover?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date"); + expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); + expect(carryover?.followupContext?.previous_filters).toEqual({ + as_of_date: "2020-03-31", + organization: 'ООО "Альтернатива Плюс"' + }); + }); + + it("builds continuation contract from extracted root carryover", () => { + const policy = buildPolicy(); + + const contract = policy.buildAddressDialogContinuationContractV2( + "остатки на эту дату", + "остатки на эту дату", + { + followupContext: { + root_intent: "inventory_on_hand_as_of_date", + previous_anchor_type: "item", + previous_anchor_value: "Рабочая станция" + }, + previousSourceIntent: "inventory_purchase_documents_for_item", + previousAddressIntent: null, + followupSelectionMode: "carry_root_context", + hasImplicitContinuationSignal: true + }, + { + predecomposeContract: { + intent: "unknown" + } + } + ); + + expect(contract.decision).toBe("continue_previous"); + expect(contract.target_intent).toBe("inventory_on_hand_as_of_date"); + expect(contract.decision_reasons).toContain("root_context_only_carryover"); + expect(contract.decision_reasons).toContain("implicit_continuation_by_llm"); + expect(contract.anchor_type).toBe("item"); + expect(contract.anchor_value).toBe("Рабочая станция"); + }); +});