ДОМЕНЫ - ВОПРОСЫ - НДС: Исправить VAT follow-up контекст даты, кодировку data-scope probe и strict account scope для open-contract fallback

This commit is contained in:
dctouch 2026-04-13 11:46:40 +03:00
parent fd159e13ac
commit 3e588ede81
7 changed files with 116 additions and 19 deletions

View File

@ -2293,7 +2293,8 @@ class AddressQueryService {
const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1" ||
plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1" ||
plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1";
plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1" ||
plan.recipe.recipe_id === "address_open_contracts_candidates_v1";
const missingSubcontoErrorDetected = Boolean(mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error));
if (missingSubcontoErrorDetected) {
let missingSubcontoResolvedByComposite = false;

View File

@ -408,10 +408,14 @@ function hasFlexibleReceivablesDebtSignal(text: string): boolean {
if (!normalized) {
return false;
}
return (
/(?:кто(?:\s+\S+){0,4}\s+нам(?:\s+\S+){0,4}\s+долж)/iu.test(normalized) ||
/(?:нам(?:\s+\S+){0,4}\s+кто(?:\s+\S+){0,4}\s+долж)/iu.test(normalized)
);
const hasFlexibleWhoOwesUs =
/(?:\u043a\u0442\u043e(?:\s+\S+){0,4}\s+\u043d\u0430\u043c(?:\s+\S+){0,4}\s+\u0434\u043e\u043b\u0436)/iu.test(normalized) ||
/(?:\u043d\u0430\u043c(?:\s+\S+){0,4}\s+\u043a\u0442\u043e(?:\s+\S+){0,4}\s+\u0434\u043e\u043b\u0436)/iu.test(normalized);
const hasTorchatToUsSignal =
/(?:\u043d\u0430\u043c(?:\s+\S+){0,3}\s+\u0442\u043e\u0440\u0447(?:\u0430\u0442|\u0438\u0442)|\u0442\u043e\u0440\u0447(?:\u0430\u0442|\u0438\u0442)(?:\s+\S+){0,3}\s+\u043d\u0430\u043c)/iu.test(
normalized
);
return hasFlexibleWhoOwesUs || hasTorchatToUsSignal;
}
function hasFlexiblePayablesDebtSignal(text: string): boolean {

View File

@ -1526,7 +1526,13 @@ function enforceStrictAccountScopeForIntent(
plan: AddressRecipeExecutionPlan,
intent: AddressIntent
): AddressRecipeExecutionPlan {
if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") {
const strictScopeIntents: AddressIntent[] = [
"list_receivables_counterparties",
"list_open_contracts",
"open_items_by_counterparty_or_contract"
];
const shouldEnforceStrictScope = strictScopeIntents.includes(intent);
if (!shouldEnforceStrictScope || plan.account_scope_mode === "strict") {
return plan;
}
return {
@ -2829,7 +2835,8 @@ export class AddressQueryService {
plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1" ||
plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1" ||
plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1";
plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1" ||
plan.recipe.recipe_id === "address_open_contracts_candidates_v1";
const missingSubcontoErrorDetected = Boolean(
mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error)
);

View File

@ -53,7 +53,7 @@ function hasAllTimeHint(text: string): boolean {
}
function hasSameDateHint(text: string): boolean {
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(
return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date)/iu.test(
String(text ?? "")
);
}
@ -651,7 +651,8 @@ function deriveIntentWithFollowupContext(
};
}
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return {
intent: "open_items_by_counterparty_or_contract",
confidence: "low",

View File

@ -5037,14 +5037,13 @@ async function resolveAssistantDataScopeProbe() {
};
}
const catalogQueryCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН<D095>?Е(Организации.Ссылка) КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН<D095>?Е(Организации.Ссылка) КАК ОрганизацияПредставление <20>?З Справочник.Организации КАК Организации"
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации"
];
const movementProbeCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН<D095>?Е(Движения.Организация) КАК ОрганизацияПредставление <20>?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ<EFBFBD>?ТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения"
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения"
];
let lastError = null;
const catalogFacts = { names: [], refs: [], pairs: [] };
@ -5175,7 +5174,7 @@ function buildAssistantOperationalBoundaryReply() {
return [
"Понимаю, что ситуация срочная.",
"Я не могу сам настраивать 1С или менять базу/конфигурацию.",
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/<EFBFBD>?Т-админа."
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа."
].join(" ");
}
function buildAssistantSafetyRefusalReply() {

View File

@ -2890,7 +2890,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
const result = await service.tryHandle("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1");
expect(["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"]).toContain(
result?.debug.selected_recipe
);
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
@ -2900,7 +2902,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
const result = await service.tryHandle("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1");
expect(["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"]).toContain(
result?.debug.selected_recipe
);
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
@ -2912,7 +2916,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts");
expect(result?.debug.selected_recipe).toBe("address_open_contracts_candidates_v1");
expect(["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"]).toContain(
result?.debug.selected_recipe
);
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
@ -2937,6 +2943,16 @@ 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 () => {
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.limited_reason_category).not.toBe("execution_error");
});
it("routes non-paying counterparties month-risk wording into receivables lane", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle(
@ -3363,6 +3379,28 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(result?.debug.result_mode).toBe("confirmed_balance");
});
it("does not regress to open-items lane for VAT debt wording after open-contracts turn", async () => {
const service = new AddressQueryService();
const seed = 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(seed?.handled).toBe(true);
const result = await service.tryHandle(
"\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_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
}
}
);
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(result?.debug.reasons).not.toContain("open_items_from_followup_context");
}, 35000);
it("routes contracts-by-counterparty intent into dedicated catalog recipe", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("покажи договора все по жуковке 51");
@ -3705,6 +3743,23 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("open_items_from_followup_context");
});
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_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31"
},
previous_anchor_type: "counterparty",
previous_anchor_value: "ООО Ромашка"
});
expect(result).not.toBeNull();
expect(result?.mode.mode).toBe("address_query");
expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2017-09-30");
expect(result?.baseReasons).not.toContain("open_items_from_followup_context");
});
it("keeps balance family in follow-up when user gives compact account token", () => {
const result = runAddressDecomposeStage("вернись на 2020-12-31 по 60", {
previous_intent: "documents_forming_balance",
@ -3784,6 +3839,27 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
});
it("keeps previous as-of date for VAT follow-up wording 'на эту дату'", () => {
const result = runAddressDecomposeStage("а скок ндс мы должны на эту дату?", {
previous_intent: "receivables_confirmed_as_of_date",
previous_filters: {
period_from: "2020-03-01",
period_to: "2020-03-31",
as_of_date: "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_confirmed_as_of_date");
expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31");
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).toContain("as_of_date_from_followup_context");
expect(result?.baseReasons).toContain("period_from_followup_context");
});
it("keeps explicit current-date VAT follow-up and does not inherit stale as-of date", () => {
const result = runAddressDecomposeStage("а на текущую дату", {
previous_intent: "vat_payable_confirmed_as_of_date",

View File

@ -29,6 +29,15 @@ describe("receivables confirmed as-of route", () => {
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
});
it("routes slang 'нам торчат' wording into exact receivables intent", () => {
const result = resolveAddressIntent(
"\u0441\u043a\u043e\u043b\u044c\u043a\u043e \u0434\u0435\u043d\u0435\u0433 \u043d\u0430\u043c \u0442\u043e\u0440\u0447\u0430\u0442 \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017"
);
expect(result.intent).toBe("receivables_confirmed_as_of_date");
expect(result.intent).not.toBe("customer_revenue_and_payments");
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
});
it("drops low-quality counterparty anchor from as-of debtor phrasing", () => {
const extracted = extractAddressFilters(
"кто является дебитором компании по состоянию на июль 2020 года",