381 lines
16 KiB
TypeScript
381 lines
16 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import { buildAssistantAddressOrchestrationRuntime } from "../src/services/assistantAddressOrchestrationRuntimeAdapter";
|
||
|
||
function buildInput(overrides: Record<string, unknown> = {}) {
|
||
const runAddressLlmPreDecompose = vi.fn(async () => ({
|
||
attempted: true,
|
||
applied: true,
|
||
effectiveMessage: "канон",
|
||
reason: "normalized_fragment_applied"
|
||
}));
|
||
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
|
||
followupContext: { id: "ctx" }
|
||
}));
|
||
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
|
||
runAddressLane: true,
|
||
livingMode: "deep_analysis",
|
||
livingReason: "address_mode_classifier_detected",
|
||
toolGateDecision: "run_address_lane",
|
||
toolGateReason: "address_mode_classifier_detected",
|
||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||
}));
|
||
const buildAddressDialogContinuationContractV2 = vi.fn(() => ({
|
||
schema_version: "address_dialog_continuation_contract_v2"
|
||
}));
|
||
|
||
return {
|
||
userMessage: "сырой вопрос",
|
||
sessionItems: [],
|
||
llmProvider: "openai",
|
||
useMock: false,
|
||
featureAddressLlmPredecomposeV1: true,
|
||
runAddressLlmPreDecompose,
|
||
buildAddressLlmPredecomposeContractV1: () => ({ schema_version: "address_llm_predecompose_contract_v1" }),
|
||
sanitizeAddressMessageForFallback: () => "sanitized",
|
||
toNonEmptyString: (value: unknown) => {
|
||
if (typeof value !== "string") {
|
||
return null;
|
||
}
|
||
const trimmed = value.trim();
|
||
return trimmed.length > 0 ? trimmed : null;
|
||
},
|
||
resolveAddressFollowupCarryoverContext,
|
||
resolveAssistantOrchestrationDecision,
|
||
buildAddressDialogContinuationContractV2,
|
||
__spies: {
|
||
runAddressLlmPreDecompose,
|
||
resolveAddressFollowupCarryoverContext,
|
||
resolveAssistantOrchestrationDecision,
|
||
buildAddressDialogContinuationContractV2
|
||
},
|
||
...overrides
|
||
} as any;
|
||
}
|
||
|
||
describe("assistant address orchestration runtime adapter", () => {
|
||
it("uses llm predecompose payload when feature is enabled", async () => {
|
||
const input = buildInput();
|
||
|
||
const output = await buildAssistantAddressOrchestrationRuntime(input);
|
||
|
||
expect(output.addressPreDecompose.reason).toBe("normalized_fragment_applied");
|
||
expect(output.addressInputMessage).toBe("канон");
|
||
expect(output.orchestrationDecision.runAddressLane).toBe(true);
|
||
expect(output.livingModeDecision.mode).toBe("deep_analysis");
|
||
expect(output.addressRuntimeMeta.toolGateDecision).toBe("run_address_lane");
|
||
expect(output.addressRuntimeMeta.routePolicyContract).toEqual(
|
||
expect.objectContaining({
|
||
schema_version: "assistant_route_policy_runtime_v1",
|
||
policy_owner: "assistantRoutePolicyRuntimeAdapter",
|
||
living_mode: "deep_analysis",
|
||
tool_gate_decision: "run_address_lane",
|
||
has_followup_context: true,
|
||
has_orchestration_contract: true
|
||
})
|
||
);
|
||
expect(output.addressRuntimeMeta.dialogContinuationContract).toEqual({
|
||
schema_version: "address_dialog_continuation_contract_v2"
|
||
});
|
||
expect(input.__spies.runAddressLlmPreDecompose).toHaveBeenCalledTimes(1);
|
||
expect(input.__spies.resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(1);
|
||
expect(input.__spies.resolveAssistantOrchestrationDecision).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("builds deterministic fallback predecompose payload when feature is disabled", async () => {
|
||
const input = buildInput({
|
||
featureAddressLlmPredecomposeV1: false,
|
||
llmProvider: "local",
|
||
runAddressLlmPreDecompose: vi.fn(async () => {
|
||
throw new Error("must not be called");
|
||
}),
|
||
resolveAssistantOrchestrationDecision: vi.fn(() => ({
|
||
runAddressLane: false,
|
||
livingMode: "chat",
|
||
livingReason: "predecompose_unsupported_mode",
|
||
toolGateDecision: "skip_address_lane",
|
||
toolGateReason: "llm_predecompose_unsupported_mode",
|
||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||
}))
|
||
});
|
||
|
||
const output = await buildAssistantAddressOrchestrationRuntime(input);
|
||
|
||
expect(output.addressPreDecompose.attempted).toBe(false);
|
||
expect(output.addressPreDecompose.applied).toBe(false);
|
||
expect(output.addressPreDecompose.provider).toBe("local");
|
||
expect(output.addressPreDecompose.reason).toBe("disabled_by_feature_flag");
|
||
expect(output.addressPreDecompose.sanitizedUserMessage).toBe("sanitized");
|
||
expect(output.addressInputMessage).toBe("сырой вопрос");
|
||
expect(output.livingModeDecision.mode).toBe("chat");
|
||
expect(output.addressRuntimeMeta.toolGateDecision).toBe("skip_address_lane");
|
||
});
|
||
|
||
it("prefers raw short follow-up over unsupported llm rewrite when carryover context exists", async () => {
|
||
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
|
||
followupContext: {
|
||
previous_intent: "inventory_on_hand_as_of_date",
|
||
previous_filters: {
|
||
organization: "ООО \\Альтернатива Плюс\\",
|
||
as_of_date: "2026-04-15"
|
||
}
|
||
}
|
||
}));
|
||
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
|
||
runAddressLane: true,
|
||
livingMode: "address_data",
|
||
livingReason: "address_lane_triggered",
|
||
toolGateDecision: "run_address_lane",
|
||
toolGateReason: "followup_context_detected",
|
||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||
}));
|
||
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
|
||
schema_version: "address_llm_predecompose_contract_v1",
|
||
source_message: sourceMessage,
|
||
canonical_message: canonicalMessage,
|
||
mode: canonicalMessage === sourceMessage ? "address_query" : "unsupported",
|
||
intent: canonicalMessage === sourceMessage ? "inventory_on_hand_as_of_date" : "unknown"
|
||
}));
|
||
|
||
const output = await buildAssistantAddressOrchestrationRuntime(
|
||
buildInput({
|
||
userMessage: "ахуен а на март 2020",
|
||
runAddressLlmPreDecompose: vi.fn(async () => ({
|
||
attempted: true,
|
||
applied: true,
|
||
effectiveMessage: "что не так в бухгалтерии за март 2020 года?",
|
||
reason: "normalized_fragment_applied",
|
||
predecomposeContract: {
|
||
mode: "unsupported",
|
||
intent: "unknown"
|
||
}
|
||
})),
|
||
buildAddressLlmPredecomposeContractV1,
|
||
resolveAddressFollowupCarryoverContext,
|
||
resolveAssistantOrchestrationDecision
|
||
})
|
||
);
|
||
|
||
expect(output.addressInputMessage).toBe("ахуен а на март 2020");
|
||
expect(output.addressPreDecompose.applied).toBe(false);
|
||
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||
expect(output.addressPreDecompose.predecomposeContract).toEqual(
|
||
expect.objectContaining({
|
||
canonical_message: "ахуен а на март 2020",
|
||
mode: "address_query",
|
||
intent: "inventory_on_hand_as_of_date"
|
||
})
|
||
);
|
||
expect(buildAddressLlmPredecomposeContractV1).toHaveBeenCalledWith({
|
||
sourceMessage: "ахуен а на март 2020",
|
||
canonicalMessage: "ахуен а на март 2020"
|
||
});
|
||
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
|
||
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
rawUserMessage: "ахуен а на март 2020",
|
||
effectiveAddressUserMessage: "ахуен а на март 2020"
|
||
})
|
||
);
|
||
});
|
||
|
||
it("prefers raw selected-object inventory action over generic canonical drift intent", async () => {
|
||
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
|
||
followupContext: {
|
||
previous_intent: "inventory_on_hand_as_of_date",
|
||
previous_filters: {
|
||
organization: "ООО \\Альтернатива Плюс\\",
|
||
as_of_date: "2016-06-30",
|
||
period_from: "2016-06-01",
|
||
period_to: "2016-06-30"
|
||
}
|
||
}
|
||
}));
|
||
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
|
||
runAddressLane: true,
|
||
livingMode: "address_data",
|
||
livingReason: "address_lane_triggered",
|
||
toolGateDecision: "run_address_lane",
|
||
toolGateReason: "address_mode_classifier_detected",
|
||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||
}));
|
||
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
|
||
schema_version: "address_llm_predecompose_contract_v1",
|
||
source_message: sourceMessage,
|
||
canonical_message: canonicalMessage,
|
||
mode: "address_query",
|
||
intent: "unknown"
|
||
}));
|
||
|
||
const rawMessage =
|
||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге';
|
||
|
||
const output = await buildAssistantAddressOrchestrationRuntime(
|
||
buildInput({
|
||
userMessage: rawMessage,
|
||
runAddressLlmPreDecompose: vi.fn(async () => ({
|
||
attempted: true,
|
||
applied: true,
|
||
effectiveMessage:
|
||
"Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту",
|
||
reason: "normalized_fragment_applied",
|
||
predecomposeContract: {
|
||
mode: "address_query",
|
||
intent: "open_items_by_counterparty_or_contract",
|
||
semantics: {
|
||
selected_object_scope_detected: true
|
||
}
|
||
}
|
||
})),
|
||
buildAddressLlmPredecomposeContractV1,
|
||
resolveAddressFollowupCarryoverContext,
|
||
resolveAssistantOrchestrationDecision
|
||
})
|
||
);
|
||
|
||
expect(output.addressInputMessage).toBe(rawMessage);
|
||
expect(output.addressPreDecompose.applied).toBe(false);
|
||
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||
expect(buildAddressLlmPredecomposeContractV1).toHaveBeenCalledWith({
|
||
sourceMessage: rawMessage,
|
||
canonicalMessage: rawMessage
|
||
});
|
||
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
|
||
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
rawUserMessage: rawMessage,
|
||
effectiveAddressUserMessage: rawMessage
|
||
})
|
||
);
|
||
});
|
||
|
||
it("prefers raw selected-object sale-destination wording over generic canonical drift intent", async () => {
|
||
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
|
||
followupContext: {
|
||
previous_intent: "inventory_on_hand_as_of_date",
|
||
previous_filters: {
|
||
as_of_date: "2020-05-31",
|
||
period_from: "2020-05-01",
|
||
period_to: "2020-05-31"
|
||
}
|
||
}
|
||
}));
|
||
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
|
||
runAddressLane: true,
|
||
livingMode: "address_data",
|
||
livingReason: "address_lane_triggered",
|
||
toolGateDecision: "run_address_lane",
|
||
toolGateReason: "address_mode_classifier_detected",
|
||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||
}));
|
||
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
|
||
schema_version: "address_llm_predecompose_contract_v1",
|
||
source_message: sourceMessage,
|
||
canonical_message: canonicalMessage,
|
||
mode: "address_query",
|
||
intent: "unknown"
|
||
}));
|
||
|
||
const rawMessage = 'По выбранному объекту "Пуф арий": куда мы продали эту позицию';
|
||
|
||
const output = await buildAssistantAddressOrchestrationRuntime(
|
||
buildInput({
|
||
userMessage: rawMessage,
|
||
runAddressLlmPreDecompose: vi.fn(async () => ({
|
||
attempted: true,
|
||
applied: true,
|
||
effectiveMessage: "Определить контрагента по реализации позиции «Пуф арий»",
|
||
reason: "normalized_fragment_applied",
|
||
predecomposeContract: {
|
||
mode: "address_query",
|
||
intent: "open_items_by_counterparty_or_contract",
|
||
semantics: {
|
||
selected_object_scope_detected: true
|
||
}
|
||
}
|
||
})),
|
||
buildAddressLlmPredecomposeContractV1,
|
||
resolveAddressFollowupCarryoverContext,
|
||
resolveAssistantOrchestrationDecision
|
||
})
|
||
);
|
||
|
||
expect(output.addressInputMessage).toBe(rawMessage);
|
||
expect(output.addressPreDecompose.applied).toBe(false);
|
||
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||
expect(buildAddressLlmPredecomposeContractV1).toHaveBeenCalledWith({
|
||
sourceMessage: rawMessage,
|
||
canonicalMessage: rawMessage
|
||
});
|
||
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
|
||
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
rawUserMessage: rawMessage,
|
||
effectiveAddressUserMessage: rawMessage
|
||
})
|
||
);
|
||
});
|
||
|
||
it("prefers raw selected-object delivery wording over generic canonical drift intent", async () => {
|
||
const resolveAddressFollowupCarryoverContext = vi.fn(() => ({
|
||
followupContext: {
|
||
previous_intent: "inventory_on_hand_as_of_date",
|
||
previous_filters: {
|
||
as_of_date: "2021-03-31",
|
||
period_from: "2021-03-01",
|
||
period_to: "2021-03-31"
|
||
}
|
||
}
|
||
}));
|
||
const resolveAssistantOrchestrationDecision = vi.fn(() => ({
|
||
runAddressLane: true,
|
||
livingMode: "address_data",
|
||
livingReason: "address_lane_triggered",
|
||
toolGateDecision: "run_address_lane",
|
||
toolGateReason: "address_mode_classifier_detected",
|
||
orchestrationContract: { schema_version: "assistant_orchestration_contract_v1" }
|
||
}));
|
||
const buildAddressLlmPredecomposeContractV1 = vi.fn(({ sourceMessage, canonicalMessage }: { sourceMessage: string; canonicalMessage: string }) => ({
|
||
schema_version: "address_llm_predecompose_contract_v1",
|
||
source_message: sourceMessage,
|
||
canonical_message: canonicalMessage,
|
||
mode: "address_query",
|
||
intent: "unknown"
|
||
}));
|
||
|
||
const rawMessage = 'По выбранному объекту "Кромка с клеем 33 дуб ниагара 137 м": кому мы это поставили';
|
||
|
||
const output = await buildAssistantAddressOrchestrationRuntime(
|
||
buildInput({
|
||
userMessage: rawMessage,
|
||
runAddressLlmPreDecompose: vi.fn(async () => ({
|
||
attempted: true,
|
||
applied: true,
|
||
effectiveMessage: "Покажи взаиморасчеты с контрагентом по выбранной позиции",
|
||
reason: "normalized_fragment_applied",
|
||
predecomposeContract: {
|
||
mode: "address_query",
|
||
intent: "bank_operations_by_counterparty",
|
||
semantics: {
|
||
selected_object_scope_detected: true
|
||
}
|
||
}
|
||
})),
|
||
buildAddressLlmPredecomposeContractV1,
|
||
resolveAddressFollowupCarryoverContext,
|
||
resolveAssistantOrchestrationDecision
|
||
})
|
||
);
|
||
|
||
expect(output.addressInputMessage).toBe(rawMessage);
|
||
expect(output.addressPreDecompose.applied).toBe(false);
|
||
expect(output.addressPreDecompose.reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||
expect(resolveAddressFollowupCarryoverContext).toHaveBeenCalledTimes(2);
|
||
expect(resolveAssistantOrchestrationDecision).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
rawUserMessage: rawMessage,
|
||
effectiveAddressUserMessage: rawMessage
|
||
})
|
||
);
|
||
});
|
||
});
|