АРЧ - Ассистент: отделить meta-followup по прошлому ответу от повторного запуска address lane

This commit is contained in:
2026-04-15 12:38:44 +03:00
parent 8866176be6
commit 70cc5a99f1
61 changed files with 4023 additions and 298 deletions
@@ -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
})
@@ -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);
});
});