From 851289b9a670c2927f80f8ce7d5717162e5b68bb Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 19 Apr 2026 12:13:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0:=20=D1=86=D0=B5=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20select?= =?UTF-8?q?ed-item=20carryover=20=D0=B2=20continuity=20policy=20=D0=B8=20t?= =?UTF-8?q?ransition=20glue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ontinuity_stabilization_plan_2026-04-17.md | 5 ++ .../services/assistantContinuityPolicy.js | 28 +++++++++++ .../services/assistantTransitionPolicy.js | 34 +++++--------- .../src/services/assistantContinuityPolicy.ts | 47 +++++++++++++++++++ .../src/services/assistantTransitionPolicy.ts | 35 +++++++------- .../tests/assistantContinuityPolicy.test.ts | 26 ++++++++++ .../tests/assistantTransitionPolicy.test.ts | 42 +++++++++++++++++ 7 files changed, 179 insertions(+), 38 deletions(-) diff --git a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md index 341ec4d..2fa3d26 100644 --- a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md +++ b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md @@ -434,6 +434,11 @@ Still open after the accepted phase12 replay: - this matters because counterparty / contract / selected-entity follow-ups are one of the heaviest remaining sources of local carryover reconstruction, and moving them under the shared continuity layer reduces another chance that route retargeting and anchor state drift apart when new domains are added; - targeted `assistantContinuityPolicy` and `assistantTransitionPolicy` regressions now protect both the helper layer and a real `displayed counterparty -> contracts` follow-up path; - the next proof after this pass should still come from a live replay, but the expected verdict should now only move if a real counterparty carryover path regresses rather than because the state mutation lived in an inline transition branch. +- the next continuity-authority pass now removes another selected-item state owner from the transition hot path: + - continuity now owns `applySelectedItemCarryover(...)`, so `previous_filters.item` plus `previous_anchor_*` for selected-object inventory follow-ups no longer mutate inline inside `assistantTransitionPolicy`; + - item carryover precedence is now explicit in one helper: navigation focus item -> continuity active item -> explicit selected-object label from the current message; + - this matters because selected-object follow-ups are one of the most fragile continuity seams in the product, and keeping their anchor mutation in the shared continuity layer reduces another chance that future inventory/domain expansion splits `focus_object` truth from follow-up carryover truth; + - targeted continuity and transition regressions now protect both the helper layer and a real short selected-item follow-up path that must keep the `item` anchor without reopening company/date drift. - the next continuity-authority pass now centralizes temporal backfill precedence for follow-up filters: - transition no longer holds a service-local block of `shouldBackfillPreviousDateScopeFromNavigation + six field-level ifs` for `as_of_date / period_from / period_to`; - shared continuity now owns that merge via `applyTemporalCarryoverFilters(...)`, while `shouldUseNavigationTemporalCarryover(...)` keeps the intent-family boundary explicit in one place; diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 56745ab..a369a3c 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -16,6 +16,7 @@ exports.applyTemporalCarryoverFilters = applyTemporalCarryoverFilters; exports.applyOrganizationCarryoverFilters = applyOrganizationCarryoverFilters; exports.applyHistoricalPartyCarryoverFilters = applyHistoricalPartyCarryoverFilters; exports.applyReferencedEntityCarryover = applyReferencedEntityCarryover; +exports.applySelectedItemCarryover = applySelectedItemCarryover; exports.buildInventoryRootFrameFromAddressDebug = buildInventoryRootFrameFromAddressDebug; exports.isGroundedAddressDebug = isGroundedAddressDebug; exports.resolveAssistantContinuitySnapshot = resolveAssistantContinuitySnapshot; @@ -352,6 +353,33 @@ function applyReferencedEntityCarryover(previousFilters, previousAnchorType, pre resolvedCounterpartyFromDisplay }; } +function applySelectedItemCarryover(previousFilters, previousAnchorType, previousAnchorValue, rootScopedPivot, shouldApplySelectedItemCarryover, navigationFocusItemLabel, continuityActiveItem, selectedObjectLabelFromUser, selectedObjectLabelFromAlternate, toNonEmptyString = fallbackToNonEmptyString) { + const nextFilters = previousFilters && typeof previousFilters === "object" ? { ...previousFilters } : {}; + if (rootScopedPivot || !shouldApplySelectedItemCarryover || toNonEmptyString(nextFilters.item)) { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue + }; + } + const selectedObjectLabel = toNonEmptyString(navigationFocusItemLabel) ?? + toNonEmptyString(continuityActiveItem) ?? + toNonEmptyString(selectedObjectLabelFromUser) ?? + toNonEmptyString(selectedObjectLabelFromAlternate); + if (!selectedObjectLabel) { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue + }; + } + nextFilters.item = selectedObjectLabel; + return { + previousFilters: nextFilters, + previousAnchorType: "item", + previousAnchorValue: selectedObjectLabel + }; +} function buildInventoryRootFrameFromAddressDebug(debug, toNonEmptyString = fallbackToNonEmptyString) { if (!debug || typeof debug !== "object") { return null; diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 6c13535..c81011c 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -667,27 +667,19 @@ function createAssistantTransitionPolicy(deps) { followupSelectionMode, resolvedCounterpartyFromDisplay } = (0, assistantContinuityPolicy_1.applyReferencedEntityCarryover)(previousFilters, previousAnchorType, previousAnchor, followupSelectionMode, resolvedEntityFromFollowup, rootScopedPivot, deps.toNonEmptyString)); - if (!rootScopedPivot && - !deps.toNonEmptyString(previousFilters.item) && - (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)) { - const selectedObjectLabel = (navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null) ?? - continuitySnapshot.activeItem ?? - extractSelectedObjectLabel(userMessage) ?? - (deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null); - if (selectedObjectLabel) { - previousFilters.item = selectedObjectLabel; - previousAnchorType = "item"; - previousAnchor = selectedObjectLabel; - } - } + ({ + previousFilters, + previousAnchorType, + previousAnchorValue: previousAnchor + } = (0, assistantContinuityPolicy_1.applySelectedItemCarryover)(previousFilters, previousAnchorType, previousAnchor, rootScopedPivot, 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, navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null, continuitySnapshot.activeItem, extractSelectedObjectLabel(userMessage), deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null, deps.toNonEmptyString)); if (explicitOrganizationClarificationSelection && !previousAnchor) { previousAnchorType = "organization"; previousAnchor = explicitOrganizationClarificationSelection; diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 1cd72e8..77049c2 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -536,6 +536,53 @@ export function applyReferencedEntityCarryover( }; } +export function applySelectedItemCarryover( + previousFilters: Record | null, + previousAnchorType: string | null, + previousAnchorValue: string | null, + rootScopedPivot: boolean, + shouldApplySelectedItemCarryover: boolean, + navigationFocusItemLabel: unknown, + continuityActiveItem: unknown, + selectedObjectLabelFromUser: unknown, + selectedObjectLabelFromAlternate: unknown, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): { + previousFilters: Record; + previousAnchorType: string | null; + previousAnchorValue: string | null; +} { + const nextFilters = + previousFilters && typeof previousFilters === "object" ? { ...previousFilters } : {}; + if (rootScopedPivot || !shouldApplySelectedItemCarryover || toNonEmptyString(nextFilters.item)) { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue + }; + } + + const selectedObjectLabel = + toNonEmptyString(navigationFocusItemLabel) ?? + toNonEmptyString(continuityActiveItem) ?? + toNonEmptyString(selectedObjectLabelFromUser) ?? + toNonEmptyString(selectedObjectLabelFromAlternate); + if (!selectedObjectLabel) { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue + }; + } + + nextFilters.item = selectedObjectLabel; + return { + previousFilters: nextFilters, + previousAnchorType: "item", + previousAnchorValue: selectedObjectLabel + }; +} + export function buildInventoryRootFrameFromAddressDebug( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index c4f4633..1c02dfa 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -3,6 +3,7 @@ import { applyHistoricalPartyCarryoverFilters, applyOrganizationCarryoverFilters, applyReferencedEntityCarryover, + applySelectedItemCarryover, applyTemporalCarryoverFilters, buildRootScopedCarryoverFilters, buildInventoryRootFrameFromAddressDebug, @@ -874,10 +875,16 @@ export function createAssistantTransitionPolicy(deps) { rootScopedPivot, deps.toNonEmptyString )); - if ( - !rootScopedPivot && - !deps.toNonEmptyString(previousFilters.item) && - (sourceIntentHint === "inventory_on_hand_as_of_date" || + ({ + previousFilters, + previousAnchorType, + previousAnchorValue: previousAnchor + } = applySelectedItemCarryover( + previousFilters, + previousAnchorType, + previousAnchor, + rootScopedPivot, + 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" || @@ -885,19 +892,13 @@ export function createAssistantTransitionPolicy(deps) { sourceIntentHint === "inventory_purchase_to_sale_chain" || sourceIntentHint === "inventory_aging_by_purchase_date" || hasSelectedObjectInventorySignalPrimary || - hasSelectedObjectInventorySignalAlternate) - ) { - const selectedObjectLabel = - (navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null) ?? - continuitySnapshot.activeItem ?? - extractSelectedObjectLabel(userMessage) ?? - (deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null); - if (selectedObjectLabel) { - previousFilters.item = selectedObjectLabel; - previousAnchorType = "item"; - previousAnchor = selectedObjectLabel; - } - } + hasSelectedObjectInventorySignalAlternate, + navigationFocusObjectType === "item" ? navigationFocusObjectLabel : null, + continuitySnapshot.activeItem, + extractSelectedObjectLabel(userMessage), + deps.toNonEmptyString(alternateMessage) ? extractSelectedObjectLabel(String(alternateMessage ?? "")) : null, + deps.toNonEmptyString + )); if (explicitOrganizationClarificationSelection && !previousAnchor) { previousAnchorType = "organization"; previousAnchor = explicitOrganizationClarificationSelection; diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 568d6f5..f8aed14 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -3,6 +3,7 @@ import { applyHistoricalPartyCarryoverFilters, applyOrganizationCarryoverFilters, applyReferencedEntityCarryover, + applySelectedItemCarryover, applyTemporalCarryoverFilters, buildRootScopedCarryoverFilters, hydrateInventoryRootFrameState, @@ -324,4 +325,29 @@ describe("assistantContinuityPolicy organization authority", () => { resolvedCounterpartyFromDisplay: true }); }); + + it("applies selected-item carryover from navigation focus before continuity and explicit labels", () => { + const carryover = applySelectedItemCarryover( + { + organization: "Org Alt" + }, + null, + null, + false, + true, + "Workstation Navigation", + "Workstation Continuity", + "Workstation User", + "Workstation Alternate" + ); + + expect(carryover).toEqual({ + previousFilters: { + organization: "Org Alt", + item: "Workstation Navigation" + }, + previousAnchorType: "item", + previousAnchorValue: "Workstation Navigation" + }); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index 069ddc0..0ea6666 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -297,6 +297,48 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.root_context_only).toBeUndefined(); }); + it("hydrates selected-item carryover through shared continuity helper for short object follow-up", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Подтвержден складской срез по выбранной позиции.", + debug: { + detected_intent: "inventory_on_hand_as_of_date", + extracted_filters: { + organization: 'РћРћРћ "Альтернатива Плюс"', + as_of_date: "2020-03-31" + } + } + }), + hasAddressFollowupContextSignal: () => true, + hasShortInventoryObjectFollowupSignal: () => true, + findRecentInventoryRootFrame: () => null, + resolveAddressIntent: () => ({ intent: "unknown" }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "по этой позиции покажи документы", + [], + null, + null, + { + session_context: { + active_focus_object: { + object_type: "item", + label: "Workstation Focus" + } + } + } + ); + + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + organization: 'РћРћРћ "Альтернатива Плюс"', + as_of_date: "2020-03-31", + item: "Workstation Focus" + }); + expect(carryover?.followupContext?.previous_anchor_type).toBe("item"); + expect(carryover?.followupContext?.previous_anchor_value).toBe("Workstation Focus"); + }); + it("hydrates follow-up organization from shared assistant authority when local history filters are empty", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({