АРЧ АП11 - Добавить item-profitability intent для selected-object follow-up в inventory contour

This commit is contained in:
2026-04-16 09:00:43 +03:00
parent a493e2fd69
commit d7c4eb781a
23 changed files with 329 additions and 8 deletions
@@ -0,0 +1,67 @@
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";
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
afterEach(() => {
executeAddressMcpQueryMock.mockReset();
vi.restoreAllMocks();
});
describe("inventory profitability selected-object regressions", () => {
const followupContext = {
previous_intent: "inventory_on_hand_as_of_date" as const,
previous_filters: {
organization: "ООО \\Альтернатива Плюс\\",
as_of_date: "2020-05-31",
period_from: "2020-05-01",
period_to: "2020-05-31"
},
previous_anchor_type: "unknown" as const,
previous_anchor_value: null
};
const selectedObjectProfitabilityMessage =
'По выбранному объекту "Четки Пост (84*117)": а сколько денег мы заработали с продажжи этих четок';
it("routes selected-object profitability wording into an item profitability intent", () => {
const result = runAddressDecomposeStage(selectedObjectProfitabilityMessage, followupContext);
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("inventory_profitability_for_item");
expect(result?.intent.intent).not.toBe("customer_revenue_and_payments");
expect(result?.filters.extracted_filters.item).toBe("Четки Пост (84*117)");
expect(result?.filters.extracted_filters.period_from).toBe("2020-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31");
});
it("returns a truthful recipe visibility gap until item profitability gets a dedicated recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(selectedObjectProfitabilityMessage, {
followupContext
});
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("LIMITED_WITH_REASON");
expect(result?.debug.detected_intent).toBe("inventory_profitability_for_item");
expect(result?.debug.selected_recipe).toBeNull();
expect(result?.debug.limited_reason_category).toBe("recipe_visibility_gap");
expect(result?.debug.extracted_filters?.item).toBe("Четки Пост (84*117)");
expect(String(result?.reply_text ?? "")).toContain("Четки Пост (84*117)");
expect(executeAddressMcpQueryMock).not.toHaveBeenCalled();
});
});
@@ -377,4 +377,68 @@ describe("assistant address orchestration runtime adapter", () => {
})
);
});
it("prefers raw selected-object profitability wording over customer revenue canonical drift", 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 =
'По выбранному объекту "Четки Пост (84*117)": а сколько денег мы заработали с продажжи этих четок';
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: "customer_revenue_and_payments",
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
})
);
});
});