ДОМЕНЫ - ВОПРОСЫ - НДС: Исправить роутинг НДС-запросов с формулировкой мы должны и добавить регрессионные тесты

This commit is contained in:
dctouch 2026-04-13 10:32:02 +03:00
parent 4205c6b3e6
commit fd159e13ac
13 changed files with 375 additions and 15 deletions

View File

@ -159,7 +159,7 @@ function toIsoDate(year, month, day) {
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
} }
function extractAsOfDate(text) { function extractAsOfDate(text) {
if (/\b(сегодня|на\s+сегодня|today|as\s+of\s+today)\b/i.test(text)) { if (/\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test(text)) {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
const ymd = text.match(DATE_YMD_PATTERN); const ymd = text.match(DATE_YMD_PATTERN);

View File

@ -559,7 +559,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text) {
if (!hasVatLexeme) { if (!hasVatLexeme) {
return false; return false;
} }
const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text); const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(text);
if (!hasPaymentCue) { if (!hasPaymentCue) {
return false; return false;
} }
@ -584,7 +584,7 @@ function hasVatPayableConfirmedSignal(text) {
if (!hasVatLexeme) { if (!hasVatLexeme) {
return false; return false;
} }
const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text); const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(text);
if (!hasPaymentCue) { if (!hasPaymentCue) {
return false; return false;
} }

View File

@ -2973,7 +2973,15 @@ class AddressQueryService {
}; };
} }
} }
if (filteredRows.length === 0) { const allowConfirmedAsOfZeroSnapshot = filteredRows.length === 0 &&
(composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date") &&
(stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") &&
!toNonEmptyFilterValue(filters.extracted_filters.counterparty) &&
!toNonEmptyFilterValue(filters.extracted_filters.contract) &&
!toNonEmptyFilterValue(filters.extracted_filters.document_ref);
if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0; const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0; const hadAnchorMatchedRows = filterByAnchors.length > 0;
const isVisibilityGapCandidate = hadBaseRows && const isVisibilityGapCandidate = hadBaseRows &&

View File

@ -27,6 +27,9 @@ function hasSameDateHint(text) {
function hasExplicitPeriodLiteral(text) { function hasExplicitPeriodLiteral(text) {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
} }
function hasExplicitCurrentDateHint(text) {
return /(?:на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодня|сегодня|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)/iu.test(String(text ?? ""));
}
function hasOpenItemsHint(text) { function hasOpenItemsHint(text) {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? "")); return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
} }
@ -358,7 +361,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) { if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date); const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10); const todayIso = new Date().toISOString().slice(0, 10);
@ -401,7 +404,10 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) { if (!sameDateRequested &&
hasFollowupSignalForConfirmed &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date); const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10); const todayIso = new Date().toISOString().slice(0, 10);
@ -433,6 +439,12 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
} }
const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage); const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage);
const asOfPrimaryIntent = intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";
const currentHasPeriod = hasExplicitPeriodWindow(merged); const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous); const previousHasPeriod = hasExplicitPeriodWindow(previous);
if ((intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") && if ((intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period") &&
@ -453,7 +465,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
reasons.push("period_from_followup_context"); reasons.push("period_from_followup_context");
} }
} }
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) { if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) {
if (previousPeriodFrom) { if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom; merged.period_from = previousPeriodFrom;
} }

View File

