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 d7db39b..341ec4d 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 @@ -428,6 +428,12 @@ Still open after the accepted phase12 replay: - this matters because `inventoryRootFrame`, `current_frame_kind`, and `root-scoped` filter precedence now converge through one authority layer before `root_context_only` pivots are decided, which reduces another hidden chance for state drift when new domains or new follow-up families are added; - targeted `assistantContinuityPolicy` and `assistantTransitionPolicy` suites are green after the move, with explicit coverage for root-frame hydration from navigation scope and for previous-date precedence over a stale inventory root frame; - a fresh live rerun of `address_truth_harness_phase12_wider_saved_session_pool` on `2026-04-19` remained semantically stable on all repaired continuity paths and again failed only on the already-known date-sensitive `today` expectations, not on the new shared root-frame state owner. +- the next continuity-authority pass now removes another dense party-anchor owner from the transition hot path: + - continuity now owns `applyHistoricalPartyCarryoverFilters(...)`, so `contract/counterparty` backfill for party-driven follow-up families no longer lives as an inline cascade inside `assistantTransitionPolicy`; + - continuity now also owns `applyReferencedEntityCarryover(...)`, so displayed entity mentions from the previous grounded answer update `previous_filters`, `previous_anchor_*`, and `followupSelectionMode` through one shared state helper instead of another transition-local mutation block; + - 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 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 5009bbc..56745ab 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -14,6 +14,8 @@ exports.buildRootScopedCarryoverFilters = buildRootScopedCarryoverFilters; exports.shouldUseNavigationTemporalCarryover = shouldUseNavigationTemporalCarryover; exports.applyTemporalCarryoverFilters = applyTemporalCarryoverFilters; exports.applyOrganizationCarryoverFilters = applyOrganizationCarryoverFilters; +exports.applyHistoricalPartyCarryoverFilters = applyHistoricalPartyCarryoverFilters; +exports.applyReferencedEntityCarryover = applyReferencedEntityCarryover; exports.buildInventoryRootFrameFromAddressDebug = buildInventoryRootFrameFromAddressDebug; exports.isGroundedAddressDebug = isGroundedAddressDebug; exports.resolveAssistantContinuitySnapshot = resolveAssistantContinuitySnapshot; @@ -281,6 +283,75 @@ function applyOrganizationCarryoverFilters(previousFilters, historicalOrganizati } return nextFilters; } +function applyHistoricalPartyCarryoverFilters(previousFilters, shouldBackfillHistoricalPartyAnchors, historicalContract, historicalCounterparty, toNonEmptyString = fallbackToNonEmptyString) { + const nextFilters = previousFilters && typeof previousFilters === "object" ? { ...previousFilters } : {}; + if (!shouldBackfillHistoricalPartyAnchors) { + return nextFilters; + } + if (!toNonEmptyString(nextFilters.contract)) { + nextFilters.contract = toNonEmptyString(historicalContract) ?? undefined; + } + if (!toNonEmptyString(nextFilters.counterparty)) { + nextFilters.counterparty = toNonEmptyString(historicalCounterparty) ?? undefined; + } + return nextFilters; +} +function applyReferencedEntityCarryover(previousFilters, previousAnchorType, previousAnchorValue, followupSelectionMode, resolvedEntityFromFollowup, rootScopedPivot, toNonEmptyString = fallbackToNonEmptyString) { + const nextFilters = previousFilters && typeof previousFilters === "object" ? { ...previousFilters } : {}; + if (rootScopedPivot || !resolvedEntityFromFollowup || typeof resolvedEntityFromFollowup !== "object") { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode, + resolvedCounterpartyFromDisplay: false + }; + } + const entityType = toNonEmptyString(resolvedEntityFromFollowup.entityType); + const entityValue = toNonEmptyString(resolvedEntityFromFollowup.value); + if (!entityType || !entityValue) { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode, + resolvedCounterpartyFromDisplay: false + }; + } + let resolvedCounterpartyFromDisplay = false; + if (entityType === "counterparty") { + nextFilters.counterparty = entityValue; + previousAnchorType = "counterparty"; + previousAnchorValue = entityValue; + resolvedCounterpartyFromDisplay = true; + } + else if (entityType === "contract") { + nextFilters.contract = entityValue; + previousAnchorType = "contract"; + previousAnchorValue = entityValue; + } + else if (entityType === "item") { + nextFilters.item = entityValue; + previousAnchorType = "item"; + previousAnchorValue = entityValue; + } + else { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode, + resolvedCounterpartyFromDisplay: false + }; + } + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode: followupSelectionMode !== "switch_to_suggested_intent" ? "carry_referenced_entity" : followupSelectionMode, + resolvedCounterpartyFromDisplay + }; +} 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 f390e1b..6c13535 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -602,18 +602,7 @@ function createAssistantTransitionPolicy(deps) { 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; - } - } + previousFilters = (0, assistantContinuityPolicy_1.applyHistoricalPartyCarryoverFilters)(previousFilters, shouldBackfillHistoricalPartyAnchors, deps.findRecentAddressFilterValue(items, "contract"), deps.findRecentAddressFilterValue(items, "counterparty"), deps.toNonEmptyString); const historicalOrganization = deps.findRecentAddressFilterValue(items, "organization"); const authorityActiveOrganization = deps.normalizeOrganizationScopeValue(organizationAuthority.activeOrganization) ?? deps.normalizeOrganizationScopeValue(organizationAuthority.continuityActiveOrganization); @@ -670,26 +659,14 @@ function createAssistantTransitionPolicy(deps) { : null); if (resolvedEntityFromFollowup && !rootScopedPivot) { displayedEntityTargetIntent = resolveDisplayedEntityRetargetIntent(userMessage, resolvedEntityFromFollowup.entityType); - 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"; - } } + ({ + previousFilters, + previousAnchorType, + previousAnchorValue: previousAnchor, + 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" || diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 5e477a5..1cd72e8 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -436,6 +436,106 @@ export function applyOrganizationCarryoverFilters( return nextFilters; } +export function applyHistoricalPartyCarryoverFilters( + previousFilters: Record | null, + shouldBackfillHistoricalPartyAnchors: boolean, + historicalContract: unknown, + historicalCounterparty: unknown, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): Record { + const nextFilters = + previousFilters && typeof previousFilters === "object" ? { ...previousFilters } : {}; + if (!shouldBackfillHistoricalPartyAnchors) { + return nextFilters; + } + if (!toNonEmptyString(nextFilters.contract)) { + nextFilters.contract = toNonEmptyString(historicalContract) ?? undefined; + } + if (!toNonEmptyString(nextFilters.counterparty)) { + nextFilters.counterparty = toNonEmptyString(historicalCounterparty) ?? undefined; + } + return nextFilters; +} + +export function applyReferencedEntityCarryover( + previousFilters: Record | null, + previousAnchorType: string | null, + previousAnchorValue: string | null, + followupSelectionMode: string | null, + resolvedEntityFromFollowup: + | { + entityType?: unknown; + value?: unknown; + } + | null + | undefined, + rootScopedPivot: boolean, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): { + previousFilters: Record; + previousAnchorType: string | null; + previousAnchorValue: string | null; + followupSelectionMode: string | null; + resolvedCounterpartyFromDisplay: boolean; +} { + const nextFilters = + previousFilters && typeof previousFilters === "object" ? { ...previousFilters } : {}; + if (rootScopedPivot || !resolvedEntityFromFollowup || typeof resolvedEntityFromFollowup !== "object") { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode, + resolvedCounterpartyFromDisplay: false + }; + } + + const entityType = toNonEmptyString(resolvedEntityFromFollowup.entityType); + const entityValue = toNonEmptyString(resolvedEntityFromFollowup.value); + if (!entityType || !entityValue) { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode, + resolvedCounterpartyFromDisplay: false + }; + } + + let resolvedCounterpartyFromDisplay = false; + if (entityType === "counterparty") { + nextFilters.counterparty = entityValue; + previousAnchorType = "counterparty"; + previousAnchorValue = entityValue; + resolvedCounterpartyFromDisplay = true; + } else if (entityType === "contract") { + nextFilters.contract = entityValue; + previousAnchorType = "contract"; + previousAnchorValue = entityValue; + } else if (entityType === "item") { + nextFilters.item = entityValue; + previousAnchorType = "item"; + previousAnchorValue = entityValue; + } else { + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode, + resolvedCounterpartyFromDisplay: false + }; + } + + return { + previousFilters: nextFilters, + previousAnchorType, + previousAnchorValue, + followupSelectionMode: + followupSelectionMode !== "switch_to_suggested_intent" ? "carry_referenced_entity" : followupSelectionMode, + resolvedCounterpartyFromDisplay + }; +} + 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 018f405..c4f4633 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -1,6 +1,8 @@ // @ts-nocheck import { + applyHistoricalPartyCarryoverFilters, applyOrganizationCarryoverFilters, + applyReferencedEntityCarryover, applyTemporalCarryoverFilters, buildRootScopedCarryoverFilters, buildInventoryRootFrameFromAddressDebug, @@ -759,18 +761,13 @@ export function createAssistantTransitionPolicy(deps) { 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; - } - } + previousFilters = applyHistoricalPartyCarryoverFilters( + previousFilters, + shouldBackfillHistoricalPartyAnchors, + deps.findRecentAddressFilterValue(items, "contract"), + deps.findRecentAddressFilterValue(items, "counterparty"), + deps.toNonEmptyString + ); const historicalOrganization = deps.findRecentAddressFilterValue(items, "organization"); const authorityActiveOrganization = deps.normalizeOrganizationScopeValue(organizationAuthority.activeOrganization) ?? @@ -861,24 +858,22 @@ export function createAssistantTransitionPolicy(deps) { userMessage, resolvedEntityFromFollowup.entityType ); - 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"; - } } + ({ + previousFilters, + previousAnchorType, + previousAnchorValue: previousAnchor, + followupSelectionMode, + resolvedCounterpartyFromDisplay + } = applyReferencedEntityCarryover( + previousFilters, + previousAnchorType, + previousAnchor, + followupSelectionMode, + resolvedEntityFromFollowup, + rootScopedPivot, + deps.toNonEmptyString + )); if ( !rootScopedPivot && !deps.toNonEmptyString(previousFilters.item) && diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index c562d5a..568d6f5 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { + applyHistoricalPartyCarryoverFilters, applyOrganizationCarryoverFilters, + applyReferencedEntityCarryover, applyTemporalCarryoverFilters, buildRootScopedCarryoverFilters, hydrateInventoryRootFrameState, @@ -281,4 +283,45 @@ describe("assistantContinuityPolicy organization authority", () => { organization: "Org Existing" }); }); + + it("backfills historical contract and counterparty only for party-driven follow-up families", () => { + const filters = applyHistoricalPartyCarryoverFilters( + {}, + true, + "Contract Legacy", + "Counterparty Legacy" + ); + + expect(filters).toEqual({ + contract: "Contract Legacy", + counterparty: "Counterparty Legacy" + }); + }); + + it("applies referenced entity carryover into filters, anchor, and selection mode", () => { + const carryover = applyReferencedEntityCarryover( + { + organization: "Org Alt" + }, + null, + null, + "carry_previous_intent", + { + entityType: "counterparty", + value: "SVK Group" + }, + false + ); + + expect(carryover).toEqual({ + previousFilters: { + organization: "Org Alt", + counterparty: "SVK Group" + }, + previousAnchorType: "counterparty", + previousAnchorValue: "SVK Group", + followupSelectionMode: "carry_referenced_entity", + resolvedCounterpartyFromDisplay: true + }); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index a7d7868..069ddc0 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -214,6 +214,47 @@ describe("assistantTransitionPolicy", () => { expect(contract.decision).toBe("continue_previous"); }); + it("retargets displayed counterparty follow-up through shared referenced-entity carryover", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "1. SVK Group\n2. Gamma", + debug: { + detected_intent: "customer_revenue_and_payments", + extracted_filters: { + organization: 'РћРћРћ "Альтернатива Плюс"', + period_from: "2017-01-01", + period_to: "2017-12-31" + } + } + }), + hasAddressFollowupContextSignal: () => true, + inferDisplayedEntityTypeFromIntent: () => "counterparty", + extractDisplayedAddressEntityCandidates: () => [{ entityType: "counterparty", value: "SVK Group" }], + resolveDisplayedAddressEntityMention: () => ({ entityType: "counterparty", value: "SVK Group" }), + resolveAddressIntent: () => ({ intent: "unknown" }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "покажи договоры по СВК", + [], + null, + null, + null + ); + + expect(carryover?.followupSelectionMode).toBe("carry_referenced_entity"); + expect(carryover?.followupContext?.target_intent).toBe("list_contracts_by_counterparty"); + expect(carryover?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(carryover?.followupContext?.previous_anchor_value).toBe("SVK Group"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + organization: 'РћРћРћ "Альтернатива Плюс"', + counterparty: "SVK Group", + period_from: "2017-01-01", + period_to: "2017-12-31" + }); + expect(carryover?.followupContext?.resolved_counterparty_from_display).toBe(true); + }); + it("retargets same-date inventory follow-up away from receivables intent", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({