NODEDC_1C/llm_normalizer/backend/tests/assistantAddressOrchestrati...

381 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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