@ -1044,6 +1044,20 @@ function countTokens(text) {
function hasPeriodLiteral(text) { function hasPeriodLiteral(text) {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text); return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
} }
function hasShortNamedPeriodFollowupLiteral(text) {
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
if (!normalized) {
return false;
}
return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:\u043d\u0430|\u0437\u0430|\u043f\u043e|na|za|for)\s+(?:(?:\u044f\u043d\u0432(?:\u0430\u0440)?|\u0444\u0435\u0432(?:\u0440\u0430\u043b)?|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440(?:\u0435\u043b)?|\u043c\u0430(?:\u0439|\u044f)|\u0438\u044e\u043d(?:\u044c)?|\u0438\u044e\u043b(?:\u044c)?|\u0430\u0432\u0433(?:\u0443\u0441\u0442)?|\u0441\u0435\u043d\u0442(?:\u044f\u0431\u0440)?|\u043e\u043a\u0442(?:\u044f\u0431\u0440)?|\u043d\u043e\u044f(?:\u0431\u0440)?|\u0434\u0435\u043a(?:\u0430\u0431\u0440)?)(?:[\u0430-\u044f\u0451]*)?|q[1-4]|(?:[1-4]|[ivx]{1,4})\s*(?:-?\u0439)?\s*\u043a\u0432(?:\.|\u0430\u0440\u0442(?:\u0430\u043b(?:\u0430|\u0435|\u0443|\u043e\u043c)?)?)?|(?:20\d{2})\s*(?:\u0433(?:\.|\u043e\u0434(?:\u0430|\u0443|\u043e\u043c)?)?))(?=$|[\s,.;:!?])/iu.test(normalized);
}
function hasShortCurrentDateFollowupLiteral(text) {
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
if (!normalized) {
return false;
}
return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:(?:\u043d\u0430|na)\s+)?(?:(?:\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?|(?:\u043d\u0430\s+)?\u0441\u0435\u0433\u043e\u0434\u043d(?:\u044f|\u0435\u0448\u043d(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?)|(?:\u043d\u0430\s+)?\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u043c\u043e\u043c\u0435\u043d\u0442|today|current\s+date|current\s+moment|now)|(?:\u043f\u043e\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e\s+\u043d\u0430|as\s+of)\s+(?:\u0441\u0435\u0433\u043e\u0434\u043d\u044f|today|current\s+date))(?=$|[\s,.;:!?])/iu.test(normalized);
}
function hasStandaloneAddressTopicSignal(text) { function hasStandaloneAddressTopicSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) { if (!normalized) {
@ -2594,6 +2608,11 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortVatCue) { if (shortVatCue) {
return true; return true;
} }
const shortCurrentDateCue = shortFollowup &&
samples.some((sample) => hasShortCurrentDateFollowupLiteral(sample));
if (shortCurrentDateCue) {
return true;
}
if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) { if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) {
return true; return true;
} }
@ -2646,6 +2665,9 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
return true; return true;
} }
if (shortFollowup && samples.some((sample) => hasShortNamedPeriodFollowupLiteral(sample))) {
return true;
}
return false; return false;
} }
function hasShortDebtMirrorFollowupSignal(userMessage) { function hasShortDebtMirrorFollowupSignal(userMessage) {

View File

@ -176,7 +176,11 @@ function toIsoDate(year: number, month: number, day: number): string | null {
} }
function extractAsOfDate(text: string): string | undefined { function extractAsOfDate(text: string): string | undefined {
if (/\b(сегодня|на\s+сегодня|today|as\s+of\s+today)\b/i.test(text)) { if (
/\b(сегодня|на\s+сегодня|на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)\b/i.test(
text
)
) {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }

View File

@ -1,4 +1,4 @@
import type { AddressIntentResolution } from "../types/addressQuery"; import type { AddressIntentResolution } from "../types/addressQuery";
const RECEIVABLES_STRONG = [ const RECEIVABLES_STRONG = [
"кто должен нам", "кто должен нам",
@ -608,7 +608,7 @@ function hasVatLiabilityConfirmedTaxPeriodSignal(text: string): boolean {
return false; return false;
} }
const hasPaymentCue = const hasPaymentCue =
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test( /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(
text text
); );
if (!hasPaymentCue) { if (!hasPaymentCue) {
@ -646,7 +646,7 @@ function hasVatPayableConfirmedSignal(text: string): boolean {
return false; return false;
} }
const hasPaymentCue = const hasPaymentCue =
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test( /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить|мы\s+должн[аы]?|должн[аы]?\s+мы)/iu.test(
text text
); );
if (!hasPaymentCue) { if (!hasPaymentCue) {

View File

@ -3620,7 +3620,16 @@ export class AddressQueryService {
} }
} }
if (filteredRows.length === 0) { const allowConfirmedAsOfZeroSnapshot =
filteredRows.length === 0 &&
(composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date") &&
(stageStatus === "no_raw_rows" || stageStatus === "materialized_but_filtered_out_by_recipe") &&
!toNonEmptyFilterValue(filters.extracted_filters.counterparty) &&
!toNonEmptyFilterValue(filters.extracted_filters.contract) &&
!toNonEmptyFilterValue(filters.extracted_filters.document_ref);
if (filteredRows.length === 0 && !allowConfirmedAsOfZeroSnapshot) {
const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0; const hadBaseRows = normalizedRows.length > 0 || mcp.fetched_rows > 0;
const hadAnchorMatchedRows = filterByAnchors.length > 0; const hadAnchorMatchedRows = filterByAnchors.length > 0;
const isVisibilityGapCandidate = const isVisibilityGapCandidate =

View File

@ -62,6 +62,12 @@ function hasExplicitPeriodLiteral(text: string): boolean {
return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? "")); return /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(String(text ?? ""));
} }
function hasExplicitCurrentDateHint(text: string): boolean {
return /(?:на\s+текущ(?:ую|ая|ий|ее|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодняшн(?:юю|ий|ей|ем|его)\s+дат(?:у|а|е|ой|ою)|на\s+сегодня|сегодня|на\s+текущ(?:ий|ую)\s+момент|today|as\s+of\s+today|current\s+date|as\s+of\s+current\s+date)/iu.test(
String(text ?? "")
);
}
function hasOpenItemsHint(text: string): boolean { function hasOpenItemsHint(text: string): boolean {
return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? "")); return /(?:open\s+items|unclosed\s+items|хвост|висят|незакрыт|не\s+закрыт|открыт|долг|задолж|позиц)/iu.test(String(text ?? ""));
} }
@ -449,7 +455,7 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) { if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage) && !hasExplicitCurrentDateHint(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date); const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10); const todayIso = new Date().toISOString().slice(0, 10);
@ -498,7 +504,12 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) { if (
!sameDateRequested &&
hasFollowupSignalForConfirmed &&
!hasExplicitPeriodLiteral(userMessage) &&
!hasExplicitCurrentDateHint(userMessage)
) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom; const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date); const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10); const todayIso = new Date().toISOString().slice(0, 10);
@ -535,6 +546,13 @@ function mergeFollowupFilters(
const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage); const hasFollowupSignal = hasAddressFollowupContextSignal(userMessage);
const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage); const hasExplicitPeriodInMessage = hasExplicitPeriodLiteral(userMessage);
const hasExplicitCurrentDateInMessage = hasExplicitCurrentDateHint(userMessage);
const asOfPrimaryIntent =
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date";
const currentHasPeriod = hasExplicitPeriodWindow(merged); const currentHasPeriod = hasExplicitPeriodWindow(merged);
const previousHasPeriod = hasExplicitPeriodWindow(previous); const previousHasPeriod = hasExplicitPeriodWindow(previous);
@ -559,7 +577,7 @@ function mergeFollowupFilters(
} }
} }
if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal) { if (!currentHasPeriod && previousHasPeriod && hasFollowupSignal && !(asOfPrimaryIntent && hasExplicitCurrentDateInMessage)) {
if (previousPeriodFrom) { if (previousPeriodFrom) {
merged.period_from = previousPeriodFrom; merged.period_from = previousPeriodFrom;
} }

View File

@ -998,6 +998,20 @@ function countTokens(text) {
function hasPeriodLiteral(text) { function hasPeriodLiteral(text) {
return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text); return /\b(20\d{2}(?:[-/.](?:0[1-9]|1[0-2]))?)\b/.test(text);
} }
function hasShortNamedPeriodFollowupLiteral(text) {
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
if (!normalized) {
return false;
}
return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:\u043d\u0430|\u0437\u0430|\u043f\u043e|na|za|for)\s+(?:(?:\u044f\u043d\u0432(?:\u0430\u0440)?|\u0444\u0435\u0432(?:\u0440\u0430\u043b)?|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440(?:\u0435\u043b)?|\u043c\u0430(?:\u0439|\u044f)|\u0438\u044e\u043d(?:\u044c)?|\u0438\u044e\u043b(?:\u044c)?|\u0430\u0432\u0433(?:\u0443\u0441\u0442)?|\u0441\u0435\u043d\u0442(?:\u044f\u0431\u0440)?|\u043e\u043a\u0442(?:\u044f\u0431\u0440)?|\u043d\u043e\u044f(?:\u0431\u0440)?|\u0434\u0435\u043a(?:\u0430\u0431\u0440)?)(?:[\u0430-\u044f\u0451]*)?|q[1-4]|(?:[1-4]|[ivx]{1,4})\s*(?:-?\u0439)?\s*\u043a\u0432(?:\.|\u0430\u0440\u0442(?:\u0430\u043b(?:\u0430|\u0435|\u0443|\u043e\u043c)?)?)?|(?:20\d{2})\s*(?:\u0433(?:\.|\u043e\u0434(?:\u0430|\u0443|\u043e\u043c)?)?))(?=$|[\s,.;:!?])/iu.test(normalized);
}
function hasShortCurrentDateFollowupLiteral(text) {
const normalized = compactWhitespace(String(text ?? "").toLowerCase());
if (!normalized) {
return false;
}
return /^(?:(?:\u0430|a|\u0438|i)\s+)?(?:(?:\u043d\u0430|na)\s+)?(?:(?:\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?|(?:\u043d\u0430\s+)?\u0441\u0435\u0433\u043e\u0434\u043d(?:\u044f|\u0435\u0448\u043d(?:[\u0430-\u044f\u0451]+)?\s+\u0434\u0430\u0442(?:[\u0430-\u044f\u0451]+)?)|(?:\u043d\u0430\s+)?\u0442\u0435\u043a\u0443\u0449(?:[\u0430-\u044f\u0451]+)?\s+\u043c\u043e\u043c\u0435\u043d\u0442|today|current\s+date|current\s+moment|now)|(?:\u043f\u043e\s+\u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e\s+\u043d\u0430|as\s+of)\s+(?:\u0441\u0435\u0433\u043e\u0434\u043d\u044f|today|current\s+date))(?=$|[\s,.;:!?])/iu.test(normalized);
}
function hasStandaloneAddressTopicSignal(text) { function hasStandaloneAddressTopicSignal(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()); const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) { if (!normalized) {
@ -2551,6 +2565,11 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortVatCue) { if (shortVatCue) {
return true; return true;
} }
const shortCurrentDateCue = shortFollowup &&
samples.some((sample) => hasShortCurrentDateFollowupLiteral(sample));
if (shortCurrentDateCue) {
return true;
}
if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) { if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) {
return true; return true;
} }
@ -2603,6 +2622,9 @@ function hasAddressFollowupContextSignal(userMessage) {
if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
return true; return true;
} }
if (shortFollowup && samples.some((sample) => hasShortNamedPeriodFollowupLiteral(sample))) {
return true;
}
return false; return false;
} }
function hasShortDebtMirrorFollowupSignal(userMessage) { function hasShortDebtMirrorFollowupSignal(userMessage) {

View File

@ -2093,6 +2093,13 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.intent).toBe("contract_usage_and_value"); expect(result.intent).toBe("contract_usage_and_value");
}); });
it("resolves VAT wording with debt-phrase as confirmed VAT payable intent", () => {
const result = resolveAddressIntent(
"\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"
);
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
});
it("resolves contracts-by-counterparty intent from list wording", () => { it("resolves contracts-by-counterparty intent from list wording", () => {
const result = resolveAddressIntent("покажи договора все по жуковке 51"); const result = resolveAddressIntent("покажи договора все по жуковке 51");
expect(result.intent).toBe("list_contracts_by_counterparty"); expect(result.intent).toBe("list_contracts_by_counterparty");
@ -3335,6 +3342,27 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.mcp_call_status).not.toBe("skipped"); expect(result?.debug.mcp_call_status).not.toBe("skipped");
}); });
it("returns factual confirmed VAT snapshot instead of partial when payable rows are absent", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("скок ндс платить надо на март 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("vat_payable_confirmed_as_of_date");
expect(["FACTUAL_LIST", "FACTUAL_SUMMARY"]).toContain(result?.response_type);
expect(result?.reply_type).not.toBe("partial_coverage");
expect(result?.debug.result_mode).toBe("confirmed_balance");
expect(result?.debug.balance_confirmed).toBe(true);
});
it("keeps mixed VAT + debt wording in VAT lane (not payables/contracts)", async () => {
const service = new AddressQueryService();
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"
);
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.result_mode).toBe("confirmed_balance");
});
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");
@ -3755,6 +3783,27 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("intent_adjusted_to_vat_followup_context"); expect(result?.baseReasons).toContain("intent_adjusted_to_vat_followup_context");
expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); expect(result?.baseReasons).toContain("as_of_date_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",
previous_filters: {
period_from: "2016-03-01",
period_to: "2016-03-31",
as_of_date: "2016-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
const todayIso = new Date().toISOString().slice(0, 10);
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(todayIso);
expect(result?.filters.extracted_filters.as_of_date).not.toBe("2016-03-31");
expect(result?.filters.extracted_filters.period_from).toBeUndefined();
expect(result?.filters.extracted_filters.period_to).toBeUndefined();
});
}); });
describe("address recipe catalog counterparty filtering", () => { describe("address recipe catalog counterparty filtering", () => {
@ -4033,3 +4082,4 @@ describe("address recipe catalog counterparty filtering", () => {
}); });
}); });

