ДОМЕНЫ - ВОПРОСЫ - НДС: Исправить 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" || const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_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_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)); const missingSubcontoErrorDetected = Boolean(mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error));
if (missingSubcontoErrorDetected) { if (missingSubcontoErrorDetected) {
let missingSubcontoResolvedByComposite = false; let missingSubcontoResolvedByComposite = false;

View File

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

View File

@ -1526,7 +1526,13 @@ function enforceStrictAccountScopeForIntent(
plan: AddressRecipeExecutionPlan, plan: AddressRecipeExecutionPlan,
intent: AddressIntent intent: AddressIntent
): AddressRecipeExecutionPlan { ): 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 plan;
} }
return { return {
@ -2829,7 +2835,8 @@ export class AddressQueryService {
plan.recipe.recipe_id === "address_movements_receivables_v1" || plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_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_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( const missingSubcontoErrorDetected = Boolean(
mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error)
); );

View File

@ -53,7 +53,7 @@ function hasAllTimeHint(text: string): boolean {
} }
function hasSameDateHint(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 ?? "") String(text ?? "")
); );
} }
@ -651,7 +651,8 @@ function deriveIntentWithFollowupContext(
}; };
} }
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { const allowOpenItemsFollowupFallback = detectedIntent.intent === "unknown" && !isVatFollowup;
if (allowOpenItemsFollowupFallback && hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return { return {
intent: "open_items_by_counterparty_or_contract", intent: "open_items_by_counterparty_or_contract",
confidence: "low", confidence: "low",

View File

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

View File

@ -2890,7 +2890,9 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
const result = await service.tryHandle("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?"); const result = await service.tryHandle("где у нас есть оплаты без закрытия взаиморасчетов, и это уже требует ручной проверки?");
expect(result?.handled).toBe(true); expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts"); 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("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported"); 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("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?"); const result = await service.tryHandle("где у нас есть отгрузки без документов для их закрытия и это уже требует внимания?");
expect(result?.handled).toBe(true); expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts"); 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("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported"); 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?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_open_contracts"); 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("missing_anchor");
expect(result?.debug.limited_reason_category).not.toBe("unsupported"); 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"); 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 () => { it("routes non-paying counterparties month-risk wording into receivables lane", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle( 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.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(result?.debug.result_mode).toBe("confirmed_balance"); 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 () => { it("routes contracts-by-counterparty intent into dedicated catalog recipe", async () => {
const service = new AddressQueryService(); const service = new AddressQueryService();
const result = await service.tryHandle("покажи договора все по жуковке 51"); 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"); 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", () => { it("keeps balance family in follow-up when user gives compact account token", () => {
const result = runAddressDecomposeStage("вернись на 2020-12-31 по 60", { const result = runAddressDecomposeStage("вернись на 2020-12-31 по 60", {
previous_intent: "documents_forming_balance", 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"); 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", () => { it("keeps explicit current-date VAT follow-up and does not inherit stale as-of date", () => {
const result = runAddressDecomposeStage("а на текущую дату", { const result = runAddressDecomposeStage("а на текущую дату", {
previous_intent: "vat_payable_confirmed_as_of_date", 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"); 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", () => { it("drops low-quality counterparty anchor from as-of debtor phrasing", () => {
const extracted = extractAddressFilters( const extracted = extractAddressFilters(
"кто является дебитором компании по состоянию на июль 2020 года", "кто является дебитором компании по состоянию на июль 2020 года",