АДРЕСНЫЙ РЕЖИМ -Step-5 - feat(assistant): стабилизация свободного LLM-роутинга, прическа маршрутов chat/address, прототип прогноза НДС

This commit is contained in:
2026-04-08 23:40:12 +03:00
parent 71762af575
commit df29798fa2
47 changed files with 11439 additions and 651 deletions
@@ -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);
});
});