View File

@ -1415,6 +1415,187 @@ describe("assistant address follow-up carryover", () => {
expect(normalizerService.normalize).not.toHaveBeenCalled(); expect(normalizerService.normalize).not.toHaveBeenCalled();
}); });
it("keeps month-only VAT follow-up phrase in address lane", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0434\u0441 \u043d\u0430\u0434\u043e \u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0432 \u043d\u0430\u043b\u043e\u0433\u043e\u0432\u0443\u044e \u043d\u0430 \u0444\u0435\u0432\u0440\u0430\u043b\u044c 2017";
const followupMessage = "\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442";
const firstVatResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "vat_liability_confirmed_for_tax_period",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-01-01",
period_to: "2017-03-31"
},
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
response_type: "FACTUAL_SUMMARY",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true
}
});
const followupVatResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "vat_liability_confirmed_for_tax_period",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-01-01",
period_to: "2017-03-31"
},
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
response_type: "FACTUAL_SUMMARY",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true,
reasons: [
"address_action_detected",
"vat_liability_confirmed_tax_period_signal_detected",
"address_followup_context_applied"
]
}
});
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return firstVatResult;
}
if (!options?.followupContext) {
return null;
}
return followupVatResult;
})
} 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-vat-march-${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?.detected_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(second.debug?.selected_recipe).toBe("address_vat_liability_confirmed_tax_period_v1");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("vat_liability_confirmed_for_tax_period");
expect(calls[1].options?.followupContext?.previous_filters?.period_from).toBe("2017-01-01");
expect(calls[1].options?.followupContext?.previous_filters?.period_to).toBe("2017-03-31");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps 'a na tekushuyu datu' VAT follow-up in address lane", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "\u0441\u043a\u043e\u043a \u043d\u0430\u0434\u043e \u043d\u0434\u0441 \u043f\u043b\u0430\u0442\u0438\u0442\u044c \u0441 \u0430\u043f\u0440\u0435\u043b\u0435 2017";
const followupMessage = "\u0430 \u043d\u0430 \u0442\u0435\u043a\u0443\u0449\u0443\u044e \u0434\u0430\u0442\u0443";
const vatAsOfResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "vat_payable_confirmed_as_of_date",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-04-01",
period_to: "2017-04-30",
as_of_date: "2017-04-30"
},
selected_recipe: "address_vat_payable_confirmed_as_of_date_v1",
response_type: "FACTUAL_SUMMARY",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true
}
});
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return vatAsOfResult;
}
if (!options?.followupContext) {
return null;
}
return vatAsOfResult;
})
} 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-vat-current-date-${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("vat_payable_confirmed_as_of_date");
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-04-30");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("passes active organization scope into address lane follow-up context", async () => { it("passes active organization scope into address lane follow-up context", async () => {
const calls: Array<{ message: string; options?: any }> = []; const calls: Array<{ message: string; options?: any }> = [];
const addressQueryService = { const addressQueryService = {

View File

@ -558,6 +558,40 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered"); expect(decision.livingReason).toBe("address_lane_triggered");
}); });
it("keeps 'a na tekushuyu datu' follow-up in address lane when previous VAT context exists", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "а на текущую дату",
effectiveAddressUserMessage: "а на текущую дату",
followupContext: {
previous_intent: "vat_payable_confirmed_as_of_date",
previous_filters: {
period_from: "2016-03-01",
period_to: "2016-03-31",
as_of_date: "2016-03-31"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
},
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
}
} as any,
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateDecision).toBe("run_address_lane");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
});
it("keeps explicit address-mode unknown-intent data query in address lane", () => { it("keeps explicit address-mode unknown-intent data query in address lane", () => {
const decision = resolveAssistantOrchestrationDecision({ const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: rawUserMessage: