Архитектура: централизовать party-anchor carryover и referenced-entity follow-up в continuity policy и transition glue

This commit is contained in:
dctouch 2026-04-19 11:58:56 +03:00
parent 0a423288b9
commit 4f56aa9b2a
7 changed files with 293 additions and 60 deletions

View File

@ -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;

View File

@ -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;

View File

@ -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" ||

View File

@ -436,6 +436,106 @@ export function applyOrganizationCarryoverFilters(
return nextFilters;
}
export function applyHistoricalPartyCarryoverFilters(
previousFilters: Record<string, unknown> | null,
shouldBackfillHistoricalPartyAnchors: boolean,
historicalContract: unknown,
historicalCounterparty: unknown,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString
): Record<string, unknown> {
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<string, unknown> | 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<string, unknown>;
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<string, unknown> | null,
toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString

View File

@ -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) &&

View File

@ -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
});
});
});

View File

@ -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: () => ({