Архитектура: закрыть phase17 clarification-resume и обновить readiness-статус перед расширением доменов
This commit is contained in:
@@ -165,4 +165,79 @@ describe("address follow-up temporal regressions", () => {
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28");
|
||||
expect(result?.baseReasons).toContain("period_from_followup_context");
|
||||
});
|
||||
it("clears counterparty noise from bare organization clarification selections that resume inventory", () => {
|
||||
const result = runAddressDecomposeStage("АЛЬТЕРНАТИВА", {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
target_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).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2026-04-19");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not re-inherit organization alias as counterparty into historical inventory follow-up", () => {
|
||||
const result = runAddressDecomposeStage("давай на июль 2017", {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
target_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
counterparty: "АЛЬТЕРНАТИВА",
|
||||
as_of_date: "2026-04-19"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "АЛЬТЕРНАТИВА",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
root_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
as_of_date: "2026-04-19"
|
||||
},
|
||||
root_anchor_type: "organization",
|
||||
root_anchor_value: "ООО Альтернатива Плюс",
|
||||
current_frame_kind: "inventory_root"
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2017-07-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2017-07-31");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBeUndefined();
|
||||
expect(result?.baseReasons).toContain("counterparty_cleared_as_organization_scope_alias");
|
||||
});
|
||||
|
||||
it("does not inherit stale historical period into a fresh counterparty document root", () => {
|
||||
const result = runAddressDecomposeStage("по чепурнову покажи все доки", {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
target_intent: "list_documents_by_counterparty",
|
||||
previous_filters: {
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
period_from: "2016-03-01",
|
||||
period_to: "2016-03-31",
|
||||
as_of_date: "2016-03-31"
|
||||
},
|
||||
previous_anchor_type: "organization",
|
||||
previous_anchor_value: "ООО Альтернатива Плюс"
|
||||
}, {
|
||||
scope_target_kind: "counterparty",
|
||||
scope_target_text: "Чепурнов",
|
||||
date_scope_kind: "missing",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBeTruthy();
|
||||
expect(result?.filters.extracted_filters.period_from).toBeUndefined();
|
||||
expect(result?.filters.extracted_filters.period_to).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,51 @@ vi.mock("../src/services/addressMcpClient", async () => {
|
||||
...actual,
|
||||
executeAddressMcpQuery: executeAddressMcpQueryMock
|
||||
};
|
||||
it("clears company-name counterparty noise when a bare organization selection resumes inventory", 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: 148261.67,
|
||||
Quantity: 22,
|
||||
SubcontoDt1: "Модуль прямоугольый 1400*110*750",
|
||||
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",
|
||||
target_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("counterparty_cleared_from_referential_organization_scope");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Модуль прямоугольый 1400*110*750");
|
||||
});
|
||||
});
|
||||
|
||||
import { AddressQueryService } from "../src/services/addressQueryService";
|
||||
|
||||
@@ -588,7 +588,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2020-06-01");
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2020-06-30");
|
||||
expect(calls[1].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
|
||||
expect(calls[1].options?.followupContext?.root_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(calls[1].options?.followupContext?.root_filters?.as_of_date).toBe("2020-06-30");
|
||||
expect(calls[1].options?.followupContext?.current_frame_kind).toBe("inventory_root");
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.warehouse).toBe("Основной склад");
|
||||
@@ -2059,12 +2059,134 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps historical inventory date follow-up alive after company clarification and a capability answer", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "покажи остатки по складу";
|
||||
const secondMessage = "Альтернатива";
|
||||
const historicalCapabilityMessage = "а исторические остатки на другие даты умеешь?";
|
||||
const dateFollowupMessage = "давай на июль 2017";
|
||||
const organization = "ООО Альтернатива Плюс";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === firstMessage) {
|
||||
return buildAddressLimitedLaneResult("missing_anchor", {
|
||||
reply_text: [
|
||||
"Нужно уточнить организацию, чтобы не смешивать компании в одном ответе.",
|
||||
"Сейчас в доступном контуре вижу такие организации:",
|
||||
"- ООО Альтернатива Плюс",
|
||||
"- ООО Лайсвуд"
|
||||
].join("\n"),
|
||||
debug: {
|
||||
...buildAddressLimitedLaneResult("missing_anchor").debug,
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
as_of_date: "2026-04-19"
|
||||
},
|
||||
organization_candidates: [organization, "ООО Лайсвуд"],
|
||||
reasons: ["organization_clarification_required", "multiple_known_organizations_detected"]
|
||||
}
|
||||
});
|
||||
}
|
||||
if (message === secondMessage && options?.followupContext && options?.activeOrganization === organization) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 19.04.2026 по ООО Альтернатива Плюс подтвержден складской остаток.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
as_of_date: "2026-04-19",
|
||||
organization
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "organization_grounded_from_scope_candidates"]
|
||||
}
|
||||
});
|
||||
}
|
||||
if (message === dateFollowupMessage && options?.followupContext) {
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "На 31.07.2017 по ООО Альтернатива Плюс подтвержден складской остаток.",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization,
|
||||
as_of_date: "2017-07-31",
|
||||
period_from: "2017-07-01",
|
||||
period_to: "2017-07-31"
|
||||
},
|
||||
reasons: ["address_followup_context_applied", "inventory_root_temporal_followup_detected"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||||
reply_type: "partial_coverage",
|
||||
debug: {}
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const sessionId = `asst-address-org-historical-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("partial_coverage");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: secondMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
|
||||
const third = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: historicalCapabilityMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(third.ok).toBe(true);
|
||||
expect(["factual", "factual_with_explanation"]).toContain(third.reply_type);
|
||||
|
||||
const fourth = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: dateFollowupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
|
||||
expect(fourth.ok).toBe(true);
|
||||
expect(fourth.reply_type).toBe("factual");
|
||||
const dateCall = calls.find((entry) => entry.message === dateFollowupMessage);
|
||||
expect(dateCall).toBeTruthy();
|
||||
expect(dateCall?.options?.followupContext?.previous_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(dateCall?.options?.followupContext?.previous_filters?.organization).toBe(organization);
|
||||
expect(dateCall?.options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitizes selected-item carryover when inventory drilldown pivots into VAT follow-up", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const followupMessage = "\u0430 \u043d\u0434\u0441?";
|
||||
const itemLabel =
|
||||
"\u041a\u0440\u043e\u043c\u043a\u0430 \u0441 \u043a\u043b\u0435\u0435\u043c 33 \u0434\u0443\u0431 \u043d\u0438\u0430\u0433\u0430\u0440\u0430 137 \u043c";
|
||||
const organization = "\u041e\u041e\u041e \\\u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441\\";
|
||||
const organization = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
|
||||
const warehouse = "\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0439 \u0441\u043a\u043b\u0430\u0434";
|
||||
|
||||
const vatResult = buildAddressLaneResult({
|
||||
@@ -2204,7 +2326,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
detected_intent_confidence: "medium",
|
||||
extracted_filters: {
|
||||
item: "Столешница 600*3050*26 дуб ниагара",
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
as_of_date: "2020-05-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_sale_trace_for_item_v1",
|
||||
@@ -2225,7 +2347,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
detected_intent_confidence: "medium",
|
||||
extracted_filters: {
|
||||
item: "Столешница 600*3050*26 дуб ниагара",
|
||||
organization: "ООО \\Альтернатива Плюс\\",
|
||||
organization: "ООО Альтернатива Плюс",
|
||||
as_of_date: "2020-05-31"
|
||||
},
|
||||
selected_recipe: "address_inventory_purchase_provenance_for_item_v1",
|
||||
@@ -2284,7 +2406,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(calls[0].message).toBe(followupMessage);
|
||||
expect(calls[0].options?.followupContext?.previous_intent).toBe("inventory_sale_trace_for_item");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.item).toBe("Столешница 600*3050*26 дуб ниагара");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe("ООО \\Альтернатива Плюс\\");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -2872,6 +2994,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
it("does not backfill stale counterparty anchors into inventory root temporal follow-ups", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const followupMessage = "остатки на июль 2019";
|
||||
const organization = 'ООО "Альтернатива Плюс"';
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
@@ -2885,7 +3008,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
extracted_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
organization,
|
||||
period_from: "2019-07-01",
|
||||
period_to: "2019-07-31",
|
||||
as_of_date: "2019-07-31"
|
||||
@@ -2950,13 +3073,13 @@ describe("assistant address follow-up carryover", () => {
|
||||
detected_mode: "address_query",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
extracted_filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
organization,
|
||||
as_of_date: "2026-04-16"
|
||||
},
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
anchor_type: "organization",
|
||||
anchor_value_raw: 'ООО "Альтернатива Плюс"',
|
||||
anchor_value_resolved: 'ООО "Альтернатива Плюс"'
|
||||
anchor_value_raw: organization,
|
||||
anchor_value_resolved: organization
|
||||
}
|
||||
} as any);
|
||||
sessions.setAddressNavigationState(sessionId, {
|
||||
@@ -2970,7 +3093,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
period_from: null,
|
||||
period_to: null
|
||||
},
|
||||
organization_scope: 'ООО "Альтернатива Плюс"'
|
||||
organization_scope: organization
|
||||
},
|
||||
result_sets: [
|
||||
{
|
||||
@@ -2978,7 +3101,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
type: "inventory_snapshot",
|
||||
route_id: "address_inventory_on_hand_as_of_date_v1",
|
||||
filters: {
|
||||
organization: 'ООО "Альтернатива Плюс"',
|
||||
organization,
|
||||
as_of_date: "2026-04-16"
|
||||
},
|
||||
entity_refs: [],
|
||||
@@ -3013,7 +3136,7 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
|
||||
expect(calls[0].options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.counterparty).toBeUndefined();
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe('ООО "Альтернатива Плюс"');
|
||||
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe(organization);
|
||||
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(calls[0].options?.followupContext?.root_filters?.counterparty).toBeUndefined();
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
|
||||
@@ -557,6 +557,55 @@ describe("assistantRoutePolicy", () => {
|
||||
expect(decision.orchestrationContract?.followup_context_detected).toBe(false);
|
||||
});
|
||||
|
||||
it("does not turn short entity follow-up into organization switch just because scope already has an active company", () => {
|
||||
const policy = buildPolicy({
|
||||
resolveAddressToolGateDecision: undefined,
|
||||
findLastOrganizationClarificationAddressDebug: () => ({
|
||||
execution_lane: "address_query",
|
||||
limited_reason_category: "missing_anchor",
|
||||
organization_candidates: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"]
|
||||
}),
|
||||
shouldEmitOrganizationSelectionReply: () => true
|
||||
});
|
||||
|
||||
const decision = policy.resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "а по свк",
|
||||
effectiveAddressUserMessage: "а по свк",
|
||||
followupContext: {
|
||||
previous_intent: "list_documents_by_counterparty",
|
||||
previous_filters: {
|
||||
counterparty: "Чепурнов П.Д."
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "Чепурнов П.Д."
|
||||
},
|
||||
sessionItems: [
|
||||
{
|
||||
role: "assistant",
|
||||
debug: {
|
||||
execution_lane: "address_query",
|
||||
answer_grounding_check: { status: "grounded" },
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
extracted_filters: {
|
||||
counterparty: "Чепурнов П.Д.",
|
||||
organization: "ООО Альтернатива Плюс"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
sessionOrganizationScope: {
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд", "РАЙМ"],
|
||||
selectedOrganization: "ООО Альтернатива Плюс",
|
||||
activeOrganization: "ООО Альтернатива Плюс"
|
||||
},
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.toolGateReason).not.toBe("organization_scope_switch_detected");
|
||||
expect(decision.orchestrationContract?.organization_scope_switch_detected).not.toBe(true);
|
||||
});
|
||||
|
||||
it("keeps company activity assessment follow-up in address lane when lifecycle intent is resolved from grounded continuity", () => {
|
||||
const policy = buildPolicy({
|
||||
resolveAddressIntent: () => ({ intent: "counterparty_activity_lifecycle", confidence: "high" }),
|
||||
|
||||
Reference in New Issue
Block a user