Архитектура: стабилизировать organization authority после late company switch и закрыть phase16 multi-company replay

This commit is contained in:
2026-04-19 15:31:50 +03:00
parent 6e6f94b08c
commit af15e21bf6
25 changed files with 1063 additions and 36 deletions
@@ -117,6 +117,33 @@ describe("address follow-up temporal regressions", () => {
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
});
it("keeps period window on inventory same-date follow-up phrased as 'по этой же дате'", () => {
const result = runAddressDecomposeStage(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u043e\u0441\u0442\u0430\u0442\u043a\u0438 \u043d\u0430 \u0441\u043a\u043b\u0430\u0434\u0435 \u043f\u043e \u044d\u0442\u043e\u0439 \u0436\u0435 \u0434\u0430\u0442\u0435",
{
previous_intent: "receivables_confirmed_as_of_date",
previous_filters: {
organization: "\u0420\u0410\u0419\u041c",
period_from: "2020-03-01",
period_to: "2020-03-31",
as_of_date: "2020-03-31"
},
previous_anchor_type: "organization",
previous_anchor_value: "\u0420\u0410\u0419\u041c"
}
);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
expect(result?.filters.extracted_filters.organization).toBe("\u0420\u0410\u0419\u041c");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
expect(result?.filters.extracted_filters.warehouse).toBeUndefined();
expect(result?.baseReasons).toContain("period_from_from_followup_context");
expect(result?.baseReasons).toContain("period_to_from_followup_context");
});
it("retargets inventory purchase-date VAT bridge into confirmed VAT period with inherited purchase month", () => {
const result = runAddressDecomposeStage("ндс можешь прикинуть на дату покупки рабочей станции?", {
previous_intent: "inventory_purchase_provenance_for_item",
@@ -79,6 +79,12 @@ describe("inventory warehouse anchor extraction", () => {
expect(filters.warehouse).toBeUndefined();
});
it("does not materialize 'по той же дате' as warehouse anchor in stock follow-up", () => {
const filters = extractAddressFilters("покажи остатки на складе по той же дате", "inventory_on_hand_as_of_date").extracted_filters;
expect(filters.warehouse).toBeUndefined();
});
it("does not materialize current-date phrasing as warehouse anchor in stock follow-up", () => {
const filters = extractAddressFilters(
"получить остатки по складу на текущую дату",
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const { executeAddressMcpQueryMock } = vi.hoisted(() => ({
executeAddressMcpQueryMock: vi.fn()
}));
vi.mock("../src/services/addressMcpClient", async () => {
const actual = await vi.importActual<typeof import("../src/services/addressMcpClient")>(
"../src/services/addressMcpClient"
);
return {
...actual,
executeAddressMcpQuery: executeAddressMcpQueryMock
};
});
import { AddressQueryService } from "../src/services/addressQueryService";
afterEach(() => {
executeAddressMcpQueryMock.mockReset();
vi.restoreAllMocks();
});
describe("referential organization scope grounding", () => {
it("grounds 'по этой компании' to the active organization and clears bogus counterparty anchor", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2026-04-19T23:59:59Z",
Registrator: "Остатки товаров на складах",
AccountDt: "41.01",
AccountKt: "00.00",
Amount: 9800,
Quantity: 2,
SubcontoDt1: "Рабочая станция универсального специалиста",
Warehouse: "Основной склад",
Organization: "РАЙМ"
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("а по этой компании что сейчас на складе?", {
activeOrganization: "РАЙМ",
knownOrganizations: ["ООО Альтернатива Плюс", "РАЙМ"],
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
organization: "ООО Альтернатива Плюс",
as_of_date: "2026-04-19"
},
previous_anchor_type: "organization",
previous_anchor_value: "ООО Альтернатива Плюс"
}
});
expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("factual");
expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date");
expect(result?.debug.extracted_filters?.organization).toBe("РАЙМ");
expect(result?.debug.extracted_filters?.counterparty).toBeUndefined();
expect(result?.debug.reasons).toContain("organization_grounded_from_referential_scope");
expect(result?.debug.reasons).toContain("counterparty_cleared_from_referential_organization_scope");
expect(String(result?.reply_text ?? "")).toContain("Рабочая станция универсального специалиста");
});
});
@@ -72,6 +72,28 @@ describe("assistantContinuityPolicy organization authority", () => {
expect(authority.organizationClarificationSelectionFromScope).toBe("Org Selected");
});
it("exposes known organizations as switch candidates even without a prior clarification turn", () => {
const authority = resolveAssistantOrganizationAuthority({
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "living_chat",
assistant_active_organization: "РАЙМ",
assistant_known_organizations: ['ООО "Альтернатива Плюс"', "РАЙМ"]
}
}
],
sessionKnownOrganizations: ['ООО "Альтернатива Плюс"']
});
expect(authority.activeOrganization).toBe("РАЙМ");
expect(authority.organizationClarificationCandidates).toEqual([
'ООО "Альтернатива Плюс"',
"РАЙМ"
]);
});
it("reads item, organization and scoped date from root-frame fallback when direct filters are missing", () => {
const facts = resolveAddressDebugContextFacts({
anchor_type: "item",
@@ -85,6 +85,53 @@ describe("assistant organization scope runtime adapter", () => {
});
});
it("prefers continuity-selected organization over stale navigation scope after late switch", () => {
const normalizeOrganizationScopeValue = vi.fn((value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null
);
const context = resolveSessionOrganizationScopeContextRuntime({
userMessage: "а по этой компании что сейчас на складе?",
items: [
{
role: "assistant",
debug: {
assistant_known_organizations: ["ООО Альтернатива Плюс", "РАЙМ"],
assistant_active_organization: "РАЙМ",
living_chat_selected_organization: "РАЙМ"
}
}
] as any[],
addressNavigationState: {
schema_version: "address_navigation_state_v1",
session_id: "asst-nav-stale-org",
updated_at: "2026-04-19T12:04:44.000Z",
session_context: {
active_result_set_id: "rs-1",
active_focus_object: null,
last_confirmed_route: "address_inventory_on_hand_as_of_date_v1",
date_scope: {
as_of_date: "2026-04-19",
period_from: null,
period_to: null
},
organization_scope: "ООО Альтернатива Плюс"
},
result_sets: [],
navigation_history: []
} as any,
extractKnownOrganizationsFromHistory: () => ["ООО Альтернатива Плюс"],
resolveOrganizationSelectionFromMessage: () => null,
normalizeOrganizationScopeValue
});
expect(context).toEqual({
knownOrganizations: ["ООО Альтернатива Плюс", "РАЙМ"],
selectedOrganization: null,
activeOrganization: "РАЙМ"
});
});
it("reuses assistant continuity authority from prior assistant debug when legacy helpers are empty", () => {
const normalizeOrganizationScopeValue = vi.fn((value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null
@@ -138,11 +185,14 @@ describe("assistant organization scope runtime adapter", () => {
});
});
it("keeps existing organization in followup filters and returns null for empty context without org", () => {
const preserved = mergeFollowupContextWithOrganizationScopeRuntime({
it("overrides stale organization in followup filters and returns null for empty context without org", () => {
const overridden = mergeFollowupContextWithOrganizationScopeRuntime({
followupContext: {
previous_filters: {
organization: "Org Existing"
},
root_filters: {
organization: "Org Existing"
}
},
organization: "Org A",
@@ -158,7 +208,8 @@ describe("assistant organization scope runtime adapter", () => {
toNonEmptyString: () => null
});
expect((preserved as any).previous_filters.organization).toBe("Org Existing");
expect((overridden as any).previous_filters.organization).toBe("Org A");
expect((overridden as any).root_filters.organization).toBe("Org A");
expect(empty).toBeNull();
});
});
@@ -95,6 +95,7 @@ function buildPolicy(overrides: Record<string, unknown> = {}) {
hasLooseAllTimeAddressLookupSignal: () => false,
hasDeepAnalysisPreferenceSignal: () => false,
hasDirectDeepAnalysisSignal: () => false,
shouldEmitOrganizationSelectionReply: () => false,
compactWhitespace: (text: string) => String(text ?? "").replace(/\s+/g, " ").trim(),
hasDeepSessionContinuationSignal: () => false,
resolveLivingAssistantModeDecision: (input: { addressLaneTriggered?: boolean }) =>
@@ -369,6 +370,59 @@ describe("assistantRoutePolicy", () => {
expect(decision.livingReason).toBe("organization_fact_lookup_signal_detected");
});
it("routes a late company switch to chat instead of reusing the old address contour", () => {
const policy = buildPolicy({
detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }),
resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date", confidence: "high" }),
resolveAddressToolGateDecision: () => ({
runAddressLane: true,
decision: "run_address_lane",
reason: "address_mode_classifier_detected"
}),
resolveOrganizationSelectionFromMessage: () => "РАЙМ",
shouldEmitOrganizationSelectionReply: () => true
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: "теперь давай по РАЙМ",
effectiveAddressUserMessage: "теперь давай по РАЙМ",
followupContext: {
previous_intent: "inventory_on_hand_as_of_date",
previous_filters: {
organization: "ООО Альтернатива Плюс",
as_of_date: "2026-04-19"
}
},
sessionItems: [
{
role: "assistant",
debug: {
execution_lane: "address_query",
answer_grounding_check: { status: "grounded" },
extracted_filters: {
organization: "ООО Альтернатива Плюс",
as_of_date: "2026-04-19"
}
}
}
],
sessionOrganizationScope: {
knownOrganizations: ["ООО Альтернатива Плюс", "РАЙМ"],
selectedOrganization: null,
activeOrganization: "ООО Альтернатива Плюс"
},
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("organization_scope_switch_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("organization_scope_switch_detected");
expect(decision.orchestrationContract?.organization_scope_switch_detected).toBe(true);
expect(decision.orchestrationContract?.organization_scope_selection).toBe("РАЙМ");
});
it("routes explicit recap wording with selected-object phrasing to chat even when address-like cues exist", () => {
const policy = buildPolicy({
hasStrongDataIntentSignal: () => true,
@@ -52,7 +52,7 @@ describe("assistant runtime contract registry", () => {
const contract = getAssistantCapabilityContract("confirmed_inventory_on_hand_as_of_date");
expect(contract).not.toBeNull();
expect(contract?.entry_modes).toEqual(["root_entry", "root_followup", "clarification_resume"]);
expect(contract?.supported_transition_classes).toEqual(["T1", "T2", "T7"]);
expect(contract?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]);
expect(contract?.requires_focus_object).toBe(false);
expect(contract?.result_shape).toBe("item_list_with_quantity_cost_warehouse_organization");
expect(contract?.required_scenario_families).toContain("colloquial");
@@ -71,6 +71,30 @@ describe("assistant runtime contract registry", () => {
expect(contract?.required_scenario_families).toContain("pronoun_followup");
});
it("declares root financial exact capabilities for debt and vat snapshots", () => {
const receivables = getAssistantCapabilityContract("confirmed_receivables_as_of_date");
const payables = getAssistantCapabilityContract("confirmed_payables_as_of_date");
const vat = getAssistantCapabilityContract("confirmed_vat_liability_for_tax_period");
expect(receivables?.intent_ids).toEqual(["receivables_confirmed_as_of_date"]);
expect(receivables?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]);
expect(receivables?.requires_focus_object).toBe(false);
expect(payables?.intent_ids).toEqual(["payables_confirmed_as_of_date"]);
expect(payables?.truth_mode_fallbacks).toEqual(["limited", "clarification_required", "unsupported"]);
expect(vat?.intent_ids).toEqual(["vat_liability_confirmed_for_tax_period"]);
expect(vat?.required_scenario_families).toContain("tax_period_followup");
});
it("resolves receivables intent to its exact runtime contract", () => {
const contract = getAssistantCapabilityContractByIntent("receivables_confirmed_as_of_date");
expect(contract?.capability_id).toBe("confirmed_receivables_as_of_date");
expect(contract?.runtime_lane).toBe("address_exact");
expect(contract?.execution_adapter).toBe("AddressQueryService");
});
it("keeps truth semantics outside answer wording for every pilot inventory capability", () => {
for (const contract of listInventoryCapabilityContracts()) {
expect(contract.coverage_gate_behavior).toBe("partial_or_blocked_if_evidence_insufficient");