ДОМЕНЫ - ВОПРОСЫ - СКЛАД - Систематизировать ответы по поставщику без поставок через анализ оплат и возвратов
This commit is contained in:
@@ -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%");
|
||||
});
|
||||
});
|
||||
+39
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user