АДРЕСНЫЙ РЕЖИМ -Step-5 - feat(assistant): стабилизация свободного LLM-роутинга, прическа маршрутов chat/address, прототип прогноза НДС
This commit is contained in:
@@ -125,6 +125,11 @@ describe("address query shape classifier", () => {
|
||||
expect(result.mode).toBe("address_query");
|
||||
});
|
||||
|
||||
it("keeps top contract wording with 'контракт' in address lane", () => {
|
||||
const result = detectAddressQuestionMode("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
|
||||
expect(result.mode).toBe("address_query");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("address compose stage utf8 headers", () => {
|
||||
@@ -1123,6 +1128,202 @@ describe("address compose stage utf8 headers", () => {
|
||||
expect(reply.text).toContain("активных договоров с минимальным бюджетом");
|
||||
expect(reply.text).toContain("1. Договор 02/20 | оборот: 100");
|
||||
});
|
||||
|
||||
it("adds deterministic why-zero explanation for VAT forecast follow-up wording", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_payable_forecast",
|
||||
[
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_68_CREDIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 9126,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_68_DEBIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 115342,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_19_DEBIT",
|
||||
account_dt: "19",
|
||||
account_kt: "",
|
||||
amount: 1602384,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_19_CREDIT",
|
||||
account_dt: "19",
|
||||
account_kt: "",
|
||||
amount: 0,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{ userMessage: "почему прогноз к уплате 0?" }
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Почему прогноз к уплате 0");
|
||||
expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)");
|
||||
expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00.");
|
||||
expect(reply.text).toContain("Разница неположительная");
|
||||
expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*");
|
||||
});
|
||||
|
||||
it("adds VAT declaration and payment deadlines for as-of-date forecast window", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_payable_forecast",
|
||||
[
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_68_CREDIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 300,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_68_DEBIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 0,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
|
||||
periodFrom: "2020-01-01",
|
||||
periodTo: "2020-03-15"
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020.");
|
||||
expect(reply.text).toContain("Налоговый период: 1 кв. 2020.");
|
||||
expect(reply.text).toContain("Срок сдачи декларации: до 25.04.2020.");
|
||||
expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 28.06.2020.");
|
||||
expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00.");
|
||||
});
|
||||
|
||||
it("builds VAT deadlines correctly for Q4 with next-year rollover", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_payable_forecast",
|
||||
[
|
||||
{
|
||||
period: "2020-12-31T00:00:00Z",
|
||||
registrator: "VAT_68_CREDIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 90,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "прогноз НДС на 31 декабря 2020",
|
||||
periodFrom: "2020-10-01",
|
||||
periodTo: "2020-12-31"
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Налоговый период: 4 кв. 2020.");
|
||||
expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021.");
|
||||
expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 28.02.2021, 28.03.2021.");
|
||||
expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00.");
|
||||
});
|
||||
|
||||
it("explains zero VAT as no-movements case when VAT turnovers are absent in window", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_payable_forecast",
|
||||
[
|
||||
{
|
||||
period: "2019-04-01T00:00:00Z",
|
||||
registrator: "VAT_68_CREDIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 0,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2019-04-01T00:00:00Z",
|
||||
registrator: "VAT_68_DEBIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 0,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2019-04-01T00:00:00Z",
|
||||
registrator: "VAT_19_DEBIT",
|
||||
account_dt: "19",
|
||||
account_kt: "",
|
||||
amount: 0,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2019-04-01T00:00:00Z",
|
||||
registrator: "VAT_19_CREDIT",
|
||||
account_dt: "19",
|
||||
account_kt: "",
|
||||
amount: 0,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "какой прогноз оплаты ндс на 12-05-2019",
|
||||
periodFrom: "2019-04-01",
|
||||
periodTo: "2019-05-12"
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
|
||||
expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*");
|
||||
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
|
||||
expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный");
|
||||
});
|
||||
|
||||
it("explains zero VAT as offset case when VAT turnovers exist but net is near zero", () => {
|
||||
const reply = composeFactualReply(
|
||||
"vat_payable_forecast",
|
||||
[
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_68_CREDIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 1000,
|
||||
analytics: []
|
||||
},
|
||||
{
|
||||
period: "2020-03-01T00:00:00Z",
|
||||
registrator: "VAT_68_DEBIT",
|
||||
account_dt: "68",
|
||||
account_kt: "",
|
||||
amount: 1000,
|
||||
analytics: []
|
||||
}
|
||||
],
|
||||
{
|
||||
userMessage: "какой прогноз оплаты ндс на 12-05-2020",
|
||||
periodFrom: "2020-04-01",
|
||||
periodTo: "2020-05-12"
|
||||
}
|
||||
);
|
||||
|
||||
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
|
||||
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
|
||||
expect(reply.text).toContain("обороты по 68* взаимно перекрылись");
|
||||
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
|
||||
});
|
||||
});
|
||||
|
||||
describe("address intent resolver expansion (M2.3a)", () => {
|
||||
@@ -1468,6 +1669,11 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
});
|
||||
|
||||
it("resolves top counterparty slang wording into customer revenue intent", () => {
|
||||
const result = resolveAddressIntent("какой самый жирный контрагент у нее? кто больше платит денег");
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
});
|
||||
|
||||
it("resolves supplier payouts profile intent from slang wording", () => {
|
||||
const result = resolveAddressIntent("кому мы больше всего сгрузили денег, топ-20 поставщиков");
|
||||
expect(result.intent).toBe("supplier_payouts_profile");
|
||||
@@ -1478,6 +1684,33 @@ describe("address intent resolver expansion (M2.3a)", () => {
|
||||
expect(result.intent).toBe("contract_usage_and_value");
|
||||
});
|
||||
|
||||
it("resolves top contract wording with 'контракт' into contract usage and value intent", () => {
|
||||
const result = resolveAddressIntent("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
|
||||
expect(result.intent).toBe("contract_usage_and_value");
|
||||
});
|
||||
|
||||
it("resolves revenue-total slang wording into customer revenue intent", () => {
|
||||
const result = resolveAddressIntent("скока денег альтернатива заработала за 22 год");
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
});
|
||||
|
||||
it("resolves overall-turnover wording into customer revenue intent", () => {
|
||||
const result = resolveAddressIntent("какие общие обороты за все время");
|
||||
expect(result.intent).toBe("customer_revenue_and_payments");
|
||||
});
|
||||
|
||||
it("resolves VAT payment forecast wording into dedicated VAT forecast intent", () => {
|
||||
const result = resolveAddressIntent("какой прогноз оплаты ндс за 12 мая 2020");
|
||||
expect(result.intent).toBe("vat_payable_forecast");
|
||||
expect(result.reasons).toContain("forecast_tax_signal_detected");
|
||||
});
|
||||
|
||||
it("resolves colloquial VAT payable estimate wording without explicit 'прогноз'", () => {
|
||||
const result = resolveAddressIntent("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
|
||||
expect(result.intent).toBe("vat_payable_forecast");
|
||||
expect(result.reasons).toContain("forecast_tax_signal_detected");
|
||||
});
|
||||
|
||||
it("resolves multi-contract counterparties wording into contract usage and value intent", () => {
|
||||
const result = resolveAddressIntent("Покажи контрагентов с несколькими договорами и какие из договоров активны.");
|
||||
expect(result.intent).toBe("contract_usage_and_value");
|
||||
@@ -1525,6 +1758,7 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
"договоры по обороту ранкни и дай топ-20",
|
||||
"contract_usage_and_value"
|
||||
);
|
||||
const vatForecast = extractAddressFilters("какой прогноз оплаты ндс за 12 мая 2020", "vat_payable_forecast");
|
||||
expect(periodProfile.extracted_filters.limit).toBeUndefined();
|
||||
expect(docSectionProfile.extracted_filters.limit).toBeUndefined();
|
||||
expect(counterpartyProfile.extracted_filters.limit).toBeUndefined();
|
||||
@@ -1533,6 +1767,7 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(customerValue.extracted_filters.limit).toBe(20);
|
||||
expect(supplierValue.extracted_filters.limit).toBe(20);
|
||||
expect(contractValue.extracted_filters.limit).toBe(20);
|
||||
expect(vatForecast.extracted_filters.limit).toBeUndefined();
|
||||
expect(periodProfile.extracted_filters.period_to).toBeDefined();
|
||||
expect(docSectionProfile.extracted_filters.period_to).toBeDefined();
|
||||
expect(counterpartyProfile.extracted_filters.period_to).toBeDefined();
|
||||
@@ -1541,6 +1776,8 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(customerValue.extracted_filters.period_to).toBeDefined();
|
||||
expect(supplierValue.extracted_filters.period_to).toBeDefined();
|
||||
expect(contractValue.extracted_filters.period_to).toBeDefined();
|
||||
expect(vatForecast.extracted_filters.period_from).toBe("2020-04-01");
|
||||
expect(vatForecast.extracted_filters.period_to).toBe("2020-05-12");
|
||||
expect(periodProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
|
||||
expect(docSectionProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
|
||||
expect(counterpartyProfile.warnings).toContain("period_to_defaulted_today_for_management_profile");
|
||||
@@ -1549,6 +1786,10 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(customerValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
|
||||
expect(supplierValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
|
||||
expect(contractValue.warnings).toContain("period_to_defaulted_today_for_management_profile");
|
||||
expect(vatForecast.warnings).toContain("period_derived_from_month_phrase");
|
||||
expect(vatForecast.warnings).toContain("period_from_derived_from_quarter_for_vat_forecast");
|
||||
expect(vatForecast.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
|
||||
expect(vatForecast.warnings).not.toContain("period_to_defaulted_today_for_management_profile");
|
||||
});
|
||||
|
||||
it("extracts short-year period for lifecycle customer list question", () => {
|
||||
@@ -1560,6 +1801,45 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(lifecycleShortYear.extracted_filters.period_to).toBe("2020-12-31");
|
||||
});
|
||||
|
||||
it("drops noisy counterparty anchor in ranking question for customer revenue profile", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"какой самый жирный контрагент у нее? кто больше платит денег",
|
||||
"customer_revenue_and_payments"
|
||||
);
|
||||
expect(extracted.extracted_filters.counterparty).toBeUndefined();
|
||||
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
|
||||
});
|
||||
|
||||
it("derives VAT forecast quarter-to-date window when plain date phrase is present", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года",
|
||||
"vat_payable_forecast"
|
||||
);
|
||||
expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(extracted.extracted_filters.period_to).toBe("2020-03-15");
|
||||
expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
|
||||
});
|
||||
|
||||
it("derives VAT forecast quarter-to-date window for explicit day+month+year phrase", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"сколько НДС нужно заплатить за 5 марта 2017 года",
|
||||
"vat_payable_forecast"
|
||||
);
|
||||
expect(extracted.extracted_filters.period_from).toBe("2017-01-01");
|
||||
expect(extracted.extracted_filters.period_to).toBe("2017-03-05");
|
||||
expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
|
||||
});
|
||||
|
||||
it("derives VAT forecast quarter-to-date window when strict as-of cue is present", () => {
|
||||
const extracted = extractAddressFilters(
|
||||
"сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
|
||||
"vat_payable_forecast"
|
||||
);
|
||||
expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(extracted.extracted_filters.period_to).toBe("2020-03-15");
|
||||
expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast");
|
||||
});
|
||||
|
||||
it("defaults as_of_date for documents_forming_balance when date is omitted", () => {
|
||||
const result = extractAddressFilters("which documents form balance for account 62", "documents_forming_balance");
|
||||
expect(result.extracted_filters.account).toBe("62");
|
||||
@@ -1633,6 +1913,16 @@ describe("address filter extraction for balance drilldown", () => {
|
||||
expect(result.warnings).toContain("period_window_cleared_for_as_of_intent");
|
||||
});
|
||||
|
||||
it("cuts report-date tail from counterparty anchor and keeps clean as_of filter", () => {
|
||||
const result = extractAddressFilters(
|
||||
"Показать незакрытые записи для контрагента 'СВК' на дату отчетности 2020-12-31",
|
||||
"open_items_by_counterparty_or_contract"
|
||||
);
|
||||
expect(result.extracted_filters.counterparty).toBe("СВК");
|
||||
expect(result.extracted_filters.as_of_date).toBe("2020-12-31");
|
||||
expect(String(result.extracted_filters.counterparty ?? "").toLowerCase()).not.toContain("отчетности");
|
||||
});
|
||||
|
||||
it("derives month period for balance snapshot from 'на май 2020'", () => {
|
||||
const result = extractAddressFilters("Какой остаток по счету 60 на май 2020", "account_balance_snapshot");
|
||||
expect(result.extracted_filters.account).toBe("60");
|
||||
@@ -2042,6 +2332,18 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
|
||||
});
|
||||
|
||||
it("routes top counterparty slang wording into customer value aggregate recipe", async () => {
|
||||
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(result?.debug.limited_reason_category).not.toBe("unsupported");
|
||||
expect(result?.debug.mcp_call_status).not.toBe("skipped");
|
||||
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
|
||||
});
|
||||
|
||||
it("routes supplier payout question into dedicated aggregate recipe", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("кому мы больше всего сгрузили денег, топ-20 поставщиков");
|
||||
@@ -2062,6 +2364,61 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
|
||||
});
|
||||
|
||||
it("routes top contract wording with 'контракт' into contract value aggregate recipe", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?");
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("contract_usage_and_value");
|
||||
expect(result?.debug.selected_recipe).toBe("address_contract_usage_and_value_v1");
|
||||
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
|
||||
expect(result?.debug.mcp_call_status).not.toBe("skipped");
|
||||
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
|
||||
});
|
||||
|
||||
it("routes revenue-total slang wording into customer value aggregate recipe (no account-missing fallback)", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("скока денег альтернатива заработала за 22 год");
|
||||
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.limited_reason_category).not.toBe("missing_anchor");
|
||||
expect(result?.debug.missing_required_filters).not.toContain("account");
|
||||
expect(result?.debug.mcp_call_status).not.toBe("skipped");
|
||||
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
|
||||
});
|
||||
|
||||
it("routes overall-turnover wording into customer value aggregate recipe", async () => {
|
||||
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.limited_reason_category).not.toBe("unsupported");
|
||||
expect(result?.debug.mcp_call_status).not.toBe("skipped");
|
||||
expect(["FACTUAL_LIST", "LIMITED_WITH_REASON", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
|
||||
});
|
||||
|
||||
it("routes VAT payment forecast wording into dedicated VAT forecast recipe", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("какой прогноз оплаты ндс за 12 мая 2020");
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("vat_payable_forecast");
|
||||
expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1");
|
||||
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
|
||||
expect(result?.debug.mcp_call_status).not.toBe("skipped");
|
||||
expect(result?.debug.extracted_filters.counterparty).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes colloquial VAT payable estimate wording into VAT forecast recipe", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года");
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(result?.debug.detected_intent).toBe("vat_payable_forecast");
|
||||
expect(result?.debug.selected_recipe).toBe("address_vat_payable_forecast_v1");
|
||||
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
|
||||
expect(result?.debug.mcp_call_status).not.toBe("skipped");
|
||||
});
|
||||
|
||||
it("routes customer lifecycle question into dedicated aggregate recipe", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("Какие заказчики работали с нами в 2020 году?");
|
||||
@@ -2137,9 +2494,25 @@ describe("address query limited taxonomy and stage diagnostics", () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи документы все по жуковке 51");
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
|
||||
expect(String(result?.assistant_reply ?? "")).not.toContain("якорь не подтвердился");
|
||||
if (result?.reply_type === "partial_coverage") {
|
||||
expect(String(result?.assistant_reply ?? "")).not.toContain("Точный якорь не подтвердился");
|
||||
expect(result?.debug.rows_matched).toBe(0);
|
||||
if (String(result?.debug.match_failure_reason ?? "").includes("counterparty_anchor_not_matched")) {
|
||||
expect(String(result?.assistant_reply ?? "")).toContain("уточните точное имя контрагента");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not keep report-date phrase inside open-items counterparty anchor", async () => {
|
||||
const service = new AddressQueryService();
|
||||
const result = await service.tryHandle("покажи хвосты по контрагенту СВК на 2020-12-31");
|
||||
expect(result?.handled).toBe(true);
|
||||
expect(String(result?.debug.extracted_filters.counterparty ?? "").toLowerCase()).toContain("свк");
|
||||
expect(String(result?.debug.extracted_filters.counterparty ?? "").toLowerCase()).not.toContain("дата отчетности");
|
||||
expect(String(result?.debug.anchor_value_raw ?? "").toLowerCase()).not.toContain("дата отчетности");
|
||||
if (result?.reply_type === "partial_coverage") {
|
||||
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2399,6 +2772,24 @@ describe("address decompose stage follow-up carryover", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces 'кроме этого документа...' pseudo-anchor with previous counterparty from follow-up context", () => {
|
||||
const result = runAddressDecomposeStage("кроме этого документа есть еще чтото?", {
|
||||
previous_intent: "list_documents_by_counterparty",
|
||||
previous_filters: {
|
||||
counterparty: "ТСЖ \\Жуковка 51\\"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ТСЖ \\Жуковка 51\\"
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.intent.intent).toBe("list_documents_by_counterparty");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBe("ТСЖ \\Жуковка 51\\");
|
||||
expect(
|
||||
result?.baseReasons?.includes("counterparty_replaced_from_followup_context") ||
|
||||
result?.baseReasons?.includes("counterparty_from_followup_context")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
|
||||
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
|
||||
previous_intent: "bank_operations_by_contract",
|
||||
@@ -2452,6 +2843,27 @@ describe("address decompose stage follow-up carryover", () => {
|
||||
expect(followup?.debug.limited_reason_category).not.toBe("missing_anchor");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps VAT explain follow-up in address lane and inherits previous period window", () => {
|
||||
const result = runAddressDecomposeStage("почему прогноз к уплате 0?", {
|
||||
previous_intent: "vat_payable_forecast",
|
||||
previous_filters: {
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mode.mode).toBe("address_query");
|
||||
expect(result?.intent.intent).toBe("vat_payable_forecast");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
|
||||
expect(
|
||||
result?.baseReasons?.includes("address_mode_from_followup_context") ||
|
||||
result?.baseReasons?.includes("intent_from_followup_context")
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("address recipe catalog counterparty filtering", () => {
|
||||
@@ -2694,5 +3106,24 @@ describe("address recipe catalog counterparty filtering", () => {
|
||||
expect(plan.query).toContain("ПОДОБНО \"60.01%\"");
|
||||
expect(plan.query).toContain("ПОДОБНО \"60.1%\"");
|
||||
});
|
||||
|
||||
it("builds VAT forecast query with safe account-prefix checks instead of presentation-like clauses", () => {
|
||||
const filters = extractAddressFilters(
|
||||
"мож прикинусь плиз скока ндс надо заплатить на 15 марта 2020 года",
|
||||
"vat_payable_forecast"
|
||||
).extracted_filters;
|
||||
const selected = selectAddressRecipe("vat_payable_forecast", filters);
|
||||
expect(selected.selected_recipe).toBeTruthy();
|
||||
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
|
||||
|
||||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 5) = \"68.02\"");
|
||||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 5) = \"68.02\"");
|
||||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 4) = \"68.2\"");
|
||||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 4) = \"68.2\"");
|
||||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетДт.Код, \"\"), 1, 2) = \"19\"");
|
||||
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Движения.СчетКт.Код, \"\"), 1, 2) = \"19\"");
|
||||
expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) ПОДОБНО");
|
||||
expect(plan.query).not.toContain("ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) ПОДОБНО");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -56,6 +56,26 @@ function buildAddressLaneResult(overrides?: Record<string, unknown>): any {
|
||||
};
|
||||
}
|
||||
|
||||
function buildAddressLimitedLaneResult(
|
||||
category: "missing_anchor" | "empty_match" = "missing_anchor",
|
||||
overrides?: Record<string, unknown>
|
||||
): any {
|
||||
const base = buildAddressLaneResult();
|
||||
return {
|
||||
...base,
|
||||
reply_text: "Нужны уточнения по якорю.",
|
||||
reply_type: "partial_coverage",
|
||||
response_type: "LIMITED_WITH_REASON",
|
||||
debug: {
|
||||
...base.debug,
|
||||
response_type: "LIMITED_WITH_REASON",
|
||||
limited_reason_category: category,
|
||||
reasons: ["address_action_detected", "address_entity_detected"]
|
||||
},
|
||||
...(overrides ?? {})
|
||||
};
|
||||
}
|
||||
|
||||
describe("assistant address follow-up carryover", () => {
|
||||
it("keeps short follow-up in address lane by reusing previous anchor context", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
@@ -186,13 +206,318 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(followupMessage);
|
||||
expect(calls[1].options?.followupContext?.previous_intent).toBe("list_documents_by_counterparty");
|
||||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(typeof calls[1].options?.followupContext?.previous_anchor_value).toBe("string");
|
||||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats short affirmative 'давай' as follow-up for previous address answer", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u0441\u0432\u043a \u0437\u0430 2020";
|
||||
const followupMessage = "\u0434\u0430\u0432\u0430\u0439";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === followupMessage && !options?.followupContext) {
|
||||
return null;
|
||||
}
|
||||
if (message === followupMessage && options?.followupContext) {
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLaneResult();
|
||||
})
|
||||
} 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-davai-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: followupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(followupMessage);
|
||||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats typo imperative 'показывыай' as implicit continuation and switches to suggested follow-up intent", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "покажи документы по свк за 2020";
|
||||
const followupMessage = "показывыай";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === followupMessage && !options?.followupContext) {
|
||||
return null;
|
||||
}
|
||||
if (message === followupMessage && options?.followupContext) {
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLaneResult();
|
||||
})
|
||||
} 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-pokazyvai-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: followupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].message).toBe(followupMessage);
|
||||
expect(calls[1].options?.followupContext?.previous_intent).toBe("bank_operations_by_counterparty");
|
||||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps previous counterparty context for referential follow-up 'кроме этого документа...'", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "покажи документы по жуковке 51";
|
||||
const followupMessage = "кроме этого документа есть еще чтото?";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === followupMessage && !options?.followupContext) {
|
||||
return null;
|
||||
}
|
||||
if (message === followupMessage && options?.followupContext) {
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
anchor_value_raw: "кроме",
|
||||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
|
||||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
limit: 20,
|
||||
counterparty: "жуковке 51"
|
||||
},
|
||||
anchor_value_raw: "жуковке 51",
|
||||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
|
||||
}
|
||||
});
|
||||
})
|
||||
} 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-referential-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: followupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(String(calls[1].options?.followupContext?.previous_anchor_value ?? "")).toContain("Жуковка 51");
|
||||
expect(String(calls[1].options?.followupContext?.previous_filters?.counterparty ?? "")).toContain("жуковке 51");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries with raw user message after rewrite degraded anchor and returns factual follow-up result", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "покажи документы по жуковке 51";
|
||||
const followupMessage = "кроме этого документа есть еще чтото?";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
const compact = String(message ?? "").trim().toLowerCase();
|
||||
if (calls.length === 1) {
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
limit: 20,
|
||||
counterparty: "жуковке 51"
|
||||
},
|
||||
anchor_value_raw: "жуковке 51",
|
||||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\"
|
||||
}
|
||||
});
|
||||
}
|
||||
if (compact === followupMessage && options?.followupContext) {
|
||||
return buildAddressLaneResult({
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
limit: 20,
|
||||
counterparty: "жуковке 51"
|
||||
},
|
||||
anchor_value_raw: "жуковке 51",
|
||||
anchor_value_resolved: "ТСЖ \\Жуковка 51\\",
|
||||
reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"]
|
||||
}
|
||||
});
|
||||
}
|
||||
if (compact.startsWith("документы по контрагенту") && options?.followupContext) {
|
||||
return buildAddressLimitedLaneResult("missing_anchor", {
|
||||
debug: {
|
||||
...buildAddressLimitedLaneResult("missing_anchor").debug,
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
limit: 20,
|
||||
counterparty: "кроме"
|
||||
},
|
||||
anchor_value_raw: "кроме",
|
||||
anchor_value_resolved: "кроме"
|
||||
}
|
||||
});
|
||||
}
|
||||
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-safe-retry-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: followupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(second.debug?.address_retry_audit?.attempted).toBe(true);
|
||||
expect(second.debug?.address_retry_audit?.initial_limited_category).toBe("missing_anchor");
|
||||
expect(second.debug?.address_retry_audit?.retry_message).toBe(followupMessage);
|
||||
|
||||
expect(calls.some((entry) => String(entry.message).toLowerCase().startsWith("документы по контрагенту"))).toBe(true);
|
||||
expect(calls.some((entry) => String(entry.message).toLowerCase() === followupMessage)).toBe(true);
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses last real address context after intermediate clarification fallback", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const lifecycleFollowupMessage = "А кто из них новые?";
|
||||
@@ -279,4 +604,72 @@ describe("assistant address follow-up carryover", () => {
|
||||
expect(String(contextualCall?.options?.followupContext?.previous_anchor_value ?? "").length).toBeGreaterThan(0);
|
||||
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not carry address follow-up context into capability question", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "покажи документы по свк за 2020";
|
||||
const capabilityMessage = "и 1с можешь настроить?";
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (String(message).toLowerCase().includes("свк")) {
|
||||
return buildAddressLaneResult();
|
||||
}
|
||||
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 chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService,
|
||||
chatClient
|
||||
);
|
||||
|
||||
const sessionId = `asst-address-followup-capability-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: capabilityMessage,
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(second.assistant_reply).toLowerCase()).toContain("не настраиваю 1с");
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(String(calls[0].message).toLowerCase()).toContain("свк");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1054,7 +1054,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
|
||||
expect(response.debug?.llm_decomposition_applied).toBe(true);
|
||||
expect(response.debug?.llm_canonical_candidate_detected).toBe(true);
|
||||
expect(response.debug?.tool_gate_decision).toBe("run_address_lane");
|
||||
expect(["llm_canonical_candidate_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
|
||||
expect(["llm_canonical_candidate_detected", "llm_canonical_data_signal_detected", "address_mode_classifier_detected"]).toContain(response.debug?.tool_gate_reason);
|
||||
});
|
||||
|
||||
it("normalizes short ordinal year like '20й' in noisy docs phrasing", async () => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extractOrganizationFactsFromRowsForTests,
|
||||
resolveOrganizationNamesByRefsForTests
|
||||
} from "../src/services/assistantService";
|
||||
|
||||
describe("assistant data scope probe parser", () => {
|
||||
it("extracts organization name and guid from object-ref style row", () => {
|
||||
const rows = [
|
||||
{
|
||||
Organization: {
|
||||
_objectRef: true,
|
||||
Guid: "5b516757-45d0-11e1-8c52-001e5848397d",
|
||||
ObjectType: "CatalogRef.Organization",
|
||||
Presentation: "OOO Alternativa Plus"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const facts = extractOrganizationFactsFromRowsForTests(rows as any);
|
||||
|
||||
expect(facts.names).toContain("OOO Alternativa Plus");
|
||||
expect(facts.refs).toContain("5b516757-45d0-11e1-8c52-001e5848397d");
|
||||
expect(
|
||||
facts.pairs.some(
|
||||
(item: any) =>
|
||||
item.ref === "5b516757-45d0-11e1-8c52-001e5848397d" &&
|
||||
item.name === "OOO Alternativa Plus"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("extracts guid from hard-key payload (name is best-effort)", () => {
|
||||
const rows = [
|
||||
{
|
||||
payload: {
|
||||
_objectRef: true,
|
||||
f1: "5b516757-45d0-11e1-8c52-001e5848397d",
|
||||
f2: "CatalogRef.Organization",
|
||||
f3: "OOO Alternativa Plus"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const facts = extractOrganizationFactsFromRowsForTests(rows as any);
|
||||
|
||||
expect(facts.refs).toContain("5b516757-45d0-11e1-8c52-001e5848397d");
|
||||
});
|
||||
|
||||
it("resolves names by guid references from ref-name pairs", () => {
|
||||
const names = resolveOrganizationNamesByRefsForTests(
|
||||
["5b516757-45d0-11e1-8c52-001e5848397d", "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"],
|
||||
{
|
||||
names: ["OOO Alternativa Plus", "OOO Drugaya"],
|
||||
refs: ["5b516757-45d0-11e1-8c52-001e5848397d", "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],
|
||||
pairs: [
|
||||
{ ref: "5b516757-45d0-11e1-8c52-001e5848397d", name: "OOO Alternativa Plus" },
|
||||
{ ref: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", name: "OOO Drugaya" }
|
||||
]
|
||||
} as any
|
||||
);
|
||||
|
||||
expect(names).toEqual(["OOO Alternativa Plus"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,792 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssistantService } from "../src/services/assistantService";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
describe("assistant living chat mode", () => {
|
||||
it("handles casual greeting in chat mode without deep-pipeline pass", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-chat-1",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-1" },
|
||||
outputText: "Hello. We can chat freely.",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-1",
|
||||
user_message: "hello, how are you?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("chat");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||||
expect(normalizer.normalize).toHaveBeenCalledTimes(1);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("does not force address lane for unsupported low-confidence predecompose canonical", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
trace_id: "norm-chat-yo",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "yo",
|
||||
normalized_fragment_text: "yoft",
|
||||
confidence: "low",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "generic_accounting",
|
||||
time_scope: { type: "missing", value: null, confidence: "low" },
|
||||
candidate_labels: ["ambiguous_human_query"],
|
||||
execution_readiness: "clarification_needed",
|
||||
route_status: "no_route",
|
||||
no_route_reason: "insufficient_specificity"
|
||||
}
|
||||
]
|
||||
},
|
||||
validation: { passed: true, errors: [] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 2,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-yo" },
|
||||
outputText: "Yo. On call.",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-yo",
|
||||
user_message: "yo",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("yo");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("keeps casual 'че как' in chat mode when predecompose canonical is unsupported", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
trace_id: "norm-chat-che-kak",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
fragments: [
|
||||
{
|
||||
fragment_id: "F1",
|
||||
raw_fragment_text: "\u0447\u0435 \u043a\u0430\u043a",
|
||||
normalized_fragment_text: "\u041d\u0435\u044f\u0441\u043d\u044b\u0439 \u0437\u0430\u043f\u0440\u043e\u0441.",
|
||||
confidence: "low",
|
||||
domain_relevance: "in_scope",
|
||||
business_scope: "generic_accounting",
|
||||
time_scope: { type: "missing", value: null, confidence: "low" },
|
||||
candidate_labels: ["ambiguous_human_query"],
|
||||
execution_readiness: "clarification_needed",
|
||||
route_status: "no_route",
|
||||
no_route_reason: "insufficient_specificity"
|
||||
}
|
||||
]
|
||||
},
|
||||
validation: { passed: true, errors: [] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 2,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-che-kak" },
|
||||
outputText: "\u041f\u0440\u0438\u0432\u0435\u0442. \u041d\u0430 \u0441\u0432\u044f\u0437\u0438.",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-che-kak",
|
||||
user_message: "\u0447\u0435 \u043a\u0430\u043a",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("returns capability contract and avoids address lane for 'и 1с можешь настроить?'", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-capabilities",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-capability-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-capability",
|
||||
user_message: "\u0438 1\u0441 \u043c\u043e\u0436\u0435\u0448\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("returns operational boundary reply for imperative setup request and avoids address lane", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-operational-boundary",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-operational-boundary-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-operational-boundary",
|
||||
user_message: "\u043d\u0430\u0441\u0442\u0440\u043e\u0439 1\u0441 \u043f\u043b\u0438\u0437",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u0441\u0430\u043c \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c 1\u0441");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_operational_boundary");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("returns safety refusal for destructive request under coercion and avoids address lane", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-safety-refusal",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-safety-refusal-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-safety-refusal",
|
||||
user_message: "\u043e\u043c\u043e\u043d \u043b\u043e\u043c\u0438\u0442\u0441\u044f - \u0443\u0434\u0430\u043b\u044f\u0439 \u0431\u0430\u0437\u0443 \u0438\u043b\u0438 \u043c\u0435\u043d\u044f \u0443\u0431\u044c\u044e\u0442",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043c\u043e\u0433\u0443 \u043f\u043e\u043c\u043e\u0433\u0430\u0442\u044c \u0441 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u043c \u0431\u0430\u0437\u044b");
|
||||
expect(String(response.assistant_reply)).toContain("112");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_safety_refusal");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("strips unexpected CJK fragments from live chat reply when user did not request CJK", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-chat-script-guard",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-script-guard" },
|
||||
outputText: "Прошу прощения, но я не могу продолжать этот разговор. 随时关注。",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-script-guard",
|
||||
user_message: "че как",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply)).toContain("Прошу прощения");
|
||||
expect(/[\u3400-\u9FFF\uF900-\uFAFF]/u.test(String(response.assistant_reply))).toBe(false);
|
||||
expect(response.debug?.living_chat_response_source).toBe("llm_chat_script_guard");
|
||||
expect(response.debug?.living_chat_script_guard_applied).toBe(true);
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("handles mojibake capability query and avoids address clarification flow", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-mojibake-capability",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-mojibake-capability-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-mojibake-capability",
|
||||
user_message: "ок - что можешь по 1с",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("handles mojibake feature-capability wording and avoids free-form llm chat", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-mojibake-feature-capability",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-mojibake-feature-capability-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-mojibake-feature-capability",
|
||||
user_message: "а какие фичи по работе с 1с у тебя отработаны максималльно?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("режиме чтения");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_capability_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("handles data-scope meta question as deterministic chat contract", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-data-scope-meta",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-data-scope-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-data-scope",
|
||||
user_message: "по какой компании мы можем работать?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("mcp-канал");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it("handles 'какая база подрублена?' as deterministic data-scope contract", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-data-scope-podrublena",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-data-scope-podrublena-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-data-scope-podrublena",
|
||||
user_message: "какая база подрублена?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply).toLowerCase()).toContain("read-only");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
it("handles typo data-scope query with misspelled company token as deterministic contract", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-data-scope-typo-company",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-data-scope-typo-company-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-data-scope-typo-company",
|
||||
user_message: "подскажи плиз с какой компинией можем поработать?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("handles no-question-mark data-scope phrase with interrogative token as deterministic contract", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: "norm-data-scope-no-qmark",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-data-scope-no-qmark-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-data-scope-no-qmark",
|
||||
user_message: "каза какой компании подключена к 1с",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(response.debug?.tool_gate_reason).toBe("assistant_data_scope_query_detected");
|
||||
expect(response.debug?.living_chat_response_source).toBe("deterministic_data_scope_contract");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("does not misroute contract ranking query to data-scope when canonical text contains 'компании'", async () => {
|
||||
const normalizer = {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
trace_id: "norm-no-datascope-contract-ranking",
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: {
|
||||
schema_version: "normalized_query_v2_0_2",
|
||||
user_message_raw: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||||
message_in_scope: true,
|
||||
scope_confidence: "high",
|
||||
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: "explicit",
|
||||
value: "all_time",
|
||||
confidence: "medium"
|
||||
},
|
||||
flags: {
|
||||
has_multi_entity_scope: false,
|
||||
asks_for_chain_explanation: false,
|
||||
asks_for_ranking_or_top: true,
|
||||
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
|
||||
}
|
||||
},
|
||||
validation: { passed: true, errors: [] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
latency_ms: 10,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({
|
||||
handled: true,
|
||||
reply_text: "Найден топ-контракт.",
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_LIST",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_mode_confidence: "high",
|
||||
query_shape: "DOCUMENT_LIST",
|
||||
query_shape_confidence: "medium",
|
||||
detected_intent: "contract_usage_and_value",
|
||||
detected_intent_confidence: "high",
|
||||
extracted_filters: { sort: "period_desc", limit: 20 },
|
||||
missing_required_filters: [],
|
||||
selected_recipe: "address_contract_usage_and_value_v1",
|
||||
mcp_call_status_legacy: "matched_non_empty",
|
||||
account_scope_mode: "preferred",
|
||||
account_scope_fallback_applied: false,
|
||||
anchor_type: "unknown",
|
||||
anchor_value_raw: null,
|
||||
anchor_value_resolved: null,
|
||||
resolver_confidence: "medium",
|
||||
ambiguity_count: 0,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: "matched_non_empty",
|
||||
rows_fetched: 20,
|
||||
raw_rows_received: 20,
|
||||
rows_after_account_scope: 20,
|
||||
rows_after_recipe_filter: 20,
|
||||
rows_materialized: 20,
|
||||
rows_matched: 20,
|
||||
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: ["contract_usage_and_value_signal_detected"]
|
||||
}
|
||||
})
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-should-not-run-for-contract-ranking" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-living-chat-no-datascope-contract-ranking",
|
||||
user_message: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(response.debug?.tool_gate_reason).not.toBe("assistant_data_scope_query_detected");
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
|
||||
|
||||
describe("assistant living router mode decision", () => {
|
||||
it("returns address_data when address lane already triggered", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "давай",
|
||||
addressLaneTriggered: true,
|
||||
useMock: false,
|
||||
predecomposeMode: "address_query",
|
||||
predecomposeModeConfidence: "high"
|
||||
});
|
||||
expect(decision.mode).toBe("address_data");
|
||||
expect(decision.reason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps deep pipeline in mock mode to avoid test-env network calls", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "привет",
|
||||
addressLaneTriggered: false,
|
||||
useMock: true,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("deep_analysis");
|
||||
expect(decision.reason).toBe("mock_mode_keeps_deep_pipeline");
|
||||
});
|
||||
|
||||
it("routes casual non-data phrase to chat mode", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "привет, как дела?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
});
|
||||
|
||||
it("keeps deep mode for strong data signal", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "покажи документы по свк за 2020",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("deep_analysis");
|
||||
expect(decision.reason).toBe("strong_data_signal_detected");
|
||||
});
|
||||
it("routes capability question to chat even when phrase contains 1С", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "и 1с можешь настроить?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_capability_query_detected");
|
||||
});
|
||||
it("routes capability question 'ok - what can you do in 1c' to chat", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "\u043e\u043a - \u0447\u0442\u043e \u043c\u043e\u0436\u0435\u0448\u044c \u043f\u043e 1\u0441",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_capability_query_detected");
|
||||
});
|
||||
it("routes feature-capability wording to chat", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "а какие фичи по работе с 1с у тебя отработаны максимально?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_capability_query_detected");
|
||||
});
|
||||
it("routes data-scope question to chat instead of address lane", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "по какой компании мы можем работать?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_data_scope_query_detected");
|
||||
});
|
||||
it("routes 'whose base is this' style question to chat", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "ну база в тебе чья? как называется контора?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_data_scope_query_detected");
|
||||
});
|
||||
it("routes 'какая база подрублена?' to data-scope chat mode", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "какая база подрублена?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_data_scope_query_detected");
|
||||
});
|
||||
it("routes typo data-scope wording with misspelled company token to chat", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "подскажи плиз с какой компинией можем поработать?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_data_scope_query_detected");
|
||||
});
|
||||
|
||||
it("routes data-scope wording without question mark when interrogative token is present", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "каза какой компании подключена к 1с",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("chat");
|
||||
expect(decision.reason).toBe("assistant_data_scope_query_detected");
|
||||
});
|
||||
|
||||
it("does not treat contract ranking data query as data-scope meta question", () => {
|
||||
const decision = resolveLivingAssistantModeDecision({
|
||||
userMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||||
addressLaneTriggered: false,
|
||||
useMock: false,
|
||||
predecomposeMode: "unsupported",
|
||||
predecomposeModeConfidence: "low"
|
||||
});
|
||||
expect(decision.mode).toBe("deep_analysis");
|
||||
expect(decision.reason).toBe("strong_data_signal_detected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant orchestration contract", () => {
|
||||
it("keeps VAT payable forecast query in address lane", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",
|
||||
effectiveAddressUserMessage: "какой прогноз оплаты ндс за 12 мая 2020",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps supported contract analytics query in address lane", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||||
effectiveAddressUserMessage: "по альтернативе вопрос. какой самый жирный контракт за всю историю у нее?",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.toolGateReason).toBe("address_mode_classifier_detected");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("keeps VAT explain follow-up in address lane when followup context is present", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "почему прогноз к уплате 0?",
|
||||
effectiveAddressUserMessage: "почему прогноз к уплате 0?",
|
||||
followupContext: {
|
||||
previous_intent: "vat_payable_forecast",
|
||||
previous_filters: {
|
||||
period_from: "2020-03-01",
|
||||
period_to: "2020-03-31"
|
||||
},
|
||||
previous_anchor_type: "unknown",
|
||||
previous_anchor_value: null
|
||||
},
|
||||
llmPreDecomposeMeta: null,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(true);
|
||||
expect(decision.toolGateDecision).toBe("run_address_lane");
|
||||
expect(decision.toolGateReason).toBe("followup_context_detected");
|
||||
expect(decision.livingMode).toBe("address_data");
|
||||
expect(decision.livingReason).toBe("address_lane_triggered");
|
||||
});
|
||||
|
||||
it("does not force address lane for deep-analysis unknown intent query with date-like token", () => {
|
||||
const decision = resolveAssistantOrchestrationDecision({
|
||||
rawUserMessage: "найди какие либо ошибки на 21 мая 2022 года",
|
||||
effectiveAddressUserMessage: "Найти ошибки в бухгалтерии за 21 мая 2022 года.",
|
||||
followupContext: null,
|
||||
llmPreDecomposeMeta: {
|
||||
applied: true,
|
||||
llmCanonicalCandidateDetected: true,
|
||||
predecomposeContract: {
|
||||
mode: "deep_analysis",
|
||||
mode_confidence: "high",
|
||||
intent: "unknown",
|
||||
intent_confidence: "low"
|
||||
}
|
||||
} as any,
|
||||
useMock: false
|
||||
});
|
||||
|
||||
expect(decision.runAddressLane).toBe(false);
|
||||
expect(decision.toolGateDecision).toBe("skip_address_lane");
|
||||
expect(decision.livingMode).toBe("deep_analysis");
|
||||
expect(["address_signal_unsupported_intent_fallback_to_deep", "no_address_signal_after_l0"]).toContain(
|
||||
decision.toolGateReason
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AssistantService } from "../src/services/assistantService";
|
||||
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
|
||||
|
||||
function buildFailedNormalizer(traceId: string) {
|
||||
return {
|
||||
normalize: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
trace_id: traceId,
|
||||
prompt_version: "normalizer_v2_0_2",
|
||||
schema_version: "v2_0_2",
|
||||
normalized: null,
|
||||
validation: { passed: false, errors: ["mock"] },
|
||||
route_hint_summary: null,
|
||||
raw_model_output: {},
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
|
||||
latency_ms: 1,
|
||||
request_count_for_case: 1
|
||||
})
|
||||
} as any;
|
||||
}
|
||||
|
||||
function buildHandledAddressLaneMojibakeReply() {
|
||||
return {
|
||||
handled: true,
|
||||
reply_text: "Найдено документов: 5.",
|
||||
reply_type: "factual",
|
||||
response_type: "FACTUAL_LIST",
|
||||
debug: {
|
||||
detected_mode: "address_query",
|
||||
detected_mode_confidence: "high",
|
||||
query_shape: "DOCUMENT_LIST",
|
||||
query_shape_confidence: "high",
|
||||
detected_intent: "list_documents_by_counterparty",
|
||||
detected_intent_confidence: "high",
|
||||
extracted_filters: {
|
||||
sort: "period_desc",
|
||||
limit: 20,
|
||||
counterparty: "svk"
|
||||
},
|
||||
missing_required_filters: [],
|
||||
selected_recipe: "address_documents_by_counterparty_v1",
|
||||
mcp_call_status_legacy: "matched_non_empty",
|
||||
account_scope_mode: "preferred",
|
||||
account_scope_fallback_applied: false,
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: "svk",
|
||||
anchor_value_resolved: "СВК",
|
||||
resolver_confidence: "high",
|
||||
ambiguity_count: 0,
|
||||
match_failure_stage: "none",
|
||||
match_failure_reason: null,
|
||||
mcp_call_status: "matched_non_empty",
|
||||
rows_fetched: 5,
|
||||
raw_rows_received: 5,
|
||||
rows_after_account_scope: 5,
|
||||
rows_after_recipe_filter: 5,
|
||||
rows_materialized: 5,
|
||||
rows_matched: 5,
|
||||
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",
|
||||
reasons: ["address_action_detected", "address_entity_detected", "document_list_signal_detected"]
|
||||
}
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("assistant outgoing encoding repair", () => {
|
||||
it("repairs mojibake in address-lane replies before returning to user", async () => {
|
||||
const normalizer = buildFailedNormalizer("norm-address-mojibake-out");
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue(buildHandledAddressLaneMojibakeReply())
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-should-not-run" },
|
||||
outputText: "unused",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-address-mojibake-out",
|
||||
user_message: "покажи документы по свк за 2020",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual");
|
||||
expect(String(response.assistant_reply)).toContain("Найдено документов");
|
||||
expect(String(response.assistant_reply)).not.toContain("РќР°");
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(1);
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("repairs mojibake in living-chat LLM replies before script guard", async () => {
|
||||
const normalizer = buildFailedNormalizer("norm-chat-mojibake-out");
|
||||
const sessions = new AssistantSessionStore();
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn().mockResolvedValue({ handled: false })
|
||||
} as any;
|
||||
const chatClient = {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
raw: { id: "chat-mojibake-out" },
|
||||
outputText: "Привет! Готов помочь.",
|
||||
usage: { input_tokens: 0, output_tokens: 0, total_tokens: 0 }
|
||||
})
|
||||
} as any;
|
||||
|
||||
const service = new AssistantService(normalizer as any, sessions, undefined as any, undefined as any, addressQueryService, chatClient);
|
||||
|
||||
const response = await service.handleMessage({
|
||||
session_id: "asst-chat-mojibake-out",
|
||||
user_message: "че как",
|
||||
llmProvider: "local",
|
||||
model: "qwen2.5",
|
||||
useMock: false
|
||||
} as any);
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(response.reply_type).toBe("factual_with_explanation");
|
||||
expect(String(response.assistant_reply)).toContain("Привет");
|
||||
expect(String(response.assistant_reply)).not.toContain("РџС");
|
||||
expect(chatClient.chat).toHaveBeenCalledTimes(1);
|
||||
expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user