ДОМЕНЫ - ВОПРОСЫ - Исправить обработку коротких debt follow-up и защиту от диагностических LLM rewrite

This commit is contained in:
dctouch 2026-04-12 22:34:59 +03:00
parent 040a55aaea
commit 98872c2f11
35 changed files with 1745 additions and 195 deletions

View File

@ -1,10 +1,14 @@
# TECH Docs Index # TECH Docs Index
Актуальные документы по operational-контру ассистента: Актуальные документы по техническому контуру ассистента:
1. `assistant_canon.md` - канон поведения ассистента.
2. `capabilities_registry.json` - реестр поддерживаемых возможностей.
3. `manual_case_decision_schema.json` - схема решений ручной разметки.
4. `ui_markup_system.md` - рабочий процесс разметки через GUI.
5. `history_colibration.md` - сводка статуса и ближайших задач.
1. `ARCH_LAYER_FOUNDATION.md` — архитектурный фундамент: разделение слоев `compute` / `navigation` / `conversational`.
2. `STATUS_2026-04-12.md` — текущий статус маршрутов, фиксов и открытых рисков.
3. `address_route_baseline_v1.json` — baseline-срез для анти-регресса по ключевым интентам.
4. `address_route_expectations_v1.json` — ожидания по `intent -> recipe/result_mode`.
5. `capabilities_registry.json` — реестр поддерживаемых capability и границ.
6. `assistant_canon.md` — канон поведения ассистента.
7. `manual_case_decision_schema.json` — схема ручного решения кейсов.
8. `ui_markup_system.md` — правила разметки и UI-процесса.
9. `history_colibration.md` — исторический журнал калибровки.
10. `PLAN_FIX.md` — долгосрочный план безопасного развития маршрутов.

View File

@ -0,0 +1,52 @@
# Статус проекта на 2026-04-12
## 1) Что уже стабильно в compute-слое
- Введены и работают exact-маршруты подтвержденного среза на дату:
- `payables_confirmed_as_of_date` (`address_payables_confirmed_as_of_date_v1`)
- `receivables_confirmed_as_of_date` (`address_receivables_confirmed_as_of_date_v1`)
- `vat_payable_confirmed_as_of_date` (`address_vat_payable_confirmed_as_of_date_v1`)
- Для этих интентов зафиксирован expected route/result mode в:
- `docs/TECH/address_route_expectations_v1.json`
- Режим результата для exact-сценариев закреплен как `confirmed_balance`.
## 2) Что исправлено в цепных (follow-up) вопросах
- Исправлен перенос даты среза в коротких продолжениях по долгам:
- после вопроса о долгах на дату follow-up по дебиторке наследует `as_of_date`, если новая дата не задана явно.
- Добавлен короткий follow-up для НДС:
- короткие реплики вида `а ндс?`/`по ндс` теперь корректно идут в VAT exact-route с переносом даты среза из контекста.
- Сохранена стратегия LLM-first нормализации с последующим детерминированным compute-роутингом.
## 3) Что уже покрыто тестами
- Добавлены/актуализированы тесты на carryover и follow-up:
- `llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts`
- `llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts`
- Проверен маршрутный baseline:
- `llm_normalizer/backend/tests/addressRouteBaseline.test.ts`
## 4) Известные ограничения (не считать багом расчета)
- В разговорных нерелевантных репликах (эмоции/брань/односложные сообщения) система может уйти в `clarification_required`; это относится к conversational-слою, не к compute-расчету.
- `query_shape` в части exact-кейсов может оставаться `UNKNOWN` при корректном `intent`; расчетный маршрут при этом работает корректно.
- Качество бизнес-категоризации контрагентов (особенно по счету 76) требует отдельной донастройки presentation-слоя.
## 5) Что в приоритете дальше
1. НДС-контур: усилить доказательную часть расчета "к уплате на дату" и добавить понятную детализацию оснований.
2. Цепные вопросы: закрепить перенос контекста между payables/receivables/VAT во всех коротких follow-up формулировках.
3. Ответы для UI: довести формат вывода до стабильной блочной структуры без markdown-зависимости.
4. Категоризация: отделить поставщиков/заказчиков от банков/госорганов/спецобязательств в итоговой выдаче.
## 6) Быстрый smoke-check (ручной)
1. `кому мы должны на сентябрь 2017`
2. `а нам кто должен?`
3. `кто нам должен на сентябрь 2017`
4. `а ндс?`
Ожидаемое поведение:
- для 1/3 — `confirmed_balance` в exact-route,
- для 2/4 — корректный follow-up с переносом даты среза, без ухода в эвристический shortlist для exact-интентов.

View File

