ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Систематизировать ответы по поставщику без поставок через анализ оплат и возвратов

This commit is contained in:
2026-04-17 02:08:02 +03:00
parent a3a61b3a0f
commit 44f1c1e11e
33 changed files with 22512 additions and 173 deletions
@@ -0,0 +1,243 @@
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 { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { AddressQueryService } from "../src/services/addressQueryService";
import { composeFactualReply } from "../src/services/address_runtime/composeStage";
afterEach(() => {
executeAddressMcpQueryMock.mockReset();
vi.restoreAllMocks();
});
describe("counterparty shipment item flow and open-items routing", () => {
it("routes counterparty shipment item-flow wording to documents by counterparty", () => {
const result = resolveAddressIntent("что нам отгружал чепурнов? какой товар или услугу?");
expect(result.intent).toBe("list_documents_by_counterparty");
expect(result.reasons).toContain("counterparty_item_flow_signal_detected");
});
it("routes account 60 tails wording to open items intent", () => {
const result = resolveAddressIntent("хвосты покажи по счету 60 на август 2022");
expect(result.intent).toBe("open_items_by_counterparty_or_contract");
});
it("includes resolved full counterparty name in document reply", () => {
const reply = composeFactualReply(
"list_documents_by_counterparty",
[
{
period: "2022-08-15T00:00:00Z",
registrator: "Поступление товаров и услуг 000000123 от 15.08.2022",
account_dt: "41.01",
account_kt: "60.01",
amount: 12500,
analytics: ["Чепурнов П.Д.", "Основной договор"],
item: "Кабель силовой",
organization: 'ООО "Альтернатива Плюс"'
}
],
{
userMessage: "покажи все документы по чапурнову",
counterpartyHint: "Чепурнов П.Д."
}
);
expect(reply.text).toContain("Контрагент: Чепурнов П.Д.");
});
it("uses purchase document query for fuzzy counterparty item-flow wording", async () => {
executeAddressMcpQueryMock
.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Чепурнов П.Д.",
Registrator: "Чепурнов П.Д."
}
],
rows: [],
error: null
})
.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2020-03-10T00:00:00Z",
Registrator: "Поступление товаров и услуг 000000001 от 10.03.2020",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 12000,
Nomenclature: "Кабель силовой",
Counterparty: "Чепурнов П.Д.",
Contract: "Основной договор",
Quantity: 2,
Organization: 'ООО "Альтернатива Плюс"'
},
{
Period: "2020-03-14T00:00:00Z",
Registrator: "Поступление товаров и услуг 000000002 от 14.03.2020",
AccountDt: "41.01",
AccountKt: "60.01",
Amount: 5400,
Nomenclature: "Патч-корд",
Counterparty: "Чепурнов П.Д.",
Contract: "Основной договор",
Quantity: 10,
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"какие товары или услуги были отгружены нашей компании контрагентом чапурновым?"
);
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("list_documents_by_counterparty");
expect(String(result?.reply_text ?? "")).toContain("Контрагент: Чепурнов П.Д.");
expect(String(result?.reply_text ?? "")).toContain("Позиции:");
expect(String(result?.reply_text ?? "")).toContain("Кабель силовой");
expect(String(result?.reply_text ?? "")).toContain("договор:");
expect(String(result?.reply_text ?? "")).toContain("дата:");
const query = String(executeAddressMcpQueryMock.mock.calls.at(-1)?.[0]?.query ?? "");
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Товары");
expect(query).toContain("Документ.ПоступлениеТоваровУслуг.Услуги");
expect(query).toContain("Товары.Ссылка.Контрагент.Наименование ПОДОБНО");
expect(query).not.toContain("контрагентом");
});
it("explains supplier payments and return when no supply rows are found", async () => {
executeAddressMcpQueryMock.mockImplementation(async (request?: { query?: string }) => {
const query = String(request?.query ?? "");
if (query.includes("Справочник.Контрагенты")) {
return {
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Counterparty: "Чепурнов П.Д.",
Registrator: "Чепурнов П.Д."
}
],
rows: [],
error: null
};
}
if (query.includes("Документ.ПоступлениеТоваровУслуг.Товары") || query.includes("Документ.ПоступлениеТоваровУслуг.Услуги")) {
return {
fetched_rows: 0,
matched_rows: 0,
raw_rows: [],
rows: [],
error: null
};
}
return {
fetched_rows: 3,
matched_rows: 3,
raw_rows: [
{
Period: "2021-06-11T12:00:01Z",
Registrator: "Списание с расчетного счета 00000000124 от 11.06.2021",
AccountDt: "60.02",
AccountKt: "51",
Amount: 119210,
SubcontoDt1: "Чепурнов П.Д.",
SubcontoDt2: "Договор № 11/1 от 25.11.2020",
SubcontoKt1: "ПАО СБЕРБАНК"
},
{
Period: "2021-05-18T12:00:00Z",
Registrator: "Списание с расчетного счета 00000000112 от 18.05.2021",
AccountDt: "60.02",
AccountKt: "51",
Amount: 180230,
SubcontoDt1: "Чепурнов П.Д.",
SubcontoDt2: "Договор № 11/1 от 25.11.2020",
SubcontoKt1: "ПАО СБЕРБАНК"
},
{
Period: "2022-01-20T12:00:03Z",
Registrator: "Поступление на расчетный счет 00000000001 от 20.01.2022",
AccountDt: "51",
AccountKt: "60.02",
Amount: 299440,
SubcontoKt1: "Чепурнов П.Д.",
SubcontoKt2: "Договор № 11/1 от 25.11.2020",
SubcontoDt1: "ПАО СБЕРБАНК"
}
],
rows: [],
error: null
};
});
const service = new AddressQueryService();
const result = await service.tryHandle(
"какие товары или услуги были отгружены нашей компании контрагентом чапурновым?"
);
expect(result?.handled).toBe(true);
expect(String(result?.reply_text ?? "")).toContain("Подтвержденных поставок товаров или услуг не найдено");
expect(String(result?.reply_text ?? "")).toContain("исходящих оплат поставщику");
expect(String(result?.reply_text ?? "")).toContain("возвратов от поставщика");
expect(String(result?.reply_text ?? "")).toContain("Договор:");
expect(result?.debug.reasons).toContain("counterparty_item_flow_no_supply_but_bank_activity_explained");
expect(executeAddressMcpQueryMock.mock.calls.length).toBeGreaterThanOrEqual(2);
});
it("keeps account 60 tails in open-items route and mentions the account in reply", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1,
matched_rows: 1,
raw_rows: [
{
Period: "2022-08-31T23:59:59Z",
Registrator: "Поступление на расчетный счет 000000001 от 31.08.2022",
AccountDt: "51",
AccountKt: "60.01",
Amount: 150000,
SubcontoKt1: "ООО Поставщик",
SubcontoKt2: "Договор поставки",
Organization: 'ООО "Альтернатива Плюс"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("хвосты покажи по счету 60 на август 2022");
expect(result?.handled).toBe(true);
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
expect(String(result?.reply_text ?? "")).toContain("счету 60");
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");
expect(query).toContain("60%");
});
});
@@ -73,10 +73,48 @@ describe("counterparty lifecycle organization scope regressions", () => {
expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("factual");
expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle");
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.extracted_filters?.organization).toBe('ООО "Альтернатива Плюс"');
expect(result?.debug.extracted_filters?.counterparty).toBeUndefined();
expect(result?.debug.match_failure_reason).toBeNull();
expect(result?.debug.rows_matched).toBe(3);
expect(String(result?.reply_text ?? "")).toContain('ООО "Ромашка"');
});
it("does not turn 'за все время' into a bogus counterparty anchor for highest-value customer wording", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2,
matched_rows: 2,
raw_rows: [
{
Period: "2020-01-15T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 120,
Counterparty: 'ООО "Ромашка"'
},
{
Period: "2020-02-20T00:00:00Z",
Registrator: "CP_CUSTOMER_ACTIVITY",
AccountDt: "62.01",
AccountKt: "90.01",
Amount: 80,
Counterparty: 'ООО "Ландыш"'
}
],
rows: [],
error: null
});
const service = new AddressQueryService();
const result = await service.tryHandle("какой у нас самый доходный клиент за все время");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(result?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(result?.debug.extracted_filters?.counterparty).toBeUndefined();
expect(String(result?.reply_text ?? "")).toContain('ООО "Ромашка"');
});
});
@@ -42,6 +42,62 @@ describe("address follow-up temporal regressions", () => {
expect(result?.baseReasons).toContain("period_derived_from_followup_context_year");
});
it("inherits the previous VAT period for 'за этот период' follow-up", () => {
const result = runAddressDecomposeStage("какой НДС мы должны примерно заплатить за этот период", {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2017-05-01",
period_to: "2017-05-31",
as_of_date: "2017-05-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2017-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2017-05-31");
expect(result?.baseReasons).toContain("period_from_from_followup_context");
});
it("keeps inherited period when a follow-up retargets from receivables into VAT for the same period", () => {
const result = runAddressDecomposeStage("а какой НДС мы должны примерно заплатить за этот период", {
previous_intent: "receivables_confirmed_as_of_date",
target_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2017-05-01",
period_to: "2017-05-31",
as_of_date: "2017-05-31"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2017-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2017-05-31");
});
it("uses follow-up target intent for short debt mirror like 'а нам?'", () => {
const result = runAddressDecomposeStage("а нам?", {
previous_intent: "payables_confirmed_as_of_date",
target_intent: "receivables_confirmed_as_of_date",
previous_filters: {
as_of_date: "2026-04-16"
},
previous_anchor_type: "organization",
previous_anchor_value: 'ООО "Альтернатива Плюс"'
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2026-04-16");
});
it("keeps same-date inventory pivot anchored to the previous VAT date", () => {
const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", {
previous_intent: "vat_payable_confirmed_as_of_date",
@@ -81,6 +81,9 @@ describe("inventory sale trace movement route", () => {
expect(result?.debug.detected_intent).toBe("inventory_sale_trace_for_item");
expect(result?.debug.selected_recipe).toBe("address_inventory_sale_trace_for_item_v1");
expect(String(result?.reply_text ?? "")).toContain("Комитет государственных услуг г. Москвы");
expect(String(result?.reply_text ?? "")).not.toMatch(
/Контрагент:\s*Рабочая станция универсального специалиста/i
);
expect(executeAddressMcpQueryMock).toHaveBeenCalledTimes(1);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
@@ -181,4 +181,65 @@ describe("address navigation state", () => {
expect(evolved.session_context.organization_scope).toBe("ООО Альтернатива Плюс");
expect(evolved.session_context.active_focus_object).toBeNull();
});
it("resets stale focus and date scope on explicit new topic turn", () => {
const initial = normalizeAddressNavigationState(
{
schema_version: "address_navigation_state_v1",
session_id: "asst-6",
updated_at: "2026-04-12T10:00:00.000Z",
session_context: {
active_result_set_id: "rs-prev",
active_focus_object: {
object_type: "counterparty",
object_id: "counterparty:chapurnov",
label: "Чепурнов",
provenance_result_set_id: "rs-prev",
selected_at: "2026-04-12T09:59:00.000Z"
},
last_confirmed_route: "address_documents_by_counterparty_v1",
date_scope: {
as_of_date: null,
period_from: "2017-01-01",
period_to: "2017-12-31"
},
organization_scope: "ООО Альтернатива Плюс"
},
result_sets: [],
navigation_history: []
} as any,
"asst-6"
);
const assistantItem = {
message_id: "msg-a5",
session_id: "asst-6",
role: "assistant",
text: "На 16.04.2026 на складе подтверждено 11 позиций.",
reply_type: "factual",
created_at: "2026-04-12T10:05:00.000Z",
trace_id: "address-791",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
extracted_filters: {
as_of_date: "2026-04-16",
organization: "ООО Альтернатива Плюс"
},
anchor_type: "organization",
anchor_value_resolved: "ООО Альтернатива Плюс",
dialog_continuation_contract_v2: {
decision: "new_topic"
}
}
} as any;
const evolved = evolveAddressNavigationStateWithAssistantItem(initial, assistantItem, 5);
expect(evolved.session_context.active_result_set_id).toBe("rs-msg-a5");
expect(evolved.session_context.active_focus_object?.label).toBe("ООО Альтернатива Плюс");
expect(evolved.session_context.date_scope.as_of_date).toBe("2026-04-16");
expect(evolved.session_context.date_scope.period_from).toBeNull();
expect(evolved.session_context.date_scope.period_to).toBeNull();
expect(evolved.session_context.last_confirmed_route).toBe("address_inventory_on_hand_as_of_date_v1");
expect(evolved.navigation_history[0]?.action).toBe("open");
});
});
@@ -13,6 +13,12 @@ describe("vat payable confirmed as-of route", () => {
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
});
it("treats colloquial 'сгрузить' wording as confirmed VAT payable intent", () => {
const result = resolveAddressIntent("какой НДС мы должны сгрузить на март 2020");
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
});
it("keeps VAT forecast intent when explicit forecast wording is used", () => {
const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020");
expect(result.intent).toBe("vat_payable_forecast");
@@ -62,5 +68,5 @@ describe("vat payable confirmed as-of route", () => {
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
}, 15000);
});
@@ -2599,5 +2599,321 @@ describe("assistant address follow-up carryover", () => {
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2020-05-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("treats short 'а нам?' as a receivables mirror follow-up after payables answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а нам?";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return {
handled: true,
reply_text: "На ту же дату нам должны 125 000 руб.",
reply_type: "factual",
response_type: "FACTUAL_SUMMARY",
debug: {
detected_mode: "address_query",
detected_intent: "receivables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
as_of_date: "2026-04-16"
},
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
reasons: ["address_action_detected", "address_followup_context_applied"]
}
};
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} 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-address-followup-a-nam-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-payables-seed",
session_id: sessionId,
role: "assistant",
text: "На сегодня мы должны поставщикам 87 000 руб.",
reply_type: "factual",
created_at: "2026-04-16T10:00:00.000Z",
trace_id: "address-payables-seed",
debug: {
detected_mode: "address_query",
detected_intent: "payables_confirmed_as_of_date",
extracted_filters: {
as_of_date: "2026-04-16"
},
selected_recipe: "address_payables_confirmed_as_of_date_v1"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.detected_intent).toBe("receivables_confirmed_as_of_date");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date");
expect(calls[0].options?.followupContext?.previous_filters?.as_of_date).toBe("2026-04-16");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps document intent for short counterparty retarget like 'а по свк'", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "а по свк";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === followupMessage && options?.followupContext) {
return buildAddressLaneResult({
reply_text: "Собран список документов по контрагенту СВК.",
debug: {
...buildAddressLaneResult().debug,
extracted_filters: {
sort: "period_desc",
limit: 20,
counterparty: "свк"
},
detected_intent: "list_documents_by_counterparty",
selected_recipe: "address_documents_by_counterparty_v1",
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} 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-address-followup-po-svk-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-docs-seed",
session_id: sessionId,
role: "assistant",
text: "Собран список документов по контрагенту Чапурнов.",
reply_type: "factual",
created_at: "2026-04-16T10:01:00.000Z",
trace_id: "address-docs-seed",
debug: {
detected_mode: "address_query",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "чапурнов",
sort: "period_desc",
limit: 20
},
selected_recipe: "address_documents_by_counterparty_v1",
anchor_type: "counterparty",
anchor_value_raw: "чапурнов",
anchor_value_resolved: "Чапурнов"
}
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(response.debug?.detected_intent).toBe("list_documents_by_counterparty");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(followupMessage);
expect(calls[0].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty");
expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Чапурнов");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("does not backfill stale counterparty anchors into inventory root temporal follow-ups", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const followupMessage = "остатки на июль 2019";
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (options?.followupContext) {
return buildAddressLaneResult({
reply_text: "На 31.07.2019 на складе подтверждено 4 позиции.",
reply_type: "factual",
debug: {
...buildAddressLaneResult().debug,
detected_intent: "inventory_on_hand_as_of_date",
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
period_from: "2019-07-01",
period_to: "2019-07-31",
as_of_date: "2019-07-31"
},
reasons: ["inventory_on_hand_signal_detected", "address_followup_context_applied"]
}
});
}
return null;
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
assistant_reply: "normalizer_fallback_should_not_be_used",
reply_type: "partial_coverage",
debug: {}
}))
} 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-address-followup-root-month-${Date.now()}`;
sessions.appendItem(sessionId, {
message_id: "msg-docs-seed",
session_id: sessionId,
role: "assistant",
text: "Собран список документов по контрагенту СВК.",
reply_type: "factual",
created_at: "2026-04-16T09:55:00.000Z",
trace_id: "address-docs-seed",
debug: {
detected_mode: "address_query",
detected_intent: "list_documents_by_counterparty",
extracted_filters: {
counterparty: "СЃРІРє",
sort: "period_desc",
limit: 20
},
selected_recipe: "address_documents_by_counterparty_v1",
anchor_type: "counterparty",
anchor_value_raw: "СЃРІРє",
anchor_value_resolved: "РЎР’Рљ"
}
} as any);
sessions.appendItem(sessionId, {
message_id: "msg-inventory-root-seed",
session_id: sessionId,
role: "assistant",
text: "На 16.04.2026 на складе подтверждено 11 позиций.",
reply_type: "factual",
created_at: "2026-04-16T10:00:00.000Z",
trace_id: "address-inventory-root-seed",
debug: {
detected_mode: "address_query",
detected_intent: "inventory_on_hand_as_of_date",
extracted_filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-04-16"
},
selected_recipe: "address_inventory_on_hand_as_of_date_v1",
anchor_type: "organization",
anchor_value_raw: 'ООО "Альтернатива Плюс"',
anchor_value_resolved: 'ООО "Альтернатива Плюс"'
}
} as any);
sessions.setAddressNavigationState(sessionId, {
session_id: sessionId,
session_context: {
active_result_set_id: "rs-inventory-root",
active_focus_object: null,
last_confirmed_route: "address_inventory_on_hand_as_of_date_v1",
date_scope: {
as_of_date: "2026-04-16",
period_from: null,
period_to: null
},
organization_scope: 'ООО "Альтернатива Плюс"'
},
result_sets: [
{
result_set_id: "rs-inventory-root",
type: "inventory_snapshot",
route_id: "address_inventory_on_hand_as_of_date_v1",
filters: {
organization: 'ООО "Альтернатива Плюс"',
as_of_date: "2026-04-16"
},
entity_refs: [],
source_refs: [],
created_from_turn: 2,
created_at: "2026-04-16T10:00:00.000Z"
}
],
navigation_history: [
{
event_id: "nav-inventory-root",
action: "open",
source_result_set_id: null,
target_object_id: null,
derived_result_set_id: "rs-inventory-root",
turn_index: 2,
created_at: "2026-04-16T10:00:00.000Z"
}
]
} as any);
const response = await service.handleMessage({
session_id: sessionId,
user_message: followupMessage,
useMock: true
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].options?.followupContext?.root_context_only).toBe(true);
expect(calls[0].options?.followupContext?.previous_intent).toBeUndefined();
expect(calls[0].options?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.previous_filters?.counterparty).toBeUndefined();
expect(calls[0].options?.followupContext?.previous_filters?.organization).toBe('ООО "Альтернатива Плюс"');
expect(calls[0].options?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date");
expect(calls[0].options?.followupContext?.root_filters?.counterparty).toBeUndefined();
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
});
@@ -80,6 +80,34 @@ describe("assistant address lane runtime adapter", () => {
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(3);
});
it("can retry with raw message after unsupported limited result", async () => {
const carryover: AssistantAddressFollowupCarryoverLike = { followupContext: { scope: "ctx" } };
const runAddressLaneAttempt = vi
.fn()
.mockResolvedValueOnce(limitedLane("unsupported"))
.mockResolvedValueOnce(limitedLane("unsupported"))
.mockResolvedValueOnce(factualLane());
const result = await runAssistantAddressLaneRuntime({
userMessage: "что нам отгружал чепурнов? какой товар или услугу?",
addressInputMessage: "Определить, что необходимо отгрузить контрагенту Чепурнов",
carryover,
shouldPreferContextualLane: false,
canRetryWithRawUserMessage: true,
runAddressLaneAttempt,
isRetryableAddressLimitedResult: (lane) =>
["missing_anchor", "empty_match", "unsupported"].includes(
String(lane?.debug?.limited_reason_category ?? "").trim()
)
});
expect(result.handled).toBe(true);
expect(result.selection?.messageUsed).toBe("что нам отгружал чепурнов? какой товар или услугу?");
expect(result.retryAudit.attempted).toBe(true);
expect(result.retryAudit.initial_limited_category).toBe("unsupported");
expect(runAddressLaneAttempt).toHaveBeenCalledTimes(3);
});
it("returns pending limited result when retry is disabled", async () => {
const runAddressLaneAttempt = vi
.fn()