ДОМЕНЫ - ВОПРОСЫ - ОТКРЫТЫЕ ДОГОВОРА - Усилить business-view exact открытых договоров: разрез по типам остатков и quality gates

This commit is contained in:
2026-04-13 18:15:06 +03:00
parent 3e588ede81
commit ef4222d159
14 changed files with 984 additions and 49 deletions
@@ -177,6 +177,110 @@ describe("address compose stage utf8 headers", () => {
expect(reply.text).toContain("Договор №19/15");
});
it("renders explicit heuristic contract-candidates reply for open-contracts intent", () => {
const reply = composeFactualReply(
"list_open_contracts",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Поступление товаров и услуг 00000000022",
account_dt: "60.01",
account_kt: "51",
amount: 150000,
analytics: ["ООО Ромашка", "Договор №19/15"]
}
],
{
periodFrom: "2020-03-01",
periodTo: "2020-03-31",
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Итого по предварительному срезу открытых договоров");
expect(reply.text).toContain("Блок 1. Статус результата");
expect(reply.text).toContain("Результат: предварительный список договоров с возможными незакрытыми расчетами.");
expect(reply.text).toContain("Блок 4. Основной список (коммерческие договоры)");
expect(reply.semantics?.result_mode).toBe("heuristic_candidates");
expect(reply.semantics?.balance_confirmed).toBe(false);
});
it("renders confirmed open-contracts snapshot for exact contract-settlements intent", () => {
const reply = composeFactualReply(
"open_contracts_confirmed_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "",
account_kt: "60.01",
amount: 150000,
analytics: ["ООО Ромашка", "Договор №19/15"]
}
],
{
periodFrom: "2020-03-01",
periodTo: "2020-03-31",
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Собран подтвержденный срез открытых договоров");
expect(reply.text).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.");
expect(reply.text).toContain("Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка.");
expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
expect(reply.semantics?.balance_confirmed).toBe(true);
});
it("splits confirmed open-contracts output by balance type and hides technical account placeholders", () => {
const reply = composeFactualReply(
"open_contracts_confirmed_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "62.01",
account_kt: "0",
amount: 100000,
analytics: ["ООО Ромашка", "Договор №19/15"]
},
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "",
account_kt: "60.01",
amount: 50000,
analytics: ["ООО Ромашка", "Договор №19/15"]
},
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: "76.09",
account_kt: "",
amount: 25000,
analytics: ["Комитет госуслуг", "ООО /Альтернатива Плюс/"]
}
],
{
periodFrom: "2020-03-01",
periodTo: "2020-03-31",
asOfDate: "2020-03-31",
useRubCurrency: true
}
);
expect(reply.text).toContain("Блок 4. Коммерческие договоры с дебиторской задолженностью");
expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью");
expect(reply.text).toContain("Блок 8. Финансовые/спорные позиции");
expect(reply.text).not.toContain("счета: 62.01; 0");
expect(reply.text).toContain("договор не похож на устойчивый договорный реквизит");
});
it("renders period coverage summary for management profile intent", () => {
const reply = composeFactualReply("period_coverage_profile", [
{
@@ -1739,7 +1843,7 @@ describe("address intent resolver expansion (M2.3a)", () => {
it("resolves unclosed contracts list query without specific anchor", () => {
const result = resolveAddressIntent("Покажи незакрытые договоры на 2020-12-31");
expect(result.intent).toBe("list_open_contracts");
expect(result.intent).toBe("open_contracts_confirmed_as_of_date");
});
it("resolves bank operations by contract for normalized phrase with linked contract wording", () => {
@@ -2923,11 +3027,12 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("keeps strict account scope for open-contract scans", async () => {
it("keeps strict account scope for confirmed open-contract scans", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("Какие незакрытые документы по договорам у нас уже давно пора проверить?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_confirmed_as_of_date_v1");
expect(result?.debug.account_scope_mode).toBe("strict");
expect(result?.debug.account_scope_fallback_applied).toBe(false);
});
@@ -2943,16 +3048,52 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("does not return execution_error for open-contracts month query when subconto fields are unavailable", async () => {
it("does not return execution_error for confirmed open-contracts month query", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"\u043a\u0430\u043a\u0438\u0435 \u0435\u0441\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442 2020"
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.debug.limited_reason_category).not.toBe("execution_error");
});
it("routes direct open-contract month query into exact confirmed mode", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("какие есть открытые договора на март 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.debug.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.debug.extracted_filters.period_to).toBe("2020-03-31");
expect(result?.debug.extracted_filters.as_of_date).toBe("2020-03-31");
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.route_expectation_reason).toBe("route_expectation_matched");
expect(result?.debug.route_expectation_expected_result_modes).toContain("confirmed_balance");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.result_mode).toBe("confirmed_balance");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_confirmed_as_of_date_v1");
expect(result?.debug.capability_route_mode).toBe("exact");
const reply = String(result?.reply_text ?? "");
if (result?.response_type !== "LIMITED_WITH_REASON") {
expect(reply).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.");
}
});
it("keeps preferred account-scope mode for heuristic open-contract fallback recipe and avoids zeroing rows", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
"Где у нас есть платежи, но нет документов для закрытия взаиморасчетов? Это уже требует ручной проверки."
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
if (result?.debug.selected_recipe === "address_open_items_by_party_or_contract_v1") {
expect(result?.debug.account_scope_mode).toBe("preferred");
expect(result?.debug.account_scope_fallback_applied).toBe(true);
expect(result?.debug.rows_after_account_scope).toBeGreaterThan(0);
expect(result?.debug.limited_reason_category).not.toBe("empty_match");
}
});
it("routes non-paying counterparties month-risk wording into receivables lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@@ -3388,7 +3529,7 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
"\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017",
{
followupContext: {
previous_intent: (seed?.debug.detected_intent as any) ?? "list_open_contracts",
previous_intent: (seed?.debug.detected_intent as any) ?? "open_contracts_confirmed_as_of_date",
previous_filters: seed?.debug.extracted_filters,
previous_anchor_type: (seed?.debug.anchor_type as any) ?? "unknown",
previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null
@@ -3743,9 +3884,20 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("open_items_from_followup_context");
});
it("derives as-of date from period for open-contract month query", () => {
const result = runAddressDecomposeStage("какие есть открытые договора на март 2020", null);
expect(result).not.toBeNull();
expect(result?.mode.mode).toBe("address_query");
expect(result?.intent.intent).toBe("open_contracts_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01");
expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
expect(result?.baseReasons).toContain("as_of_date_derived_from_period_for_open_contracts");
});
it("keeps VAT debt follow-up in VAT intent even after open-contract context", () => {
const result = runAddressDecomposeStage("\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017", {
previous_intent: "list_open_contracts",
previous_intent: "open_contracts_confirmed_as_of_date",
previous_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
@@ -4083,8 +4235,8 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор");
});
it("allows extended limit for open-contracts intent", () => {
const selected = selectAddressRecipe("list_open_contracts", {
it("allows extended limit for confirmed open-contracts intent", () => {
const selected = selectAddressRecipe("open_contracts_confirmed_as_of_date", {
as_of_date: "2020-12-31",
limit: 1000
});
@@ -4097,6 +4249,21 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.limit).toBe(1000);
});
it("builds exact balance query for confirmed open-contracts snapshot", () => {
const selected = selectAddressRecipe("open_contracts_confirmed_as_of_date", {
as_of_date: "2020-12-31"
});
expect(selected.selected_recipe?.recipe_id).toBe("address_open_contracts_confirmed_as_of_date_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
as_of_date: "2020-12-31"
});
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки(");
expect(plan.query).toContain("СуммаРазвернутыйОстатокДт");
expect(plan.query).toContain("СуммаРазвернутыйОстатокКт");
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 2) = \"60\"");
});
it("injects account condition into movements query for account snapshot", () => {
const filters = extractAddressFilters(
"Какой остаток по счету 60 на дату 2020-07-31",
@@ -619,7 +619,7 @@ describe("assistant orchestration contract", () => {
expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false);
});
it("keeps list_open_contracts query in address lane despite 'unclosed' wording", () => {
it("keeps confirmed open-contracts query in address lane despite 'unclosed' wording", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31",
effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043f\u043e \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e \u043d\u0430 \u043a\u043e\u043d\u0435\u0446 \u0434\u0435\u043a\u0430\u0431\u0440\u044f 2020 \u0433\u043e\u0434\u0430.",
@@ -632,7 +632,7 @@ describe("assistant orchestration contract", () => {
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "list_open_contracts",
intent: "open_contracts_confirmed_as_of_date",
intent_confidence: "medium"
}
} as any,
@@ -3,11 +3,10 @@ import { AddressQueryService } from "../src/services/addressQueryService";
import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService";
describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("keeps real run 17:51 data-heavy prompts in address lane", () => {
it("keeps real run 17:51 prompts with explicit accounting signals in address lane", () => {
const realRunPrompts = [
"Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?",
"Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?",
"В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности."
"\u043a\u0430\u043a\u0438\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0438\u0435 \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0443\u0436\u0435 \u0434\u0430\u0432\u043d\u043e \u0432\u0438\u0441\u044f\u0442 \u0431\u0435\u0437 \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f?",
"\u0432 \u043a\u0430\u043a\u0438\u0445 \u0441\u0434\u0435\u043b\u043a\u0430\u0445 \u043c\u044b \u0432\u0438\u0434\u0438\u043c \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0438, \u043d\u043e \u0434\u0435\u043d\u044c\u0433\u0438 \u0442\u0430\u043a \u0438 \u043d\u0435 \u043f\u0440\u0438\u0448\u043b\u0438?"
];
for (const prompt of realRunPrompts) {
@@ -28,8 +27,29 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
}
});
it("keeps vague counterparty risk wording in deep analysis until stronger data anchor appears", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage:
"\u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u044b \u0443 \u043d\u0430\u0441 \u0434\u0430\u0432\u043d\u043e \u043d\u0435 \u043f\u043b\u0430\u0442\u044f\u0442 \u0438 \u044d\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0445\u043e\u0436\u0435 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443?",
effectiveAddressUserMessage:
"\u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u044b \u0443 \u043d\u0430\u0441 \u0434\u0430\u0432\u043d\u043e \u043d\u0435 \u043f\u043b\u0430\u0442\u044f\u0442 \u0438 \u044d\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0445\u043e\u0436\u0435 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443?",
followupContext: null,
llmPreDecomposeMeta: null,
useMock: false
});
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateReason).toBe("deep_analysis_signal_fallback_to_deep");
expect(decision.livingMode).toBe("deep_analysis");
expect(decision.livingReason).toBe("deep_analysis_signal_fallback_to_deep");
});
it("keeps short follow-up style prompts out of chat drift when predecompose says unsupported", () => {
const shortFollowups = ["без воды?", "и коротко?", "прям сейчас?"];
const shortFollowups = [
"\u0430 \u0431\u0435\u0437 \u0441\u0432\u043e\u0434\u043a\u0438?",
"\u0438 \u043f\u043e \u044d\u0442\u043e\u043c\u0443 \u0442\u043e\u0436\u0435?",
"\u043f\u0440\u044f\u043c \u0433\u0434\u0435 \u0436\u0435?"
];
for (const prompt of shortFollowups) {
const decision = resolveLivingAssistantModeDecision({
@@ -47,7 +67,7 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("routes data-scope slang wording to chat mode", () => {
const decision = resolveLivingAssistantModeDecision({
userMessage: "по каким конторам можем общаться?",
userMessage: "\u0430 \u043a\u0430\u043a\u0430\u044f \u0432\u043e\u043e\u0431\u0449\u0435 \u0431\u0430\u0437\u0430 \u0441\u044e\u0434\u0430 \u043f\u043e\u0434\u0440\u0443\u0431\u043b\u0435\u043d\u0430?",
addressLaneTriggered: false,
useMock: false,
predecomposeMode: "unsupported",
@@ -60,11 +80,11 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
it("keeps open-contracts request in address lane even with stale deep followup context", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "Покажи незакрытые договоры на 2020-12-31",
effectiveAddressUserMessage: "Покажи незакрытые договоры на 2020-12-31",
rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31",
effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31",
followupContext: {
previous_question_id: "msg-prev",
last_user_message: "почему так по закрытию месяца",
last_user_message: "\u043f\u043e\u0447\u0435\u043c\u0443 \u0442\u0430\u043a \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u043c\u0435\u0441\u044f\u0446\u0430",
active_domain: "month_close_costs_20_44",
active_requirement_ids: ["R1"],
uncovered_requirement_ids: ["R1"],
@@ -77,7 +97,7 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
predecomposeContract: {
mode: "address_query",
mode_confidence: "high",
intent: "list_open_contracts",
intent: "open_contracts_confirmed_as_of_date",
intent_confidence: "medium"
}
} as any,
@@ -90,19 +110,33 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => {
expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false);
});
it("uses soft unsupported aggregate replies instead of rigid old template", async () => {
it("supports strongest aggregate revenue route while keeping unsupported turnover prompt soft", async () => {
const service = new AddressQueryService();
const prompts = ["какой самый доходный год?", "какие обороты по альтернативе за 2020 год"];
for (const prompt of prompts) {
const result = await service.tryHandle(prompt);
const reply = String(result?.reply_text ?? "");
const strongestRevenue = await service.tryHandle(
"\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?"
);
const strongestReply = String(strongestRevenue?.reply_text ?? "");
expect(strongestRevenue?.handled).toBe(true);
expect(strongestRevenue?.reply_type).toBe("factual");
expect(strongestRevenue?.debug.detected_intent).toBe("customer_revenue_and_payments");
expect(strongestRevenue?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1");
expect(strongestReply).toContain(
"\u0422\u043e\u043f-3 \u043b\u0435\u0442 \u043f\u043e \u0441\u0443\u043c\u043c\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439"
);
expect(result?.handled).toBe(true);
expect(result?.reply_type).toBe("partial_coverage");
expect(result?.debug.limited_reason_category).toBe("unsupported");
expect(reply).toContain("Что могу сделать сейчас:");
expect(reply).not.toMatch(/Сейчас этот тип вопроса вне поддерживаемого контура адресного режима/iu);
}
const unsupportedTurnover = await service.tryHandle(
"\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434"
);
const unsupportedReply = String(unsupportedTurnover?.reply_text ?? "");
expect(unsupportedTurnover?.handled).toBe(true);
expect(unsupportedTurnover?.reply_type).toBe("partial_coverage");
expect(unsupportedTurnover?.debug.limited_reason_category).toBe("unsupported");
expect(unsupportedReply).toContain(
"\u0427\u0442\u043e \u043c\u043e\u0433\u0443 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0441\u0435\u0439\u0447\u0430\u0441:"
);
expect(unsupportedReply).not.toContain(
"\u0421\u0435\u0439\u0447\u0430\u0441 \u044d\u0442\u043e\u0442 \u0442\u0438\u043f \u0432\u043e\u043f\u0440\u043e\u0441\u0430 \u0432\u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430"
);
});
});