@ -1,6 +1,6 @@
{ {
"schema_version": "address_route_baseline_v1", "schema_version": "address_route_baseline_v1",
"updated_at": "2026-04-12T12:00:00.000Z", "updated_at": "2026-04-12T20:50:00.000Z",
"entries": [ "entries": [
{ {
"intent": "payables_confirmed_as_of_date", "intent": "payables_confirmed_as_of_date",
@ -14,6 +14,12 @@
"capability_layer": "compute", "capability_layer": "compute",
"capability_route_mode": "exact" "capability_route_mode": "exact"
}, },
{
"intent": "vat_payable_confirmed_as_of_date",
"capability_id": "confirmed_vat_payable_as_of_date",
"capability_layer": "compute",
"capability_route_mode": "exact"
},
{ {
"intent": "list_payables_counterparties", "intent": "list_payables_counterparties",
"capability_id": "payables_candidates_list", "capability_id": "payables_candidates_list",

View File

@ -1,6 +1,6 @@
{ {
"schema_version": "address_route_expectations_v1", "schema_version": "address_route_expectations_v1",
"updated_at": "2026-04-12T13:00:00.000Z", "updated_at": "2026-04-12T20:50:00.000Z",
"entries": [ "entries": [
{ {
"intent": "payables_confirmed_as_of_date", "intent": "payables_confirmed_as_of_date",
@ -14,6 +14,12 @@
"expected_requested_result_modes": ["confirmed_balance"], "expected_requested_result_modes": ["confirmed_balance"],
"expected_result_modes": ["confirmed_balance"] "expected_result_modes": ["confirmed_balance"]
}, },
{
"intent": "vat_payable_confirmed_as_of_date",
"expected_selected_recipes": ["address_vat_payable_confirmed_as_of_date_v1"],
"expected_requested_result_modes": ["confirmed_balance"],
"expected_result_modes": ["confirmed_balance"]
},
{ {
"intent": "list_payables_counterparties", "intent": "list_payables_counterparties",
"expected_selected_recipes": ["address_movements_payables_v1", "address_open_items_by_party_or_contract_v1"], "expected_selected_recipes": ["address_movements_payables_v1", "address_open_items_by_party_or_contract_v1"],

View File

@ -1,6 +1,6 @@
{ {
"schema_version": "capabilities_registry_v1", "schema_version": "capabilities_registry_v1",
"updated_at": "2026-04-09T00:00:00.000Z", "updated_at": "2026-04-12T20:50:00.000Z",
"assistant_mode": "read_only", "assistant_mode": "read_only",
"groups": [ "groups": [
{ {
@ -11,6 +11,7 @@
"maturity_status": "partial", "maturity_status": "partial",
"supported_operations": [ "supported_operations": [
"vat_period_snapshot", "vat_period_snapshot",
"vat_payable_confirmed_as_of_date",
"vat_payable_forecast", "vat_payable_forecast",
"vat_turnover_breakdown" "vat_turnover_breakdown"
], ],
@ -32,6 +33,7 @@
"Почему НДС к уплате ноль?" "Почему НДС к уплате ноль?"
], ],
"related_routes": [ "related_routes": [
"address_vat_payable_confirmed_as_of_date_v1",
"address_vat_payable_forecast_v1" "address_vat_payable_forecast_v1"
], ],
"safe_alternatives": [ "safe_alternatives": [

View File

@ -8,7 +8,8 @@ const COMPUTE_EXACT_INTENTS = new Set([
"account_balance_snapshot", "account_balance_snapshot",
"documents_forming_balance", "documents_forming_balance",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date" "receivables_confirmed_as_of_date",
"vat_payable_confirmed_as_of_date"
]); ]);
const NAVIGATION_INTENTS = new Set([ const NAVIGATION_INTENTS = new Set([
"list_documents_by_counterparty", "list_documents_by_counterparty",
@ -39,6 +40,9 @@ function defaultCapabilityId(intent) {
if (intent === "receivables_confirmed_as_of_date") { if (intent === "receivables_confirmed_as_of_date") {
return "confirmed_receivables_as_of_date"; return "confirmed_receivables_as_of_date";
} }
if (intent === "vat_payable_confirmed_as_of_date") {
return "confirmed_vat_payable_as_of_date";
}
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
return "payables_candidates_list"; return "payables_candidates_list";
} }
@ -74,6 +78,14 @@ function resolveCapabilityEnabled(intent) {
: "receivables_confirmed_route_disabled_by_flag" : "receivables_confirmed_route_disabled_by_flag"
}; };
} }
if (intent === "vat_payable_confirmed_as_of_date") {
return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
reason: config_1.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
? "vat_payable_confirmed_route_enabled"
: "vat_payable_confirmed_route_disabled_by_flag"
};
}
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
return { return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,

View File

@ -574,6 +574,43 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (questionCue && (rankingCue || paymentCue)) { if (questionCue && (rankingCue || paymentCue)) {
return true; return true;
} }
const hasTemporalCue = /(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(value);
const hasGenericEntityCue = /(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test(value);
if (hasTemporalCue && hasGenericEntityCue) {
return true;
}
const lowQualityTimeTokens = new Set([
"по",
"состоянию",
"состояние",
"на",
"дату",
"дата",
"конец",
"период",
"месяц",
"году",
"год",
"квартал",
"январь",
"февраль",
"март",
"апрель",
"май",
"июнь",
"июль",
"август",
"сентябрь",
"октябрь",
"ноябрь",
"декабрь"
]);
const meaningfulNonTemporalTokens = tokens.filter((token) => isLikelyCounterpartyToken(token) &&
!lowQualityTimeTokens.has(token) &&
!/^(?:19|20)\d{2}$/.test(token));
if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0; return meaningfulTokens.length === 0;
} }
@ -751,6 +788,9 @@ function requiredFiltersByIntent(intent) {
if (intent === "receivables_confirmed_as_of_date") { if (intent === "receivables_confirmed_as_of_date") {
return ["as_of_date"]; return ["as_of_date"];
} }
if (intent === "vat_payable_confirmed_as_of_date") {
return ["as_of_date"];
}
if (intent === "list_documents_by_counterparty" || if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") { intent === "list_contracts_by_counterparty") {
@ -765,7 +805,8 @@ function usesAsOfPrimaryWindow(intent) {
return (intent === "open_items_by_counterparty_or_contract" || return (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date"); intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date");
} }
function extractAddressFilters(userMessage, intent) { function extractAddressFilters(userMessage, intent) {
const rawText = String(userMessage ?? "").trim(); const rawText = String(userMessage ?? "").trim();
@ -928,7 +969,8 @@ function extractAddressFilters(userMessage, intent) {
if ((intent === "account_balance_snapshot" || if ((intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date") && intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") &&
!filters.as_of_date) { !filters.as_of_date) {
if (filters.period_to) { if (filters.period_to) {
filters.as_of_date = filters.period_to; filters.as_of_date = filters.period_to;

View File

@ -535,10 +535,20 @@ function hasAccountBalanceSignal(text) {
} }
function hasForecastTaxSignal(text) { function hasForecastTaxSignal(text) {
const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text); const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text);
const hasVatPayableEstimatePattern = /(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test(text); return hasForecastLexeme && hasTaxLexeme;
return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern); }
function hasVatPayableConfirmedSignal(text) {
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
if (!hasVatLexeme) {
return false;
}
const hasPaymentCue = /(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(text);
if (!hasPaymentCue) {
return false;
}
const hasDateOrPeriodCue = /(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(text);
return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text);
} }
function hasPeriodCoverageProfileSignal(text) { function hasPeriodCoverageProfileSignal(text) {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) { if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
@ -862,7 +872,7 @@ function hasSupplierTailRiskSignal(text) {
return hasSupplier && hasTail && (hasRisk || hasPeriodCue); return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
} }
function hasPayablesDebtLifecycleSignal(text) { function hasPayablesDebtLifecycleSignal(text) {
const hasOweSignal = /(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test(text); const hasOweSignal = /(?:кому\s+мы\s+долж(?:ен|ны|эны|эна|эно)?|мы\s+долж(?:ен|ны|эны|эна|эно)?|кому\s+долж(?:ен|ны|эны|эна|эно)?|долж[нэ](?:ы|а|о)?\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[а-яё]{0,6})?)/iu.test(text);
if (!hasOweSignal) { if (!hasOweSignal) {
return false; return false;
} }
@ -874,7 +884,7 @@ function hasPayablesDebtLifecycleSignal(text) {
return true; return true;
} }
function hasReceivablesDebtLifecycleSignal(text) { function hasReceivablesDebtLifecycleSignal(text) {
const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|ны)?|кто\s+долж(?:ен|ны)?\s+нам|нам\s+долж(?:ен|ны)|должник(?:и|ов|а)?|дебитор(?:ы|ов|ск)?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text); const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|ны|эны|эна|эно)?|кто\s+долж(?:ен|ны|эны|эна|эно)?\s+нам|нам\s+долж(?:ен|ны|эны|эна|эно)?|должник(?:[а-яё]{0,6})?|дебитор(?:[а-яё]{0,6})?|дебиторск(?:[а-яё]{0,6})?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text);
if (!hasOweUsSignal) { if (!hasOweUsSignal) {
return false; return false;
} }
@ -1258,6 +1268,13 @@ function resolveAddressIntent(userMessage) {
reasons: ["forecast_tax_signal_detected"] reasons: ["forecast_tax_signal_detected"]
}; };
} }
if (hasVatPayableConfirmedSignal(text)) {
return {
intent: "vat_payable_confirmed_as_of_date",
confidence: "high",
reasons: ["vat_payable_confirmed_signal_detected"]
};
}
if (hasAny(text, RECEIVABLES_STRONG)) { if (hasAny(text, RECEIVABLES_STRONG)) {
const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text); const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text);
const reasons = ["receivables_signal_detected"]; const reasons = ["receivables_signal_detected"];

View File

@ -29,6 +29,7 @@ const RESULT_SET_TYPE_BY_INTENT = {
supplier_payouts_profile: "counterparty_list", supplier_payouts_profile: "counterparty_list",
list_payables_counterparties: "counterparty_list", list_payables_counterparties: "counterparty_list",
payables_confirmed_as_of_date: "balance_snapshot", payables_confirmed_as_of_date: "balance_snapshot",
vat_payable_confirmed_as_of_date: "balance_snapshot",
receivables_confirmed_as_of_date: "balance_snapshot", receivables_confirmed_as_of_date: "balance_snapshot",
list_receivables_counterparties: "counterparty_list", list_receivables_counterparties: "counterparty_list",
list_contracts_by_counterparty: "contract_list", list_contracts_by_counterparty: "contract_list",

View File

@ -648,7 +648,8 @@ function isConfirmedBalanceIntent(intent) {
return (intent === "account_balance_snapshot" || return (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date"); intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date");
} }
function resolveAsOfDateBasis(filters) { function resolveAsOfDateBasis(filters) {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
@ -818,7 +819,7 @@ function enforceStrictAccountScopeForIntent(plan, intent) {
account_scope_mode: "strict" account_scope_mode: "strict"
}; };
} }
function resolveExecutionFiltersForPayablesConfirmedBalance(filters, analysisDate) { function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) {
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date); const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
const periodTo = normalizeAnalysisDateHint(filters.period_to); const periodTo = normalizeAnalysisDateHint(filters.period_to);
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null; const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
@ -1302,6 +1303,9 @@ function buildLimitedOffers(input) {
else if (input.intent === "receivables_confirmed_as_of_date") { else if (input.intent === "receivables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
} }
else if (input.intent === "vat_payable_confirmed_as_of_date") {
offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*");
}
else if (input.intent === "payables_confirmed_as_of_date") { else if (input.intent === "payables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
} }
@ -1348,7 +1352,8 @@ function buildLimitedIntentSignalLine(input) {
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату." payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.",
vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату."
}; };
const byShape = { const byShape = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.", AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
@ -1474,16 +1479,15 @@ function buildLimitedExecutionResult(input) {
}); });
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters); const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode); const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode);
const reasons = (input.intent.intent === "payables_confirmed_as_of_date" || input.intent.intent === "receivables_confirmed_as_of_date") && const exactLimitedReason = input.intent.intent === "payables_confirmed_as_of_date"
!reasonsWithConfirmedFallback.includes(input.intent.intent === "payables_confirmed_as_of_date"
? "exact_payables_mode_limited_response" ? "exact_payables_mode_limited_response"
: "exact_receivables_mode_limited_response") : input.intent.intent === "receivables_confirmed_as_of_date"
? [ ? "exact_receivables_mode_limited_response"
...reasonsWithConfirmedFallback, : input.intent.intent === "vat_payable_confirmed_as_of_date"
input.intent.intent === "payables_confirmed_as_of_date" ? "exact_vat_payable_mode_limited_response"
? "exact_payables_mode_limited_response" : null;
: "exact_receivables_mode_limited_response" const reasons = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason)
] ? [...reasonsWithConfirmedFallback, exactLimitedReason]
: reasonsWithConfirmedFallback; : reasonsWithConfirmedFallback;
const routeExpectationAudit = input.routeExpectationAudit ?? const routeExpectationAudit = input.routeExpectationAudit ??
buildRouteExpectationAudit({ buildRouteExpectationAudit({
@ -1591,13 +1595,20 @@ class AddressQueryService {
const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") &&
requestedResultMode === "confirmed_balance"; requestedResultMode === "confirmed_balance";
const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
const payablesConfirmedExecution = confirmedBalancePayablesIntent const payablesConfirmedExecution = confirmedBalancePayablesIntent
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const executionFilters = payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters = payablesConfirmedExecution?.executionFilters ??
receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ??
filters.extracted_filters;
if (payablesConfirmedExecution?.asOfDerived && if (payablesConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) { !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) {
if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) { if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) {
@ -1616,6 +1627,15 @@ class AddressQueryService {
baseReasons.push("as_of_date_derived_for_confirmed_receivables"); baseReasons.push("as_of_date_derived_for_confirmed_receivables");
} }
} }
if (vatPayableConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) {
if (!filters.warnings.includes("as_of_date_derived_for_confirmed_vat_payable")) {
filters.warnings.push("as_of_date_derived_for_confirmed_vat_payable");
}
if (!baseReasons.includes("as_of_date_derived_for_confirmed_vat_payable")) {
baseReasons.push("as_of_date_derived_for_confirmed_vat_payable");
}
}
const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent); const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent);
const capabilityAudit = buildCapabilityAudit(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({ const shadowRouteAudit = buildShadowRouteAudit({
@ -1686,6 +1706,10 @@ class AddressQueryService {
!baseReasons.includes("confirmed_balance_exact_receivables_intent")) { !baseReasons.includes("confirmed_balance_exact_receivables_intent")) {
baseReasons.push("confirmed_balance_exact_receivables_intent"); baseReasons.push("confirmed_balance_exact_receivables_intent");
} }
if (intent.intent === "vat_payable_confirmed_as_of_date" &&
!baseReasons.includes("confirmed_balance_exact_vat_payable_intent")) {
baseReasons.push("confirmed_balance_exact_vat_payable_intent");
}
if (requestedResultMode === "confirmed_balance" && if (requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" && recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { !baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
@ -2658,10 +2682,15 @@ class AddressQueryService {
routeExpectationAudit: finalRouteExpectationAudit routeExpectationAudit: finalRouteExpectationAudit
}); });
} }
if (((intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || const exactConfirmedIntent = (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") ||
(intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date")) && (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date") ||
factualResultSemantics.balance_confirmed !== true) { (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date");
const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables"; if (exactConfirmedIntent && factualResultSemantics.balance_confirmed !== true) {
const exactModeName = intent.intent === "payables_confirmed_as_of_date"
? "payables"
: intent.intent === "receivables_confirmed_as_of_date"
? "receivables"
: "vat_payable";
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
shape, shape,
@ -2686,7 +2715,9 @@ class AddressQueryService {
materializationDropReason: rowDiagnostics.materializationDropReason, materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap", category: "recipe_visibility_gap",
reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`, reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`,
nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", nextStep: intent.intent === "vat_payable_confirmed_as_of_date"
? "specify as_of_date/organization or provide VAT settlement registers to prove exact VAT payable balance"
: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance",
limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`],
reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`],
capabilityAudit, capabilityAudit,

View File

@ -66,6 +66,28 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
УПОРЯДОЧИТЬ ПО УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__ Сумма __ORDER_DIRECTION__
`; `;
const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"Остатки на дату" КАК Регистратор,
"" КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокКт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
ГДЕ
Остатки.СуммаРазвернутыйОстатокКт > 0
И (__VAT_PAYABLE_ACCOUNTS_MATCH__)
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const BANK_DOCS_QUERY_TEMPLATE = ` const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период, БанкСписание.Дата КАК Период,
@ -549,6 +571,17 @@ const BASE_RECIPES = [
account_scope_mode: "preferred", account_scope_mode: "preferred",
query_template: "vat_payable_forecast_profile" query_template: "vat_payable_forecast_profile"
}, },
{
recipe_id: "address_vat_payable_confirmed_as_of_date_v1",
intent: "vat_payable_confirmed_as_of_date",
purpose: "Build confirmed VAT payable snapshot as-of date from balances on VAT payable accounts",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "limit", "sort"],
default_limit: 200,
account_scope: ["68"],
account_scope_mode: "strict",
query_template: "vat_payable_confirmed_as_of_balance_profile"
},
{ {
recipe_id: "address_contracts_by_counterparty_v1", recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty", intent: "list_contracts_by_counterparty",
@ -960,6 +993,24 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_68_PREFIXES)) .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_19_PREFIXES))
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "vat_payable_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "contracts_by_counterparty_profile" : recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "payables_confirmed_as_of_balance_profile" : recipe.query_template === "payables_confirmed_as_of_balance_profile"

View File

@ -1969,6 +1969,80 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n") text: lines.join("\n")
}; };
} }
if (intent === "vat_payable_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const confirmedRows = rows.filter((row) => {
const amount = row.amount ?? 0;
if (!Number.isFinite(amount) || amount <= 0) {
return false;
}
const section = extractAccountSectionCode(row.account_kt);
return section === "68";
});
const byAccount = new Map();
for (const row of confirmedRows) {
const account = String(row.account_kt ?? "").trim() || "68*";
const registrator = String(row.registrator ?? "").trim();
const amount = row.amount ?? 0;
const current = byAccount.get(account);
if (!current) {
byAccount.set(account, {
account,
total: amount,
operations: 1,
lastPeriod: row.period,
refs: registrator ? new Set([registrator]) : new Set()
});
continue;
}
current.total += amount;
current.operations += 1;
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (registrator) {
current.refs.add(registrator);
}
}
const accountRows = Array.from(byAccount.values())
.filter((item) => Number.isFinite(item.total) && item.total > 0)
.sort((a, b) => b.total - a.total || b.operations - a.operations || a.account.localeCompare(b.account, "ru"));
const totalVatPayable = accountRows.reduce((sum, item) => sum + item.total, 0);
const lines = [
`Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный срез НДС к уплате по состоянию на дату.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счетам НДС к уплате (68*).",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
"",
"Блок 4. Подтвержденные позиции"
];
if (accountRows.length > 0) {
lines.push(...accountRows.slice(0, 12).map((item, index) => {
const refs = Array.from(item.refs).slice(0, 2).join("; ");
return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`;
}));
}
else {
lines.push("- Подтвержденный остаток НДС к уплате на дату среза не найден.");
}
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
if (intent === "account_balance_snapshot") { if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0); const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [ const lines = [

View File

@ -30,6 +30,12 @@ function hasExplicitPeriodLiteral(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 ?? ""));
} }
function hasVatCue(text) {
return /(?:^|[\s,.;:!?()\-])(?:ндс|vat)(?=$|[\s,.;:!?()\-])/iu.test(String(text ?? ""));
}
function hasVatForecastCue(text) {
return /(?:прогноз|forecast|прикин|оцен|план)/iu.test(String(text ?? ""));
}
function hasDocumentSignal(text) { function hasDocumentSignal(text) {
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? "")); return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? ""));
} }
@ -349,11 +355,23 @@ 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)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
} }
if (intent === "open_items_by_counterparty_or_contract" || if (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date") { intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") {
const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage);
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract); const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract || const shouldInheritContract = !currentContract ||
@ -380,6 +398,16 @@ 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)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
} }
if (allTimeRequested) { if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) { if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
@ -436,6 +464,7 @@ function resolveMissingRequiredFilters(intent, filters) {
documents_forming_balance: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"], payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"],
vat_payable_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"], list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"],
@ -466,6 +495,17 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor); const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor);
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor); const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
const isVatFollowup = hasVatCue(normalizedMessage);
if (detectedIntent.intent === "unknown" && isVatFollowup) {
const vatIntent = hasVatForecastCue(normalizedMessage)
? "vat_payable_forecast"
: "vat_payable_confirmed_as_of_date";
return {
intent: vatIntent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"]
};
}
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return { return {
intent: "open_items_by_counterparty_or_contract", intent: "open_items_by_counterparty_or_contract",

View File

@ -94,7 +94,8 @@ function inferAggregationProfile(intent, shape) {
if (intent === "account_balance_snapshot" || if (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date") { intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") {
return "balance_snapshot"; return "balance_snapshot";
} }
if (intent === "open_items_by_counterparty_or_contract" || if (intent === "open_items_by_counterparty_or_contract" ||

View File

@ -2033,7 +2033,7 @@ function textMojibakeScoreForAddress(value) {
const source = String(value ?? ""); const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length; const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ’“”•–—™љ›њќћџ]/g) ?? []).length; const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ<EFBFBD>?’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length; const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length; const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2; return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
@ -2043,7 +2043,7 @@ function looksLikeMojibakeForAddress(value) {
if (!source.trim()) { if (!source.trim()) {
return false; return false;
} }
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ’“”•–—™љ›њќћџ]/.test(source)) { if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ<EFBFBD>?’“”•–—™љ›њќћџ]/.test(source)) {
return true; return true;
} }
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) { if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
@ -2248,7 +2248,7 @@ function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? "")) return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase() .toLowerCase()
.replace(/ё/g, "е") .replace(/ё/g, "е")
.replace(/[«»"'`“”„’]/g, " ") .replace(/[«»"'`“”„’<EFBFBD>?]/g, " ")
.replace(/[^a-zа-я0-9\s._-]+/giu, " ")); .replace(/[^a-zа-я0-9\s._-]+/giu, " "));
} }
function normalizeCounterpartyTokenForFollowupMatch(value) { function normalizeCounterpartyTokenForFollowupMatch(value) {
@ -2294,7 +2294,7 @@ function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknow
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) { if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
counterpartyCandidate = parts[1] ?? counterpartyCandidate; counterpartyCandidate = parts[1] ?? counterpartyCandidate;
} }
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`]+|["'«»“”„`]+$/gu, "")); const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`<EFBFBD>?]+|["'«»“”„`<>?]+$/gu, ""));
if (!cleanedCandidate || cleanedCandidate.length < 2) { if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue; continue;
} }
@ -2558,61 +2558,110 @@ function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) {
return tokenCount > 0 && tokenCount <= 4; return tokenCount > 0 && tokenCount <= 4;
} }
function hasAddressFollowupContextSignal(userMessage) { function hasAddressFollowupContextSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repaired = repairAddressMojibake(String(userMessage ?? "")); const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase()); const repairedText = compactWhitespace(repaired.toLowerCase());
if (!text) { const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false; return false;
} }
if (hasStandaloneAddressTopicSignal(text)) { const hasAny = (pattern) => samples.some((sample) => pattern.test(sample));
const hasMarker = () => samples.some((sample) => hasFollowupMarker(sample));
const hasPointer = () => samples.some((sample) => hasReferentialPointer(sample));
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
const shortFollowup = minTokens <= 8;
const ultraShortFollowup = minTokens <= 3;
const debtRoleSwapToReceivables = shortFollowup &&
/^(?:\u0430|\u0438)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e\b/iu.test(rawText);
if (debtRoleSwapToReceivables) {
return true;
}
const debtRoleSwapToPayables = shortFollowup &&
/^(?:\u0430|\u0438)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443\b/iu.test(rawText);
if (debtRoleSwapToPayables) {
return true;
}
const shortContinuationCue = ultraShortFollowup &&
/^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)\b/iu.test(rawText);
if (shortContinuationCue) {
return true;
}
const shortVatCue = ultraShortFollowup &&
/^(?:(?:\u0430|\u0438)\s+)?(?:(?:\u043f\u043e|po)\s+)?(?:\u043d\u0434\u0441|vat)(?=$|[\s,.;:!?])/iu.test(rawText);
if (shortVatCue) {
return true;
}
if (shortFollowup && hasAny(/^(?:а|и)\s+(?:нам\s+)?кто\b/iu)) {
return true;
}
if (shortFollowup && hasAny(/^(?:а|и)\s+(?:мы\s+)?кому\b/iu)) {
return true;
}
if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)\b/iu)) {
return true;
}
if (hasStandaloneAddressTopicSignal(rawText || repairedText)) {
return false; return false;
} }
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) { if (shouldHandleAsAssistantCapabilityMetaQuery(rawText || repairedText)) {
return false; return false;
} }
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) { if (hasAny(/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu)) {
return true; return true;
} }
if (hasReferentialPointer(text)) { if (hasPointer()) {
return true; return true;
} }
if (/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(text)) { if (hasAny(/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu)) {
return true; return true;
} }
const shortFollowup = countTokens(text) <= 8; if (hasAny(/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu)) {
if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) {
return true; return true;
} }
if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) { if (hasAny(/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu) && minTokens <= 12) {
return true; return true;
} }
if (shortFollowup && hasFollowupMarker(text)) { if (shortFollowup && hasMarker()) {
return true; return true;
} }
if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) { if (shortFollowup && hasAny(/(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu)) {
return true;
}
if (shortFollowup && hasAny(/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu)) {
return true; return true;
} }
if (shortFollowup && if (shortFollowup &&
/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) { hasAny(/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu) &&
return true; hasAny(/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu)) {
}
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
return true; return true;
} }
if (shortFollowup && if (shortFollowup &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) && hasAny(/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) { !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) {
return true; return true;
} }
if (shortFollowup && if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
return true;
}
if (shortFollowup && hasPeriodLiteral(text)) {
return true; return true;
} }
return false; return false;
} }
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!normalized || countTokens(normalized) > 10) {
return null;
}
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized);
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized);
if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") &&
hasReceivablesCue) {
return "receivables_confirmed_as_of_date";
}
if ((previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties") &&
hasPayablesCue) {
return "payables_confirmed_as_of_date";
}
return null;
}
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) { function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressItem = findLastAddressAssistantItem(items);
const previousAddressDebug = previousAddressItem?.debug ?? null; const previousAddressDebug = previousAddressItem?.debug ?? null;
@ -2621,9 +2670,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
Boolean(followupOffer?.enabled) && Boolean(followupOffer?.enabled) &&
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage); const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent);
const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null;
const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage)
? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary);
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate)
: false; : false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null; const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage) const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
@ -2632,7 +2687,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { if (hasStandaloneAddressTopic &&
!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasImplicitContinuationSignal &&
!hasIndexReferenceSignal) {
return null; return null;
} }
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
@ -2644,6 +2703,9 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent); const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
let previousIntent = sourceIntent; let previousIntent = sourceIntent;
let followupSelectionMode = "carry_previous_intent"; let followupSelectionMode = "carry_previous_intent";
if (debtRoleSwapIntent) {
previousIntent = debtRoleSwapIntent;
}
if (hasImplicitContinuationSignal) { if (hasImplicitContinuationSignal) {
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
? toNonEmptyString(followupOffer.suggested_intents[0]) ? toNonEmptyString(followupOffer.suggested_intents[0])
@ -3725,7 +3787,8 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_contracts_by_counterparty", "list_contracts_by_counterparty",
"contract_usage_overview", "contract_usage_overview",
"contract_usage_and_value", "contract_usage_and_value",
"vat_payable_forecast" "vat_payable_forecast",
"vat_payable_confirmed_as_of_date"
]); ]);
function resolveAssistantOrchestrationDecision(input) { function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
@ -4939,14 +5002,14 @@ async function resolveAssistantDataScopeProbe() {
}; };
} }
const catalogQueryCandidates = [ const catalogQueryCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации" "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК ОрганизацияПредставление <20>?З Справочник.Организации КАК Организации"
]; ];
const movementProbeCandidates = [ const movementProbeCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ", "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Движения.Организация) КАК ОрганизацияПредставление <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ<EFBFBD>?ТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения" "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения"
]; ];
let lastError = null; let lastError = null;
const catalogFacts = { names: [], refs: [], pairs: [] }; const catalogFacts = { names: [], refs: [], pairs: [] };
@ -5077,7 +5140,7 @@ function buildAssistantOperationalBoundaryReply() {
return [ return [
"Понимаю, что ситуация срочная.", "Понимаю, что ситуация срочная.",
"Я не могу сам настраивать 1С или менять базу/конфигурацию.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.",
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/<EFBFBD>?Т-админа."
].join(" "); ].join(" ");
} }
function buildAssistantSafetyRefusalReply() { function buildAssistantSafetyRefusalReply() {

View File

@ -27,7 +27,8 @@ const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"account_balance_snapshot", "account_balance_snapshot",
"documents_forming_balance", "documents_forming_balance",
"payables_confirmed_as_of_date", "payables_confirmed_as_of_date",
"receivables_confirmed_as_of_date" "receivables_confirmed_as_of_date",
"vat_payable_confirmed_as_of_date"
]); ]);
const NAVIGATION_INTENTS = new Set<AddressIntent>([ const NAVIGATION_INTENTS = new Set<AddressIntent>([
"list_documents_by_counterparty", "list_documents_by_counterparty",
@ -62,6 +63,9 @@ function defaultCapabilityId(intent: AddressIntent): string {
if (intent === "receivables_confirmed_as_of_date") { if (intent === "receivables_confirmed_as_of_date") {
return "confirmed_receivables_as_of_date"; return "confirmed_receivables_as_of_date";
} }
if (intent === "vat_payable_confirmed_as_of_date") {
return "confirmed_vat_payable_as_of_date";
}
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
return "payables_candidates_list"; return "payables_candidates_list";
} }
@ -98,6 +102,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
: "receivables_confirmed_route_disabled_by_flag" : "receivables_confirmed_route_disabled_by_flag"
}; };
} }
if (intent === "vat_payable_confirmed_as_of_date") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1,
reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1
? "vat_payable_confirmed_route_enabled"
: "vat_payable_confirmed_route_disabled_by_flag"
};
}
if (intent === "list_payables_counterparties") { if (intent === "list_payables_counterparties") {
return { return {
enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,

View File

@ -643,6 +643,92 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (questionCue && (rankingCue || paymentCue)) { if (questionCue && (rankingCue || paymentCue)) {
return true; return true;
} }
const moneyAsOfPhraseCue =
/(?:денег|деньг|money|cash)/iu.test(value) &&
/(?:на\s+(?:данн(?:ую|ой|ая|ое)|эту|ту)\s+дат|on\s+(?:this|that)\s+date|as\s+of\s+(?:this|that)\s+date)/iu.test(
value
);
if (moneyAsOfPhraseCue) {
return true;
}
const hasTemporalCue =
/(?:по\s+состоянию|на\s+дат|на\s+конец|за\s+(?:период|месяц|год|квартал)|\b(?:19|20)\d{2}\b|\bянвар|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)/iu.test(
value
);
const hasGenericEntityCue =
/(?:компан|организац|контрагент|поставщик|клиент|покупател|дебитор|кредитор|counterparty|company|supplier|customer)/iu.test(
value
);
if (hasTemporalCue && hasGenericEntityCue) {
return true;
}
const lowQualityTimeTokens = new Set([
"по",
"состоянию",
"состояние",
"на",
"дату",
"дата",
"конец",
"период",
"месяц",
"году",
"год",
"квартал",
"январь",
"февраль",
"март",
"апрель",
"май",
"июнь",
"июль",
"август",
"сентябрь",
"октябрь",
"ноябрь",
"декабрь"
]);
const lowQualityGenericTokens = new Set([
"деньги",
"денег",
"деньгам",
"деньгами",
"денежный",
"денежные",
"данную",
"данной",
"данный",
"данное",
"эту",
"этой",
"этот",
"этом",
"ту",
"той",
"тот",
"том",
"вцелом",
"целом"
]);
const meaningfulNonTemporalTokens = tokens.filter(
(token) =>
isLikelyCounterpartyToken(token) &&
!lowQualityTimeTokens.has(token) &&
!/^(?:19|20)\d{2}$/.test(token)
);
if (meaningfulNonTemporalTokens.length === 0 && hasTemporalCue) {
return true;
}
const meaningfulNonGenericTokens = tokens.filter(
(token) =>
isLikelyCounterpartyToken(token) &&
!lowQualityTimeTokens.has(token) &&
!lowQualityGenericTokens.has(token) &&
!/^(?:19|20)\d{2}$/.test(token)
);
if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0; return meaningfulTokens.length === 0;
} }
@ -843,6 +929,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "receivables_confirmed_as_of_date") { if (intent === "receivables_confirmed_as_of_date") {
return ["as_of_date"]; return ["as_of_date"];
} }
if (intent === "vat_payable_confirmed_as_of_date") {
return ["as_of_date"];
}
if ( if (
intent === "list_documents_by_counterparty" || intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" || intent === "bank_operations_by_counterparty" ||
@ -861,7 +950,8 @@ function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
); );
} }
@ -1050,7 +1140,8 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
(intent === "account_balance_snapshot" || (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date") && intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date") &&
!filters.as_of_date !filters.as_of_date
) { ) {
if (filters.period_to) { if (filters.period_to) {

View File

@ -403,6 +403,28 @@ function hasAny(text: string, patterns: string[]): boolean {
return patterns.some((item) => text.includes(item)); return patterns.some((item) => text.includes(item));
} }
function hasFlexibleReceivablesDebtSignal(text: string): boolean {
const normalized = String(text ?? "");
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)
);
}
function hasFlexiblePayablesDebtSignal(text: string): boolean {
const normalized = String(text ?? "");
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)
);
}
function tokenizeText(text: string): string[] { function tokenizeText(text: string): string[] {
return String(text ?? "") return String(text ?? "")
.toLowerCase() .toLowerCase()
@ -576,13 +598,27 @@ function hasAccountBalanceSignal(text: string): boolean {
function hasForecastTaxSignal(text: string): boolean { function hasForecastTaxSignal(text: string): boolean {
const hasForecastLexeme = const hasForecastLexeme =
/(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text); /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text); const hasTaxLexeme = /(?:ндс|vat|налог)/iu.test(text);
const hasVatPayableEstimatePattern = return hasForecastLexeme && hasTaxLexeme;
/(?:(?:сколько|скока|скок).{0,48}(?:ндс|vat).{0,48}(?:надо|нужно|к\s+уплате|заплатить|уплатить|платеж|платежа|платежей|платежку)|(?:ндс|vat).{0,48}(?:к\s+уплате|надо|нужно|заплатить|уплатить)|(?:сколько|скока|скок).{0,32}(?:надо|нужно).{0,32}(?:заплатить|уплатить).{0,32}(?:ндс|vat))/iu.test( }
function hasVatPayableConfirmedSignal(text: string): boolean {
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
if (!hasVatLexeme) {
return false;
}
const hasPaymentCue =
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(
text text
); );
return (hasForecastLexeme && hasTaxLexeme) || (hasVatLexeme && hasVatPayableEstimatePattern); if (!hasPaymentCue) {
return false;
}
const hasDateOrPeriodCue =
/(?:на\s+дат|по\s+состоянию|на\s+конец|за\s+(?:\d{4}|январ|феврал|март|апрел|май|июн|июл|август|сентябр|октябр|ноябр|декабр)|квартал|месяц|год|период|\b\d{4}[./-]\d{2}[./-]\d{2}\b)/iu.test(
text
);
return hasDateOrPeriodCue || /(?:сколько|скока|скок)/iu.test(text);
} }
function hasPeriodCoverageProfileSignal(text: string): boolean { function hasPeriodCoverageProfileSignal(text: string): boolean {
@ -1006,7 +1042,7 @@ function hasSupplierTailRiskSignal(text: string): boolean {
function hasPayablesDebtLifecycleSignal(text: string): boolean { function hasPayablesDebtLifecycleSignal(text: string): boolean {
const hasOweSignal = const hasOweSignal =
/(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test( /(?:кому\s+мы\s+долж(?:ен|ны|эны|эна|эно)?|мы\s+долж(?:ен|ны|эны|эна|эно)?|кому\s+долж(?:ен|ны|эны|эна|эно)?|долж[нэ](?:ы|а|о)?\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:[а-яё]{0,6})?)/iu.test(
text text
); );
if (!hasOweSignal) { if (!hasOweSignal) {
@ -1022,7 +1058,7 @@ function hasPayablesDebtLifecycleSignal(text: string): boolean {
function hasReceivablesDebtLifecycleSignal(text: string): boolean { function hasReceivablesDebtLifecycleSignal(text: string): boolean {
const hasOweUsSignal = const hasOweUsSignal =
/(?:кто\s+нам\s+долж(?:ен|ны)?|кто\s+долж(?:ен|ны)?\s+нам|нам\s+долж(?:ен|ны)|должник(?:и|ов|а)?|дебитор(?:ы|ов|ск)?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test( /(?:кто\s+нам\s+долж(?:ен|ны|эны|эна|эно)?|кто\s+долж(?:ен|ны|эны|эна|эно)?\s+нам|нам\s+долж(?:ен|ны|эны|эна|эно)?|должник(?:[а-яё]{0,6})?|дебитор(?:[а-яё]{0,6})?|дебиторск(?:[а-яё]{0,6})?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(
text text
); );
if (!hasOweUsSignal) { if (!hasOweUsSignal) {
@ -1475,11 +1511,23 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
}; };
} }
if (hasAny(text, RECEIVABLES_STRONG)) { if (hasVatPayableConfirmedSignal(text)) {
const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text); return {
intent: "vat_payable_confirmed_as_of_date",
confidence: "high",
reasons: ["vat_payable_confirmed_signal_detected"]
};
}
if (hasAny(text, RECEIVABLES_STRONG) || hasFlexibleReceivablesDebtSignal(text)) {
const receivablesDebtLifecycleSignal =
hasReceivablesDebtLifecycleSignal(text) || hasFlexibleReceivablesDebtSignal(text);
const reasons = ["receivables_signal_detected"]; const reasons = ["receivables_signal_detected"];
if (receivablesDebtLifecycleSignal) { if (receivablesDebtLifecycleSignal) {
reasons.push("receivables_debt_lifecycle_signal_detected"); reasons.push("receivables_debt_lifecycle_signal_detected");
if (hasFlexibleReceivablesDebtSignal(text)) {
reasons.push("receivables_signal_detected_flexible_phrase");
}
} }
return { return {
intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties", intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties",
@ -1488,11 +1536,15 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
}; };
} }
if (hasAny(text, PAYABLES_STRONG)) { if (hasAny(text, PAYABLES_STRONG) || hasFlexiblePayablesDebtSignal(text)) {
const reasons = ["payables_signal_detected"]; const reasons = ["payables_signal_detected"];
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text); const payablesDebtLifecycleSignal =
hasPayablesDebtLifecycleSignal(text) || hasFlexiblePayablesDebtSignal(text);
if (payablesDebtLifecycleSignal) { if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected"); reasons.push("payables_debt_lifecycle_signal_detected");
if (hasFlexiblePayablesDebtSignal(text)) {
reasons.push("payables_signal_detected_flexible_phrase");
}
} }
return { return {
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties", intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",

View File

@ -38,6 +38,7 @@ const RESULT_SET_TYPE_BY_INTENT: Partial<Record<AddressIntent, AddressResultSetT
supplier_payouts_profile: "counterparty_list", supplier_payouts_profile: "counterparty_list",
list_payables_counterparties: "counterparty_list", list_payables_counterparties: "counterparty_list",
payables_confirmed_as_of_date: "balance_snapshot", payables_confirmed_as_of_date: "balance_snapshot",
vat_payable_confirmed_as_of_date: "balance_snapshot",
receivables_confirmed_as_of_date: "balance_snapshot", receivables_confirmed_as_of_date: "balance_snapshot",
list_receivables_counterparties: "counterparty_list", list_receivables_counterparties: "counterparty_list",
list_contracts_by_counterparty: "contract_list", list_contracts_by_counterparty: "contract_list",

View File

@ -802,7 +802,8 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
intent === "account_balance_snapshot" || intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
); );
} }
@ -1023,7 +1024,7 @@ function enforceStrictAccountScopeForIntent(
}; };
} }
function resolveExecutionFiltersForPayablesConfirmedBalance( function resolveExecutionFiltersForConfirmedBalance(
filters: AddressFilterSet, filters: AddressFilterSet,
analysisDate: string | null analysisDate: string | null
): { ): {
@ -1636,6 +1637,8 @@ function buildLimitedOffers(input: {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "receivables_confirmed_as_of_date") { } else if (input.intent === "receivables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76");
} else if (input.intent === "vat_payable_confirmed_as_of_date") {
offers.push("показать подтвержденную сумму НДС к уплате на дату среза по счетам 68*");
} else if (input.intent === "payables_confirmed_as_of_date") { } else if (input.intent === "payables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
} else if (input.intent === "list_payables_counterparties") { } else if (input.intent === "list_payables_counterparties") {
@ -1689,7 +1692,8 @@ function buildLimitedIntentSignalLine(input: {
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату." payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.",
vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату."
}; };
const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = { const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = {
@ -1887,19 +1891,17 @@ function buildLimitedExecutionResult(input: {
undefined, undefined,
resultSemantics.result_mode resultSemantics.result_mode
); );
const exactLimitedReason =
input.intent.intent === "payables_confirmed_as_of_date"
? "exact_payables_mode_limited_response"
: input.intent.intent === "receivables_confirmed_as_of_date"
? "exact_receivables_mode_limited_response"
: input.intent.intent === "vat_payable_confirmed_as_of_date"
? "exact_vat_payable_mode_limited_response"
: null;
const reasons = const reasons =
(input.intent.intent === "payables_confirmed_as_of_date" || input.intent.intent === "receivables_confirmed_as_of_date") && exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason)
!reasonsWithConfirmedFallback.includes( ? [...reasonsWithConfirmedFallback, exactLimitedReason]
input.intent.intent === "payables_confirmed_as_of_date"
? "exact_payables_mode_limited_response"
: "exact_receivables_mode_limited_response"
)
? [
...reasonsWithConfirmedFallback,
input.intent.intent === "payables_confirmed_as_of_date"
? "exact_payables_mode_limited_response"
: "exact_receivables_mode_limited_response"
]
: reasonsWithConfirmedFallback; : reasonsWithConfirmedFallback;
const routeExpectationAudit = const routeExpectationAudit =
input.routeExpectationAudit ?? input.routeExpectationAudit ??
@ -2014,15 +2016,23 @@ export class AddressQueryService {
requestedResultMode === "confirmed_balance"; requestedResultMode === "confirmed_balance";
const confirmedBalanceReceivablesIntent = const confirmedBalanceReceivablesIntent =
intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
const confirmedBalanceVatPayableIntent =
intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
const payablesConfirmedExecution = const payablesConfirmedExecution =
confirmedBalancePayablesIntent confirmedBalancePayablesIntent
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null; : null;
const executionFilters = const executionFilters =
payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; payablesConfirmedExecution?.executionFilters ??
receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ??
filters.extracted_filters;
if ( if (
payablesConfirmedExecution?.asOfDerived && payablesConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0) !(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)
@ -2045,6 +2055,17 @@ export class AddressQueryService {
baseReasons.push("as_of_date_derived_for_confirmed_receivables"); baseReasons.push("as_of_date_derived_for_confirmed_receivables");
} }
} }
if (
vatPayableConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)
) {
if (!filters.warnings.includes("as_of_date_derived_for_confirmed_vat_payable")) {
filters.warnings.push("as_of_date_derived_for_confirmed_vat_payable");
}
if (!baseReasons.includes("as_of_date_derived_for_confirmed_vat_payable")) {
baseReasons.push("as_of_date_derived_for_confirmed_vat_payable");
}
}
const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent); const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent);
const capabilityAudit = buildCapabilityAudit(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({ const shadowRouteAudit = buildShadowRouteAudit({
@ -2120,6 +2141,12 @@ export class AddressQueryService {
) { ) {
baseReasons.push("confirmed_balance_exact_receivables_intent"); baseReasons.push("confirmed_balance_exact_receivables_intent");
} }
if (
intent.intent === "vat_payable_confirmed_as_of_date" &&
!baseReasons.includes("confirmed_balance_exact_vat_payable_intent")
) {
baseReasons.push("confirmed_balance_exact_vat_payable_intent");
}
if ( if (
requestedResultMode === "confirmed_balance" && requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" && recipeIntent === "open_items_by_counterparty_or_contract" &&
@ -3227,12 +3254,17 @@ export class AddressQueryService {
routeExpectationAudit: finalRouteExpectationAudit routeExpectationAudit: finalRouteExpectationAudit
}); });
} }
if ( const exactConfirmedIntent =
((intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") ||
(intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date")) && (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date") ||
factualResultSemantics.balance_confirmed !== true (intent.intent === "vat_payable_confirmed_as_of_date" && composeIntent === "vat_payable_confirmed_as_of_date");
) { if (exactConfirmedIntent && factualResultSemantics.balance_confirmed !== true) {
const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables"; const exactModeName =
intent.intent === "payables_confirmed_as_of_date"
? "payables"
: intent.intent === "receivables_confirmed_as_of_date"
? "receivables"
: "vat_payable";
return buildLimitedExecutionResult({ return buildLimitedExecutionResult({
mode, mode,
shape, shape,
@ -3257,7 +3289,10 @@ export class AddressQueryService {
materializationDropReason: rowDiagnostics.materializationDropReason, materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap", category: "recipe_visibility_gap",
reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`, reasonText: `exact ${exactModeName} mode: confirmed balance was not proven for the requested as-of slice`,
nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance", nextStep:
intent.intent === "vat_payable_confirmed_as_of_date"
? "specify as_of_date/organization or provide VAT settlement registers to prove exact VAT payable balance"
: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance",
limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`],
reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`],
capabilityAudit, capabilityAudit,

View File

@ -72,6 +72,29 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
Сумма __ORDER_DIRECTION__ Сумма __ORDER_DIRECTION__
`; `;
const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"Остатки на дату" КАК Регистратор,
"" КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокКт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
ГДЕ
Остатки.СуммаРазвернутыйОстатокКт > 0
И (__VAT_PAYABLE_ACCOUNTS_MATCH__)
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const BANK_DOCS_QUERY_TEMPLATE = ` const BANK_DOCS_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период, БанкСписание.Дата КАК Период,
@ -566,6 +589,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred", account_scope_mode: "preferred",
query_template: "vat_payable_forecast_profile" query_template: "vat_payable_forecast_profile"
}, },
{
recipe_id: "address_vat_payable_confirmed_as_of_date_v1",
intent: "vat_payable_confirmed_as_of_date",
purpose: "Build confirmed VAT payable snapshot as-of date from balances on VAT payable accounts",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "limit", "sort"],
default_limit: 200,
account_scope: ["68"],
account_scope_mode: "strict",
query_template: "vat_payable_confirmed_as_of_balance_profile"
},
{ {
recipe_id: "address_contracts_by_counterparty_v1", recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty", intent: "list_contracts_by_counterparty",
@ -1057,6 +1091,28 @@ export function buildAddressRecipePlan(
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES)) .replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_19_PREFIXES))
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES)) .replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "vat_payable_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr =
(typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll(
"__VAT_PAYABLE_ACCOUNTS_MATCH__",
buildAccountPrefixPredicate("Остатки.Счет", VAT_PAYABLE_68_PREFIXES)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "contracts_by_counterparty_profile" : recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "payables_confirmed_as_of_balance_profile" : recipe.query_template === "payables_confirmed_as_of_balance_profile"

View File

@ -2510,6 +2510,97 @@ export function composeFactualReply(
}; };
} }
if (intent === "vat_payable_confirmed_as_of_date") {
const asOfDate = resolvePayablesAsOfDate(options);
const confirmedRows = rows.filter((row) => {
const amount = row.amount ?? 0;
if (!Number.isFinite(amount) || amount <= 0) {
return false;
}
const section = extractAccountSectionCode(row.account_kt);
return section === "68";
});
const byAccount = new Map<
string,
{
account: string;
total: number;
operations: number;
lastPeriod: string | null;
refs: Set<string>;
}
>();
for (const row of confirmedRows) {
const account = String(row.account_kt ?? "").trim() || "68*";
const registrator = String(row.registrator ?? "").trim();
const amount = row.amount ?? 0;
const current = byAccount.get(account);
if (!current) {
byAccount.set(account, {
account,
total: amount,
operations: 1,
lastPeriod: row.period,
refs: registrator ? new Set([registrator]) : new Set()
});
continue;
}
current.total += amount;
current.operations += 1;
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
if (registrator) {
current.refs.add(registrator);
}
}
const accountRows = Array.from(byAccount.values())
.filter((item) => Number.isFinite(item.total) && item.total > 0)
.sort((a, b) => b.total - a.total || b.operations - a.operations || a.account.localeCompare(b.account, "ru"));
const totalVatPayable = accountRows.reduce((sum, item) => sum + item.total, 0);
const lines: string[] = [
`Итого подтвержденный НДС к уплате на ${formatDateRu(asOfDate)}: ${formatMoneyRub(totalVatPayable)}.`,
"",
"Блок 1. Статус результата",
"- Результат: подтвержденный срез НДС к уплате по состоянию на дату.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(asOfDate)}.`,
"- Контур: остатки по счетам НДС к уплате (68*).",
"",
"Блок 3. Сводка",
`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`,
`- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`,
"",
"Блок 4. Подтвержденные позиции"
];
if (accountRows.length > 0) {
lines.push(
...accountRows.slice(0, 12).map((item, index) => {
const refs = Array.from(item.refs).slice(0, 2).join("; ");
return `${index + 1}. ${item.account} | остаток НДС к уплате: ${formatMoneyRub(item.total)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${refs ? ` | source refs: ${refs}` : ""}`;
})
);
} else {
lines.push("- Подтвержденный остаток НДС к уплате на дату среза не найден.");
}
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
if (intent === "account_balance_snapshot") { if (intent === "account_balance_snapshot") {
const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0); const movementSum = rows.reduce((sum, row) => sum + (row.amount ?? 0), 0);
const lines = [ const lines = [

View File

@ -66,6 +66,14 @@ 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 ?? ""));
} }
function hasVatCue(text: string): boolean {
return /(?:^|[\s,.;:!?()\-])(?:ндс|vat)(?=$|[\s,.;:!?()\-])/iu.test(String(text ?? ""));
}
function hasVatForecastCue(text: string): boolean {
return /(?:прогноз|forecast|прикин|оцен|план)/iu.test(String(text ?? ""));
}
function hasDocumentSignal(text: string): boolean { function hasDocumentSignal(text: string): boolean {
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? "")); return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? ""));
} }
@ -437,14 +445,26 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (!sameDateRequested && !hasExplicitPeriodLiteral(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
} }
if ( if (
intent === "open_items_by_counterparty_or_contract" || intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" || intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
) { ) {
const hasFollowupSignalForConfirmed = hasAddressFollowupContextSignal(userMessage);
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract); const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = const shouldInheritContract =
@ -474,6 +494,16 @@ function mergeFollowupFilters(
reasons.push("as_of_date_from_followup_context"); reasons.push("as_of_date_from_followup_context");
} }
} }
if (!sameDateRequested && hasFollowupSignalForConfirmed && !hasExplicitPeriodLiteral(userMessage)) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
const currentAsOfDate = toNonEmptyString(merged.as_of_date);
const todayIso = new Date().toISOString().slice(0, 10);
const currentLooksDefaultedToToday = currentAsOfDate === todayIso;
if (inheritedAsOfDate && (!currentAsOfDate || currentLooksDefaultedToToday) && currentAsOfDate !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
} }
if (allTimeRequested) { if (allTimeRequested) {
@ -539,6 +569,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
documents_forming_balance: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"], payables_confirmed_as_of_date: ["as_of_date"],
receivables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"],
vat_payable_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"], list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"],
@ -577,6 +608,18 @@ function deriveIntentWithFollowupContext(
const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor); const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor);
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor); const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty; const hasAnyPartyAnchor = hasPreviousContract || hasPreviousCounterparty;
const isVatFollowup = hasVatCue(normalizedMessage);
if (detectedIntent.intent === "unknown" && isVatFollowup) {
const vatIntent: AddressIntent = hasVatForecastCue(normalizedMessage)
? "vat_payable_forecast"
: "vat_payable_confirmed_as_of_date";
return {
intent: vatIntent,
confidence: "low",
reasons: [...detectedIntent.reasons, "intent_adjusted_to_vat_followup_context"]
};
}
if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) { if (hasOpenItemsHint(normalizedMessage) && hasAnyPartyAnchor) {
return { return {

View File

@ -193,7 +193,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
intent === "account_balance_snapshot" || intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" || intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" ||
intent === "receivables_confirmed_as_of_date" intent === "receivables_confirmed_as_of_date" ||
intent === "vat_payable_confirmed_as_of_date"
) { ) {
return "balance_snapshot"; return "balance_snapshot";
} }

View File

@ -1990,7 +1990,7 @@ function textMojibakeScoreForAddress(value) {
const source = String(value ?? ""); const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length; const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length; const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ’“”•–—™љ›њќћџ]/g) ?? []).length; const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ<EFBFBD>?’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length; const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length; const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2; return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
@ -2000,7 +2000,7 @@ function looksLikeMojibakeForAddress(value) {
if (!source.trim()) { if (!source.trim()) {
return false; return false;
} }
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ’“”•–—™љ›њќћџ]/.test(source)) { if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ<EFBFBD>?’“”•–—™љ›њќћџ]/.test(source)) {
return true; return true;
} }
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) { if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
@ -2205,7 +2205,7 @@ function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? "")) return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase() .toLowerCase()
.replace(/ё/g, "е") .replace(/ё/g, "е")
.replace(/[«»"'`“”„’]/g, " ") .replace(/[«»"'`“”„’<EFBFBD>?]/g, " ")
.replace(/[^a-zа-я0-9\s._-]+/giu, " ")); .replace(/[^a-zа-я0-9\s._-]+/giu, " "));
} }
function normalizeCounterpartyTokenForFollowupMatch(value) { function normalizeCounterpartyTokenForFollowupMatch(value) {
@ -2251,7 +2251,7 @@ function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknow
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) { if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
counterpartyCandidate = parts[1] ?? counterpartyCandidate; counterpartyCandidate = parts[1] ?? counterpartyCandidate;
} }
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»`]+|["'«»`]+$/gu, "")); const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»`<EFBFBD>?]+|["'«»`<EFBFBD>?]+$/gu, ""));
if (!cleanedCandidate || cleanedCandidate.length < 2) { if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue; continue;
} }
@ -2515,61 +2515,133 @@ function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) {
return tokenCount > 0 && tokenCount <= 4; return tokenCount > 0 && tokenCount <= 4;
} }
function hasAddressFollowupContextSignal(userMessage) { function hasAddressFollowupContextSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repaired = repairAddressMojibake(String(userMessage ?? "")); const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase()); const repairedText = compactWhitespace(repaired.toLowerCase());
if (!text) { const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false; return false;
} }
if (hasStandaloneAddressTopicSignal(text)) { const hasAny = (pattern) => samples.some((sample) => pattern.test(sample));
const hasMarker = () => samples.some((sample) => hasFollowupMarker(sample));
const hasPointer = () => samples.some((sample) => hasReferentialPointer(sample));
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
const shortFollowup = minTokens <= 8;
const ultraShortFollowup = minTokens <= 3;
const debtRoleSwapToReceivables = shortFollowup &&
(/^(?:\u0430|a|\u0438|i)\s+(?:\u043d\u0430\u043c\s+)?\u043a\u0442\u043e(?=$|[\s,.;:!?])/iu.test(rawText) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(rawText));
if (debtRoleSwapToReceivables) {
return true;
}
const debtRoleSwapToPayables = shortFollowup &&
(/^(?:\u0430|a|\u0438|i)\s+(?:\u043c\u044b\s+)?\u043a\u043e\u043c\u0443(?=$|[\s,.;:!?])/iu.test(rawText) ||
/^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(rawText));
if (debtRoleSwapToPayables) {
return true;
}
const shortContinuationCue = ultraShortFollowup &&
(/^(?:\u0434\u0430\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0439|\u043f\u043e\u043a\u0430\u0437\u044b\u0432\u044b\u0430\u0439|\u0435\u0449[\u0435\u0451]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText) ||
/^(?:рґр°рір°р|рїрѕрєр°р·срір°р|рїрѕрєр°р·сріср°р|рµс[рµс]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu.test(rawText));
if (shortContinuationCue) {
return true;
}
const shortVatCue = ultraShortFollowup &&
/^(?:(?:\u0430|\u0438)\s+)?(?:(?:\u043f\u043e|po)\s+)?(?:\u043d\u0434\u0441|vat)(?=$|[\s,.;:!?])/iu.test(rawText);
if (shortVatCue) {
return true;
}
if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu)) {
return true;
}
if (shortFollowup && hasAny(/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu)) {
return true;
}
if (ultraShortFollowup && hasAny(/^(?:давай|показывай|показывыай|ещ[её]|also|again|go|ok|okay)(?=$|[\s,.;:!?])/iu)) {
return true;
}
if (hasStandaloneAddressTopicSignal(rawText || repairedText)) {
return false; return false;
} }
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) { if (shouldHandleAsAssistantCapabilityMetaQuery(rawText || repairedText)) {
return false; return false;
} }
if (/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu.test(text)) { if (hasAny(/(?:за\s+вс[её]\s+время|за\s+весь\s+период|за\s+всю\s+истори(?:ю|и)|за\s+любой\s+период|for\s+all\s+time|all\s+time|for\s+entire\s+period|entire\s+period|for\s+any\s+period|any\s+period)/iu)) {
return true; return true;
} }
if (hasReferentialPointer(text)) { if (hasPointer()) {
return true; return true;
} }
if (/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu.test(text)) { if (hasAny(/(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|same\s+date|the\s+same\s+date|as\s+of\s+same\s+date)/iu)) {
return true; return true;
} }
const shortFollowup = countTokens(text) <= 8; if (hasAny(/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu)) {
if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) {
return true; return true;
} }
if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) { if (hasAny(/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu) && minTokens <= 12) {
return true; return true;
} }
if (shortFollowup && hasFollowupMarker(text)) { if (shortFollowup && hasMarker()) {
return true; return true;
} }
if (shortFollowup && /(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu.test(text)) { if (shortFollowup && hasAny(/(?:^|\s)(?:также|тоже|also|same|again|ещ[её]|теперь|then|now)(?=$|[\s,.;:!?])/iu)) {
return true;
}
if (shortFollowup && hasAny(/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu)) {
return true; return true;
} }
if (shortFollowup && if (shortFollowup &&
/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) { hasAny(/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu) &&
return true; hasAny(/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu)) {
}
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
return true; return true;
} }
if (shortFollowup && if (shortFollowup &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) && hasAny(/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) { !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) {
return true; return true;
} }
if (shortFollowup && if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu.test(text) &&
!/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu.test(text)) {
return true;
}
if (shortFollowup && hasPeriodLiteral(text)) {
return true; return true;
} }
return false; return false;
} }
function hasShortDebtMirrorFollowupSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
return false;
}
const minTokens = samples.reduce((min, sample) => Math.min(min, countTokens(sample)), Number.POSITIVE_INFINITY);
if (minTokens > 8) {
return false;
}
return samples.some((sample) => /^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(sample) ||
/^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(sample));
}
function resolveDebtRoleSwapFollowupIntent(userMessage, previousIntent) {
const normalized = compactWhitespace(String(userMessage ?? "").toLowerCase());
if (!normalized || countTokens(normalized) > 10) {
return null;
}
const hasReceivablesCue = /(?:нам\s+кто\s+долж|кто\s+нам\s+долж|кто\s+долж[а-яё]*\s+нам|дебитор|к\s+получению|к\s+взысканию|receivable)/iu.test(normalized) ||
/^(?:а|a|и|i)\s+(?:нам\s+)?кто(?=$|[\s,.;:!?])/iu.test(normalized) ||
/^(?:р°|a|рё|i)\s+(?:рЅр°рј\s+)?рєсрѕ(?=$|[\s,.;:!?])/iu.test(normalized);
const hasPayablesCue = /(?:мы\s+кому\s+долж|кому\s+мы\s+долж|кому\s+долж[а-яё]*\s+мы|кредитор|к\s+уплате|payable)/iu.test(normalized) ||
/^(?:а|a|и|i)\s+(?:мы\s+)?кому(?=$|[\s,.;:!?])/iu.test(normalized) ||
/^(?:р°|a|рё|i)\s+(?:рјс\s+)?рєрѕрјсѓ(?=$|[\s,.;:!?])/iu.test(normalized);
if ((previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties") &&
hasReceivablesCue) {
return "receivables_confirmed_as_of_date";
}
if ((previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties") &&
hasPayablesCue) {
return "payables_confirmed_as_of_date";
}
return null;
}
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) { function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
const previousAddressItem = findLastAddressAssistantItem(items); const previousAddressItem = findLastAddressAssistantItem(items);
const previousAddressDebug = previousAddressItem?.debug ?? null; const previousAddressDebug = previousAddressItem?.debug ?? null;
@ -2578,9 +2650,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
Boolean(followupOffer?.enabled) && Boolean(followupOffer?.enabled) &&
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) || (isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); (toNonEmptyString(alternateMessage) ? isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false));
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage); const sourceIntentHint = toNonEmptyString(previousAddressDebug?.detected_intent);
const debtRoleSwapPrimary = sourceIntentHint ? resolveDebtRoleSwapFollowupIntent(userMessage, sourceIntentHint) : null;
const debtRoleSwapAlternate = sourceIntentHint && toNonEmptyString(alternateMessage)
? resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
const hasPrimaryFollowupSignal = hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary);
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage) const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) ? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate)
: false; : false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null; const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage) const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
@ -2589,7 +2667,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal; const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) || const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false); (toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { if (hasStandaloneAddressTopic &&
!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasImplicitContinuationSignal &&
!hasIndexReferenceSignal) {
return null; return null;
} }
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) { if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
@ -2601,6 +2683,9 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent); const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
let previousIntent = sourceIntent; let previousIntent = sourceIntent;
let followupSelectionMode = "carry_previous_intent"; let followupSelectionMode = "carry_previous_intent";
if (debtRoleSwapIntent) {
previousIntent = debtRoleSwapIntent;
}
if (hasImplicitContinuationSignal) { if (hasImplicitContinuationSignal) {
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents) const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
? toNonEmptyString(followupOffer.suggested_intents[0]) ? toNonEmptyString(followupOffer.suggested_intents[0])
@ -3106,6 +3191,13 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) {
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) || /(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) ||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(source)); /\b\d{2}(?:[.,]\d{1,2})\b/u.test(source));
} }
function hasPredecomposeDiagnosticUncertaintyLead(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
return /^(?:неясно|не\s+ясно|непонятно|не\s+понятно|unclear|not\s+clear|ambiguous|unknown)(?=$|[\s,.;:!?])/iu.test(normalized);
}
function attachAddressPredecomposeContract(meta, sourceMessage) { function attachAddressPredecomposeContract(meta, sourceMessage) {
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? ""); const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
@ -3207,6 +3299,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate); const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate);
const sourceIntentKnown = sourceIntentResolution.intent !== "unknown"; const sourceIntentKnown = sourceIntentResolution.intent !== "unknown";
const candidateIntentKnown = candidateIntentResolution.intent !== "unknown"; const candidateIntentKnown = candidateIntentResolution.intent !== "unknown";
const candidateStartsWithDiagnosticUncertainty = hasPredecomposeDiagnosticUncertaintyLead(candidate);
if (candidateStartsWithDiagnosticUncertainty && sourceIntentKnown) {
return attachAddressPredecomposeContract({
...baseMeta,
attempted: true,
applied: false,
traceId: normalized?.trace_id ?? null,
llmCanonicalCandidateDetected: true,
effectiveMessage: userMessage,
reason: "normalized_fragment_rejected_diagnostic_rewrite",
fallbackRuleHit: null,
sanitizedUserMessage
}, userMessage);
}
const intentConflict = sourceIntentKnown && const intentConflict = sourceIntentKnown &&
candidateIntentKnown && candidateIntentKnown &&
sourceIntentResolution.intent !== candidateIntentResolution.intent; sourceIntentResolution.intent !== candidateIntentResolution.intent;
@ -3473,6 +3579,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
isAddressLlmPreDecomposeCandidate(repairedInputMessage) || isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
hasAccountingSignal(addressInputMessage) || hasAccountingSignal(addressInputMessage) ||
hasAccountingSignal(repairedInputMessage) || hasAccountingSignal(repairedInputMessage) ||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
sameDateAccountFollowupSignal; sameDateAccountFollowupSignal;
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" && const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") && (llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
@ -3491,6 +3599,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
!followupContext && !followupContext &&
!hasClassifierSignal && !hasClassifierSignal &&
!hasIntentSignal && !hasIntentSignal &&
!hasLexicalAddressSignal &&
!strongDataSignalFromRawMessage && !strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) { !strongDataSignalFromEffectiveMessage) {
return { return {
@ -3683,7 +3792,8 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_contracts_by_counterparty", "list_contracts_by_counterparty",
"contract_usage_overview", "contract_usage_overview",
"contract_usage_and_value", "contract_usage_and_value",
"vat_payable_forecast" "vat_payable_forecast",
"vat_payable_confirmed_as_of_date"
]); ]);
export function resolveAssistantOrchestrationDecision(input) { export function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? ""); const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
@ -3764,7 +3874,11 @@ export function resolveAssistantOrchestrationDecision(input) {
const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) || const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) ||
hasAddressFollowupContextSignal(repairedRawUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) ||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage); hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery && const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
!capabilityMetaQuery && !capabilityMetaQuery &&
@ -3879,7 +3993,11 @@ export function resolveAssistantOrchestrationDecision(input) {
hasAddressFollowupContextSignal(rawUserMessage) || hasAddressFollowupContextSignal(rawUserMessage) ||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) || hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
hasAddressFollowupContextSignal(repairedRawUserMessage) || hasAddressFollowupContextSignal(repairedRawUserMessage) ||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage)); hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected && const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) || Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) || (llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
@ -4896,14 +5014,14 @@ async function resolveAssistantDataScopeProbe() {
}; };
} }
const catalogQueryCandidates = [ const catalogQueryCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации", "ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации" "ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК ОрганизацияПредставление <20>?З Справочник.Организации КАК Организации"
]; ];
const movementProbeCandidates = [ const movementProbeCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ", "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Движения.Организация) КАК ОрганизацияПредставление <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ<EFBFBD>?ТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения" "ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения"
]; ];
let lastError = null; let lastError = null;
const catalogFacts = { names: [], refs: [], pairs: [] }; const catalogFacts = { names: [], refs: [], pairs: [] };
@ -5034,7 +5152,7 @@ function buildAssistantOperationalBoundaryReply() {
return [ return [
"Понимаю, что ситуация срочная.", "Понимаю, что ситуация срочная.",
"Я не могу сам настраивать 1С или менять базу/конфигурацию.", "Я не могу сам настраивать 1С или менять базу/конфигурацию.",
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа." "Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/<EFBFBD>?Т-админа."
].join(" "); ].join(" ");
} }
function buildAssistantSafetyRefusalReply() { function buildAssistantSafetyRefusalReply() {

View File

@ -10,6 +10,7 @@ export type AddressIntent =
| "supplier_payouts_profile" | "supplier_payouts_profile"
| "contract_usage_and_value" | "contract_usage_and_value"
| "vat_payable_forecast" | "vat_payable_forecast"
| "vat_payable_confirmed_as_of_date"
| "list_contracts_by_counterparty" | "list_contracts_by_counterparty"
| "list_open_contracts" | "list_open_contracts"
| "list_payables_counterparties" | "list_payables_counterparties"
@ -131,6 +132,7 @@ export interface AddressRecipeDefinition {
| "contract_value_profile" | "contract_value_profile"
| "contracts_by_counterparty_profile" | "contracts_by_counterparty_profile"
| "vat_payable_forecast_profile" | "vat_payable_forecast_profile"
| "vat_payable_confirmed_as_of_balance_profile"
| "payables_confirmed_as_of_balance_profile" | "payables_confirmed_as_of_balance_profile"
| "receivables_confirmed_as_of_balance_profile"; | "receivables_confirmed_as_of_balance_profile";
required_filters: Array<keyof AddressFilterSet>; required_filters: Array<keyof AddressFilterSet>;

View File

@ -24,6 +24,15 @@ describe("address capability policy", () => {
expect(isCapabilityRouteBlocked(decision)).toBe(false); expect(isCapabilityRouteBlocked(decision)).toBe(false);
}); });
it("maps confirmed VAT payable intent to compute exact capability", () => {
const decision = resolveAddressCapabilityRouteDecision("vat_payable_confirmed_as_of_date");
expect(decision.capability_id).toBe("confirmed_vat_payable_as_of_date");
expect(decision.capability_layer).toBe("compute");
expect(decision.capability_route_mode).toBe("exact");
expect(decision.capability_route_enabled).toBe(true);
expect(isCapabilityRouteBlocked(decision)).toBe(false);
});
it("maps document drilldown intent to navigation capability", () => { it("maps document drilldown intent to navigation capability", () => {
const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract"); const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract");
expect(decision.capability_id).toBe("documents_drilldown"); expect(decision.capability_id).toBe("documents_drilldown");

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
describe("debt lifecycle typo tolerance", () => {
it("routes payables typo phrasing to exact confirmed payables intent", () => {
const result = resolveAddressIntent("кому мы должэны на май 2021");
expect(result.intent).toBe("payables_confirmed_as_of_date");
expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected");
});
it("routes receivables typo phrasing to exact confirmed receivables intent", () => {
const result = resolveAddressIntent("кто нам должэны на июль 2020");
expect(result.intent).toBe("receivables_confirmed_as_of_date");
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
});
});

View File

@ -1855,6 +1855,12 @@ describe("address intent resolver expansion (M2.3a)", () => {
expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected"); expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected");
}); });
it("resolves repair phrasing 'кто нам в целом должен' as receivables debt lifecycle intent", () => {
const result = resolveAddressIntent("нет вопрос кто нам в целом должен на денег на эту дату");
expect(result.intent).toBe("receivables_confirmed_as_of_date");
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
});
it("keeps out-of-scope supplier control wording as unknown intent", () => { it("keeps out-of-scope supplier control wording as unknown intent", () => {
const result = resolveAddressIntent( const result = resolveAddressIntent(
"Какие поставщики у нас уже пару месяцев сдают акты без приходок. Может, их надо проконтролировать отдельно чтоб не засорять бухгалтерию дальше?" "Какие поставщики у нас уже пару месяцев сдают акты без приходок. Может, их надо проконтролировать отдельно чтоб не засорять бухгалтерию дальше?"
@ -2022,6 +2028,15 @@ describe("address filter extraction for balance drilldown", () => {
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality"); expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
}); });
it("drops pseudo-counterparty 'деньги на данную дату' from diagnostic rewrite phrase", () => {
const extracted = extractAddressFilters(
"Неясно, кто должен компании деньги на данную дату.",
"unknown"
);
expect(extracted.extracted_filters.counterparty).toBeUndefined();
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
});
it("does not capture narrative filler as counterparty in broad docs-vs-money question", () => { it("does not capture narrative filler as counterparty in broad docs-vs-money question", () => {
const extracted = extractAddressFilters( const extracted = extractAddressFilters(
"В каких случаях мы видим ситуацию, когда документы есть, а денег нет и пока не предвидится?", "В каких случаях мы видим ситуацию, когда документы есть, а денег нет и пока не предвидится?",
@ -3243,6 +3258,26 @@ describe("address decompose stage follow-up carryover", () => {
expect(result?.baseReasons).toContain("address_followup_context_applied"); expect(result?.baseReasons).toContain("address_followup_context_applied");
}); });
it("inherits as_of_date for receivables follow-up without explicit period", () => {
const result = runAddressDecomposeStage("\u0430 \u043d\u0430\u043c \u043a\u0442\u043e \u0434\u043e\u043b\u0436\u0435\u043d?.", {
previous_intent: "receivables_confirmed_as_of_date",
previous_filters: {
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
previous_anchor_type: "unknown",
previous_anchor_value: null
});
expect(result).not.toBeNull();
expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date");
expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30");
expect(result?.filters.extracted_filters.as_of_date).toBe("2017-09-30");
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
expect(result?.baseReasons).toContain("address_followup_context_applied");
});
it("keeps contract scope when follow-up asks for bank operations without explicit anchor", () => { it("keeps contract scope when follow-up asks for bank operations without explicit anchor", () => {
const result = runAddressDecomposeStage("а теперь банковские операции", { const result = runAddressDecomposeStage("а теперь банковские операции", {
previous_intent: "list_documents_by_contract", previous_intent: "list_documents_by_contract",
@ -3415,6 +3450,27 @@ describe("address decompose stage follow-up carryover", () => {
result?.baseReasons?.includes("intent_from_followup_context") result?.baseReasons?.includes("intent_from_followup_context")
).toBe(true); ).toBe(true);
}); });
it("promotes short 'а ндс?' follow-up to confirmed VAT intent with inherited as-of date", () => {
const result = runAddressDecomposeStage("\u0430 \u043d\u0434\u0441?", {
previous_intent: "payables_confirmed_as_of_date",
previous_filters: {
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
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("2017-09-30");
expect(result?.filters.extracted_filters.period_from).toBe("2017-09-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-09-30");
expect(result?.baseReasons).toContain("intent_adjusted_to_vat_followup_context");
expect(result?.baseReasons).toContain("as_of_date_from_followup_context");
});
}); });
describe("address recipe catalog counterparty filtering", () => { describe("address recipe catalog counterparty filtering", () => {

View File

@ -7,12 +7,36 @@ import { evaluateAddressRouteExpectation } from "../src/services/addressRouteExp
import { AddressQueryService } from "../src/services/addressQueryService"; import { AddressQueryService } from "../src/services/addressQueryService";
describe("receivables confirmed as-of route", () => { describe("receivables confirmed as-of route", () => {
it("routes canonical debtor phrasing into exact receivables intent", () => {
const result = resolveAddressIntent("кто является дебитором компании по состоянию на июль 2020 года");
expect(result.intent).toBe("receivables_confirmed_as_of_date");
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
});
it("keeps exact receivables route for canonical debtor phrasing in runtime", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("кто является дебитором компании по состоянию на июль 2020 года");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("receivables_confirmed_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.limited_reason_category).not.toBe("missing_anchor");
});
it("routes 'кто нам должен' wording into exact receivables intent", () => { it("routes 'кто нам должен' wording into exact receivables intent", () => {
const result = resolveAddressIntent("кто нам должен на июль 2020"); const result = resolveAddressIntent("кто нам должен на июль 2020");
expect(result.intent).toBe("receivables_confirmed_as_of_date"); expect(result.intent).toBe("receivables_confirmed_as_of_date");
expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected");
}); });
it("drops low-quality counterparty anchor from as-of debtor phrasing", () => {
const extracted = extractAddressFilters(
"кто является дебитором компании по состоянию на июль 2020 года",
"receivables_confirmed_as_of_date"
);
expect(extracted.extracted_filters.counterparty).toBeUndefined();
});
it("selects confirmed receivables recipe and builds balance query", () => { it("selects confirmed receivables recipe and builds balance query", () => {
const filters = extractAddressFilters("кто нам должен на июль 2020", "receivables_confirmed_as_of_date").extracted_filters; const filters = extractAddressFilters("кто нам должен на июль 2020", "receivables_confirmed_as_of_date").extracted_filters;
const selected = selectAddressRecipe("receivables_confirmed_as_of_date", filters); const selected = selectAddressRecipe("receivables_confirmed_as_of_date", filters);

View File

@ -34,6 +34,17 @@ describe("address route expectations contract", () => {
expect(audit.reason).toBe("route_expectation_matched"); expect(audit.reason).toBe("route_expectation_matched");
}); });
it("matches expected recipe and result mode for exact VAT payable route", () => {
const audit = evaluateAddressRouteExpectation({
intent: "vat_payable_confirmed_as_of_date",
selectedRecipe: "address_vat_payable_confirmed_as_of_date_v1",
requestedResultMode: "confirmed_balance",
resultMode: "confirmed_balance"
});
expect(audit.status).toBe("matched");
expect(audit.reason).toBe("route_expectation_matched");
});
it("detects selected recipe mismatch", () => { it("detects selected recipe mismatch", () => {
const audit = evaluateAddressRouteExpectation({ const audit = evaluateAddressRouteExpectation({
intent: "payables_confirmed_as_of_date", intent: "payables_confirmed_as_of_date",

View File

@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { resolveAddressIntent } from "../src/services/addressIntentResolver";
import { extractAddressFilters } from "../src/services/addressFilterExtractor";
import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog";
import { resolveAddressCapabilityRouteDecision } from "../src/services/addressCapabilityPolicy";
import { evaluateAddressRouteExpectation } from "../src/services/addressRouteExpectations";
import { AddressQueryService } from "../src/services/addressQueryService";
describe("vat payable confirmed as-of route", () => {
it("routes VAT payable question into exact confirmed intent", () => {
const result = resolveAddressIntent("сколько НДС к уплате на март 2020");
expect(result.intent).toBe("vat_payable_confirmed_as_of_date");
expect(result.reasons).toContain("vat_payable_confirmed_signal_detected");
});
it("keeps VAT forecast intent when explicit forecast wording is used", () => {
const result = resolveAddressIntent("какой прогноз оплаты ндс на март 2020");
expect(result.intent).toBe("vat_payable_forecast");
expect(result.reasons).toContain("forecast_tax_signal_detected");
});
it("derives as_of_date for confirmed VAT route from period boundary", () => {
const extracted = extractAddressFilters("сколько НДС к уплате на март 2020", "vat_payable_confirmed_as_of_date");
expect(extracted.extracted_filters.period_from).toBe("2020-03-01");
expect(extracted.extracted_filters.period_to).toBe("2020-03-31");
expect(extracted.extracted_filters.as_of_date).toBe("2020-03-31");
});
it("selects confirmed VAT recipe and builds balance query", () => {
const filters = extractAddressFilters("сколько НДС к уплате на март 2020", "vat_payable_confirmed_as_of_date").extracted_filters;
const selected = selectAddressRecipe("vat_payable_confirmed_as_of_date", filters);
expect(selected.selected_recipe?.recipe_id).toBe("address_vat_payable_confirmed_as_of_date_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, filters);
expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки");
expect(plan.query).toContain("СуммаРазвернутыйОстатокКт");
expect(plan.query).toContain("Остатки.Счет");
expect(plan.query).toContain("68.02");
});
it("exposes compute exact capability and route expectation for confirmed VAT route", () => {
const capability = resolveAddressCapabilityRouteDecision("vat_payable_confirmed_as_of_date");
expect(capability.capability_id).toBe("confirmed_vat_payable_as_of_date");
expect(capability.capability_layer).toBe("compute");
expect(capability.capability_route_mode).toBe("exact");
const expectation = evaluateAddressRouteExpectation({
intent: "vat_payable_confirmed_as_of_date",
selectedRecipe: "address_vat_payable_confirmed_as_of_date_v1",
requestedResultMode: "confirmed_balance",
resultMode: "confirmed_balance"
});
expect(expectation.status).toBe("matched");
});
it("uses exact VAT route in runtime for monthly as-of query", 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(result?.debug.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.route_expectation_status).toBe("matched");
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
});

View File

@ -1081,6 +1081,340 @@ describe("assistant address follow-up carryover", () => {
expect(String(calls[0].message).toLowerCase()).toContain("свк"); expect(String(calls[0].message).toLowerCase()).toContain("свк");
expect(chatClient.chat).toHaveBeenCalledTimes(0); expect(chatClient.chat).toHaveBeenCalledTimes(0);
}); });
it("keeps debt lifecycle follow-up context for 'а нам кто должен?.' after payables as-of answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage =
"\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017";
const followupMessage =
"\u0430 \u043d\u0430\u043c \u043a\u0442\u043e \u0434\u043e\u043b\u0436\u0435\u043d?.";
const payablesResult = buildAddressLaneResult({
reply_text:
"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u0441\u0442\u0432 \u043a \u043e\u043f\u043b\u0430\u0442\u0435 \u043d\u0430 30.09.2017",
debug: {
...buildAddressLaneResult().debug,
query_shape: "UNKNOWN",
query_shape_confidence: "low",
detected_intent: "payables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
selected_recipe: "address_payables_confirmed_as_of_date_v1",
response_type: "FACTUAL_LIST",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true,
reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"]
}
});
const receivablesResult = buildAddressLaneResult({
reply_text:
"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u043d\u044b\u0439 \u0441\u0440\u0435\u0437 \u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\u043e\u0439 \u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u0438 \u043d\u0430 30.09.2017",
debug: {
...buildAddressLaneResult().debug,
query_shape: "UNKNOWN",
query_shape_confidence: "low",
detected_intent: "receivables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
response_type: "FACTUAL_LIST",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true,
reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"]
}
});
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return payablesResult;
}
if (message === followupMessage) {
if (!options?.followupContext) {
return null;
}
return receivablesResult;
}
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-debt-${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("receivables_confirmed_as_of_date");
expect(second.debug?.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date");
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("mirrors receivables->payables for short follow-up 'a мы кому' and keeps as-of date", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage = "кто нам должен на сентябрь 2017";
const followupMessage = "a мы кому";
const receivablesResult = buildAddressLaneResult({
reply_text: "Подтвержденный срез дебиторской задолженности на 30.09.2017",
debug: {
...buildAddressLaneResult().debug,
query_shape: "UNKNOWN",
query_shape_confidence: "low",
detected_intent: "receivables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
selected_recipe: "address_receivables_confirmed_as_of_date_v1",
response_type: "FACTUAL_LIST",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true,
reasons: ["receivables_debt_lifecycle_signal_detected", "confirmed_balance_exact_receivables_intent"]
}
});
const payablesResult = buildAddressLaneResult({
reply_text: "Подтвержденный срез обязательств к оплате на 30.09.2017",
debug: {
...buildAddressLaneResult().debug,
query_shape: "UNKNOWN",
query_shape_confidence: "low",
detected_intent: "payables_confirmed_as_of_date",
detected_intent_confidence: "high",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
selected_recipe: "address_payables_confirmed_as_of_date_v1",
response_type: "FACTUAL_LIST",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true,
reasons: ["payables_debt_lifecycle_signal_detected", "confirmed_balance_exact_payables_intent"]
}
});
const addressQueryService = {
tryHandle: vi.fn(async (message: string, options?: any) => {
calls.push({ message, options });
if (message === firstMessage) {
return receivablesResult;
}
if (message === followupMessage) {
if (!options?.followupContext) {
return null;
}
if (options?.followupContext?.previous_intent !== "payables_confirmed_as_of_date") {
return null;
}
return payablesResult;
}
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-debt-mirror-${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("payables_confirmed_as_of_date");
expect(second.debug?.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1");
expect(calls).toHaveLength(2);
expect(calls[1].message).toBe(followupMessage);
expect(calls[1].options?.followupContext?.previous_intent).toBe("payables_confirmed_as_of_date");
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-30");
expect(normalizerService.normalize).not.toHaveBeenCalled();
});
it("keeps short VAT follow-up in address lane after debt as-of answer", async () => {
const calls: Array<{ message: string; options?: any }> = [];
const firstMessage =
"\u043a\u043e\u043c\u0443 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017";
const followupMessage = "\u0430 \u043d\u0434\u0441?";
const payablesResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "payables_confirmed_as_of_date",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
selected_recipe: "address_payables_confirmed_as_of_date_v1",
response_type: "FACTUAL_LIST",
requested_result_mode: "confirmed_balance",
result_mode: "confirmed_balance",
balance_confirmed: true
}
});
const vatResult = buildAddressLaneResult({
debug: {
...buildAddressLaneResult().debug,
detected_intent: "vat_payable_confirmed_as_of_date",
extracted_filters: {
sort: "period_desc",
limit: 20,
period_from: "2017-09-01",
period_to: "2017-09-30",
as_of_date: "2017-09-30"
},
selected_recipe: "address_vat_payable_confirmed_as_of_date_v1",
response_type: "FACTUAL_LIST",
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 payablesResult;
}
if (!options?.followupContext) {
return null;
}
return vatResult;
})
} 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-${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_payable_confirmed_as_of_date");
expect(second.debug?.selected_recipe).toBe("address_vat_payable_confirmed_as_of_date_v1");
expect(calls).toHaveLength(2);
expect(typeof calls[1].message).toBe("string");
expect(String(calls[1].message).length).toBeGreaterThan(0);
expect(calls[1].options?.followupContext?.previous_filters?.as_of_date).toBe("2017-09-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

@ -442,6 +442,105 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_substitution"); expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_substitution");
}); });
it("rejects diagnostic canonical rewrite like 'Неясно...' for debt-intent repair message", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const sourceMessage = "нет вопрос кто нам в целом должен на денег на эту дату";
const candidateMessage = "Неясно, кто должен компании деньги на данную дату.";
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-diagnostic-rewrite",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: sourceMessage,
message_in_scope: true,
scope_confidence: "medium",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: sourceMessage,
normalized_fragment_text: candidateMessage,
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: [],
account_hints: [],
document_hints: [],
register_hints: [],
time_scope: {
type: "implicit",
value: null,
confidence: "low"
},
flags: {
has_multi_entity_scope: false,
asks_for_chain_explanation: false,
asks_for_ranking_or_top: false,
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
}
},
raw_model_output: null,
validation: { passed: true, errors: [] },
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
latency_ms: 10,
prompt_version: "normalizer_v2_0_2",
schema_version: "v2_0_2",
request_count_for_case: 1
}))
} as any;
const sessions = new AssistantSessionStore();
const service = new AssistantService(
normalizerService,
sessions as any,
{} as any,
{ persistSession: vi.fn() } as any,
addressQueryService
);
const response = await service.handleMessage({
session_id: `asst-predecompose-diagnostic-rewrite-${Date.now()}`,
user_message: sourceMessage,
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(response.reply_type).toBe("factual");
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe(sourceMessage);
expect(calls[0].message).not.toBe(candidateMessage);
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_diagnostic_rewrite");
expect(String(response.debug?.llm_decomposition_effective_message ?? "")).toBe(sourceMessage);
});
it("rejects follow-up intent injection when llm adds documents to same-date account prompt", async () => { it("rejects follow-up intent injection when llm adds documents to same-date account prompt", async () => {
const calls: Array<{ message: string }> = []; const calls: Array<{ message: string }> = [];
const addressQueryService = { const addressQueryService = {
@ -1054,7 +1153,8 @@ describe("assistant address llm pre-decompose candidate preference", () => {
[ [
"llm_predecompose_semantic_guard_rejected", "llm_predecompose_semantic_guard_rejected",
"llm_predecompose_unsupported_mode", "llm_predecompose_unsupported_mode",
"address_signal_unsupported_intent_fallback_to_deep" "address_signal_unsupported_intent_fallback_to_deep",
"non_domain_query_indexed"
] ]
).toContain(response.debug?.address_tool_gate_reason); ).toContain(response.debug?.address_tool_gate_reason);
}); });

View File

@ -315,6 +315,36 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("address_lane_triggered"); expect(decision.livingReason).toBe("address_lane_triggered");
}); });
it("keeps short mirror follow-up 'a мы кому' in address lane instead of non-domain chat", () => {
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "a мы кому",
effectiveAddressUserMessage: "a мы кому",
followupContext: null,
llmPreDecomposeMeta: {
applied: false,
reason: "normalized_fragment_rejected_semantic_guard",
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "unknown",
intent_confidence: "low"
},
semanticExtractionContract: {
valid: false,
apply_canonical_recommended: false,
reason_codes: ["unsupported_low_confidence_contract"]
}
} 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("routes unsupported turnover-by-organization query to deep analysis", () => { it("routes unsupported turnover-by-organization query to deep analysis", () => {
const decision = resolveAssistantOrchestrationDecision({ const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: "\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", rawUserMessage: "\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",