Compare commits

..

2 Commits

38 changed files with 3209 additions and 277 deletions

View File

@ -1,10 +1,14 @@
# 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",
"updated_at": "2026-04-12T12:00:00.000Z",
"updated_at": "2026-04-12T20:50:00.000Z",
"entries": [
{
"intent": "payables_confirmed_as_of_date",
@ -14,6 +14,12 @@
"capability_layer": "compute",
"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",
"capability_id": "payables_candidates_list",

View File

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

View File

@ -8,7 +8,8 @@ const COMPUTE_EXACT_INTENTS = new Set([
"account_balance_snapshot",
"documents_forming_balance",
"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([
"list_documents_by_counterparty",
@ -39,6 +40,9 @@ function defaultCapabilityId(intent) {
if (intent === "receivables_confirmed_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") {
return "payables_candidates_list";
}
@ -74,6 +78,14 @@ function resolveCapabilityEnabled(intent) {
: "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") {
return {
enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,

View File

@ -574,6 +574,77 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (questionCue && (rankingCue || paymentCue)) {
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));
return meaningfulTokens.length === 0;
}
@ -751,6 +822,9 @@ function requiredFiltersByIntent(intent) {
if (intent === "receivables_confirmed_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" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") {
@ -765,7 +839,8 @@ function usesAsOfPrimaryWindow(intent) {
return (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
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) {
const rawText = String(userMessage ?? "").trim();
@ -928,7 +1003,8 @@ function extractAddressFilters(userMessage, intent) {
if ((intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
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) {
if (filters.period_to) {
filters.as_of_date = filters.period_to;

View File

@ -385,6 +385,22 @@ const CONTRACT_LIST_BY_COUNTERPARTY_HINTS = [
function hasAny(text, patterns) {
return patterns.some((item) => text.includes(item));
}
function hasFlexibleReceivablesDebtSignal(text) {
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) {
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) {
return String(text ?? "")
.toLowerCase()
@ -535,10 +551,20 @@ function hasAccountBalanceSignal(text) {
}
function hasForecastTaxSignal(text) {
const hasForecastLexeme = /(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|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) || (hasVatLexeme && hasVatPayableEstimatePattern);
return hasForecastLexeme && hasTaxLexeme;
}
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) {
if (hasAny(text, PERIOD_COVERAGE_PROFILE_HINTS)) {
@ -862,7 +888,7 @@ function hasSupplierTailRiskSignal(text) {
return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
}
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) {
return false;
}
@ -874,7 +900,7 @@ function hasPayablesDebtLifecycleSignal(text) {
return true;
}
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) {
return false;
}
@ -1258,11 +1284,21 @@ function resolveAddressIntent(userMessage) {
reasons: ["forecast_tax_signal_detected"]
};
}
if (hasAny(text, RECEIVABLES_STRONG)) {
const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text);
if (hasVatPayableConfirmedSignal(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"];
if (receivablesDebtLifecycleSignal) {
reasons.push("receivables_debt_lifecycle_signal_detected");
if (hasFlexibleReceivablesDebtSignal(text)) {
reasons.push("receivables_signal_detected_flexible_phrase");
}
}
return {
intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties",
@ -1270,11 +1306,14 @@ function resolveAddressIntent(userMessage) {
reasons
};
}
if (hasAny(text, PAYABLES_STRONG)) {
if (hasAny(text, PAYABLES_STRONG) || hasFlexiblePayablesDebtSignal(text)) {
const reasons = ["payables_signal_detected"];
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text) || hasFlexiblePayablesDebtSignal(text);
if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected");
if (hasFlexiblePayablesDebtSignal(text)) {
reasons.push("payables_signal_detected_flexible_phrase");
}
}
return {
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",

View File

@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.executeAddressMcpQuery = executeAddressMcpQuery;
exports.executeAddressMcpMetadata = executeAddressMcpMetadata;
const config_1 = require("../config");
const iconv_lite_1 = __importDefault(require("iconv-lite"));
function toStringValue(value) {
@ -165,7 +166,7 @@ function parseRowsFromTextTable(source) {
}
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload) {
function parseRowsPayload(payload, options = {}) {
if (!payload || typeof payload !== "object") {
return {
ok: false,
@ -208,6 +209,13 @@ function parseExecutePayload(payload) {
error: null
};
}
if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) {
return {
ok: true,
rows: [normalizeMojibakeValue(source.data)],
error: null
};
}
return {
ok: true,
rows: [],
@ -261,7 +269,7 @@ async function executeAddressMcpQuery(input) {
};
}
const payload = responseText.trim() ? JSON.parse(responseText) : {};
const parsed = parseExecutePayload(payload);
const parsed = parseRowsPayload(payload);
if (!parsed.ok) {
return {
fetched_rows: 0,
@ -294,3 +302,90 @@ async function executeAddressMcpQuery(input) {
clearTimeout(timeout);
}
}
async function executeAddressMcpMetadata(input) {
const endpoint = buildMcpUrl("/api/get_metadata");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(300, config_1.ASSISTANT_MCP_TIMEOUT_MS));
try {
const body = {};
if (typeof input.filter === "string" && input.filter.trim().length > 0) {
body.filter = input.filter.trim();
}
if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) {
body.meta_type = input.meta_type.trim();
}
else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) {
const values = input.meta_type
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
if (values.length > 0) {
body.meta_type = values;
}
}
if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) {
body.name_mask = input.name_mask.trim();
}
if (typeof input.limit === "number" && Number.isFinite(input.limit)) {
body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit)));
}
if (typeof input.offset === "number" && Number.isFinite(input.offset)) {
body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset)));
}
if (Array.isArray(input.sections) && input.sections.length > 0) {
const sections = input.sections
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
if (sections.length > 0) {
body.sections = sections;
}
}
if (input.extension_name !== undefined) {
body.extension_name = input.extension_name;
}
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify(body),
signal: controller.signal
});
const responseText = await response.text();
if (!response.ok) {
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
};
}
const payload = responseText.trim() ? JSON.parse(responseText) : {};
const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true });
if (!parsed.ok) {
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: parsed.error
};
}
return {
fetched_rows: parsed.rows.length,
raw_rows: parsed.rows,
rows: parsed.rows,
error: null
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP fetch failed: ${message}`
};
}
finally {
clearTimeout(timeout);
}
}

View File

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

View File

@ -15,6 +15,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const VAT_METADATA_PROBE_LIMIT = 100;
const VAT_SOURCE_PROBE_MAX_OBJECTS = 8;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "РегистрСведений", "Документ"];
const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"];
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@ -122,6 +126,262 @@ function valueAsString(value) {
}
return String(value);
}
function normalizeIsoDateForQuery(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
const candidate = new Date(Date.UTC(year, month - 1, day));
if (candidate.getUTCFullYear() !== year ||
candidate.getUTCMonth() + 1 !== month ||
candidate.getUTCDate() !== day) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function toDateTimeExprForQuery(isoDate) {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, 23, 59, 59)`;
}
function shouldProbeVatSourcesForForecast(userMessage) {
const text = String(userMessage ?? "")
.toLowerCase()
.replace(/ё/g, "е");
if (!text.trim()) {
return false;
}
return /(?:в\s+налогов|почему|из\s+чего|источн|декларац|книга\s+продаж|книга\s+покупок|вычет|восстанов)/iu.test(text);
}
function detectVatMetadataObjectType(fullName) {
const normalized = String(fullName ?? "").trim();
if (!normalized) {
return null;
}
if (normalized.startsWith("Документ.")) {
return "document";
}
if (normalized.startsWith("РегистрНакопления.") || normalized.startsWith("РегистрСведений.")) {
return "register";
}
return null;
}
function extractVatMetadataObjects(rows) {
const out = [];
const seen = new Set();
for (const row of rows) {
const fullName = valueAsString(row.ПолноеИмя ?? row.full_name ?? row.FullName ?? row.Имя ?? row.name ?? row.Name).trim() || null;
if (!fullName) {
continue;
}
const objectType = detectVatMetadataObjectType(fullName);
if (!objectType) {
continue;
}
if (seen.has(fullName)) {
continue;
}
seen.add(fullName);
const synonym = valueAsString(row.Синоним ?? row.synonym ?? row.Synonym ?? row.Представление ?? row.presentation).trim() || null;
out.push({
fullName,
synonym,
objectType
});
}
return out;
}
function scoreVatMetadataObject(item) {
const fullName = item.fullName.toLowerCase();
const synonym = String(item.synonym ?? "").toLowerCase();
let score = item.objectType === "register" ? 120 : 80;
if (fullName.includes("книгипродаж") || synonym.includes("продаж")) {
score += 60;
}
if (fullName.includes("книгипокупок") || synonym.includes("покуп")) {
score += 60;
}
if (fullName.includes("начислен") || synonym.includes("начислен")) {
score += 40;
}
if (fullName.includes("предъявлен") || synonym.includes("предъявлен")) {
score += 40;
}
if (fullName.includes("оплатындс") || synonym.includes("в бюджет")) {
score += 35;
}
if (fullName.includes("декларац")) {
score -= 25;
}
if (fullName.includes("пояснен")) {
score -= 25;
}
return score;
}
function buildVatObjectProbeQuery(object, asOfExpr) {
if (object.objectType === "document") {
return `
ВЫБРАТЬ ПЕРВЫЕ 1
Док.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Док.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
${object.fullName} КАК Док
ГДЕ
Док.Дата <= ${asOfExpr}
УПОРЯДОЧИТЬ ПО
Док.Дата УБЫВ
`.trim();
}
return `
ВЫБРАТЬ ПЕРВЫЕ 1
Движения.Период КАК Период,
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
${object.fullName} КАК Движения
ГДЕ
Движения.Период <= ${asOfExpr}
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
`.trim();
}
async function probeVatDirectSources(filters) {
const asOfDate = normalizeIsoDateForQuery(filters.as_of_date) ??
normalizeIsoDateForQuery(filters.period_to) ??
normalizeIsoDateForQuery(filters.period_from);
if (!asOfDate) {
return {
status: "skipped",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["as_of_date_not_resolved_for_vat_probe"]
};
}
const asOfExpr = toDateTimeExprForQuery(asOfDate);
if (!asOfExpr) {
return {
status: "skipped",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["as_of_expr_not_resolved_for_vat_probe"]
};
}
const metadataRequests = VAT_METADATA_PROBE_TYPES.flatMap((metaType) => VAT_METADATA_PROBE_MASKS.map((nameMask) => ({
meta_type: metaType,
name_mask: nameMask,
limit: VAT_METADATA_PROBE_LIMIT
})));
const metadataResponses = await Promise.all(metadataRequests.map((request) => (0, addressMcpClient_1.executeAddressMcpMetadata)(request)));
const metadataErrors = [];
const metadataObjectsBuffer = [];
for (const [index, response] of metadataResponses.entries()) {
const request = metadataRequests[index];
if (response.error) {
metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`);
continue;
}
metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows));
}
const deduplicatedObjects = new Map();
for (const item of metadataObjectsBuffer) {
const existing = deduplicatedObjects.get(item.fullName);
if (!existing) {
deduplicatedObjects.set(item.fullName, item);
continue;
}
if (!existing.synonym && item.synonym) {
deduplicatedObjects.set(item.fullName, {
...existing,
synonym: item.synonym
});
}
}
const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()).sort((a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru"));
const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS);
const probeRows = [];
for (const object of metadataObjects) {
const probeQuery = buildVatObjectProbeQuery(object, asOfExpr);
const probeResult = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: probeQuery,
limit: 1
});
if (probeResult.error) {
probeRows.push({
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: probeResult.error
});
continue;
}
const firstRow = probeResult.raw_rows[0] ?? null;
const lastPeriod = firstRow !== null
? valueAsString(firstRow.Период ?? firstRow.period).trim() ||
null
: null;
const sampleRegistrator = firstRow !== null
? valueAsString(firstRow.Регистратор ??
firstRow.registrator ??
firstRow.Registrator).trim() || null
: null;
probeRows.push({
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: probeResult.raw_rows.length > 0 ? "ok" : "empty",
rowsFetched: probeResult.fetched_rows,
lastPeriod,
sampleRegistrator
});
}
const status = metadataResponses.every((item) => item.error) ? "error" : "ok";
const allErrors = [
...metadataErrors,
...probeRows
.filter((item) => item.status === "error")
.map((item) => `${item.fullName}: ${valueAsString(item.error).slice(0, 120)}`)
];
return {
status,
objectsTotal: discoveredMetadataObjects.length,
documentsTotal: discoveredMetadataObjects.filter((item) => item.objectType === "document").length,
registersTotal: discoveredMetadataObjects.filter((item) => item.objectType === "register").length,
probedSources: probeRows,
errors: allErrors
};
}
function transliterateCyrillicToLatin(value) {
const map = {
а: "a",
@ -648,7 +908,8 @@ function isConfirmedBalanceIntent(intent) {
return (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
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) {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
@ -818,7 +1079,7 @@ function enforceStrictAccountScopeForIntent(plan, intent) {
account_scope_mode: "strict"
};
}
function resolveExecutionFiltersForPayablesConfirmedBalance(filters, analysisDate) {
function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) {
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
@ -1302,6 +1563,9 @@ function buildLimitedOffers(input) {
else if (input.intent === "receivables_confirmed_as_of_date") {
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") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
}
@ -1348,7 +1612,8 @@ function buildLimitedIntentSignalLine(input) {
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату."
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату.",
vat_payable_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез НДС к уплате на дату."
};
const byShape = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
@ -1474,16 +1739,15 @@ function buildLimitedExecutionResult(input) {
});
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
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") &&
!reasonsWithConfirmedFallback.includes(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"
]
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 = exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason)
? [...reasonsWithConfirmedFallback, exactLimitedReason]
: reasonsWithConfirmedFallback;
const routeExpectationAudit = input.routeExpectationAudit ??
buildRouteExpectationAudit({
@ -1591,13 +1855,20 @@ class AddressQueryService {
const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_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
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: 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 &&
!(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")) {
@ -1616,6 +1887,15 @@ class AddressQueryService {
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 capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({
@ -1646,12 +1926,15 @@ class AddressQueryService {
shadowRouteAudit
});
}
const composeOptionsFromFilters = (filterSet) => ({
const composeOptionsFromFilters = (filterSet, options = {}) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode
requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
@ -1686,6 +1969,10 @@ class AddressQueryService {
!baseReasons.includes("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" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
@ -2611,7 +2898,29 @@ class AddressQueryService {
shadowRouteAudit
});
}
const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters));
const vatProbeRequired = composeIntent === "vat_payable_confirmed_as_of_date" ||
(composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage));
const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null;
const shouldEmphasizeNumbers = composeIntent === "vat_payable_forecast" ||
composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date";
const shouldUseRubCurrency = composeIntent === "vat_payable_forecast";
const factual = (0, composeStage_1.composeFactualReply)(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters, {
vatDirectSourceProbe,
emphasizeNumbers: shouldEmphasizeNumbers,
useRubCurrency: shouldUseRubCurrency
}));
const vatProbeLimitations = vatProbeRequired && vatDirectSourceProbe
? vatDirectSourceProbe.status === "error"
? ["vat_source_probe_error"]
: vatDirectSourceProbe.status === "skipped"
? ["vat_source_probe_skipped"]
: vatDirectSourceProbe.errors.length > 0
? ["vat_source_probe_partial_errors"]
: []
: [];
const factualLimitations = [...filters.warnings, ...vatProbeLimitations];
const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: composeIntent,
selectedRecipe: effectiveRecipeId,
@ -2658,10 +2967,15 @@ class AddressQueryService {
routeExpectationAudit: finalRouteExpectationAudit
});
}
if (((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")) &&
factualResultSemantics.balance_confirmed !== true) {
const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables";
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 === "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"
: intent.intent === "receivables_confirmed_as_of_date"
? "receivables"
: "vat_payable";
return buildLimitedExecutionResult({
mode,
shape,
@ -2686,7 +3000,9 @@ class AddressQueryService {
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
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`],
reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`],
capabilityAudit,
@ -2753,7 +3069,7 @@ class AddressQueryService {
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
...factualResultSemantics,
limitations: filters.warnings,
limitations: factualLimitations,
reasons: withConfirmedBalanceFallbackReason(reasonsWithRouteExpectation, requestedResultMode, factual.semantics, factualResultSemantics.result_mode)
}
};

View File

@ -66,6 +66,28 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
УПОРЯДОЧИТЬ ПО
Сумма __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 = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
@ -549,6 +571,17 @@ const BASE_RECIPES = [
account_scope_mode: "preferred",
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",
intent: "list_contracts_by_counterparty",
@ -960,27 +993,27 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", config_1.VAT_PAYABLE_19_PREFIXES))
.replaceAll("__VAT19_KT_MATCH__", buildAccountPrefixPredicate("Движения.СчетКт", config_1.VAT_PAYABLE_19_PREFIXES))
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "payables_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)
: 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_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 PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "receivables_confirmed_as_of_balance_profile"
(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"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "payables_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)
@ -992,23 +1025,41 @@ function buildAddressRecipePlan(recipe, filters) {
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
return PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
: recipe.query_template === "receivables_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 RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: MOVEMENTS_QUERY_TEMPLATE
.replace("__LIMIT__", String(resolvedLimit))
.replace("__WHERE_CLAUSE__", (() => {
const extraConditions = [];
const accountCondition = buildMovementAccountCondition(filters);
if (accountCondition) {
extraConditions.push(accountCondition);
}
return buildWhereClause(filters, "Движения.Период", extraConditions);
})())
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
return {
recipe,
query,

View File

@ -101,8 +101,35 @@ function formatNumberWithDots(value, fractionDigits = 0) {
function formatMoneyRub(value) {
return `${formatNumberWithDots(value, 2)}`;
}
function formatVatProbeStatusRu(status) {
if (status === "ok") {
return "есть движения";
}
if (status === "empty") {
return "движения не найдены";
}
return "ошибка запроса";
}
function emphasizeNumericTokens(line) {
return line;
if (!line) {
return line;
}
const chunks = line.split(/(`[^`]*`)/g);
return chunks
.map((chunk, index) => {
if (index % 2 === 1) {
return chunk;
}
return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => {
const before = offset > 0 ? source[offset - 1] : "";
const after = offset + match.length < source.length ? source[offset + match.length] : "";
if (before === "*" || after === "*") {
return match;
}
return `**${match}**`;
});
})
.join("");
}
function parseIsoDateToken(value) {
const source = String(value ?? "").trim();
@ -135,6 +162,21 @@ function buildIsoDateWithMonthShift(year, monthOneBased, day, monthShift = 0) {
const date = new Date(Date.UTC(year, monthOneBased - 1 + monthShift, day));
return date.toISOString().slice(0, 10);
}
function shiftIsoDateToNextBusinessDay(isoDate) {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return isoDate;
}
const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day));
for (let guard = 0; guard < 10; guard += 1) {
const dayOfWeek = date.getUTCDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
return date.toISOString().slice(0, 10);
}
date.setUTCDate(date.getUTCDate() + 1);
}
return isoDate;
}
function deriveVatDeadlineCalendar(periodFrom, periodTo) {
const reference = parseIsoDateToken(periodTo) ?? parseIsoDateToken(periodFrom);
if (!reference) {
@ -147,10 +189,10 @@ function deriveVatDeadlineCalendar(periodFrom, periodTo) {
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
const declarationDueDate = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1));
const payment1 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1));
const payment2 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2));
const payment3 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3));
return {
periodLabel: `${quarterNumber} кв. ${reference.year}`,
quarterStart,
@ -270,6 +312,13 @@ function needsVatWhyExplanation(userMessage) {
}
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function needsVatCalendarDetails(userMessage) {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
return /(?:срок|когда|дата\s+уплат|декларац|дол(?:я|ями)|по\s+частям|платежн(?:ый|ого)\s+график)/iu.test(text);
}
function detectRankingLimit(userMessage, fallback = 20) {
const text = normalizeQuestionText(userMessage);
if (!text) {
@ -1147,6 +1196,8 @@ function contractCandidatesFromRows(rows) {
return uniqueStrings(candidates);
}
function composeFactualReply(intent, rows, options = {}) {
const applyNumericEmphasis = (line) => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line);
const joinLines = (lines) => lines.map(applyNumericEmphasis).join("\n");
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map();
for (const row of rows) {
@ -1926,30 +1977,54 @@ function composeFactualReply(intent, rows, options = {}) {
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const formatForecastMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const lines = [
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
`Строк агрегата: ${rows.length}.`,
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
`Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`,
`Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
"Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).",
"",
"База расчета:",
`- Строк агрегата: ${formatNumberWithDots(rows.length)}.`,
`- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`,
`- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`,
`- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`);
if (vatProbe.probedSources.length > 0) {
lines.push(...vatProbe.probedSources.slice(0, 6).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`;
}));
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
lines.push("В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00.");
lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`);
}
else if (vatToPay === 0 && netVatIsEffectivelyZero) {
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
lines.push(`В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.`);
}
else if (vatToPay === 0 && netVat < 0) {
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
lines.push(`В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.`);
}
if (vatToPay === 0) {
lines.push("Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов.");
lines.push("", "Чеклист проверки в 1С (почему к уплате 0):", `1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${periodWindowLabel ?? "расчета"}.`, "2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).", "3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).", "4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.", "5) Убедитесь, что документы проведены, период закрыт корректно и нет неподтвержденных/неперепроведенных документов.");
}
if (vatCalendar) {
if (vatCalendar && shouldShowCalendarDetails) {
const periodWindowLabel = vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
: `${formatDateRu(vatCalendar.quarterStart)}..${formatDateRu(vatCalendar.quarterEnd)}`;
@ -1957,16 +2032,107 @@ function composeFactualReply(intent, rows, options = {}) {
const installmentRaw = vatToPay / 3;
const installmentRounded = Number(installmentRaw.toFixed(2));
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
lines.push(`Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.");
lines.push("", `Период расчета (срез обязательств): ${periodWindowLabel}.`, `Налоговый период: ${vatCalendar.periodLabel}.`, `Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`, `Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`, `Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`, "Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С.");
}
if (explainWhyRequested) {
lines.push("Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`, netVat <= 0
lines.push("", "Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).", `За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`, netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.", "Важно: это оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*; финальную сумму налога подтверждают регистры НДС и декларация.");
}
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
text: joinLines(lines)
};
}
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*)."
];
const vatProbe = options.vatDirectSourceProbe ?? null;
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push("", "Блок 2.1. MCP-проверка VAT-источников", `- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`);
if (vatProbe.probedSources.length > 0) {
lines.push(...vatProbe.probedSources.slice(0, 4).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const suffix = item.status === "ok"
? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}`
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`;
}));
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Блок 2.1. MCP-проверка VAT-источников", "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*).");
}
lines.push("", "Блок 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: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
if (intent === "account_balance_snapshot") {
@ -2082,7 +2248,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@ -2149,7 +2315,7 @@ function composeFactualReply(intent, rows, options = {}) {
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@ -2271,7 +2437,7 @@ function composeFactualReply(intent, rows, options = {}) {
];
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@ -2282,7 +2448,7 @@ function composeFactualReply(intent, rows, options = {}) {
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(fallbackLines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
@ -2293,7 +2459,7 @@ function composeFactualReply(intent, rows, options = {}) {
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",

View File

@ -30,6 +30,12 @@ function hasExplicitPeriodLiteral(text) {
function hasOpenItemsHint(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) {
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");
}
}
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" ||
intent === "list_open_contracts" ||
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 currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract ||
@ -380,6 +398,16 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
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 (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
@ -436,6 +464,7 @@ function resolveMissingRequiredFilters(intent, filters) {
documents_forming_balance: ["account", "as_of_date"],
payables_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"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
@ -466,6 +495,17 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor);
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
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) {
return {
intent: "open_items_by_counterparty_or_contract",

View File

@ -94,7 +94,8 @@ function inferAggregationProfile(intent, shape) {
if (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
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";
}
if (intent === "open_items_by_counterparty_or_contract" ||

View File

@ -2033,7 +2033,7 @@ function textMojibakeScoreForAddress(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/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 doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
@ -2043,7 +2043,7 @@ function looksLikeMojibakeForAddress(value) {
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ’“”•–—™љ›њќћџ]/.test(source)) {
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ<EFBFBD>?’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
@ -2248,7 +2248,7 @@ function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`“”„’]/g, " ")
.replace(/[«»"'`“”„’<EFBFBD>?]/g, " ")
.replace(/[^a-zа-я0-9\s._-]+/giu, " "));
}
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] ?? "")) {
counterpartyCandidate = parts[1] ?? counterpartyCandidate;
}
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`]+|["'«»“”„`]+$/gu, ""));
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`<EFBFBD>?]+|["'«»“”„`<>?]+$/gu, ""));
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
@ -2558,61 +2558,133 @@ function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) {
return tokenCount > 0 && tokenCount <= 4;
}
function hasAddressFollowupContextSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
if (!text) {
const repairedText = compactWhitespace(repaired.toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
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;
}
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) {
if (shouldHandleAsAssistantCapabilityMetaQuery(rawText || repairedText)) {
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;
}
if (hasReferentialPointer(text)) {
if (hasPointer()) {
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;
}
const shortFollowup = countTokens(text) <= 8;
if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) {
if (hasAny(/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu)) {
return true;
}
if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) {
if (hasAny(/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu) && minTokens <= 12) {
return true;
}
if (shortFollowup && hasFollowupMarker(text)) {
if (shortFollowup && hasMarker()) {
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;
}
if (shortFollowup &&
/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) {
return true;
}
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
hasAny(/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu) &&
hasAny(/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu)) {
return true;
}
if (shortFollowup &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) {
hasAny(/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu) &&
!hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) {
return true;
}
if (shortFollowup &&
/(?:^|\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)) {
if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
return true;
}
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) {
const previousAddressItem = findLastAddressAssistantItem(items);
const previousAddressDebug = previousAddressItem?.debug ?? null;
@ -2621,9 +2693,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
Boolean(followupOffer?.enabled) &&
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(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)
? hasAddressFollowupContextSignal(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate)
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
@ -2632,7 +2710,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
if (hasStandaloneAddressTopic &&
!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasImplicitContinuationSignal &&
!hasIndexReferenceSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
@ -2644,6 +2726,9 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
let previousIntent = sourceIntent;
let followupSelectionMode = "carry_previous_intent";
if (debtRoleSwapIntent) {
previousIntent = debtRoleSwapIntent;
}
if (hasImplicitContinuationSignal) {
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
? toNonEmptyString(followupOffer.suggested_intents[0])
@ -3149,6 +3234,13 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) {
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.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) {
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
@ -3250,6 +3342,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate);
const sourceIntentKnown = sourceIntentResolution.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 &&
candidateIntentKnown &&
sourceIntentResolution.intent !== candidateIntentResolution.intent;
@ -3516,6 +3622,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
hasAccountingSignal(addressInputMessage) ||
hasAccountingSignal(repairedInputMessage) ||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
sameDateAccountFollowupSignal;
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
@ -3533,6 +3641,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
!followupContext &&
!hasClassifierSignal &&
!hasIntentSignal &&
!hasLexicalAddressSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return {
@ -3725,7 +3834,8 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_contracts_by_counterparty",
"contract_usage_overview",
"contract_usage_and_value",
"vat_payable_forecast"
"vat_payable_forecast",
"vat_payable_confirmed_as_of_date"
]);
function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
@ -3806,7 +3916,11 @@ function resolveAssistantOrchestrationDecision(input) {
const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) ||
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage);
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
!capabilityMetaQuery &&
@ -3921,7 +4035,11 @@ function resolveAssistantOrchestrationDecision(input) {
hasAddressFollowupContextSignal(rawUserMessage) ||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage));
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
@ -4939,14 +5057,14 @@ async function resolveAssistantDataScopeProbe() {
};
}
const catalogQueryCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации"
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК ОрганизацияПредставление <20>?З Справочник.Организации КАК Организации"
];
const movementProbeCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения"
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Движения.Организация) КАК ОрганизацияПредставление <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ<EFBFBD>?ТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения"
];
let lastError = null;
const catalogFacts = { names: [], refs: [], pairs: [] };
@ -5077,7 +5195,7 @@ function buildAssistantOperationalBoundaryReply() {
return [
"Понимаю, что ситуация срочная.",
"Я не могу сам настраивать 1С или менять базу/конфигурацию.",
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа."
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/<EFBFBD>?Т-админа."
].join(" ");
}
function buildAssistantSafetyRefusalReply() {

View File

@ -27,7 +27,8 @@ const COMPUTE_EXACT_INTENTS = new Set<AddressIntent>([
"account_balance_snapshot",
"documents_forming_balance",
"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>([
"list_documents_by_counterparty",
@ -62,6 +63,9 @@ function defaultCapabilityId(intent: AddressIntent): string {
if (intent === "receivables_confirmed_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") {
return "payables_candidates_list";
}
@ -98,6 +102,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re
: "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") {
return {
enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1,

View File

@ -643,6 +643,92 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (questionCue && (rankingCue || paymentCue)) {
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));
return meaningfulTokens.length === 0;
}
@ -843,6 +929,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "receivables_confirmed_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" ||
intent === "bank_operations_by_counterparty" ||
@ -861,7 +950,8 @@ function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
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 === "documents_forming_balance" ||
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
) {
if (filters.period_to) {

View File

@ -403,6 +403,28 @@ function hasAny(text: string, patterns: string[]): boolean {
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[] {
return String(text ?? "")
.toLowerCase()
@ -576,13 +598,27 @@ function hasAccountBalanceSignal(text: string): boolean {
function hasForecastTaxSignal(text: string): boolean {
const hasForecastLexeme =
/(?:прогноз|forecast|план(?:\s+платежа|\s+оплаты)?|прикин(?:уть|ем|у|ь|ул|ули|усь|усь))/iu.test(text);
const hasVatLexeme = /(?:ндс|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(
return hasForecastLexeme && hasTaxLexeme;
}
function hasVatPayableConfirmedSignal(text: string): boolean {
const hasVatLexeme = /(?:ндс|vat)/iu.test(text);
if (!hasVatLexeme) {
return false;
}
const hasPaymentCue =
/(?:к\s+уплате|надо|нужно|заплатить|уплатить|плат[её]ж|платежку|в\s+налогов|в\s+бюджет|должн[аы]?\s+заплатить)/iu.test(
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 {
@ -1006,7 +1042,7 @@ function hasSupplierTailRiskSignal(text: string): boolean {
function hasPayablesDebtLifecycleSignal(text: string): boolean {
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
);
if (!hasOweSignal) {
@ -1022,7 +1058,7 @@ function hasPayablesDebtLifecycleSignal(text: string): boolean {
function hasReceivablesDebtLifecycleSignal(text: string): boolean {
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
);
if (!hasOweUsSignal) {
@ -1475,11 +1511,23 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
if (hasAny(text, RECEIVABLES_STRONG)) {
const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text);
if (hasVatPayableConfirmedSignal(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"];
if (receivablesDebtLifecycleSignal) {
reasons.push("receivables_debt_lifecycle_signal_detected");
if (hasFlexibleReceivablesDebtSignal(text)) {
reasons.push("receivables_signal_detected_flexible_phrase");
}
}
return {
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 payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
const payablesDebtLifecycleSignal =
hasPayablesDebtLifecycleSignal(text) || hasFlexiblePayablesDebtSignal(text);
if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected");
if (hasFlexiblePayablesDebtSignal(text)) {
reasons.push("payables_signal_detected_flexible_phrase");
}
}
return {
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",

View File

@ -1,4 +1,4 @@
import {
import {
ASSISTANT_MCP_CHANNEL,
ASSISTANT_MCP_PROXY_URL,
ASSISTANT_MCP_TIMEOUT_MS
@ -17,6 +17,13 @@ export interface AddressMcpQueryResult {
error: string | null;
}
export interface AddressMcpMetadataRowsResult {
fetched_rows: number;
raw_rows: Array<Record<string, unknown>>;
rows: Array<Record<string, unknown>>;
error: string | null;
}
function toStringValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
@ -188,7 +195,12 @@ function parseRowsFromTextTable(source: string): Array<Record<string, unknown>>
return normalizeMojibakeRows(rows);
}
function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
function parseRowsPayload(
payload: unknown,
options: {
allowSingleObjectRow?: boolean;
} = {}
): AddressMcpQueryResult {
if (!payload || typeof payload !== "object") {
return {
ok: false,
@ -240,6 +252,14 @@ function parseExecutePayload(payload: unknown): AddressMcpQueryResult {
};
}
if (source.data && typeof source.data === "object" && options.allowSingleObjectRow) {
return {
ok: true,
rows: [normalizeMojibakeValue(source.data) as Record<string, unknown>],
error: null
};
}
return {
ok: true,
rows: [],
@ -312,7 +332,7 @@ export async function executeAddressMcpQuery(input: {
}
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
const parsed = parseExecutePayload(payload);
const parsed = parseRowsPayload(payload);
if (!parsed.ok) {
return {
fetched_rows: 0,
@ -344,3 +364,100 @@ export async function executeAddressMcpQuery(input: {
clearTimeout(timeout);
}
}
export async function executeAddressMcpMetadata(input: {
filter?: string;
meta_type?: string | string[];
name_mask?: string;
limit?: number;
offset?: number;
sections?: string[];
extension_name?: string | null;
}): Promise<AddressMcpMetadataRowsResult> {
const endpoint = buildMcpUrl("/api/get_metadata");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), Math.max(300, ASSISTANT_MCP_TIMEOUT_MS));
try {
const body: Record<string, unknown> = {};
if (typeof input.filter === "string" && input.filter.trim().length > 0) {
body.filter = input.filter.trim();
}
if (typeof input.meta_type === "string" && input.meta_type.trim().length > 0) {
body.meta_type = input.meta_type.trim();
} else if (Array.isArray(input.meta_type) && input.meta_type.length > 0) {
const values = input.meta_type
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
if (values.length > 0) {
body.meta_type = values;
}
}
if (typeof input.name_mask === "string" && input.name_mask.trim().length > 0) {
body.name_mask = input.name_mask.trim();
}
if (typeof input.limit === "number" && Number.isFinite(input.limit)) {
body.limit = Math.max(1, Math.min(1000, Math.trunc(input.limit)));
}
if (typeof input.offset === "number" && Number.isFinite(input.offset)) {
body.offset = Math.max(0, Math.min(1_000_000, Math.trunc(input.offset)));
}
if (Array.isArray(input.sections) && input.sections.length > 0) {
const sections = input.sections
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
if (sections.length > 0) {
body.sections = sections;
}
}
if (input.extension_name !== undefined) {
body.extension_name = input.extension_name;
}
const response = await fetch(endpoint, {
method: "POST",
headers: {
"content-type": "application/json; charset=utf-8"
},
body: JSON.stringify(body),
signal: controller.signal
});
const responseText = await response.text();
if (!response.ok) {
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP HTTP ${response.status}: ${responseText.slice(0, 240)}`
};
}
const payload = responseText.trim() ? (JSON.parse(responseText) as unknown) : {};
const parsed = parseRowsPayload(payload, { allowSingleObjectRow: true });
if (!parsed.ok) {
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: parsed.error
};
}
return {
fetched_rows: parsed.rows.length,
raw_rows: parsed.rows,
rows: parsed.rows,
error: null
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
fetched_rows: 0,
raw_rows: [],
rows: [],
error: `MCP fetch failed: ${message}`
};
} finally {
clearTimeout(timeout);
}
}

View File

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

View File

@ -27,10 +27,16 @@ import {
selectAddressRecipe,
type AddressRecipeExecutionPlan
} from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient";
import { executeAddressMcpMetadata, executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, inferReplyType, type ComposeReplySemantics } from "./address_runtime/composeStage";
import {
composeFactualReply,
inferReplyType,
type ComposeReplySemantics,
type VatDirectSourceProbeItem,
type VatDirectSourceProbeSummary
} from "./address_runtime/composeStage";
import {
isCapabilityRouteBlocked,
resolveAddressCapabilityRouteDecision,
@ -80,6 +86,10 @@ const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const VAT_METADATA_PROBE_LIMIT = 100;
const VAT_SOURCE_PROBE_MAX_OBJECTS = 8;
const VAT_METADATA_PROBE_TYPES = ["РегистрНакопления", "РегистрСведений", "Документ"] as const;
const VAT_METADATA_PROBE_MASKS = ["ндс", "книгапродаж", "книгапокупок", "счетфактур", "вычет", "восстанов"] as const;
const PARTY_ANCHOR_STOPWORDS = new Set([
"ооо",
"ао",
@ -130,6 +140,12 @@ const ACCOUNT_ALIAS_MAP: Record<string, string[]> = {
"62": ["покупатель", "покупателями", "расчеты с покупателями"],
"76": ["прочие расчеты", "прочими дебиторами и кредиторами"]
};
interface VatMetadataObject {
fullName: string;
synonym: string | null;
objectType: "document" | "register";
}
const COUNTERPARTY_CATALOG_LOOKUP_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
@ -201,6 +217,293 @@ function valueAsString(value: unknown): string {
return String(value);
}
function normalizeIsoDateForQuery(value: unknown): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
if (!trimmed) {
return null;
}
const match = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
const candidate = new Date(Date.UTC(year, month - 1, day));
if (
candidate.getUTCFullYear() !== year ||
candidate.getUTCMonth() + 1 !== month ||
candidate.getUTCDate() !== day
) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}`;
}
function toDateTimeExprForQuery(isoDate: string): string | null {
const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
return null;
}
return `ДАТАВРЕМЯ(${year}, ${month}, ${day}, 23, 59, 59)`;
}
function shouldProbeVatSourcesForForecast(userMessage: string): boolean {
const text = String(userMessage ?? "")
.toLowerCase()
.replace(/ё/g, "е");
if (!text.trim()) {
return false;
}
return /(?:в\s+налогов|почему|из\s+чего|источн|декларац|книга\s+продаж|книга\s+покупок|вычет|восстанов)/iu.test(text);
}
function detectVatMetadataObjectType(fullName: string): VatMetadataObject["objectType"] | null {
const normalized = String(fullName ?? "").trim();
if (!normalized) {
return null;
}
if (normalized.startsWith("Документ.")) {
return "document";
}
if (normalized.startsWith("РегистрНакопления.") || normalized.startsWith("РегистрСведений.")) {
return "register";
}
return null;
}
function extractVatMetadataObjects(rows: Array<Record<string, unknown>>): VatMetadataObject[] {
const out: VatMetadataObject[] = [];
const seen = new Set<string>();
for (const row of rows) {
const fullName =
valueAsString(row.ПолноеИмя ?? row.full_name ?? row.FullName ?? row.Имя ?? row.name ?? row.Name).trim() || null;
if (!fullName) {
continue;
}
const objectType = detectVatMetadataObjectType(fullName);
if (!objectType) {
continue;
}
if (seen.has(fullName)) {
continue;
}
seen.add(fullName);
const synonym =
valueAsString(row.Синоним ?? row.synonym ?? row.Synonym ?? row.Представление ?? row.presentation).trim() || null;
out.push({
fullName,
synonym,
objectType
});
}
return out;
}
function scoreVatMetadataObject(item: VatMetadataObject): number {
const fullName = item.fullName.toLowerCase();
const synonym = String(item.synonym ?? "").toLowerCase();
let score = item.objectType === "register" ? 120 : 80;
if (fullName.includes("книгипродаж") || synonym.includes("продаж")) {
score += 60;
}
if (fullName.includes("книгипокупок") || synonym.includes("покуп")) {
score += 60;
}
if (fullName.includes("начислен") || synonym.includes("начислен")) {
score += 40;
}
if (fullName.includes("предъявлен") || synonym.includes("предъявлен")) {
score += 40;
}
if (fullName.includes("оплатындс") || synonym.includes("в бюджет")) {
score += 35;
}
if (fullName.includes("декларац")) {
score -= 25;
}
if (fullName.includes("пояснен")) {
score -= 25;
}
return score;
}
function buildVatObjectProbeQuery(object: VatMetadataObject, asOfExpr: string): string {
if (object.objectType === "document") {
return `
ВЫБРАТЬ ПЕРВЫЕ 1
Док.Дата КАК Период,
ПРЕДСТАВЛЕНИЕ(Док.Ссылка) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
${object.fullName} КАК Док
ГДЕ
Док.Дата <= ${asOfExpr}
УПОРЯДОЧИТЬ ПО
Док.Дата УБЫВ
`.trim();
}
return `
ВЫБРАТЬ ПЕРВЫЕ 1
Движения.Период КАК Период,
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
"" КАК СчетДт,
"" КАК СчетКт,
0 КАК Сумма
ИЗ
${object.fullName} КАК Движения
ГДЕ
Движения.Период <= ${asOfExpr}
УПОРЯДОЧИТЬ ПО
Движения.Период УБЫВ
`.trim();
}
async function probeVatDirectSources(filters: AddressFilterSet): Promise<VatDirectSourceProbeSummary> {
const asOfDate =
normalizeIsoDateForQuery(filters.as_of_date) ??
normalizeIsoDateForQuery(filters.period_to) ??
normalizeIsoDateForQuery(filters.period_from);
if (!asOfDate) {
return {
status: "skipped",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["as_of_date_not_resolved_for_vat_probe"]
};
}
const asOfExpr = toDateTimeExprForQuery(asOfDate);
if (!asOfExpr) {
return {
status: "skipped",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["as_of_expr_not_resolved_for_vat_probe"]
};
}
const metadataRequests: Array<{ meta_type: string; name_mask: string; limit: number }> = VAT_METADATA_PROBE_TYPES.flatMap(
(metaType) =>
VAT_METADATA_PROBE_MASKS.map((nameMask) => ({
meta_type: metaType,
name_mask: nameMask,
limit: VAT_METADATA_PROBE_LIMIT
}))
);
const metadataResponses = await Promise.all(metadataRequests.map((request) => executeAddressMcpMetadata(request)));
const metadataErrors: string[] = [];
const metadataObjectsBuffer: VatMetadataObject[] = [];
for (const [index, response] of metadataResponses.entries()) {
const request = metadataRequests[index];
if (response.error) {
metadataErrors.push(`${request.meta_type}:${request.name_mask}:${response.error}`);
continue;
}
metadataObjectsBuffer.push(...extractVatMetadataObjects(response.rows));
}
const deduplicatedObjects = new Map<string, VatMetadataObject>();
for (const item of metadataObjectsBuffer) {
const existing = deduplicatedObjects.get(item.fullName);
if (!existing) {
deduplicatedObjects.set(item.fullName, item);
continue;
}
if (!existing.synonym && item.synonym) {
deduplicatedObjects.set(item.fullName, {
...existing,
synonym: item.synonym
});
}
}
const discoveredMetadataObjects = Array.from(deduplicatedObjects.values()).sort(
(a, b) => scoreVatMetadataObject(b) - scoreVatMetadataObject(a) || a.fullName.localeCompare(b.fullName, "ru")
);
const metadataObjects = discoveredMetadataObjects.slice(0, VAT_SOURCE_PROBE_MAX_OBJECTS);
const probeRows: VatDirectSourceProbeItem[] = [];
for (const object of metadataObjects) {
const probeQuery = buildVatObjectProbeQuery(object, asOfExpr);
const probeResult = await executeAddressMcpQuery({
query: probeQuery,
limit: 1
});
if (probeResult.error) {
probeRows.push({
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: "error",
rowsFetched: probeResult.fetched_rows,
error: probeResult.error
});
continue;
}
const firstRow = probeResult.raw_rows[0] ?? null;
const lastPeriod =
firstRow !== null
? valueAsString((firstRow as Record<string, unknown>).Период ?? (firstRow as Record<string, unknown>).period).trim() ||
null
: null;
const sampleRegistrator =
firstRow !== null
? valueAsString(
(firstRow as Record<string, unknown>).Регистратор ??
(firstRow as Record<string, unknown>).registrator ??
(firstRow as Record<string, unknown>).Registrator
).trim() || null
: null;
probeRows.push({
fullName: object.fullName,
synonym: object.synonym,
objectType: object.objectType,
status: probeResult.raw_rows.length > 0 ? "ok" : "empty",
rowsFetched: probeResult.fetched_rows,
lastPeriod,
sampleRegistrator
});
}
const status: VatDirectSourceProbeSummary["status"] = metadataResponses.every((item) => item.error) ? "error" : "ok";
const allErrors = [
...metadataErrors,
...probeRows
.filter((item) => item.status === "error")
.map((item) => `${item.fullName}: ${valueAsString(item.error).slice(0, 120)}`)
];
return {
status,
objectsTotal: discoveredMetadataObjects.length,
documentsTotal: discoveredMetadataObjects.filter((item) => item.objectType === "document").length,
registersTotal: discoveredMetadataObjects.filter((item) => item.objectType === "register").length,
probedSources: probeRows,
errors: allErrors
};
}
function transliterateCyrillicToLatin(value: string): string {
const map: Record<string, string> = {
а: "a",
@ -802,7 +1105,8 @@ function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
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 +1327,7 @@ function enforceStrictAccountScopeForIntent(
};
}
function resolveExecutionFiltersForPayablesConfirmedBalance(
function resolveExecutionFiltersForConfirmedBalance(
filters: AddressFilterSet,
analysisDate: string | null
): {
@ -1636,6 +1940,8 @@ function buildLimitedOffers(input: {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "receivables_confirmed_as_of_date") {
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") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
} else if (input.intent === "list_payables_counterparties") {
@ -1689,7 +1995,8 @@ function buildLimitedIntentSignalLine(input: {
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
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>> = {
@ -1887,19 +2194,17 @@ function buildLimitedExecutionResult(input: {
undefined,
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 =
(input.intent.intent === "payables_confirmed_as_of_date" || input.intent.intent === "receivables_confirmed_as_of_date") &&
!reasonsWithConfirmedFallback.includes(
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"
]
exactLimitedReason && !reasonsWithConfirmedFallback.includes(exactLimitedReason)
? [...reasonsWithConfirmedFallback, exactLimitedReason]
: reasonsWithConfirmedFallback;
const routeExpectationAudit =
input.routeExpectationAudit ??
@ -2014,15 +2319,23 @@ export class AddressQueryService {
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
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters =
payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.executionFilters ?? filters.extracted_filters;
payablesConfirmedExecution?.executionFilters ??
receivablesConfirmedExecution?.executionFilters ??
vatPayableConfirmedExecution?.executionFilters ??
filters.extracted_filters;
if (
payablesConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)
@ -2045,6 +2358,17 @@ export class AddressQueryService {
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 capabilityAudit = buildCapabilityAudit(intent.intent);
const shadowRouteAudit = buildShadowRouteAudit({
@ -2075,12 +2399,22 @@ export class AddressQueryService {
shadowRouteAudit
});
}
const composeOptionsFromFilters = (filterSet: AddressFilterSet) => ({
const composeOptionsFromFilters = (
filterSet: AddressFilterSet,
options: {
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
} = {}
) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode
requestedResultMode,
vatDirectSourceProbe: options.vatDirectSourceProbe ?? undefined,
emphasizeNumbers: options.emphasizeNumbers ?? undefined,
useRubCurrency: options.useRubCurrency ?? undefined
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
@ -2120,6 +2454,12 @@ export class AddressQueryService {
) {
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" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
@ -3177,7 +3517,36 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(composeIntent, filteredRows, composeOptionsFromFilters(executionFilters));
const vatProbeRequired =
composeIntent === "vat_payable_confirmed_as_of_date" ||
(composeIntent === "vat_payable_forecast" && shouldProbeVatSourcesForForecast(userMessage));
const vatDirectSourceProbe = vatProbeRequired ? await probeVatDirectSources(executionFilters) : null;
const shouldEmphasizeNumbers =
composeIntent === "vat_payable_forecast" ||
composeIntent === "vat_payable_confirmed_as_of_date" ||
composeIntent === "payables_confirmed_as_of_date" ||
composeIntent === "receivables_confirmed_as_of_date";
const shouldUseRubCurrency = composeIntent === "vat_payable_forecast";
const factual = composeFactualReply(
composeIntent,
filteredRows,
composeOptionsFromFilters(executionFilters, {
vatDirectSourceProbe,
emphasizeNumbers: shouldEmphasizeNumbers,
useRubCurrency: shouldUseRubCurrency
})
);
const vatProbeLimitations =
vatProbeRequired && vatDirectSourceProbe
? vatDirectSourceProbe.status === "error"
? ["vat_source_probe_error"]
: vatDirectSourceProbe.status === "skipped"
? ["vat_source_probe_skipped"]
: vatDirectSourceProbe.errors.length > 0
? ["vat_source_probe_partial_errors"]
: []
: [];
const factualLimitations = [...filters.warnings, ...vatProbeLimitations];
const factualResultSemantics = mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: composeIntent,
@ -3227,12 +3596,17 @@ export class AddressQueryService {
routeExpectationAudit: finalRouteExpectationAudit
});
}
if (
((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")) &&
factualResultSemantics.balance_confirmed !== true
) {
const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables";
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 === "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"
: intent.intent === "receivables_confirmed_as_of_date"
? "receivables"
: "vat_payable";
return buildLimitedExecutionResult({
mode,
shape,
@ -3257,7 +3631,10 @@ export class AddressQueryService {
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
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`],
reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`],
capabilityAudit,
@ -3325,7 +3702,7 @@ export class AddressQueryService {
route_expectation_expected_requested_result_modes: finalRouteExpectationAudit.expectedRequestedResultModes,
route_expectation_expected_result_modes: finalRouteExpectationAudit.expectedResultModes,
...factualResultSemantics,
limitations: filters.warnings,
limitations: factualLimitations,
reasons: withConfirmedBalanceFallbackReason(
reasonsWithRouteExpectation,
requestedResultMode,

View File

@ -72,6 +72,29 @@ const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
Сумма __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 = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
БанкСписание.Дата КАК Период,
@ -566,6 +589,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
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",
intent: "list_contracts_by_counterparty",
@ -1057,6 +1091,28 @@ export function buildAddressRecipePlan(
.replaceAll("__VAT68_DT_MATCH__", buildAccountPrefixPredicate("Движения.СчетДт", VAT_PAYABLE_68_PREFIXES))
.replaceAll("__VAT19_DT_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"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "payables_confirmed_as_of_balance_profile"

View File

@ -14,12 +14,35 @@ export interface ComposeStageRow {
analytics: string[];
}
export interface VatDirectSourceProbeItem {
fullName: string;
synonym?: string | null;
objectType: "document" | "register";
status: "ok" | "empty" | "error";
rowsFetched: number;
lastPeriod?: string | null;
sampleRegistrator?: string | null;
error?: string | null;
}
export interface VatDirectSourceProbeSummary {
status: "ok" | "error" | "skipped";
objectsTotal: number;
documentsTotal: number;
registersTotal: number;
probedSources: VatDirectSourceProbeItem[];
errors: string[];
}
interface ComposeFactualReplyOptions {
userMessage?: string;
periodFrom?: string;
periodTo?: string;
asOfDate?: string;
requestedResultMode?: AddressResultMode;
vatDirectSourceProbe?: VatDirectSourceProbeSummary | null;
emphasizeNumbers?: boolean;
useRubCurrency?: boolean;
}
export interface ComposeReplySemantics {
@ -175,8 +198,36 @@ function formatMoneyRub(value: number): string {
return `${formatNumberWithDots(value, 2)}`;
}
function formatVatProbeStatusRu(status: VatDirectSourceProbeItem["status"]): string {
if (status === "ok") {
return "есть движения";
}
if (status === "empty") {
return "движения не найдены";
}
return "ошибка запроса";
}
function emphasizeNumericTokens(line: string): string {
return line;
if (!line) {
return line;
}
const chunks = line.split(/(`[^`]*`)/g);
return chunks
.map((chunk, index) => {
if (index % 2 === 1) {
return chunk;
}
return chunk.replace(/\b-?(?:\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,]\d+)?\b/g, (match, offset, source) => {
const before = offset > 0 ? source[offset - 1] : "";
const after = offset + match.length < source.length ? source[offset + match.length] : "";
if (before === "*" || after === "*") {
return match;
}
return `**${match}**`;
});
})
.join("");
}
function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null {
@ -219,6 +270,22 @@ function buildIsoDateWithMonthShift(
return date.toISOString().slice(0, 10);
}
function shiftIsoDateToNextBusinessDay(isoDate: string): string {
const parsed = parseIsoDateToken(isoDate);
if (!parsed) {
return isoDate;
}
const date = new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day));
for (let guard = 0; guard < 10; guard += 1) {
const dayOfWeek = date.getUTCDay();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
return date.toISOString().slice(0, 10);
}
date.setUTCDate(date.getUTCDate() + 1);
}
return isoDate;
}
function deriveVatDeadlineCalendar(
periodFrom: string | null | undefined,
periodTo: string | null | undefined
@ -243,10 +310,12 @@ function deriveVatDeadlineCalendar(
const quarterEndDay = new Date(Date.UTC(reference.year, quarterEndMonth, 0)).getUTCDate();
const quarterStart = toIsoDate(reference.year, quarterStartMonth, 1);
const quarterEnd = toIsoDate(reference.year, quarterEndMonth, quarterEndDay);
const declarationDueDate = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1);
const payment1 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1);
const payment2 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2);
const payment3 = buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3);
const declarationDueDate = shiftIsoDateToNextBusinessDay(
buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 25, 1)
);
const payment1 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 1));
const payment2 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 2));
const payment3 = shiftIsoDateToNextBusinessDay(buildIsoDateWithMonthShift(reference.year, quarterEndMonth, 28, 3));
return {
periodLabel: `${quarterNumber} кв. ${reference.year}`,
@ -384,6 +453,14 @@ function needsVatWhyExplanation(userMessage: string | null | undefined): boolean
return /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text);
}
function needsVatCalendarDetails(userMessage: string | null | undefined): boolean {
const text = normalizeQuestionText(userMessage);
if (!text) {
return false;
}
return /(?:срок|когда|дата\s+уплат|декларац|дол(?:я|ями)|по\s+частям|платежн(?:ый|ого)\s+график)/iu.test(text);
}
function detectRankingLimit(userMessage: string | null | undefined, fallback = 20): number {
const text = normalizeQuestionText(userMessage);
if (!text) {
@ -1464,6 +1541,9 @@ export function composeFactualReply(
rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } {
const applyNumericEmphasis = (line: string): string => (options.emphasizeNumbers ? emphasizeNumericTokens(line) : line);
const joinLines = (lines: string[]): string => lines.map(applyNumericEmphasis).join("\n");
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
@ -2442,32 +2522,72 @@ export function composeFactualReply(
const vatActivityDetected = totalVatTurnoverAbs > 0.0000001;
const netVatIsEffectivelyZero = Math.abs(netVat) <= 0.005;
const explainWhyRequested = needsVatWhyExplanation(options.userMessage);
const shouldShowCalendarDetails = needsVatCalendarDetails(options.userMessage);
const vatCalendar = deriveVatDeadlineCalendar(options.periodFrom, options.periodTo);
const formatForecastMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const periodWindowLabel =
options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const lines = [
"Собран прогноз НДС к уплате по фактическим проводкам (НДС-субсчета 68.02*/19*).",
`Строк агрегата: ${rows.length}.`,
`Оборот по кредиту 68*: ${formatMoney(turnover68Credit)}.`,
`Оборот по дебету 68*: ${formatMoney(turnover68Debit)}.`,
`Нетто НДС (68 Кт - 68 Дт): ${formatMoney(netVat)}.`,
`Прогноз НДС к уплате: ${formatMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatMoney(carryoverOrOverpayment)}.`,
`Справочно по 19*: дебет ${formatMoney(turnover19Debit)}, кредит ${formatMoney(turnover19Credit)}.`
`Собран прогноз НДС к уплате: ${formatForecastMoney(vatToPay)}.`,
`Потенциальный перенос/переплата: ${formatForecastMoney(carryoverOrOverpayment)}.`,
`Период оценки: ${periodWindowLabel ?? "не задан (использован доступный срез)"}.`,
"Режим результата: предварительная оценка по проводкам 68.02*/19* (не подтвержденная сумма налога по декларации).",
"",
"База расчета:",
`- Строк агрегата: ${formatNumberWithDots(rows.length)}.`,
`- Оборот по кредиту 68*: ${formatForecastMoney(turnover68Credit)}.`,
`- Оборот по дебету 68*: ${formatForecastMoney(turnover68Debit)}.`,
`- Нетто НДС (68 Кт - 68 Дт): ${formatForecastMoney(netVat)}.`,
`- Справочно по 19*: дебет ${formatForecastMoney(turnover19Debit)}, кредит ${formatForecastMoney(turnover19Credit)}.`
];
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push(
"",
"Покрытие VAT-источников через MCP:",
`- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`
);
if (vatProbe.probedSources.length > 0) {
lines.push(
...vatProbe.probedSources.slice(0, 6).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`;
})
);
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
} else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
lines.push(
"В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен 0.00."
`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(
0
)}.`
);
} else if (vatToPay === 0 && netVatIsEffectivelyZero) {
lines.push("В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате 0.00.");
lines.push(
`В выбранном окне обороты по 68* взаимно перекрылись (нетто близко к нулю), поэтому к уплате ${formatForecastMoney(0)}.`
);
} else if (vatToPay === 0 && netVat < 0) {
lines.push("В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате 0.00.");
lines.push(
`В выбранном окне дебет 68* превышает кредит 68*; сумма показана как перенос/переплата, к уплате ${formatForecastMoney(0)}.`
);
}
if (vatToPay === 0) {
lines.push(
"",
"Чеклист проверки в 1С (почему к уплате 0):",
`1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : "расчета"}.`,
`1) Проверьте ОСВ/анализ счета по 68.02 и 19 за окно ${periodWindowLabel ?? "расчета"}.`,
"2) Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный по счетам 68.02*/19* (включая субсчета).",
"3) Сверьте счета-фактуры, корректировки и момент принятия НДС к вычету (не попали ли в другой период).",
"4) Сверьте книгу продаж/покупок и операции Помощника по учету НДС за тот же период.",
@ -2475,7 +2595,7 @@ export function composeFactualReply(
);
}
if (vatCalendar) {
if (vatCalendar && shouldShowCalendarDetails) {
const periodWindowLabel =
vatCalendar.windowFrom && vatCalendar.windowTo
? `${formatDateRu(vatCalendar.windowFrom)}..${formatDateRu(vatCalendar.windowTo)}`
@ -2485,18 +2605,20 @@ export function composeFactualReply(
const installmentRounded = Number(installmentRaw.toFixed(2));
const installmentThird = Number((vatToPay - installmentRounded * 2).toFixed(2));
lines.push(
"",
`Период расчета (срез обязательств): ${periodWindowLabel}.`,
`Налоговый период: ${vatCalendar.periodLabel}.`,
`Срок сдачи декларации: до ${formatDateRu(vatCalendar.declarationDueDate)}.`,
`Сроки уплаты: ${formatDateRu(payment1)}, ${formatDateRu(payment2)}, ${formatDateRu(payment3)}.`,
`Ориентир по долям к уплате: ${formatMoney(installmentRounded)} / ${formatMoney(installmentRounded)} / ${formatMoney(installmentThird)}.`,
`Ориентир по долям к уплате: ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentRounded)} / ${formatForecastMoney(installmentThird)}.`,
"Важно: даже при нулевой сумме к уплате декларация по НДС подается в установленный срок; переносы по выходным/праздникам сверяйте по календарю ФНС/1С."
);
}
if (explainWhyRequested) {
lines.push(
"",
"Почему прогноз к уплате 0: в текущей модели используем формулу max(0, 68 Кт - 68 Дт).",
`За период 68 Кт = ${formatMoney(turnover68Credit)}, 68 Дт = ${formatMoney(turnover68Debit)}, разница = ${formatMoney(netVat)}.`,
`За период 68 Кт = ${formatForecastMoney(turnover68Credit)}, 68 Дт = ${formatForecastMoney(turnover68Debit)}, разница = ${formatForecastMoney(netVat)}.`,
netVat <= 0
? "Разница неположительная, поэтому к уплате = 0, а отрицательная часть показана как перенос/переплата."
: "Разница положительная, поэтому к уплате берется эта положительная величина.",
@ -2506,7 +2628,136 @@ export function composeFactualReply(
return {
responseType: "FACTUAL_SUMMARY",
text: lines.join("\n")
text: joinLines(lines)
};
}
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*)."
];
const vatProbe = options.vatDirectSourceProbe ?? null;
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push(
"",
"Блок 2.1. MCP-проверка VAT-источников",
`- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`
);
if (vatProbe.probedSources.length > 0) {
lines.push(
...vatProbe.probedSources.slice(0, 4).map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
const suffix =
item.status === "ok"
? `${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.sampleRegistrator ? ` | пример: ${item.sampleRegistrator}` : ""}`
: item.status === "error" && item.error
? ` | ошибка: ${item.error}`
: "";
return `${index + 1}. ${name} | ${formatVatProbeStatusRu(item.status)}${suffix}`;
})
);
}
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
} else if (vatProbe && vatProbe.status === "error") {
lines.push(
"",
"Блок 2.1. MCP-проверка VAT-источников",
"- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."
);
}
lines.push(
"",
"Блок 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: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
@ -2641,7 +2892,7 @@ export function composeFactualReply(
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@ -2721,7 +2972,7 @@ export function composeFactualReply(
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
@ -2868,7 +3119,7 @@ export function composeFactualReply(
];
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
@ -2880,7 +3131,7 @@ export function composeFactualReply(
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(fallbackLines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
@ -2892,7 +3143,7 @@ export function composeFactualReply(
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.map(emphasizeNumericTokens).join("\n"),
text: joinLines(lines),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",

View File

@ -66,6 +66,14 @@ function hasOpenItemsHint(text: string): boolean {
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 {
return /(?:док(?:и|умент|ументы|ументов|ументами)|docs?|documents?|doki|docy|doci)/iu.test(String(text ?? ""));
}
@ -437,14 +445,26 @@ function mergeFollowupFilters(
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" ||
intent === "list_open_contracts" ||
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 currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract =
@ -474,6 +494,16 @@ function mergeFollowupFilters(
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) {
@ -539,6 +569,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
documents_forming_balance: ["account", "as_of_date"],
payables_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"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
@ -577,6 +608,18 @@ function deriveIntentWithFollowupContext(
const hasPreviousContract = Boolean(previousContract ?? previousContractFromAnchor);
const hasPreviousCounterparty = Boolean(previousCounterparty ?? previousCounterpartyFromAnchor);
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) {
return {

View File

@ -193,7 +193,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
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";
}

View File

@ -1990,7 +1990,7 @@ function textMojibakeScoreForAddress(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/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 doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
@ -2000,7 +2000,7 @@ function looksLikeMojibakeForAddress(value) {
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ’“”•–—™љ›њќћџ]/.test(source)) {
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ<EFBFBD>?’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
@ -2205,7 +2205,7 @@ function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`“”„’]/g, " ")
.replace(/[«»"'`“”„’<EFBFBD>?]/g, " ")
.replace(/[^a-zа-я0-9\s._-]+/giu, " "));
}
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] ?? "")) {
counterpartyCandidate = parts[1] ?? counterpartyCandidate;
}
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»`]+|["'«»`]+$/gu, ""));
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»`<EFBFBD>?]+|["'«»`<EFBFBD>?]+$/gu, ""));
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
@ -2515,61 +2515,133 @@ function isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) {
return tokenCount > 0 && tokenCount <= 4;
}
function hasAddressFollowupContextSignal(userMessage) {
const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase());
const repaired = repairAddressMojibake(String(userMessage ?? ""));
const text = compactWhitespace(repaired.toLowerCase());
if (!text) {
const repairedText = compactWhitespace(repaired.toLowerCase());
const samples = [rawText, repairedText].filter((item) => item.length > 0);
if (samples.length === 0) {
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;
}
if (shouldHandleAsAssistantCapabilityMetaQuery(text)) {
if (shouldHandleAsAssistantCapabilityMetaQuery(rawText || repairedText)) {
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;
}
if (hasReferentialPointer(text)) {
if (hasPointer()) {
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;
}
const shortFollowup = countTokens(text) <= 8;
if (/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu.test(text)) {
if (hasAny(/(?:кроме|помимо)\s+(?:этого|этой|этот|эту|этих|этого\s+документа|этого\s+договора|этого\s+контрагента)/iu)) {
return true;
}
if (/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu.test(text) && countTokens(text) <= 12) {
if (hasAny(/(?:есть\s+ещ[её]|что\s+ещ[её]|ещ[её]\s+что|ещ[её]\s+что-?то|остал(?:ось|ось\?)|друг(?:ое|ие))/iu) && minTokens <= 12) {
return true;
}
if (shortFollowup && hasFollowupMarker(text)) {
if (shortFollowup && hasMarker()) {
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;
}
if (shortFollowup &&
/(?:кто\s+из\s+(?:них|этих|тех)|кто\s+нов(?:ые|ых|ый)|кто\s+потом\s+исчез|кто\s+был\s+(?:только|ровно)\s+один\s+раз)/iu.test(text)) {
return true;
}
if (shortFollowup && /^(?:а|и)\s+кто\b/iu.test(text)) {
hasAny(/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu) &&
hasAny(/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu)) {
return true;
}
if (shortFollowup &&
/(?:почему|why|из[-\s]?за\s+чего|как\s+так|reason)/iu.test(text) &&
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(text)) {
hasAny(/(?:^|\s)по\s+[a-zа-яё][a-zа-яё0-9._-]{1,}(?=$|[\s,.;:!?])/iu) &&
!hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) {
return true;
}
if (shortFollowup &&
/(?:^|\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)) {
if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
return true;
}
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) {
const previousAddressItem = findLastAddressAssistantItem(items);
const previousAddressDebug = previousAddressItem?.debug ?? null;
@ -2578,9 +2650,15 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
Boolean(followupOffer?.enabled) &&
(isImplicitAddressContinuationByLlm(userMessage, llmPreDecomposeMeta) ||
(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)
? hasAddressFollowupContextSignal(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate)
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
@ -2589,7 +2667,11 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
if (hasStandaloneAddressTopic &&
!hasPrimaryFollowupSignal &&
!hasAlternateFollowupSignal &&
!hasImplicitContinuationSignal &&
!hasIndexReferenceSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
@ -2601,6 +2683,9 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const sourceIntent = toNonEmptyString(previousAddressDebug.detected_intent);
let previousIntent = sourceIntent;
let followupSelectionMode = "carry_previous_intent";
if (debtRoleSwapIntent) {
previousIntent = debtRoleSwapIntent;
}
if (hasImplicitContinuationSignal) {
const suggestedIntent = Array.isArray(followupOffer?.suggested_intents)
? toNonEmptyString(followupOffer.suggested_intents[0])
@ -3106,6 +3191,13 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) {
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.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) {
const canonicalMessage = toNonEmptyString(meta?.effectiveMessage) ?? String(sourceMessage ?? "");
const predecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({
@ -3207,6 +3299,20 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const candidateIntentResolution = (0, addressIntentResolver_1.resolveAddressIntent)(candidate);
const sourceIntentKnown = sourceIntentResolution.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 &&
candidateIntentKnown &&
sourceIntentResolution.intent !== candidateIntentResolution.intent;
@ -3473,6 +3579,8 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
isAddressLlmPreDecomposeCandidate(repairedInputMessage) ||
hasAccountingSignal(addressInputMessage) ||
hasAccountingSignal(repairedInputMessage) ||
hasShortDebtMirrorFollowupSignal(rawMessageForGate) ||
hasShortDebtMirrorFollowupSignal(repairedInputMessage) ||
sameDateAccountFollowupSignal;
const hasUnsupportedLowConfidencePredecomposeSignal = llmContractMode === "unsupported" &&
(llmContractModeConfidence === "low" || llmContractModeConfidence === "medium") &&
@ -3491,6 +3599,7 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
!followupContext &&
!hasClassifierSignal &&
!hasIntentSignal &&
!hasLexicalAddressSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return {
@ -3683,7 +3792,8 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_contracts_by_counterparty",
"contract_usage_overview",
"contract_usage_and_value",
"vat_payable_forecast"
"vat_payable_forecast",
"vat_payable_confirmed_as_of_date"
]);
export function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
@ -3764,7 +3874,11 @@ export function resolveAssistantOrchestrationDecision(input) {
const explicitAddressFollowupSignal = hasAddressFollowupContextSignal(rawUserMessage) ||
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage);
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage);
const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal;
const deterministicNonDomainGuard = Boolean(!dataScopeMetaQuery &&
!capabilityMetaQuery &&
@ -3879,7 +3993,11 @@ export function resolveAssistantOrchestrationDecision(input) {
hasAddressFollowupContextSignal(rawUserMessage) ||
hasAddressFollowupContextSignal(effectiveAddressUserMessage) ||
hasAddressFollowupContextSignal(repairedRawUserMessage) ||
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage));
hasAddressFollowupContextSignal(repairedEffectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(rawUserMessage) ||
hasShortDebtMirrorFollowupSignal(effectiveAddressUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedRawUserMessage) ||
hasShortDebtMirrorFollowupSignal(repairedEffectiveAddressUserMessage));
const supportedAddressIntentDetected = !strictDeepInvestigationCueDetected &&
Boolean((intentResolution.intent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(intentResolution.intent)) ||
(llmContractIntent && ADDRESS_INTENTS_KEEP_ADDRESS_LANE.has(llmContractIntent)) ||
@ -4896,14 +5014,14 @@ async function resolveAssistantDataScopeProbe() {
};
}
const catalogQueryCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация ИЗ Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕНИЕ(Организации.Ссылка) КАК ОрганизацияПредставление ИЗ Справочник.Организации КАК Организации"
"ВЫБРАТЬ ПЕРВЫЕ 20 ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК Организация <20>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.Наименование КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 20 Организации.НаименованиеПолное КАК Организация <EFBFBD>?З Справочник.Организации КАК Организации",
"ВЫБРАТЬ ПЕРВЫЕ 100 Организации.Ссылка КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Организации.Ссылка) КАК ОрганизацияПредставление <20>?З Справочник.Организации КАК Организации"
];
const movementProbeCandidates = [
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕНИЕ(Движения.Организация) КАК ОрганизацияПредставление ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧИТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация ИЗ РегистрБухгалтерии.Хозрасчетный КАК Движения"
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация, ПРЕДСТАВЛЕН<EFBFBD>?Е(Движения.Организация) КАК ОрганизацияПредставление <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения УПОРЯДОЧ<EFBFBD>?ТЬ ПО Движения.Период УБЫВ",
"ВЫБРАТЬ ПЕРВЫЕ 60 Движения.Организация КАК Организация <EFBFBD>?З РегистрБухгалтерии.Хозрасчетный КАК Движения"
];
let lastError = null;
const catalogFacts = { names: [], refs: [], pairs: [] };
@ -5034,7 +5152,7 @@ function buildAssistantOperationalBoundaryReply() {
return [
"Понимаю, что ситуация срочная.",
"Я не могу сам настраивать 1С или менять базу/конфигурацию.",
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/ИТ-админа."
"Могу помочь безопасно: разберем симптомы и подготовим точные шаги для вашего 1С/<EFBFBD>?Т-админа."
].join(" ");
}
function buildAssistantSafetyRefusalReply() {

View File

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

View File

@ -24,6 +24,15 @@ describe("address capability policy", () => {
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", () => {
const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract");
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

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { executeAddressMcpQuery } from "../src/services/addressMcpClient";
import { afterEach, describe, expect, it, vi } from "vitest";
import { executeAddressMcpMetadata, executeAddressMcpQuery } from "../src/services/addressMcpClient";
const ORIGINAL_FETCH = globalThis.fetch;
@ -44,4 +44,33 @@ describe("address MCP encoding repair", () => {
expect(result.rows[0]?.["Контрагент"]).toBe("Группа СВК");
expect(result.rows[0]?.["Регистратор"]).toContain("Поступление");
});
it("parses get_metadata text-table payload", async () => {
const payload = {
success: true,
data: `[2]{"FullName","Synonym"}:
Document.VATDoc,VAT document
Register.VATRegister,VAT register`
};
globalThis.fetch = vi.fn(async () =>
new Response(JSON.stringify(payload), {
status: 200,
headers: {
"content-type": "application/json"
}
})
) as typeof fetch;
const result = await executeAddressMcpMetadata({
meta_type: "Документ",
name_mask: "ндс",
limit: 10
});
expect(result.error).toBeNull();
expect(result.fetched_rows).toBe(2);
expect(result.rows[0]?.["FullName"]).toBe("Document.VATDoc");
expect(result.rows[0]?.["Synonym"]).toBe("VAT document");
});
});

View File

@ -1256,6 +1256,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Почему прогноз к уплате 0");
expect(reply.text).toContain("max(0, 68 Кт - 68 Дт)");
expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("За период 68 Кт = 9126.00, 68 Дт = 115342.00, разница = -106216.00.");
expect(reply.text).toContain("Разница неположительная");
expect(reply.text).toContain("оперативный прогноз по оборотам НДС-субсчетов 68.02*/19*");
@ -1283,7 +1284,7 @@ describe("address compose stage utf8 headers", () => {
}
],
{
userMessage: "сколько НДС нужно заплатить по состоянию на 15 марта 2020 года",
userMessage: "какие сроки уплаты и сдачи декларации по НДС по состоянию на 15 марта 2020 года",
periodFrom: "2020-01-01",
periodTo: "2020-03-15"
}
@ -1292,8 +1293,8 @@ describe("address compose stage utf8 headers", () => {
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Период расчета (срез обязательств): 01.01.2020..15.03.2020.");
expect(reply.text).toContain("Налоговый период: 1 кв. 2020.");
expect(reply.text).toContain("Срок сдачи декларации: до 25.04.2020.");
expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 28.06.2020.");
expect(reply.text).toContain("Срок сдачи декларации: до 27.04.2020.");
expect(reply.text).toContain("Сроки уплаты: 28.04.2020, 28.05.2020, 29.06.2020.");
expect(reply.text).toContain("Ориентир по долям к уплате: 100.00 / 100.00 / 100.00.");
});
@ -1311,7 +1312,7 @@ describe("address compose stage utf8 headers", () => {
}
],
{
userMessage: "прогноз НДС на 31 декабря 2020",
userMessage: "когда платить НДС за 4 квартал 2020",
periodFrom: "2020-10-01",
periodTo: "2020-12-31"
}
@ -1320,7 +1321,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Налоговый период: 4 кв. 2020.");
expect(reply.text).toContain("Срок сдачи декларации: до 25.01.2021.");
expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 28.02.2021, 28.03.2021.");
expect(reply.text).toContain("Сроки уплаты: 28.01.2021, 01.03.2021, 29.03.2021.");
expect(reply.text).toContain("Ориентир по долям к уплате: 30.00 / 30.00 / 30.00.");
});
@ -1369,7 +1370,7 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("не найдено движений по НДС-субсчетам 68.02*/19*");
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
expect(reply.text).toContain("Проверьте наличие движений в РегистрБухгалтерии.Хозрасчетный");
@ -1404,10 +1405,160 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("Собран прогноз НДС к уплате: 0.00.");
expect(reply.text).toContain("обороты по 68* взаимно перекрылись");
expect(reply.text).toContain("Чеклист проверки в 1С (почему к уплате 0):");
});
it("adds MCP VAT source coverage block for VAT forecast response", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2019-12-31T23:59:59Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 1000,
analytics: []
}
],
{
userMessage: "прикинь ндс за декабрь 2019",
periodFrom: "2019-12-01",
periodTo: "2019-12-31",
vatDirectSourceProbe: {
status: "ok",
objectsTotal: 5,
documentsTotal: 2,
registersTotal: 3,
probedSources: [
{
fullName: "РегистрНакопления.НДСПродажи",
objectType: "register",
status: "ok",
rowsFetched: 1,
lastPeriod: "2019-12-31T23:59:59Z"
},
{
fullName: "РегистрНакопления.НДСПокупки",
objectType: "register",
status: "empty",
rowsFetched: 0
}
],
errors: []
}
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Покрытие VAT-источников через MCP");
expect(reply.text).toContain("Найдено VAT-объектов: 5");
expect(reply.text).toContain("РегистрНакопления.НДСПродажи");
});
it("formats VAT forecast amounts in rubles and emphasizes numbers when requested", () => {
const reply = composeFactualReply(
"vat_payable_forecast",
[
{
period: "2019-12-31T23:59:59Z",
registrator: "VAT_68_CREDIT",
account_dt: "68",
account_kt: "",
amount: 1234567.89,
analytics: []
}
],
{
userMessage: "прикинь ндс",
useRubCurrency: true,
emphasizeNumbers: true
}
);
expect(reply.text).toContain("**1.234.567,89** ₽");
expect(reply.text).toContain("Собран прогноз НДС к уплате:");
});
it("adds MCP VAT source probe block for confirmed VAT as-of response", () => {
const reply = composeFactualReply(
"vat_payable_confirmed_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: null,
account_kt: "68.02",
amount: 123456.78,
analytics: []
}
],
{
asOfDate: "2020-03-31",
vatDirectSourceProbe: {
status: "ok",
objectsTotal: 3,
documentsTotal: 1,
registersTotal: 2,
probedSources: [
{
fullName: "РегистрНакопления.НДСНачисленный",
objectType: "register",
status: "ok",
rowsFetched: 1,
lastPeriod: "2020-03-31T23:59:59Z",
sampleRegistrator: "Отражение начисления НДС 0001"
},
{
fullName: "Документ.РегистрацияОплатыНДСВБюджет",
objectType: "document",
status: "empty",
rowsFetched: 0
}
],
errors: []
}
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Блок 2.1. MCP-проверка VAT-источников");
expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3");
expect(reply.text).toContain("Источников с движениями до даты среза: 1");
expect(reply.text).toContain("РегистрНакопления.НДСНачисленный");
});
it("adds VAT probe error note for confirmed VAT as-of response", () => {
const reply = composeFactualReply(
"vat_payable_confirmed_as_of_date",
[
{
period: "2020-03-31T23:59:59Z",
registrator: "Остатки на дату",
account_dt: null,
account_kt: "68.02",
amount: 1000,
analytics: []
}
],
{
asOfDate: "2020-03-31",
vatDirectSourceProbe: {
status: "error",
objectsTotal: 0,
documentsTotal: 0,
registersTotal: 0,
probedSources: [],
errors: ["metadata timeout"]
}
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Probe VAT-источников завершился ошибкой");
});
});
describe("address intent resolver expansion (M2.3a)", () => {
@ -1855,6 +2006,12 @@ describe("address intent resolver expansion (M2.3a)", () => {
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", () => {
const result = resolveAddressIntent(
"Какие поставщики у нас уже пару месяцев сдают акты без приходок. Может, их надо проконтролировать отдельно чтоб не засорять бухгалтерию дальше?"
@ -2022,6 +2179,15 @@ describe("address filter extraction for balance drilldown", () => {
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", () => {
const extracted = extractAddressFilters(
"В каких случаях мы видим ситуацию, когда документы есть, а денег нет и пока не предвидится?",
@ -3243,6 +3409,26 @@ describe("address decompose stage follow-up carryover", () => {
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", () => {
const result = runAddressDecomposeStage("а теперь банковские операции", {
previous_intent: "list_documents_by_contract",
@ -3415,6 +3601,27 @@ describe("address decompose stage follow-up carryover", () => {
result?.baseReasons?.includes("intent_from_followup_context")
).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", () => {

View File

@ -7,12 +7,36 @@ import { evaluateAddressRouteExpectation } from "../src/services/addressRouteExp
import { AddressQueryService } from "../src/services/addressQueryService";
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", () => {
const result = resolveAddressIntent("кто нам должен на июль 2020");
expect(result.intent).toBe("receivables_confirmed_as_of_date");
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", () => {
const filters = extractAddressFilters("кто нам должен на июль 2020", "receivables_confirmed_as_of_date").extracted_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");
});
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", () => {
const audit = evaluateAddressRouteExpectation({
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(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 () => {
const calls: Array<{ message: string; options?: any }> = [];
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");
});
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 () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
@ -1054,7 +1153,8 @@ describe("assistant address llm pre-decompose candidate preference", () => {
[
"llm_predecompose_semantic_guard_rejected",
"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);
});

View File

@ -315,6 +315,36 @@ describe("assistant orchestration contract", () => {
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", () => {
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",