АРЧ - Ассистент: отделить meta-followup по прошлому ответу от повторного запуска address lane
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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("implicit organization stock scope", () => {
|
||||
it("uses llm semantic hints to ground informal organization wording without turning it into warehouse anchor", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23: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("что на складе конторы альтернатива", {
|
||||
llmSemanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.reply_type).toBe("factual");
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_on_hand_as_of_date_v1");
|
||||
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("Альтернатива");
|
||||
expect(result?.debug.extracted_filters?.warehouse).toBeUndefined();
|
||||
expect(result?.debug.semantic_frame?.scope_kind).toBe("explicit_anchor");
|
||||
expect(result?.debug.semantic_frame?.anchor_kind).toBe("organization");
|
||||
expect(result?.debug.semantic_frame?.anchor_value).toBe("Альтернатива");
|
||||
expect(result?.debug.as_of_date_basis).toBe("implicit_current_snapshot");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Модуль прямоугольый 1400*110*750");
|
||||
});
|
||||
|
||||
it("re-grounds warehouse-like informal company wording to live organization candidate set", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 833.33,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Четки Пост (84*117)",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО КОТ ССЫТ ВО ДВОРЕ"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("что на складе конторы ссыт кот", {
|
||||
activeOrganization: "ООО КОТ ССЫТ ВО ДВОРЕ",
|
||||
knownOrganizations: ["ООО КОТ ССЫТ ВО ДВОРЕ", "ООО Альтернатива Плюс"]
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.reply_type).toBe("factual");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО КОТ ССЫТ ВО ДВОРЕ");
|
||||
expect(result?.debug.extracted_filters?.warehouse).toBeUndefined();
|
||||
expect(result?.debug.anchor_type).toBe("organization");
|
||||
expect(result?.debug.reasons).toContain("warehouse_anchor_regrounded_to_organization_scope");
|
||||
expect(result?.debug.reasons).toContain("organization_scope_live_grounding_recovered_rows");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Четки Пост (84*117)");
|
||||
});
|
||||
|
||||
it("handles slang stock-state wording as current inventory snapshot for grounded organization scope", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 34490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Диван трехместный",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Альтернатива Плюс"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("чекни плиз чо там на складе альтернативы происходит", {
|
||||
activeOrganization: "ООО Альтернатива Плюс",
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"]
|
||||
});
|
||||
|
||||
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.selected_recipe).toBe("address_inventory_on_hand_as_of_date_v1");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(result?.debug.extracted_filters?.warehouse).toBeUndefined();
|
||||
expect(result?.debug.as_of_date_basis).toBe("implicit_current_snapshot");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Диван трехместный");
|
||||
});
|
||||
|
||||
it("handles short colloquial stock query as current inventory snapshot for grounded organization scope", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 6490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Пуф арий",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: "ООО Альтернатива Плюс"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("че на складах альтернативы", {
|
||||
activeOrganization: "ООО Альтернатива Плюс",
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"]
|
||||
});
|
||||
|
||||
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.selected_recipe).toBe("address_inventory_on_hand_as_of_date_v1");
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(result?.debug.extracted_filters?.warehouse).toBeUndefined();
|
||||
expect(result?.debug.anchor_type).toBe("organization");
|
||||
expect(result?.debug.as_of_date_basis).toBe("implicit_current_snapshot");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Пуф арий");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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("implicit self-scope stock snapshot", () => {
|
||||
it("does not turn 'у нас' into a literal warehouse anchor", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 498472.5,
|
||||
Quantity: 3,
|
||||
SubcontoDt1: "Конструкция трансформер рабочей станции 1300*900*2000",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("что на складе у нас");
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.reply_type).toBe("factual");
|
||||
expect(result?.response_type).toBe("FACTUAL_LIST");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.debug.selected_recipe).toBe("address_inventory_on_hand_as_of_date_v1");
|
||||
expect(result?.debug.mcp_call_status).toBe("matched_non_empty");
|
||||
expect(result?.debug.extracted_filters?.warehouse).toBeUndefined();
|
||||
expect(result?.debug.as_of_date_basis).toBe("implicit_current_snapshot");
|
||||
expect(result?.debug.semantic_frame?.scope_kind).toBe("implicit_self_scope");
|
||||
expect(result?.debug.semantic_frame?.anchor_kind).toBe("self_scope");
|
||||
expect(result?.debug.semantic_frame?.date_scope_kind).toBe("implicit_current");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Конструкция трансформер рабочей станции 1300*900*2000");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("grounds implicit self-scope to active organization when one is in focus", async () => {
|
||||
executeAddressMcpQueryMock.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2026-04-15T23:59:59Z",
|
||||
Registrator: "Остатки товаров на складах",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "00.00",
|
||||
Amount: 34490,
|
||||
Quantity: 1,
|
||||
SubcontoDt1: "Диван трехместный",
|
||||
Warehouse: "Основной склад",
|
||||
Organization: 'ООО "Альтернатива Плюс"'
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("что на складе у нас", {
|
||||
activeOrganization: "ООО Альтернатива Плюс",
|
||||
knownOrganizations: ["ООО Альтернатива Плюс", "ООО Лайсвуд"]
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.extracted_filters?.organization).toBe("ООО Альтернатива Плюс");
|
||||
expect(result?.debug.semantic_frame?.scope_kind).toBe("implicit_self_scope");
|
||||
expect(result?.debug.semantic_frame?.anchor_kind).toBe("self_scope");
|
||||
expect(result?.debug.reasons).toContain("organization_from_active_scope");
|
||||
expect(String(result?.reply_text ?? "")).toContain("Диван трехместный");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runAddressDecomposeStage } from "../src/services/address_runtime/decomposeStage";
|
||||
|
||||
describe("inventory root frame follow-up", () => {
|
||||
it("restores the root inventory frame for a temporal patch after drilldown", () => {
|
||||
const result = runAddressDecomposeStage("а на май 2020", {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item: "Кресло орион",
|
||||
organization: "альтернатива",
|
||||
counterparty: "альтернатива",
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Кресло орион",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
root_filters: {
|
||||
organization: "альтернатива",
|
||||
counterparty: "альтернатива",
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
root_anchor_type: "organization",
|
||||
root_anchor_value: "ООО \\Альтернатива Плюс\\",
|
||||
current_frame_kind: "inventory_drilldown"
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.baseReasons).toContain("intent_restored_to_inventory_root_frame");
|
||||
expect(result?.filters.extracted_filters.item).toBeUndefined();
|
||||
expect(result?.filters.extracted_filters.organization).toBe("альтернатива");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBe("альтернатива");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-05-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-05-31");
|
||||
});
|
||||
|
||||
it("derives a relative month from the root frame year", () => {
|
||||
const result = runAddressDecomposeStage("а на май этого же года", {
|
||||
previous_intent: "inventory_purchase_provenance_for_item",
|
||||
previous_filters: {
|
||||
item: "Кресло орион",
|
||||
organization: "альтернатива",
|
||||
counterparty: "альтернатива",
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
previous_anchor_type: "item",
|
||||
previous_anchor_value: "Кресло орион",
|
||||
root_intent: "inventory_on_hand_as_of_date",
|
||||
root_filters: {
|
||||
organization: "альтернатива",
|
||||
counterparty: "альтернатива",
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
root_anchor_type: "organization",
|
||||
root_anchor_value: "ООО \\Альтернатива Плюс\\",
|
||||
current_frame_kind: "inventory_drilldown"
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-05-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31");
|
||||
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-05-31");
|
||||
expect(result?.baseReasons).toContain("period_derived_from_inventory_root_frame_year");
|
||||
});
|
||||
});
|
||||
@@ -521,4 +521,64 @@ describe("inventory selected-object follow-up", () => {
|
||||
expect(result?.debug.rows_matched).toBeGreaterThan(0);
|
||||
expect(String(result?.reply_text ?? "")).not.toContain("совпадений не нашлось");
|
||||
});
|
||||
|
||||
it("clears carried as-of date during history recovery for selected-object provenance after dated stock slice", async () => {
|
||||
executeAddressMcpQueryMock
|
||||
.mockResolvedValueOnce({
|
||||
fetched_rows: 0,
|
||||
matched_rows: 0,
|
||||
raw_rows: [],
|
||||
rows: [],
|
||||
error: null
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
fetched_rows: 1,
|
||||
matched_rows: 1,
|
||||
raw_rows: [
|
||||
{
|
||||
Period: "2020-06-18T00:00:00Z",
|
||||
Registrator: "Поступление товаров и услуг 00000000101 от 18.06.2020 0:00:00",
|
||||
AccountDt: "41.01",
|
||||
AccountKt: "60.01",
|
||||
Amount: 13490,
|
||||
SubcontoDt1: "Кресло орион",
|
||||
SubcontoDt3: "Основной склад",
|
||||
SubcontoKt1: "ООО \\Гамма-мебель\\",
|
||||
SubcontoKt2: "Договор поставки № 11 от 15.06.2020",
|
||||
Organization: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
],
|
||||
rows: [],
|
||||
error: null
|
||||
});
|
||||
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle('По выбранному объекту "Кресло орион": кто поставил это?', {
|
||||
followupContext: {
|
||||
previous_intent: "inventory_on_hand_as_of_date",
|
||||
previous_filters: {
|
||||
as_of_date: "2020-03-31",
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31",
|
||||
organization: "ООО \\Альтернатива Плюс\\"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ООО \\Альтернатива Плюс\\"
|
||||
}
|
||||
});
|
||||
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.response_type).toBe("FACTUAL_SUMMARY");
|
||||
expect(result?.debug.detected_intent).toBe("inventory_purchase_provenance_for_item");
|
||||
expect(result?.debug.extracted_filters?.item).toBe("Кресло орион");
|
||||
expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31");
|
||||
expect(result?.debug.extracted_filters?.period_from).toBe("2020-03-01");
|
||||
expect(result?.debug.extracted_filters?.period_to).toBe("2020-03-31");
|
||||
expect(result?.debug.reasons).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.reasons).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(result?.debug.limitations).toContain("as_of_date_cleared_for_history_recovery");
|
||||
expect(result?.debug.limitations).toContain("period_window_auto_broadened_to_available_data");
|
||||
expect(String(result?.reply_text ?? "")).toContain("ООО \\Гамма-мебель\\");
|
||||
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,4 +23,15 @@ describe("inventory warehouse anchor extraction", () => {
|
||||
expect(filters.as_of_date).toBe("2019-03-31");
|
||||
expect(filters.warehouse).toBeUndefined();
|
||||
});
|
||||
it("treats 'у нас' as implicit self-scope instead of literal warehouse anchor", () => {
|
||||
const result = extractAddressFilters("что на складе у нас", "inventory_on_hand_as_of_date");
|
||||
|
||||
expect(result.extracted_filters.warehouse).toBeUndefined();
|
||||
expect(result.warnings).toContain("warehouse_self_scope_detected");
|
||||
expect(result.semantic_frame?.scope_kind).toBe("implicit_self_scope");
|
||||
expect(result.semantic_frame?.anchor_kind).toBe("self_scope");
|
||||
expect(result.semantic_frame?.anchor_value).toBeNull();
|
||||
expect(result.semantic_frame?.date_scope_kind).toBe("implicit_current");
|
||||
expect(result.semantic_frame?.date_basis_hint).toBe("implicit_current_snapshot");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,4 +193,42 @@ describe("assistant address attempt runtime adapter", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards llm semantic hints from address runtime into lane attempt runtime input", async () => {
|
||||
const runAddressLaneAttemptRuntime = vi.fn(async () => ({
|
||||
response_type: "READY"
|
||||
}));
|
||||
const runAddressRuntime = vi.fn(async (input: any) => {
|
||||
await input.runAddressLaneAttempt("что на складе конторы альтернатива", null, null, {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
});
|
||||
|
||||
return {
|
||||
handled: false,
|
||||
response: null,
|
||||
addressRuntimeMetaForDeep: null
|
||||
};
|
||||
});
|
||||
|
||||
await runAssistantAddressAttemptRuntime(
|
||||
buildInput({
|
||||
runAddressRuntime,
|
||||
runAddressLaneAttemptRuntime
|
||||
})
|
||||
);
|
||||
|
||||
expect(runAddressLaneAttemptRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messageUsed: "что на складе конторы альтернатива",
|
||||
llmSemanticHints: expect.objectContaining({
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива"
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,6 +475,10 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-06-30");
|
||||
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?.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("Основной склад");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ function buildInput(overrides: Record<string, unknown> = {}) {
|
||||
carryMeta: { followupContext: { previous_intent: "docs_by_counterparty" } },
|
||||
analysisDateHint: "2020-08-31",
|
||||
activeOrganization: "Org A",
|
||||
knownOrganizations: ["Org A", "Org B"],
|
||||
mergeFollowupContextWithOrganizationScope,
|
||||
runAddressQueryTryHandle,
|
||||
...overrides
|
||||
@@ -24,6 +25,7 @@ describe("assistant address lane attempt input builder", () => {
|
||||
expect(runtimeInput.messageUsed).toBe("Show overdue docs");
|
||||
expect(runtimeInput.analysisDateHint).toBe("2020-08-31");
|
||||
expect(runtimeInput.activeOrganization).toBe("Org A");
|
||||
expect(runtimeInput.knownOrganizations).toEqual(["Org A", "Org B"]);
|
||||
expect(runtimeInput.carryMeta).toEqual({
|
||||
followupContext: { previous_intent: "docs_by_counterparty" }
|
||||
});
|
||||
@@ -37,6 +39,7 @@ describe("assistant address lane attempt input builder", () => {
|
||||
carryMeta: null,
|
||||
analysisDateHint: null,
|
||||
activeOrganization: null,
|
||||
knownOrganizations: [],
|
||||
mergeFollowupContextWithOrganizationScope,
|
||||
runAddressQueryTryHandle
|
||||
})
|
||||
|
||||
+11
-3
@@ -23,7 +23,10 @@ describe("assistant address lane attempt query options builder", () => {
|
||||
scopedFollowupContext: {
|
||||
previous_intent: "docs_by_counterparty",
|
||||
active_organization: "Org A"
|
||||
}
|
||||
},
|
||||
activeOrganization: "Org A",
|
||||
knownOrganizations: ["Org A", "Org B"],
|
||||
llmSemanticHints: null
|
||||
});
|
||||
|
||||
expect(options).toEqual({
|
||||
@@ -31,14 +34,19 @@ describe("assistant address lane attempt query options builder", () => {
|
||||
previous_intent: "docs_by_counterparty",
|
||||
active_organization: "Org A"
|
||||
},
|
||||
analysisDateHint: "2020-07-31"
|
||||
analysisDateHint: "2020-07-31",
|
||||
activeOrganization: "Org A",
|
||||
knownOrganizations: ["Org A", "Org B"]
|
||||
});
|
||||
});
|
||||
|
||||
it("builds query options with only analysis date when scoped context is missing", () => {
|
||||
const options = buildAssistantAddressLaneAttemptQueryOptions({
|
||||
analysisDateHint: null,
|
||||
scopedFollowupContext: null
|
||||
scopedFollowupContext: null,
|
||||
activeOrganization: null,
|
||||
knownOrganizations: [],
|
||||
llmSemanticHints: null
|
||||
});
|
||||
|
||||
expect(options).toEqual({
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("assistant address lane attempt runtime adapter", () => {
|
||||
},
|
||||
analysisDateHint: "2020-07-31",
|
||||
activeOrganization: "ООО Тест",
|
||||
knownOrganizations: ["ООО Тест", "ООО Лютик"],
|
||||
mergeFollowupContextWithOrganizationScope: () => ({
|
||||
previous_intent: "docs_by_counterparty",
|
||||
active_organization: "ООО Тест"
|
||||
@@ -27,7 +28,9 @@ describe("assistant address lane attempt runtime adapter", () => {
|
||||
previous_intent: "docs_by_counterparty",
|
||||
active_organization: "ООО Тест"
|
||||
},
|
||||
analysisDateHint: "2020-07-31"
|
||||
analysisDateHint: "2020-07-31",
|
||||
activeOrganization: "ООО Тест",
|
||||
knownOrganizations: ["ООО Тест", "ООО Лютик"]
|
||||
});
|
||||
expect(result).toEqual({
|
||||
response_type: "READY"
|
||||
@@ -41,6 +44,7 @@ describe("assistant address lane attempt runtime adapter", () => {
|
||||
carryMeta: null,
|
||||
analysisDateHint: null,
|
||||
activeOrganization: null,
|
||||
knownOrganizations: [],
|
||||
mergeFollowupContextWithOrganizationScope: () => null,
|
||||
runAddressQueryTryHandle
|
||||
});
|
||||
@@ -49,4 +53,36 @@ describe("assistant address lane attempt runtime adapter", () => {
|
||||
analysisDateHint: null
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards llm semantic hints into query options", async () => {
|
||||
const runAddressQueryTryHandle = vi.fn(async () => ({
|
||||
response_type: "READY"
|
||||
}));
|
||||
|
||||
await runAssistantAddressLaneAttemptRuntime({
|
||||
messageUsed: "что на складе конторы альтернатива",
|
||||
carryMeta: null,
|
||||
analysisDateHint: null,
|
||||
llmSemanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
},
|
||||
activeOrganization: null,
|
||||
knownOrganizations: ["ООО Альтернатива Плюс"],
|
||||
mergeFollowupContextWithOrganizationScope: () => null,
|
||||
runAddressQueryTryHandle
|
||||
});
|
||||
|
||||
expect(runAddressQueryTryHandle).toHaveBeenCalledWith("что на складе конторы альтернатива", {
|
||||
analysisDateHint: null,
|
||||
knownOrganizations: ["ООО Альтернатива Плюс"],
|
||||
llmSemanticHints: expect.objectContaining({
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива"
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +164,59 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
if (message === "получить остатки по складу для организации 'альтернатива'") {
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: `handled: ${message}`,
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_LIST",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_mode_confidence: "high",
|
||||
query_shape: "UNKNOWN",
|
||||
query_shape_confidence: "low",
|
||||
detected_intent: "inventory_on_hand_as_of_date",
|
||||
detected_intent_confidence: "high",
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
organization: "альтернатива",
|
||||
counterparty: "альтернатива",
|
||||
as_of_date: "2026-04-15"
|
||||
},
|
||||
missing_required_filters: [],
|
||||
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
|
||||
mcp_call_status_legacy: "matched_non_empty",
|
||||
account_scope_mode: "strict",
|
||||
account_scope_fallback_applied: false,
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: "альтернатива",
|
||||
anchor_value_resolved: "ООО \\Альтернатива Плюс\\",
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: "matched_non_empty",
|
||||
rows_fetched: 1,
|
||||
raw_rows_received: 1,
|
||||
rows_after_account_scope: 1,
|
||||
rows_after_recipe_filter: 1,
|
||||
rows_materialized: 1,
|
||||
rows_matched: 1,
|
||||
raw_row_keys_sample: [],
|
||||
materialization_drop_reason: "none",
|
||||
account_token_raw: null,
|
||||
account_token_normalized: null,
|
||||
account_scope_fields_checked: ["account_dt", "account_kt", "registrator", "analytics"],
|
||||
account_scope_match_strategy: "account_code_regex_plus_alias_map_v1",
|
||||
account_scope_drop_reason: "not_applicable",
|
||||
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
|
||||
limited_reason_category: null,
|
||||
response_type: "FACTUAL_LIST",
|
||||
limitations: [],
|
||||
reasons: ["inventory_on_hand_signal_detected"]
|
||||
}
|
||||
};
|
||||
}
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
@@ -449,6 +502,177 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
]).toContain(response.debug?.llm_decomposition_reason);
|
||||
});
|
||||
|
||||
it("prefers raw selected-object sale follow-up when llm rewrite drifts into generic open-items intent", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string) => {
|
||||
calls.push({ message });
|
||||
return buildAddressLaneResult(message);
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async (payload: any) => {
|
||||
if (payload?.userQuestion === "какие остатки по складу у альтернативы") {
|
||||
return {
|
||||
trace_id: "norm-predecompose-root-stock",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "какие остатки по складу у альтернативы",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "какие остатки по складу у альтернативы",
|
||||
normalized_fragment_text: "получить остатки по складу для организации 'альтернатива'",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: [],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: false,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
};
|
||||
}
|
||||
return {
|
||||
trace_id: "norm-predecompose-selected-object-sale-drift",
|
||||
ok: true,
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw:
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге',
|
||||
message_in_scope: true,
|
||||
scope_confidence: "medium",
|
||||
contains_multiple_tasks: false,
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text:
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге',
|
||||
normalized_fragment_text:
|
||||
"Определить контрагента, которому была реализована позиция «Рабочая станция универсального специалиста (индивидуальное изготовление)» по выбранному объекту",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "company_specific_accounting",
|
||||
entity_hints: [],
|
||||
account_hints: [],
|
||||
document_hints: [],
|
||||
register_hints: [],
|
||||
time_scope: {
|
||||
type: "missing",
|
||||
value: null,
|
||||
confidence: "low"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: false,
|
||||
asks_for_period_summary: false,
|
||||
asks_for_rule_check: false,
|
||||
asks_for_anomaly_scan: false,
|
||||
asks_for_exact_object_trace: true,
|
||||
asks_for_evidence: false,
|
||||
mentions_period_close_context: false
|
||||
},
|
||||
candidate_labels: ["simple_factual"],
|
||||
confidence: "medium",
|
||||
execution_readiness: "executable",
|
||||
clarification_reason: null,
|
||||
soft_assumption_used: [],
|
||||
route_status: "routed",
|
||||
no_route_reason: null
|
||||
}
|
||||
],
|
||||
discarded_fragments: [],
|
||||
global_notes: {
|
||||
needs_clarification: false,
|
||||
clarification_reason: null
|
||||
}
|
||||
},
|
||||
raw_model_output: null,
|
||||
validation: { passed: true, errors: [] },
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
request_count_for_case: 1
|
||||
};
|
||||
})
|
||||
} 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-predecompose-selected-object-sale-${Date.now()}`;
|
||||
await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: "какие остатки по складу у альтернативы",
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message:
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге',
|
||||
llmProvider: "local",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(
|
||||
'По выбранному объекту "Рабочая станция универсального специалиста (индивидуальное изготовление)": кому мы это продали в итоге'
|
||||
);
|
||||
expect(response.debug?.llm_decomposition_reason).toBe("followup_raw_message_preferred_over_llm_rewrite");
|
||||
});
|
||||
|
||||
it("does not treat service verb as counterparty anchor when llm rewrites noisy bank phrase", async () => {
|
||||
const calls: Array<{ message: string }> = [];
|
||||
const addressQueryService = {
|
||||
|
||||
@@ -99,5 +99,142 @@ describe("assistant address orchestration runtime adapter", () => {
|
||||
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
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("assistant address runtime adapter", () => {
|
||||
runAddressLaneRuntime
|
||||
});
|
||||
|
||||
expect(runAddressLaneAttempt).toHaveBeenCalledWith("canon", null, "2020-07-31");
|
||||
expect(runAddressLaneAttempt).toHaveBeenCalledWith("canon", null, "2020-07-31", null);
|
||||
expect(finalizeAddressLaneResponse).toHaveBeenCalledWith(
|
||||
{ handled: true },
|
||||
"canon",
|
||||
@@ -193,4 +193,87 @@ describe("assistant address runtime adapter", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("passes llm semantic hints from orchestration metadata into lane attempts", async () => {
|
||||
const runAddressLaneAttempt = vi.fn(async () => ({
|
||||
handled: true
|
||||
}));
|
||||
|
||||
const result = await runAssistantAddressRuntime({
|
||||
featureAssistantAddressQueryV1: true,
|
||||
sessionId: "asst-4",
|
||||
userMessage: "что на складе конторы альтернатива",
|
||||
sessionItems: [],
|
||||
llmProvider: "local",
|
||||
useMock: false,
|
||||
featureAddressLlmPredecomposeV1: true,
|
||||
runAddressLlmPreDecompose: async () => ({}),
|
||||
buildAddressLlmPredecomposeContractV1: () => ({}),
|
||||
sanitizeAddressMessageForFallback: (value) => value,
|
||||
toNonEmptyString: (value) => (typeof value === "string" && value.trim() ? value.trim() : null),
|
||||
resolveAddressFollowupCarryoverContext: () => null,
|
||||
resolveAssistantOrchestrationDecision: () => ({}),
|
||||
buildAddressDialogContinuationContractV2: () => ({}),
|
||||
runtimeAnalysisContextAsOfDate: null,
|
||||
payloadContextPeriodHint: null,
|
||||
compactWhitespace: (value) => value.replace(/\s+/g, " ").trim(),
|
||||
runAddressLaneAttempt,
|
||||
isRetryableAddressLimitedResult: () => false,
|
||||
finalizeAddressLaneResponse: () => ({ ok: "address" }),
|
||||
tryHandleLivingChat: async () => null,
|
||||
logEvent: () => {},
|
||||
nowIso: () => "2026-04-10T00:00:00.000Z",
|
||||
runAddressOrchestrationRuntime: async () => ({
|
||||
addressPreDecompose: {},
|
||||
addressInputMessage: "что на складе конторы альтернатива",
|
||||
carryover: null,
|
||||
orchestrationDecision: { runAddressLane: true },
|
||||
addressRuntimeMeta: {
|
||||
attempted: true,
|
||||
semanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
},
|
||||
livingModeDecision: { mode: "address_data", reason: "address_lane_triggered" }
|
||||
}),
|
||||
runAddressToolGateRuntime: async () => ({
|
||||
handled: false,
|
||||
response: null
|
||||
}),
|
||||
runAddressLaneRuntime: async (input) => {
|
||||
const addressLane = await input.runAddressLaneAttempt(input.addressInputMessage, null, input.llmSemanticHints ?? null);
|
||||
return {
|
||||
handled: true,
|
||||
selection: {
|
||||
addressLane: addressLane ?? { handled: true },
|
||||
messageUsed: input.addressInputMessage,
|
||||
carryMeta: null
|
||||
},
|
||||
retryAudit: {
|
||||
attempted: false,
|
||||
reason: null,
|
||||
initial_limited_category: null,
|
||||
retry_message: null,
|
||||
retry_used_followup_context: false,
|
||||
retry_result_category: null
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
expect(runAddressLaneAttempt).toHaveBeenCalledWith(
|
||||
"что на складе конторы альтернатива",
|
||||
null,
|
||||
null,
|
||||
expect.objectContaining({
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива"
|
||||
})
|
||||
);
|
||||
expect(result.handled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
|
||||
import {
|
||||
buildAddressLlmPredecomposeContractV1,
|
||||
buildAddressSemanticExtractionContractV1
|
||||
} from "../src/services/address_runtime/predecomposeContract";
|
||||
|
||||
describe("assistant living router mode decision", () => {
|
||||
it("returns address_data when address lane already triggered", () => {
|
||||
@@ -471,7 +475,9 @@ describe("assistant orchestration contract", () => {
|
||||
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(["address_signal_detected", "address_intent_resolver_detected"]).toContain(String(decision.toolGateReason));
|
||||
expect(["address_signal_detected", "address_intent_resolver_detected", "address_mode_classifier_detected"]).toContain(
|
||||
String(decision.toolGateReason)
|
||||
);
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
@@ -772,6 +778,95 @@ describe("assistant orchestration contract", () => {
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps slang stock-state query with organization scope in address lane instead of deep fallback", () => {
|
||||
const rawUserMessage = "чекни плиз чо там на складе альтернативы происходит";
|
||||
const effectiveAddressUserMessage = "проверь, что происходит на складе у компании 'альтернатива'";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
semanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
});
|
||||
const semanticExtractionContract = buildAddressSemanticExtractionContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
predecomposeContract
|
||||
});
|
||||
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage,
|
||||
effectiveAddressUserMessage,
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract,
|
||||
semanticExtractionContract
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
|
||||
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
|
||||
expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps short colloquial stock query with organization scope in address lane instead of chat fallback", () => {
|
||||
const rawUserMessage = "че на складах альтернативы";
|
||||
const effectiveAddressUserMessage = "что находится на складах у компании 'альтернатива'";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
semanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
});
|
||||
const semanticExtractionContract = buildAddressSemanticExtractionContractV1({
|
||||
sourceMessage: rawUserMessage,
|
||||
canonicalMessage: effectiveAddressUserMessage,
|
||||
predecomposeContract
|
||||
});
|
||||
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage,
|
||||
effectiveAddressUserMessage,
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract,
|
||||
semanticExtractionContract
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect([
|
||||
"address_intent_resolver_detected",
|
||||
"address_mode_classifier_detected",
|
||||
"llm_canonical_data_signal_detected",
|
||||
"address_signal_detected"
|
||||
]).toContain(
|
||||
String(decision.toolGateReason)
|
||||
);
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps open-contracts request in address lane even with stale deep followup context when LLM contract is absent", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
mergeKnownOrganizations,
|
||||
normalizeOrganizationScopeSearchText,
|
||||
resolveOrganizationSelectionFromMessage,
|
||||
scoreOrganizationMentionInMessage
|
||||
} from "../src/services/assistantOrganizationMatcher";
|
||||
|
||||
describe("assistant organization matcher", () => {
|
||||
it("deduplicates known organizations by normalized search key", () => {
|
||||
expect(
|
||||
mergeKnownOrganizations([
|
||||
'ООО "Альтернатива Плюс"',
|
||||
"ооо альтернатива плюс",
|
||||
"ООО Лайсвуд"
|
||||
])
|
||||
).toEqual(['ООО "Альтернатива Плюс"', "ООО Лайсвуд"]);
|
||||
});
|
||||
|
||||
it("matches incomplete or reordered organization mention against live candidates", () => {
|
||||
const resolved = resolveOrganizationSelectionFromMessage("дай что сегодня на складе в конторе ссыт кот", [
|
||||
"ООО КОТ ССЫТ ВО ДВОРЕ",
|
||||
"ООО Альтернатива Плюс"
|
||||
]);
|
||||
|
||||
expect(resolved).toBe("ООО КОТ ССЫТ ВО ДВОРЕ");
|
||||
});
|
||||
|
||||
it("scores direct and fuzzy token overlap above ambiguity threshold", () => {
|
||||
const score = scoreOrganizationMentionInMessage(
|
||||
normalizeOrganizationScopeSearchText("что на складе конторы альтернатива"),
|
||||
'ООО "Альтернатива Плюс"'
|
||||
);
|
||||
|
||||
expect(score).toBeGreaterThanOrEqual(90);
|
||||
});
|
||||
});
|
||||
@@ -58,5 +58,71 @@ describe("address semantic extraction contract", () => {
|
||||
expect(semantic.apply_canonical_recommended).toBe(true);
|
||||
expect(["high", "medium"]).toContain(semantic.quality);
|
||||
});
|
||||
});
|
||||
it("marks self-scope stock snapshot wording as implicit current scope, not explicit date", () => {
|
||||
const sourceMessage = "что на складе у нас";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage,
|
||||
canonicalMessage: sourceMessage
|
||||
});
|
||||
|
||||
expect(predecomposeContract.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(predecomposeContract.period.has_explicit_period).toBe(false);
|
||||
expect(predecomposeContract.semantics.scope_kind).toBe("implicit_self_scope");
|
||||
expect(predecomposeContract.semantics.anchor_kind).toBe("self_scope");
|
||||
expect(predecomposeContract.semantics.date_scope_kind).toBe("implicit_current");
|
||||
expect(predecomposeContract.semantics.date_basis_hint).toBe("implicit_current_snapshot");
|
||||
});
|
||||
|
||||
it("accepts llm semantic hints for organization-scoped informal warehouse wording", () => {
|
||||
const sourceMessage = "что на складе конторы альтернатива";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage,
|
||||
canonicalMessage: sourceMessage,
|
||||
semanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "Альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(predecomposeContract.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(predecomposeContract.entities.organization).toBe("Альтернатива");
|
||||
expect(predecomposeContract.entities.counterparty).toBeNull();
|
||||
expect(predecomposeContract.semantics.scope_kind).toBe("explicit_anchor");
|
||||
expect(predecomposeContract.semantics.anchor_kind).toBe("organization");
|
||||
expect(predecomposeContract.semantics.anchor_value).toBe("Альтернатива");
|
||||
expect(predecomposeContract.period.has_explicit_period).toBe(false);
|
||||
expect(predecomposeContract.semantics.date_scope_kind).toBe("implicit_current");
|
||||
});
|
||||
|
||||
it("keeps slang stock-state rewrite as address snapshot instead of deep investigation", () => {
|
||||
const sourceMessage = "чекни плиз чо там на складе альтернативы происходит";
|
||||
const canonicalMessage = "проверь, что происходит на складе у компании 'альтернатива'";
|
||||
const predecomposeContract = buildAddressLlmPredecomposeContractV1({
|
||||
sourceMessage,
|
||||
canonicalMessage,
|
||||
semanticHints: {
|
||||
scope_target_kind: "organization",
|
||||
scope_target_text: "альтернатива",
|
||||
date_scope_kind: "implicit_current",
|
||||
self_scope_detected: false,
|
||||
selected_object_scope_detected: false
|
||||
}
|
||||
});
|
||||
const semantic = buildAddressSemanticExtractionContractV1({
|
||||
sourceMessage,
|
||||
canonicalMessage,
|
||||
predecomposeContract
|
||||
});
|
||||
|
||||
expect(predecomposeContract.mode).toBe("address_query");
|
||||
expect(predecomposeContract.intent).toBe("inventory_on_hand_as_of_date");
|
||||
expect(predecomposeContract.entities.organization).toBe("альтернатива");
|
||||
expect(semantic.guard_hints.deep_investigation_signal_detected).toBe(false);
|
||||
expect(semantic.guard_hints.canonical_data_signal_detected).toBe(true);
|
||||
expect(semantic.valid).toBe(true);
|
||||
expect(semantic.apply_canonical_recommended).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user