Закрепить reviewed routes и защиту бизнес-ответов

This commit is contained in:
dctouch 2026-05-12 15:59:23 +03:00
parent b625f9af5b
commit 4fcf349894
47 changed files with 5341 additions and 416 deletions

View File

@ -77,7 +77,7 @@ exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = toBooleanFlag(process.env.FEAT
exports.ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "http://127.0.0.1:6003").replace(/\/+$/, "");
exports.ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
exports.ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
exports.ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
exports.ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 128)));
exports.VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
exports.VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);
exports.DATA_DIR = process.env.DATA_DIR ?? path_1.default.resolve(exports.MODULE_ROOT, "data");

View File

@ -1733,8 +1733,7 @@ function extractAddressFilters(userMessage, intent) {
const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase");
const preserveDerivedPeriodWindow = usesAsOfPrimaryWindow(intent) ||
intent === "inventory_on_hand_as_of_date" ||
const preserveDerivedPeriodWindow = intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_supplier_stock_overlap_as_of_date";
if (periodWasDerivedHeuristically && !warnings.includes("exact_historical_period_window_requested")) {
warnings.push("exact_historical_period_window_requested");

View File

@ -1672,7 +1672,8 @@ function hasVatPeriodInspectionBridgeSignal(text) {
const hasPeriodCue = /(?:\b(?:19|20)\d{2}\b|за\s+(?:\d{4}|год|период|квартал|месяц|январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)|\b[1-4]\s*(?:кв|квартал))/iu.test(normalized);
const hasInspectionCue = /(?:что\s+с|позици|основан|не\s+хватает|налогов[а-яё]*\s+вывод|вывод|декларац|книга\s+(?:продаж|покупок)|расшифр|разбор)/iu.test(normalized);
const forecastOnlyCue = /(?:прогноз|план|примерн|ориентировочн)/iu.test(normalized) && !hasInspectionCue;
return hasPeriodCue && hasInspectionCue && !forecastOnlyCue;
const hasVatMovementInspectionCue = /(?:покаж|движен|операц|по\s+сч(?:е|ё)т|покаж|движен|операц|РїРѕ\s+СЃС‡(?:Рµ|С)С|show|movement|movements|operation|operations|account)/iu.test(normalized);
return hasPeriodCue && (hasInspectionCue || hasVatMovementInspectionCue) && !forecastOnlyCue;
}
function resolveUnicodeAddressIntentBridge(text) {
const normalized = String(text ?? "").trim().toLowerCase();
@ -2044,6 +2045,16 @@ function resolveAddressIntent(userMessage) {
reasons
};
}
const hasExplicitVatLiabilityPeriodBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\b(?:19|20)\d{2}\b|\u0437\u0430\s+(?:\d{4}|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446))/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u043d\u0430\u0447\u0438\u0441\u043b|\u0443\u043f\u043b\u0430\u0447|\u0443\u043f\u043b\u0430\u0442|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u043e\u043a\u0443\u043f|\u0432\u044b\u0447\u0435\u0442|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043f\u043e\u0437\u0438\u0446|liability|payable|charged|paid|sales|purchase|deduction|position)/iu.test(text);
if (hasExplicitVatLiabilityPeriodBridge) {
return {
intent: "vat_liability_confirmed_for_tax_period",
confidence: "high",
reasons: ["vat_liability_explicit_period_bridge_signal_detected"]
};
}
const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) &&
/(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\u0432\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text);

View File

@ -197,6 +197,51 @@ const OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const DEBT_DUE_DATE_AGING_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"DUE_DATE_OPEN_BALANCE" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
"" КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты,
"debit_open_balance" КАК НаправлениеОстатка
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
__WHERE_DT__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"DUE_DATE_OPEN_BALANCE" КАК Регистратор,
"" КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокКт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты,
"credit_open_balance" КАК НаправлениеОстатка
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
__WHERE_KT__
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
@ -723,7 +768,7 @@ const BASE_RECIPES = [
purpose: "Build customer value ranking and incoming deal profile from bank inflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
default_limit: 200,
account_scope_mode: "preferred",
query_template: "customer_revenue_profile"
},
@ -733,7 +778,7 @@ const BASE_RECIPES = [
purpose: "Build supplier payout ranking and outgoing deal profile from bank outflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
default_limit: 200,
account_scope_mode: "preferred",
query_template: "supplier_payout_profile"
},
@ -778,6 +823,17 @@ const BASE_RECIPES = [
account_scope_mode: "preferred",
query_template: "vat_liability_confirmed_tax_period_profile"
},
{
recipe_id: "address_accounting_financial_result_for_organization_v1",
intent: "accounting_financial_result_for_organization",
purpose: "Build reviewed accounting financial-result aggregate from 90/91/99 period-close movements",
required_filters: ["period_from", "period_to"],
optional_filters: ["organization", "limit", "sort"],
default_limit: 32,
account_scope: ["90", "91", "99"],
account_scope_mode: "strict",
query_template: "accounting_financial_result_profile"
},
{
recipe_id: "address_inventory_on_hand_as_of_date_v1",
intent: "inventory_on_hand_as_of_date",
@ -888,6 +944,17 @@ const BASE_RECIPES = [
account_scope_mode: "strict",
query_template: "open_contracts_confirmed_as_of_balance_profile"
},
{
recipe_id: "address_debt_due_date_aging_for_organization_v1",
intent: "debt_due_date_aging_for_organization",
purpose: "Check open 60/62/76 settlements against contract payment-term fields and settlement document dates before claiming overdue debt",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 400,
account_scope: ["60", "62", "76"],
account_scope_mode: "strict",
query_template: "debt_due_date_aging_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@ -1093,6 +1160,32 @@ function buildContractValueWhereClause(filters, fieldPath, contractFieldPath) {
`${contractFieldPath} <> ЗНАЧЕНИЕ(Справочник.ДоговорыКонтрагентов.ПустаяСсылка)`
]);
}
function buildContractReferenceCondition(filters, fieldPaths) {
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
if (!contract) {
return null;
}
const contractTokens = Array.from(new Set(contract
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
.map((token) => token.trim())
.filter((token) => token.length >= 3)
.filter((token) => !["договор", "дог"].includes(token.toLowerCase()))));
const tokens = contractTokens.length > 0 ? contractTokens : [contract];
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => {
const tokenConditions = tokens.map((token) => {
const escapedToken = toQueryStringLiteral(token);
return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`;
});
return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`;
});
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function normalizeAccountTokenForQuery(value) {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/);
@ -1178,6 +1271,35 @@ function buildAccountPrefixPredicate(fieldPath, prefixes) {
const clauses = normalizedPrefixes.map((prefix) => `ПОДСТРОКА(ЕСТЬNULL(${fieldPath}.Код, ""), 1, ${prefix.length}) = "${prefix}"`);
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildDebtDueDateAgingWhereClause(filters, amountFieldPath, accountPredicate) {
const conditions = [
`${amountFieldPath} > 0`,
`(${accountPredicate})`,
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
buildCounterpartyReferenceCondition(filters, ["Остатки.Субконто1"]),
buildContractReferenceCondition(filters, ["Остатки.Субконто2"])
].filter((item) => Boolean(item));
return `ГДЕ\n ${conditions.join("\n И ")}`;
}
function buildDebtDueDateAgingQuery(filters, resolvedLimit) {
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) ??
"ТЕКУЩАЯДАТА()";
const accountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]);
return DEBT_DUE_DATE_AGING_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__WHERE_DT__", buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокДт", accountPredicate))
.replaceAll("__WHERE_KT__", buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокКт", accountPredicate))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryMovementQuery(filters, resolvedLimit, side) {
const debitPredicate = buildAccountPrefixPredicate("Движения.СчетДт", ["41.01"]);
const creditPredicate = buildAccountPrefixPredicate("Движения.СчетКт", ["41.01"]);
@ -1246,6 +1368,148 @@ function buildCounterpartyReferenceCondition(filters, fieldPaths) {
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
const ORGANIZATION_REFERENCE_STOP_WORDS = new Set([
"ооо",
"зао",
"оао",
"ао",
"пао",
"ип",
"на",
"за",
"по",
"конец",
"начало",
"год",
"года",
"период",
"можно",
"точно",
"понять",
"какая",
"какой",
"какие",
"какую",
"компания",
"компании",
"организация",
"организации",
"дебиторка",
"дебиторки",
"кредиторка",
"кредиторки",
"просрочена",
"просроченные",
"просрочка",
"срок",
"оплаты",
"прибыль",
"маржа",
"ндс"
]);
const ORGANIZATION_REFERENCE_BOUNDARY_WORDS = new Set([
"на",
"за",
"конец",
"начало",
"можно",
"точно",
"понять",
"какая",
"какой",
"какие",
"какую",
"дебиторка",
"дебиторки",
"кредиторка",
"кредиторки",
"просрочена",
"просроченные",
"просрочка",
"прибыль",
"маржа",
"ндс"
]);
function organizationReferenceTokens(organization) {
const rawTokens = organization
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
.map((token) => token.trim())
.filter((token) => token.length > 0);
const boundaryIndex = rawTokens.findIndex((token) => {
const lower = token.toLowerCase();
return /^\d+$/.test(token) || ORGANIZATION_REFERENCE_BOUNDARY_WORDS.has(lower);
});
const scopedTokens = boundaryIndex > 0 ? rawTokens.slice(0, boundaryIndex) : rawTokens;
return Array.from(new Set(scopedTokens
.filter((token) => token.length >= 3)
.filter((token) => !/^\d+$/.test(token))
.filter((token) => !ORGANIZATION_REFERENCE_STOP_WORDS.has(token.toLowerCase())))).slice(0, 4);
}
function buildOrganizationReferenceCondition(filters, fieldPaths) {
const organization = typeof filters.organization === "string" ? filters.organization.trim() : "";
if (!organization) {
return null;
}
const organizationTokens = organizationReferenceTokens(organization);
const tokens = organizationTokens.length > 0 ? organizationTokens : [organization];
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => {
const tokenConditions = tokens.map((token) => {
const escapedToken = toQueryStringLiteral(token);
return `(Организации.Наименование ПОДОБНО "%${escapedToken}%" ИЛИ Организации.НаименованиеПолное ПОДОБНО "%${escapedToken}%")`;
});
const referenceSubquery = `(ВЫБРАТЬ Организации.Ссылка ИЗ Справочник.Организации КАК Организации ` +
`ГДЕ ${tokenConditions.length === 1 ? tokenConditions[0] : tokenConditions.join(" И ")})`;
return `${fieldPath} В ${referenceSubquery}`;
});
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildAccountingFinancialResultAggregateSelect(filters, marker, debitLabel, creditLabel, debitPrefixes, creditPrefixes) {
const whereClause = buildWhereClause(filters, "Движения.Период", [
debitPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетДт", debitPrefixes) : null,
creditPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетКт", creditPrefixes) : null,
buildOrganizationReferenceCondition(filters, ["Движения.Организация"])
].filter((item) => Boolean(item)));
return `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"${marker}" КАК Регистратор,
"${debitLabel}" КАК СчетДт,
"${creditLabel}" КАК СчетКт,
ЕСТЬNULL(СУММА(Движения.Сумма), 0) КАК Сумма,
"" КАК СубконтоДт1,
"" КАК СубконтоДт2,
"" КАК СубконтоДт3,
"" КАК СубконтоКт1,
"" КАК СубконтоКт2,
"" КАК СубконтоКт3,
"" КАК Организация
ИЗ
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
${whereClause}`;
}
function buildAccountingFinancialResultQuery(filters) {
const rows = [
["ACC90_REVENUE_KT", "ANY", "90.01", [], ["90.01"]],
["ACC90_COST_DT", "90.02", "ANY", ["90.02"], []],
["ACC90_SELLING_DT", "90.07", "ANY", ["90.07"], []],
["ACC90_ADMIN_DT", "90.08", "ANY", ["90.08"], []],
["ACC90_RESULT_TO_99_PROFIT", "90.09", "99", ["90.09"], ["99"]],
["ACC90_RESULT_FROM_99_LOSS", "99", "90.09", ["99"], ["90.09"]],
["ACC91_RESULT_TO_99_PROFIT", "91.09", "99", ["91.09"], ["99"]],
["ACC91_RESULT_FROM_99_LOSS", "99", "91.09", ["99"], ["91.09"]],
["ACC99_TO84_PROFIT_TRANSFER", "99", "84", ["99"], ["84"]],
["ACC84_TO99_LOSS_TRANSFER", "84", "99", ["84"], ["99"]]
];
return rows
.map(([marker, debitLabel, creditLabel, debitPrefixes, creditPrefixes]) => buildAccountingFinancialResultAggregateSelect(filters, marker, debitLabel, creditLabel, [...debitPrefixes], [...creditPrefixes]).trim())
.join("\nОБЪЕДИНИТЬ ВСЕ\n");
}
function buildInventorySaleDocumentQuery(filters, resolvedLimit) {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
@ -1324,6 +1588,8 @@ function maxLimitForIntent(intent) {
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "accounting_financial_result_for_organization" ||
intent === "debt_due_date_aging_for_organization" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
@ -1374,7 +1640,8 @@ function buildAddressRecipePlan(recipe, filters) {
recipe.query_template === "counterparty_roles_profile" ||
recipe.query_template === "contract_usage_profile" ||
recipe.query_template === "vat_payable_forecast_profile" ||
recipe.query_template === "vat_liability_confirmed_tax_period_profile";
recipe.query_template === "vat_liability_confirmed_tax_period_profile" ||
recipe.query_template === "accounting_financial_result_profile";
const baseLimit = typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
: recipe.default_limit;
@ -1467,97 +1734,65 @@ function buildAddressRecipePlan(recipe, filters) {
.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
.replaceAll("__PERIOD_TO_EXPR__", periodToExpr);
})()
: recipe.query_template === "vat_payable_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0
? toDateTimeExpr(filters.as_of_date, true)
: null) ??
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_on_hand_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)
: recipe.query_template === "accounting_financial_result_profile"
? buildAccountingFinancialResultQuery(filters)
: recipe.query_template === "debt_due_date_aging_profile"
? buildDebtDueDateAgingQuery(filters, resolvedLimit)
: 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_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_profitability_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_trading_margin_proxy_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
(typeof filters.period_to === "string" && filters.period_to.trim().length > 0
? toDateTimeExpr(filters.period_to, true)
: null) ??
(typeof filters.period_from === "string" && filters.period_from.trim().length > 0
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__VAT_PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", config_1.VAT_PAYABLE_68_PREFIXES))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_on_hand_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 INVENTORY_ON_HAND_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__INVENTORY_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["41.01"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: recipe.query_template === "inventory_purchase_provenance_profile"
? buildInventoryPurchaseDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_documents_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_supplier_stock_overlap_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "inventory_sale_trace_profile"
? buildInventorySaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_profitability_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_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 OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]))
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
})()
: 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)
: 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"
: recipe.query_template === "inventory_trading_margin_proxy_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_purchase_to_sale_chain_profile"
? buildInventoryPurchaseToSaleDocumentQuery(filters, resolvedLimit)
: recipe.query_template === "inventory_aging_by_purchase_date_profile"
? buildInventoryMovementQuery(filters, resolvedLimit, "dt")
: recipe.query_template === "contracts_by_counterparty_profile"
? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit))
: recipe.query_template === "open_contracts_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)
@ -1569,23 +1804,59 @@ function buildAddressRecipePlan(recipe, filters) {
? toDateTimeExpr(filters.period_from, true)
: null) ??
"ТЕКУЩАЯДАТА()";
return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE
return OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"]))
.replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "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));
: 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)
: 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"
? (() => {
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

@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.contractCandidatesFromRows = contractCandidatesFromRows;
exports.composeFactualReply = composeFactualReply;
exports.inferReplyType = inferReplyType;
const assistantOrganizationMatcher_1 = require("../assistantOrganizationMatcher");
const replyPackaging_1 = require("./replyPackaging");
const counterpartyAnalyticsReplyBuilders_1 = require("./counterpartyAnalyticsReplyBuilders");
const inventoryReplyBuilders_1 = require("./inventoryReplyBuilders");
@ -515,10 +516,12 @@ function detectValueRankingFocus(userMessage) {
if (asksTotalMoneyEarned) {
return "total_flow";
}
const hasCounterpartyRankingSubject = /(?:клиент|заказчик|покупател|контрагент|customer|client|counterpart|\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442)/iu.test(text);
const asksExplicitYearBreakdown = /(?:по\s+годам|за\s+какие\s+годы|динамик\w*\s+по\s+год|yearly\s+breakdown|by\s+year|\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\u043a\u0430\u043a\u0438\u0435\s+\u0433\u043e\u0434\u044b|\u0434\u0438\u043d\u0430\u043c\u0438\u043a\w*\s+\u043f\u043e\s+\u0433\u043e\u0434)/iu.test(text);
const asksYearlyRevenueRanking = /(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text);
if (asksYearlyRevenueRanking) {
if (asksYearlyRevenueRanking && (!hasCounterpartyRankingSubject || asksExplicitYearBreakdown)) {
return "top_years_by_total";
}
if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) {
@ -2516,7 +2519,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const nonErrorProbeRows = orderedProbeRows.filter((item) => item.status !== "error");
const visibleProbeRows = (nonErrorProbeRows.length > 0 ? nonErrorProbeRows : orderedProbeRows).slice(0, 6);
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`);
lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`);
if (visibleProbeRows.length > 0) {
lines.push(...visibleProbeRows.map((item, index) => {
const name = item.synonym ? `${item.fullName} (${item.synonym})` : item.fullName;
@ -2536,7 +2539,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*.");
lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
lines.push(`В выбранном окне не найдено движений по НДС-субсчетам 68.02*/19*; поэтому оперативный прогноз к уплате равен ${formatForecastMoney(0)}.`);
@ -2588,12 +2591,15 @@ function composeFactualReplyBody(intent, rows, options = {}) {
const periodWindowLabel = options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const formatConfirmedMoney = (value) => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const organizationLabel = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период — ${formatConfirmedMoney(vatToPay)}.`,
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`,
`Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`,
"Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.",
"",
"Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
@ -2602,14 +2608,14 @@ function composeFactualReplyBody(intent, rows, options = {}) {
if (vatProbe && vatProbe.status === "ok") {
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push("", "Покрытие VAT-источников через MCP:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`);
lines.push("", "Покрытие VAT-источников в 1С:", `- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`, `- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`, `- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`, `- Источников с ошибкой запроса: ${formatNumberWithDots(erroredSources)}.`);
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников.");
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников.");
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия.");
lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.", "Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия.");
if (vatProbe.errors.length > 0) {
lines.push(`- Детали probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
@ -2679,7 +2685,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
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)}.`);
lines.push("", "Блок 2.1. Проверка VAT-источников в 1С", `- 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;
@ -2696,7 +2702,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
}
}
else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Блок 2.1. MCP-проверка VAT-источников", "- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*).");
lines.push("", "Блок 2.1. Проверка VAT-источников в 1С", "- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*).");
}
lines.push("", "Блок 3. Сводка", `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, `- Подтвержденных позиций по НДС: ${formatNumberWithDots(accountRows.length)}.`, "", "Блок 4. Подтвержденные позиции");
if (accountRows.length > 0) {

View File

@ -48,9 +48,9 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const includeTotal = focus === "full_profile" || focus === "total_only";
const includeRoles = focus === "full_profile" || focus === "roles_only";
const directLead = focus === "suppliers_only"
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
? `Поставщиков с ролью поставщика: ${supplierOnly}.`
: focus === "customers_only"
? `Заказчиков (только customer-роль): ${customerOnly}.`
? `Заказчиков с ролью покупателя: ${customerOnly}.`
: focus === "mixed_only"
? `Контрагентов со смешанной ролью: ${mixedActive}.`
: includeTotal && totalCounterparties > 0
@ -74,9 +74,9 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
}
if (includeRoles) {
if (resolvedActive > 0 || activeCounterparties > 0) {
lines.push("Роли контрагентов по активности:");
lines.push(`Заказчики (только customer-роль): ${customerOnly}.`);
lines.push(`Поставщики (только supplier-роль): ${supplierOnly}.`);
lines.push("Распределение ролей по активности:");
lines.push(`Заказчики с ролью покупателя: ${customerOnly}.`);
lines.push(`Поставщики с ролью поставщика: ${supplierOnly}.`);
lines.push(`Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`);
if (otherCounterparties !== null) {
@ -88,10 +88,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
}
}
if (focus === "suppliers_only") {
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
lines.push(`Поставщиков с ролью поставщика: ${supplierOnly}.`);
}
if (focus === "customers_only") {
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
lines.push(`Заказчиков с ролью покупателя: ${customerOnly}.`);
}
if (focus === "mixed_only") {
lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`);
@ -387,6 +387,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const limit = deps.detectRankingLimit(options.userMessage, 20);
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
const asksSingleBestCounterparty = focus === "top_by_total" &&
/(?:какой|кто|which|who|какой|кто)/iu.test(normalizedQuestion) &&
/(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больше\s+всего|сам(?:ый|ая|РѕРµ|ые)|наибол|РїСЂРёРЅ[её]СЃ)/iu.test(normalizedQuestion) &&
!/(?:\btop\b|топ|рейтинг|список|первые|покажи\s+топ|дай\s+топ|покаж\w*\s+топ|дай\s+топ)/iu.test(normalizedQuestion);
const effectiveLimit = asksSingleBestCounterparty ? 1 : limit;
const byCounterparty = new Map();
const byYear = new Map();
const deals = [];
@ -554,7 +559,7 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
? `Топ-${visible.length} поставщиков по максимальной разовой выплате:`
: `Топ-${visible.length} заказчиков по максимальной сумме одной входящей операции:`;
lines.unshift(heading);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | max single: ${item.maxSingle} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`));
lines.push(...visible.map((item, index) => `${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`));
return (0, replyContracts_1.buildFactualListReply)(lines);
}
if (focus === "top_by_avg_check_min_ops") {
@ -592,8 +597,11 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
lines.push(...visible.map((item, index) => `${index + 1}. ${formatOptionalDate(item.period, deps.formatDateRu)} | ${item.counterparty} | ${item.registrator} | ${deps.formatMoneyRub(item.amount)}`));
return (0, replyContracts_1.buildFactualListReply)(lines);
}
const visible = rankedByTotal.slice(0, limit);
const visible = rankedByTotal.slice(0, effectiveLimit);
const singleCandidateOnly = rankedByTotal.length === 1;
const rankingPeriodLabel = options.periodFrom && options.periodTo
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
: "за доступное время";
const heading = singleCandidateOnly
? isSupplier
? "Найденный поставщик по сумме выплат:"
@ -603,14 +611,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
const leadingCounterparty = visible[0] ?? null;
lines.unshift(heading);
if (options.periodFrom && options.periodTo) {
lines.push(`Период рейтинга: ${rankingPeriodLabel}.`);
}
if (leadingCounterparty) {
const directAnswerLine = singleCandidateOnly
? isSupplier
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
lines.unshift(directAnswerLine);
}
lines.push(...visible.map((item, index) => {

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryAnswerDraft = buildAssistantMcpDiscoveryAnswerDraft;
const counterpartyRoleHeuristics_1 = require("./counterpartyRoleHeuristics");
exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION = "assistant_mcp_discovery_answer_draft_v1";
function normalizeReasonCode(value) {
const normalized = value
@ -371,6 +372,26 @@ function metadataRouteFamilyLabelRu(routeFamily) {
}
return null;
}
function isInventoryReserveBoundaryTurn(pilot) {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "inventory_reserve_boundary" || unsupported === "inventory_reserve_liquidation_boundary";
}
function isProfitMarginBoundaryTurn(pilot) {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "profit_margin_boundary" || unsupported === "profit_margin_boundary";
}
function isDebtDueDateBoundaryTurn(pilot) {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "debt_due_date_boundary" || unsupported === "debt_due_date_boundary";
}
function isVendorRiskBoundaryTurn(pilot) {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "vendor_risk_procurement_boundary" || unsupported === "vendor_risk_procurement_boundary";
}
function businessOverviewInventoryUnknownLabel(overview) {
if (overview.inventory_staleness_risk_proxy) {
return "резервы/списания/ликвидационная стоимость склада";
@ -433,6 +454,67 @@ function inlineBusinessOverviewAmount(value) {
.replace(/\s*руб\.$/u, " рублей")
.replace(/[\s.]+$/u, "");
}
function isFinancialInstitutionBucket(bucket) {
if (!bucket) {
return false;
}
return (bucket.counterparty_role_hint === "bank_or_financial_institution" ||
(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(bucket.axis_value));
}
function firstNonFinancialInstitutionBucket(buckets) {
return (buckets ?? []).find((bucket) => !isFinancialInstitutionBucket(bucket)) ?? null;
}
function rankedBucketAmountLabel(bucket) {
return `${bucket.axis_value}${bucket.total_amount_human_ru}`;
}
function businessOverviewIncomingLeaderLine(overview) {
const leader = overview.top_customers[0];
if (!leader) {
return null;
}
if (!isFinancialInstitutionBucket(leader)) {
return `Самый крупный подтвержденный клиент в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`;
}
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1));
const nonFinancialText = nonFinancial
? ` Крупнейший небанковский входящий контрагент в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.`
: "";
return (`Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` +
"По названию это банк/финансовая организация, поэтому без проверки назначения платежа не называю это клиентской выручкой или бизнес-заказчиком." +
nonFinancialText);
}
function businessOverviewOutgoingLeaderLine(overview) {
const leader = overview.top_suppliers?.[0];
if (!leader) {
return null;
}
if (!isFinancialInstitutionBucket(leader)) {
return `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`;
}
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1));
const nonFinancialText = nonFinancial
? ` Крупнейший небанковский получатель исходящих денег в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.`
: "";
return (`Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` +
"По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком." +
nonFinancialText);
}
function businessOverviewSupplierBoundaryBasis(overview) {
const leader = overview.top_suppliers?.[0] ?? null;
if (!leader) {
return "есть только общий срез исходящих платежей без надежного vendor-risk профиля";
}
const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount);
if (isFinancialInstitutionBucket(leader)) {
const base = share
? `крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})`
: `крупнейший получатель исходящих денег: ${rankedBucketAmountLabel(leader)}`;
return `${base}; по названию это банк/финансовая организация, поэтому этот факт нельзя считать доказанной зависимостью от одного обычного поставщика`;
}
return share
? `крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})`
: `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${rankedBucketAmountLabel(leader)}`;
}
function businessOverviewHeadlineMetricsLine(overview) {
const parts = [];
if (overview.incoming_customer_revenue.rows_with_amount > 0) {
@ -444,14 +526,72 @@ function businessOverviewHeadlineMetricsLine(overview) {
if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) {
parts.push(`расчетное операционное нетто ${inlineBusinessOverviewAmount(overview.net_amount_human_ru)}`);
}
if (overview.accounting_financial_result) {
const result = overview.accounting_financial_result;
const direction = result.final_result_direction === "profit"
? "учетная прибыль"
: result.final_result_direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const amount = result.final_result_direction === "loss"
? `минус ${inlineBusinessOverviewAmount(result.final_result_amount_human_ru)}`
: inlineBusinessOverviewAmount(result.final_result_amount_human_ru);
const margin = result.net_margin_to_revenue_pct === null
? "маржа к выручке 90.01 не рассчитана"
: `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`;
parts.push(`${direction} 90/91/99 ${amount}; ${margin}`);
}
const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview);
if (strongestIncomingYear) {
parts.push(`самый сильный год по подтвержденным входящим поступлениям ${strongestIncomingYear.year_bucket}: ${inlineBusinessOverviewAmount(strongestIncomingYear.incoming_total_amount_human_ru)}`);
}
return parts.length > 0
? `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат`
? overview.accounting_financial_result
? `${parts.join("; ")}. Финрезультат ограничен найденными строками 1С и не является внешним аудитом или юридически подтвержденной отчетностью`
: `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат`
: null;
}
function businessOverviewAccountingFinancialResultText(overview) {
const result = overview.accounting_financial_result;
if (!result) {
return null;
}
const direction = result.final_result_direction === "profit"
? "учетная прибыль"
: result.final_result_direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const signedAmount = result.final_result_direction === "loss"
? `минус ${result.final_result_amount_human_ru}`
: result.final_result_amount_human_ru;
const marginText = result.net_margin_to_revenue_pct === null
? "маржа к выручке 90.01 не рассчитана"
: `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`;
const basis = result.final_transfer_basis === "account_99_to_84_period_close"
? "по закрытию 99 на 84"
: "по закрытию 90/91 на 99";
return `По бухгалтерскому маршруту 90/91/99 за ${result.period_scope} подтвержден ${direction}: ${signedAmount}; ${marginText}. Основа: ${basis}, ${result.period_close_rows_with_amount} строк(и) закрытия периода с суммой. Это учетный финрезультат по найденным строкам 1С, не внешний аудит и не юридически подтвержденная отчетность.`;
}
function businessOverviewDebtDueDateAgingText(overview) {
const aging = overview.debt_due_date_aging;
if (!aging) {
return null;
}
if (aging.evidence_status === "confirmed_overdue") {
const top = aging.top_overdue_items?.[0] ?? null;
const topText = top
? ` Самая старая строка: due date ${top.due_date}, просрочка ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.`
: "";
return `Due-date aging на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`;
}
if (aging.evidence_status === "no_payment_terms_configured") {
return `Due-date aging на ${aging.as_of_date} проверен по открытым расчетам: брутто ${aging.gross_open_amount_human_ru}, строк с суммой ${aging.rows_with_amount}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`;
}
if (aging.evidence_status === "insufficient_due_date_basis") {
return `Due-date aging на ${aging.as_of_date} запускался, но по строкам с установленным сроком оплаты не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`;
}
return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`;
}
function headlineFor(mode, pilot) {
const askedMonthlyBreakdown = pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" ||
pilot.derived_value_flow?.aggregation_axis === "month";
@ -469,6 +609,35 @@ function headlineFor(mode, pilot) {
}
if (isBusinessOverviewPilot(pilot) && pilot.derived_business_overview && mode === "confirmed_with_bounded_inference") {
const overview = pilot.derived_business_overview;
if (isProfitMarginBoundaryTurn(pilot)) {
const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview);
if (accountingFinancialResultText) {
return accountingFinancialResultText;
}
return "Нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финрезультат.";
}
if (isDebtDueDateBoundaryTurn(pilot)) {
const dueDateText = businessOverviewDebtDueDateAgingText(overview);
if (dueDateText) {
return dueDateText;
}
return "Нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута по договорам, срокам оплаты и погашению расчетов.";
}
if (isInventoryReserveBoundaryTurn(pilot)) {
const inventoryBasis = overview.inventory_staleness_risk_proxy
? "есть только складской staleness-risk proxy по найденным строкам"
: overview.inventory_position || overview.inventory_turnover_proxy
? "есть только ограниченные складские proxy-сигналы по найденным строкам"
: "нет отдельного складского среза на дату и проверки учетной политики резервов";
return `Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя; ${inventoryBasis}. Можно честно говорить только о необходимости отдельной проверки склада, списаний/резервов и ликвидационной стоимости, не превращая proxy в доказанный факт резерва.`;
}
if (isVendorRiskBoundaryTurn(pilot)) {
const supplierLeader = overview.top_suppliers?.[0] ?? null;
const proxyLabel = isFinancialInstitutionBucket(supplierLeader)
? "outgoing cash concentration proxy"
: "procurement concentration proxy";
return `Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${businessOverviewSupplierBoundaryBasis(overview)}. Это сигнал концентрации исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов.`;
}
const families = [];
if (overview.incoming_customer_revenue.rows_with_amount > 0 ||
overview.outgoing_supplier_payout.rows_with_amount > 0) {
@ -492,6 +661,9 @@ function headlineFor(mode, pilot) {
if (overview.tax_position) {
families.push("НДС-позиция");
}
if (overview.accounting_financial_result) {
families.push("учетный финрезультат 90/91/99");
}
if (overview.trading_margin_proxy) {
families.push("торговый margin proxy");
}
@ -507,6 +679,9 @@ function headlineFor(mode, pilot) {
if (overview.debt_staleness_risk_proxy) {
families.push("staleness risk proxy открытых расчетов");
}
if (overview.debt_due_date_aging) {
families.push("due-date aging открытых расчетов");
}
if (overview.inventory_position) {
families.push("складской срез на дату");
}
@ -516,18 +691,22 @@ function headlineFor(mode, pilot) {
if (overview.inventory_staleness_risk_proxy) {
families.push("staleness risk proxy склада");
}
const unknownFamilies = [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"];
const unknownFamilies = overview.accounting_financial_result
? ["аудированная/юридически подтвержденная прибыль"]
: [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"];
if (!overview.tax_position) {
unknownFamilies.push("НДС");
}
if (!overview.debt_position) {
unknownFamilies.push("долговой срез");
}
unknownFamilies.push(overview.debt_staleness_risk_proxy
? "договорные сроки оплаты/due-date просрочка"
: overview.debt_open_settlement_quality
? "due-date просрочка"
: "качество открытых расчетов");
if (!overview.debt_due_date_aging) {
unknownFamilies.push(overview.debt_staleness_risk_proxy
? "договорные сроки оплаты/due-date просрочка"
: overview.debt_open_settlement_quality
? "due-date просрочка"
: "качество открытых расчетов");
}
unknownFamilies.push(businessOverviewInventoryUnknownLabel(overview));
const metricLead = businessOverviewHeadlineMetricsLine(overview);
if (metricLead) {
@ -725,9 +904,14 @@ function buildMustNotClaim(pilot) {
claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis.");
claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt.");
claims.push("Do not present business overview debt staleness risk proxy as confirmed overdue debt, contractual delinquency, credit risk, or due-date aging.");
claims.push("Do not claim contractual overdue debt unless the due-date aging route found configured payment terms and enough settlement-date evidence.");
claims.push("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health.");
claims.push("Do not present business overview inventory turnover proxy as full inventory liquidity, FIFO turnover, obsolescence analysis, or liquidation value.");
claims.push("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value.");
if (pilot.derived_business_overview?.top_customers?.some(isFinancialInstitutionBucket) ||
pilot.derived_business_overview?.top_suppliers?.some(isFinancialInstitutionBucket)) {
claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof.");
}
if (pilot.derived_business_overview?.missing_proof_families?.length) {
claims.push("Do not present business overview missing proof families as checked, executed, or confirmed routes.");
}
@ -736,6 +920,9 @@ function buildMustNotClaim(pilot) {
if (pilot.derived_ranked_value_flow) {
claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization.");
claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist.");
if (pilot.derived_ranked_value_flow.ranked_values.some(isFinancialInstitutionBucket)) {
claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof.");
}
}
if (isDocumentPilot(pilot)) {
claims.push("Do not claim full document history outside the checked period.");
@ -885,24 +1072,38 @@ function derivedRankedValueFlowConfirmedLine(pilot) {
return null;
}
const leader = ranking.ranked_values[0];
const leaderLooksFinancial = isFinancialInstitutionBucket(leader);
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
const roleCaveat = leaderLooksFinancial
? ranking.value_flow_direction === "outgoing_supplier_payout"
? " По названию это банк/финансовая организация, поэтому без назначения платежа/договора не называю это обычным поставщиком."
: " По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой или бизнес-заказчиком."
: "";
if (ranking.ranked_values.length === 1) {
const singleLead = ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один контрагент"
: "В проверенных входящих поступлениях найден один контрагент";
const singleLead = leaderLooksFinancial
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один банковский/финансовый получатель"
: "В проверенных входящих поступлениях найден один банковский/финансовый источник"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один контрагент"
: "В проверенных входящих поступлениях найден один контрагент";
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; сравнение с другими контрагентами может быть неполным."
: " Других контрагентов в этом проверенном срезе не найдено, поэтому это не полноценный сравнительный рейтинг.";
return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${limitCaveat}`;
return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${limitCaveat}`;
}
const directionLead = ranking.ranking_need === "bottom_asc"
const directionLead = leaderLooksFinancial
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
: "Меньше всего денег принёс контрагент"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "Больше всего заплатили контрагенту"
: "Больше всего денег принёс контрагент";
? "Крупнейший получатель исходящих денег"
: "Крупнейший входящий денежный источник"
: ranking.ranking_need === "bottom_asc"
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
: "Меньше всего денег принёс контрагент"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "Больше всего заплатили контрагенту"
: "Больше всего денег принёс контрагент";
const tail = ranking.ranked_values
.slice(1, 3)
.map((bucket) => `${bucket.axis_value}${bucket.total_amount_human_ru}`)
@ -911,7 +1112,7 @@ function derivedRankedValueFlowConfirmedLine(pilot) {
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; рейтинг может быть неполным."
: "";
return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`;
return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${trail}${limitCaveat}`;
}
function derivedValueFlowConfirmedLine(pilot) {
const flow = pilot.derived_value_flow;
@ -1070,13 +1271,13 @@ function derivedBusinessOverviewConfirmedLines(pilot) {
if (strongestIncomingYear) {
lines.push(`Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket}${strongestIncomingYear.incoming_total_amount_human_ru} по ${strongestIncomingYear.incoming_rows_with_amount} строкам с суммой. Это не бухгалтерская прибыль.`);
}
const leader = overview.top_customers[0];
if (leader) {
lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`);
const incomingLeaderLine = businessOverviewIncomingLeaderLine(overview);
if (incomingLeaderLine) {
lines.push(incomingLeaderLine);
}
const supplierLeader = overview.top_suppliers?.[0];
if (supplierLeader) {
lines.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}.`);
const outgoingLeaderLine = businessOverviewOutgoingLeaderLine(overview);
if (outgoingLeaderLine) {
lines.push(outgoingLeaderLine);
}
if (overview.yearly_breakdown?.length) {
lines.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(overview.yearly_breakdown.length)}.`);
@ -1124,6 +1325,12 @@ function derivedBusinessOverviewConfirmedLines(pilot) {
: "сбалансирован";
lines.push(`НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.`);
}
if (overview.accounting_financial_result) {
const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview);
if (accountingFinancialResultText) {
lines.push(accountingFinancialResultText);
}
}
if (overview.trading_margin_proxy) {
const proxy = overview.trading_margin_proxy;
const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`;
@ -1156,6 +1363,10 @@ function derivedBusinessOverviewConfirmedLines(pilot) {
const counterpartyText = proxy.top_contract_counterparty ? ` / ${proxy.top_contract_counterparty}` : "";
lines.push(`Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.`);
}
const dueDateText = businessOverviewDebtDueDateAgingText(overview);
if (dueDateText) {
lines.push(dueDateText);
}
if (overview.inventory_position) {
const leader = overview.inventory_position.top_items[0];
const leaderText = leader
@ -1203,6 +1414,13 @@ function businessOverviewCustomerConcentrationLine(overview) {
return null;
}
const share = percentText(leader.total_amount, overview.incoming_customer_revenue.total_amount);
if (isFinancialInstitutionBucket(leader)) {
const base = share
? `Крупнейший входящий денежный источник ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru})`
: `Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}`;
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1));
return `${base}. По названию это банк/финансовая организация, поэтому это не доказывает клиентскую выручку или зависимость от клиента.${nonFinancial ? ` Крупнейший небанковский входящий контрагент: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`;
}
return share
? `Концентрация входящего потока: крупнейший подтвержденный клиент ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru}). Это сигнал зависимости от клиента, а не полный customer-risk аудит.`
: `Крупнейший подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`;
@ -1213,6 +1431,13 @@ function businessOverviewSupplierConcentrationLine(overview) {
return null;
}
const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount);
if (isFinancialInstitutionBucket(leader)) {
const base = share
? `Концентрация исходящего потока: крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru})`
: `Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}`;
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1));
return `${base}. По названию это банк/финансовая организация, поэтому это не доказательство зависимости от обычного поставщика без проверки назначения платежа/договора.${nonFinancial ? ` Крупнейший небанковский получатель исходящих денег: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`;
}
return share
? `Концентрация исходящего потока: крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru}). Это сигнал procurement concentration по найденным строкам, а не полный vendor-risk аудит или структура всех расходов.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`;
@ -1257,6 +1482,18 @@ function businessOverviewRiskSynthesisLine(overview) {
: `маржинальность proxy ${overview.trading_margin_proxy.margin_to_revenue_pct}%`;
signals.push(`торговый спред proxy ${overview.trading_margin_proxy.gross_spread_proxy_human_ru}, ${marginText}`);
}
if (overview.accounting_financial_result) {
const result = overview.accounting_financial_result;
const direction = result.final_result_direction === "profit"
? "учетная прибыль"
: result.final_result_direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const marginText = result.net_margin_to_revenue_pct === null
? "маржа к выручке 90.01 не рассчитана"
: `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`;
signals.push(`${direction} 90/91/99 ${result.final_result_amount_human_ru}, ${marginText}`);
}
if (overview.debt_position) {
const debtDirection = overview.debt_position.net_debt_position_direction === "net_receivable"
? `дебиторка больше кредиторки на ${overview.debt_position.net_debt_position_amount_human_ru}`
@ -1275,6 +1512,16 @@ function businessOverviewRiskSynthesisLine(overview) {
if (overview.debt_staleness_risk_proxy) {
signals.push(`staleness risk proxy открытых расчетов: ${debtStalenessRiskBandRu(overview.debt_staleness_risk_proxy.risk_band)}, возраст ${overview.debt_staleness_risk_proxy.max_contract_age_days} дн., концентрация старейшего крупного договора ${overview.debt_staleness_risk_proxy.top_contract_share_pct}%`);
}
if (overview.debt_due_date_aging) {
const aging = overview.debt_due_date_aging;
signals.push(aging.evidence_status === "confirmed_overdue"
? `due-date aging: подтвержденная просрочка ${aging.overdue_amount_human_ru}, строк ${aging.overdue_rows}`
: aging.evidence_status === "no_payment_terms_configured"
? "due-date aging: проверено, но сроки оплаты в договорах не установлены; подтвержденной просрочки нет"
: aging.evidence_status === "insufficient_due_date_basis"
? "due-date aging: не хватило даты расчетного документа для честного расчета просрочки"
: `due-date aging: проверено, подтвержденной просрочки не найдено`);
}
if (overview.document_activity_profile) {
const topDocument = overview.document_activity_profile.top_document_types[0];
const topSection = overview.document_activity_profile.top_account_sections[0];
@ -1411,6 +1658,9 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
if (pilot.derived_business_overview?.tax_position) {
pushReason(reasonCodes, "answer_contains_business_overview_tax_position");
}
if (pilot.derived_business_overview?.accounting_financial_result) {
pushReason(reasonCodes, "answer_contains_business_overview_accounting_financial_result");
}
if (pilot.derived_business_overview?.trading_margin_proxy) {
pushReason(reasonCodes, "answer_contains_business_overview_trading_margin_proxy");
}
@ -1441,6 +1691,10 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
if (pilot.derived_business_overview?.debt_staleness_risk_proxy) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_staleness_risk_proxy");
}
if (pilot.derived_business_overview?.debt_due_date_aging) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_due_date_aging");
pushReason(reasonCodes, `answer_contains_business_overview_debt_due_date_aging_${pilot.derived_business_overview.debt_due_date_aging.evidence_status}`);
}
if (pilot.derived_business_overview?.inventory_position) {
pushReason(reasonCodes, "answer_contains_business_overview_inventory_position");
}

View File

@ -33,6 +33,11 @@ function isMcpDiscoveryEntryPointContract(value) {
return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" &&
record?.policy_owner === "assistantMcpDiscoveryRuntimeEntryPoint");
}
function isRouteCandidateContract(value) {
const record = toRecordObject(value);
return (record?.schema_version === "assistant_mcp_route_candidate_v1" &&
record?.policy_owner === "assistantMcpDiscoveryRuntimeBridge");
}
function resolveEntryPoint(input) {
if (isMcpDiscoveryEntryPointContract(input.entryPoint)) {
return input.entryPoint;
@ -47,6 +52,7 @@ function buildAssistantMcpDiscoveryDebugAttachmentFields(input) {
const bridge = toRecordObject(entryPoint?.bridge);
const planner = toRecordObject(bridge?.planner);
const chainAlignment = toRecordObject(planner?.catalog_chain_template_alignment);
const routeCandidate = isRouteCandidateContract(bridge?.route_candidate) ? bridge.route_candidate : null;
const answerDraft = toRecordObject(bridge?.answer_draft);
return {
assistant_mcp_discovery_entry_point_v1: entryPoint,
@ -59,6 +65,16 @@ function buildAssistantMcpDiscoveryDebugAttachmentFields(input) {
mcp_discovery_catalog_chain_alignment_status: toNonEmptyString(chainAlignment?.alignment_status),
mcp_discovery_catalog_chain_top_match: toNonEmptyString(chainAlignment?.top_chain_template_match),
mcp_discovery_catalog_chain_selected_matches_top: chainAlignment?.selected_chain_matches_top === true,
mcp_discovery_route_candidate_v1: routeCandidate,
mcp_discovery_route_candidate_status: toNonEmptyString(routeCandidate?.candidate_status),
mcp_discovery_route_candidate_fact_family: toNonEmptyString(routeCandidate?.business_fact_family),
mcp_discovery_route_candidate_action_family: toNonEmptyString(routeCandidate?.action_family),
mcp_discovery_route_candidate_proof_expectation: toNonEmptyString(routeCandidate?.proof_expectation),
mcp_discovery_route_candidate_missing_axes: toStringArray(routeCandidate?.missing_axes),
mcp_discovery_route_candidate_provided_axes: toStringArray(routeCandidate?.provided_axes),
mcp_discovery_route_candidate_executable_now: routeCandidate?.executable_now === true,
mcp_discovery_route_candidate_enablement_reason: toNonEmptyString(routeCandidate?.enablement_reason),
mcp_discovery_route_candidate_next_action: toNonEmptyString(routeCandidate?.recommended_next_action),
mcp_discovery_answer_mode: toNonEmptyString(answerDraft?.answer_mode),
mcp_discovery_business_fact_answer_allowed: bridge?.business_fact_answer_allowed === true,
mcp_discovery_user_facing_response_allowed: bridge?.user_facing_response_allowed === true,

View File

@ -6,6 +6,7 @@ const addressMcpClient_1 = require("./addressMcpClient");
const assistantMcpDiscoveryRuntimeAdapter_1 = require("./assistantMcpDiscoveryRuntimeAdapter");
const assistantMcpDiscoveryPolicy_1 = require("./assistantMcpDiscoveryPolicy");
const addressRecipeCatalog_1 = require("./addressRecipeCatalog");
const counterpartyRoleHeuristics_1 = require("./counterpartyRoleHeuristics");
exports.ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION = "assistant_mcp_discovery_pilot_executor_v1";
const DEFAULT_DEPS = {
executeAddressMcpQuery: addressMcpClient_1.executeAddressMcpQuery,
@ -200,6 +201,16 @@ function buildBusinessOverviewDebtFilters(planner) {
sort: "period_asc"
};
}
function shouldRunDebtDueDateAgingProbe(planner) {
const actionFamily = toNonEmptyString(planner.data_need_graph?.action_family);
const turnActionFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_action_family);
const unsupportedFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family);
const proofExpectation = toNonEmptyString(planner.data_need_graph?.proof_expectation);
const combined = [actionFamily, turnActionFamily, unsupportedFamily, proofExpectation]
.filter((item) => Boolean(item))
.join(" ");
return /(?:debt_due_date_boundary|due[-_ ]?date|overdue|aging|просроч|срок\s+оплат|дебиторк|кредиторск)/iu.test(combined);
}
function buildBusinessOverviewInventoryFilters(planner) {
const meaning = planner.discovery_plan.turn_meaning_ref;
const organization = toNonEmptyString(meaning?.explicit_organization_scope);
@ -231,6 +242,17 @@ function buildBusinessOverviewTradingMarginFilters(planner) {
sort: "period_asc"
};
}
function buildBusinessOverviewAccountingFinancialResultFilters(planner) {
const filters = buildBusinessOverviewTradingMarginFilters(planner);
if (!filters) {
return null;
}
return {
...filters,
limit: Math.max(32, planner.discovery_plan.execution_budget.max_rows_per_probe),
sort: "period_asc"
};
}
function buildInventoryExactFilters(planner) {
const meaning = planner.discovery_plan.turn_meaning_ref;
const subject = firstEntityCandidate(planner);
@ -715,7 +737,8 @@ async function executeCoverageAwareValueFlowQuery(input) {
});
executedProbeCount += 1;
probeResults.push(queryResultToProbeResult(input.primitiveId, broadResult));
const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= input.maxRowsPerProbe;
const broadLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, broadRecipePlan.limit));
const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= broadLimitThreshold;
if (broadResult.error) {
pushUnique(queryLimitations, broadResult.error);
return {
@ -772,7 +795,8 @@ async function executeCoverageAwareValueFlowQuery(input) {
pushUnique(queryLimitations, chunkResult.error);
continue;
}
if (chunkResult.matched_rows >= input.maxRowsPerProbe) {
const chunkLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, chunkPlan.limit));
if (chunkResult.matched_rows >= chunkLimitThreshold) {
anyChunkLimited = true;
}
chunkResults.push(chunkResult);
@ -1604,6 +1628,13 @@ function extractContractDateFromText(value) {
if (!text || !/(?:договор|contract|дог\.)/iu.test(text)) {
return null;
}
return extractAnyDateFromText(text);
}
function extractAnyDateFromText(value) {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const isoLikeMatch = text.match(/(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
if (isoLikeMatch) {
return normalizeDateParts(isoLikeMatch[1], isoLikeMatch[2], isoLikeMatch[3]);
@ -1614,6 +1645,59 @@ function extractContractDateFromText(value) {
}
return null;
}
function rowContractDateValue(row) {
const explicit = rowTextValue(row, ["ДатаДоговора", "ContractDate", "contract_date"]);
return extractAnyDateFromText(explicit) ?? rowOpenSettlementContractStartDateValue(row);
}
function rowSettlementDocumentDateValue(row) {
const explicit = rowTextValue(row, [
"ДатаДокументаРасчетов",
"SettlementDocumentDate",
"settlement_document_date"
]);
const settlementDocument = rowTextValue(row, [
"ДокументРасчетов",
"SettlementDocument",
"settlement_document"
]);
return extractAnyDateFromText(explicit) ?? extractAnyDateFromText(settlementDocument) ?? extractAnyDateFromText(rowDocumentValue(row));
}
function rowPaymentTermIsSetValue(row) {
const candidate = rowTextValue(row, ["УстановленСрокОплаты", "PaymentTermIsSet", "payment_term_is_set"]);
if (typeof row["УстановленСрокОплаты"] === "boolean") {
return row["УстановленСрокОплаты"] === true;
}
if (typeof row["PaymentTermIsSet"] === "boolean") {
return row["PaymentTermIsSet"] === true;
}
if (!candidate) {
return false;
}
return /^(?:true|истина|да|yes|1)$/iu.test(candidate.trim());
}
function rowPaymentTermDaysValue(row) {
const value = rowNumberValue(row, ["СрокОплаты", "PaymentTermDays", "payment_term_days"]);
if (value === null || !Number.isFinite(value) || value <= 0) {
return null;
}
return Math.trunc(value);
}
function addDaysToIsoDate(isoDate, days) {
const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match || !Number.isFinite(days)) {
return null;
}
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
date.setUTCDate(date.getUTCDate() + Math.trunc(days));
if (Number.isNaN(date.getTime())) {
return null;
}
return [
String(date.getUTCFullYear()).padStart(4, "0"),
String(date.getUTCMonth() + 1).padStart(2, "0"),
String(date.getUTCDate()).padStart(2, "0")
].join("-");
}
function earlierIsoDate(left, right) {
if (!left) {
return right;
@ -2038,7 +2122,8 @@ function deriveRankedValueFlow(result, input) {
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount)
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
counterparty_role_hint: (0, counterpartyRoleHeuristics_1.counterpartyRoleHintForName)(axisValue)
}))
.sort((left, right) => {
const amountDelta = right.total_amount - left.total_amount;
@ -2199,6 +2284,99 @@ function deriveBusinessOverviewTaxPosition(result, periodScope) {
inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows"
};
}
function accountingFinancialResultMarkerAmount(result, marker) {
let total = 0;
for (const row of result.rows) {
if (String(rowDocumentValue(row) ?? "") !== marker) {
continue;
}
const amount = rowAmountValue(row);
if (amount !== null && Number.isFinite(amount)) {
total += amount;
}
}
return total;
}
function accountingFinancialResultNonZeroCount(values) {
return values.filter((value) => Math.abs(value) > 0).length;
}
function deriveBusinessOverviewAccountingFinancialResult(result, periodScope) {
if (!result || result.error || result.matched_rows <= 0 || !periodScope) {
return null;
}
const salesRevenueAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_REVENUE_KT");
const costOfSalesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_COST_DT");
const sellingExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_SELLING_DT");
const adminExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_ADMIN_DT");
const salesProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_TO_99_PROFIT");
const salesLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_FROM_99_LOSS");
const otherProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_TO_99_PROFIT");
const otherLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_FROM_99_LOSS");
const profitTransferTo84 = accountingFinancialResultMarkerAmount(result, "ACC99_TO84_PROFIT_TRANSFER");
const lossTransferFrom84 = accountingFinancialResultMarkerAmount(result, "ACC84_TO99_LOSS_TRANSFER");
const amountSignals = [
salesRevenueAccounting,
costOfSalesAccounting,
sellingExpensesAccounting,
adminExpensesAccounting,
salesProfitTo99,
salesLossFrom99,
otherProfitTo99,
otherLossFrom99,
profitTransferTo84,
lossTransferFrom84
];
const rowsWithAmount = accountingFinancialResultNonZeroCount(amountSignals);
if (rowsWithAmount <= 0) {
return null;
}
const salesResultAmount = salesProfitTo99 - salesLossFrom99;
const otherResultAmount = otherProfitTo99 - otherLossFrom99;
const hasFinalTransfer = profitTransferTo84 > 0 || lossTransferFrom84 > 0;
const finalResultAmount = hasFinalTransfer
? profitTransferTo84 - lossTransferFrom84
: salesResultAmount + otherResultAmount;
const finalResultDirection = finalResultAmount > 0
? "profit"
: finalResultAmount < 0
? "loss"
: "balanced";
const netMarginToRevenuePct = salesRevenueAccounting > 0 ? percentageOfTotal(finalResultAmount, salesRevenueAccounting) : null;
const periodCloseRowsWithAmount = accountingFinancialResultNonZeroCount([
salesProfitTo99,
salesLossFrom99,
otherProfitTo99,
otherLossFrom99,
profitTransferTo84,
lossTransferFrom84
]);
return {
period_scope: periodScope,
rows_matched: result.matched_rows,
rows_with_amount: rowsWithAmount,
sales_revenue_accounting: salesRevenueAccounting,
sales_revenue_accounting_human_ru: formatAmountHumanRu(salesRevenueAccounting),
cost_of_sales_accounting: costOfSalesAccounting,
cost_of_sales_accounting_human_ru: formatAmountHumanRu(costOfSalesAccounting),
selling_expenses_accounting: sellingExpensesAccounting,
selling_expenses_accounting_human_ru: formatAmountHumanRu(sellingExpensesAccounting),
admin_expenses_accounting: adminExpensesAccounting,
admin_expenses_accounting_human_ru: formatAmountHumanRu(adminExpensesAccounting),
sales_result_amount: salesResultAmount,
sales_result_amount_human_ru: formatAmountHumanRu(Math.abs(salesResultAmount)),
other_result_amount: otherResultAmount,
other_result_amount_human_ru: formatAmountHumanRu(Math.abs(otherResultAmount)),
final_result_amount: finalResultAmount,
final_result_amount_human_ru: formatAmountHumanRu(Math.abs(finalResultAmount)),
final_result_direction: finalResultDirection,
net_margin_to_revenue_pct: netMarginToRevenuePct,
final_transfer_basis: hasFinalTransfer
? "account_99_to_84_period_close"
: "account_90_91_to_99_period_close",
period_close_rows_with_amount: periodCloseRowsWithAmount,
inference_basis: "account_90_91_99_period_close_aggregate_confirmed_1c_rows"
};
}
function deriveBusinessOverviewTradingMarginProxy(result, periodScope) {
if (!result || result.error || result.matched_rows <= 0 || !periodScope) {
return null;
@ -2554,6 +2732,121 @@ function deriveBusinessOverviewDebtStalenessRiskProxy(quality) {
inference_basis: "contract_date_age_and_open_balance_concentration_confirmed_1c_rows"
};
}
function deriveBusinessOverviewDebtDueDateAging(input) {
if (!input.debtAsOfDate || !input.dueDateResult || input.dueDateResult.error || input.dueDateResult.matched_rows <= 0) {
return null;
}
const overdueItems = [];
let rowsWithAmount = 0;
let grossOpenAmount = 0;
let rowsWithPaymentTerms = 0;
let rowsWithoutPaymentTerms = 0;
let rowsWithoutDocumentDate = 0;
let overdueAmount = 0;
let notYetDueAmount = 0;
let notYetDueRows = 0;
for (const row of input.dueDateResult.rows) {
const amount = rowAmountValue(row);
if (amount === null) {
continue;
}
const absAmount = Math.abs(amount);
if (absAmount <= 0) {
continue;
}
rowsWithAmount += 1;
grossOpenAmount += absAmount;
const paymentTermIsSet = rowPaymentTermIsSetValue(row);
const paymentTermDays = rowPaymentTermDaysValue(row);
if (!paymentTermIsSet || paymentTermDays === null) {
rowsWithoutPaymentTerms += 1;
continue;
}
rowsWithPaymentTerms += 1;
const documentDate = rowSettlementDocumentDateValue(row) ?? rowContractDateValue(row);
if (!documentDate) {
rowsWithoutDocumentDate += 1;
continue;
}
const dueDate = addDaysToIsoDate(documentDate, paymentTermDays);
if (!dueDate) {
rowsWithoutDocumentDate += 1;
continue;
}
const overdueDays = dueDate < input.debtAsOfDate ? daysBetweenIsoDates(dueDate, input.debtAsOfDate) : null;
if (overdueDays !== null && overdueDays > 0) {
overdueAmount += absAmount;
overdueItems.push({
counterparty: rowCounterpartyValue(row),
contract: rowContractValue(row),
settlement_document: rowTextValue(row, [
"ДокументРасчетов",
"SettlementDocument",
"settlement_document"
]) ?? rowDocumentValue(row),
document_date: documentDate,
due_date: dueDate,
payment_term_days: paymentTermDays,
overdue_days: overdueDays,
amount: absAmount,
amount_human_ru: formatAmountHumanRu(absAmount)
});
}
else {
notYetDueRows += 1;
notYetDueAmount += absAmount;
}
}
if (rowsWithAmount <= 0) {
return null;
}
const topOverdueItems = overdueItems
.sort((left, right) => {
const daysDelta = right.overdue_days - left.overdue_days;
if (daysDelta !== 0) {
return daysDelta;
}
const amountDelta = right.amount - left.amount;
return amountDelta !== 0 ? amountDelta : String(left.contract ?? "").localeCompare(String(right.contract ?? ""), "ru");
})
.slice(0, 5)
.map((item) => ({
...item,
share_of_overdue_amount_pct: percentageOfTotal(item.amount, overdueAmount)
}));
const oldestDueDate = overdueItems
.map((item) => item.due_date)
.sort()[0] ?? null;
const maxOverdueDays = overdueItems.reduce((max, item) => max === null ? item.overdue_days : Math.max(max, item.overdue_days), null);
const evidenceStatus = overdueItems.length > 0
? "confirmed_overdue"
: rowsWithPaymentTerms <= 0
? "no_payment_terms_configured"
: rowsWithoutDocumentDate >= rowsWithPaymentTerms
? "insufficient_due_date_basis"
: "no_overdue_found";
return {
as_of_date: input.debtAsOfDate,
rows_matched: input.dueDateResult.matched_rows,
rows_with_amount: rowsWithAmount,
gross_open_amount: grossOpenAmount,
gross_open_amount_human_ru: formatAmountHumanRu(grossOpenAmount),
rows_with_payment_terms: rowsWithPaymentTerms,
rows_without_payment_terms: rowsWithoutPaymentTerms,
rows_without_document_date: rowsWithoutDocumentDate,
overdue_rows: overdueItems.length,
overdue_amount: overdueAmount,
overdue_amount_human_ru: formatAmountHumanRu(overdueAmount),
not_yet_due_rows: notYetDueRows,
not_yet_due_amount: notYetDueAmount,
not_yet_due_amount_human_ru: formatAmountHumanRu(notYetDueAmount),
oldest_due_date: oldestDueDate,
max_overdue_days: maxOverdueDays,
top_overdue_items: topOverdueItems,
evidence_status: evidenceStatus,
inference_basis: "contract_payment_terms_and_settlement_document_dates_from_open_balance_rows"
};
}
function debtStalenessRiskBandRu(riskBand) {
if (riskBand === "high") {
return "высокая зона внимания";
@ -2748,7 +3041,7 @@ function buildBusinessOverviewMissingProofFamilies(input) {
items.push(item);
}
};
if (missing.has("profit_margin") || missing.has("accounting_profit_margin")) {
if ((missing.has("profit_margin") || missing.has("accounting_profit_margin")) && !input.accountingFinancialResult) {
pushUnique({
family: "accounting_profit_margin",
current_status: input.tradingMarginProxy ? "proxy_only_currently" : "reviewed_route_not_wired",
@ -2759,7 +3052,7 @@ function buildBusinessOverviewMissingProofFamilies(input) {
must_not_claim: "clean_profit_accounting_margin_or_full_pnl"
});
}
if (missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) {
if ((missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) && !input.debtDueDateAging) {
pushUnique({
family: "debt_due_date_aging_quality",
current_status: input.debtStalenessRiskProxy
@ -2830,6 +3123,7 @@ function deriveBusinessOverview(input) {
});
const activityPeriod = deriveActivityPeriod(input.lifecycleResult);
const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope);
const accountingFinancialResult = deriveBusinessOverviewAccountingFinancialResult(input.accountingFinancialResultResult, input.periodScope);
const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope);
const debtPosition = deriveBusinessOverviewDebtPosition({
receivablesResult: input.receivablesResult,
@ -2844,6 +3138,10 @@ function deriveBusinessOverview(input) {
const counterpartyProfile = deriveBusinessOverviewCounterpartyProfile(input.counterpartyProfileResult, input.periodScope);
const contractUsageProfile = deriveBusinessOverviewContractUsageProfile(input.contractUsageProfileResult, input.periodScope);
const debtStalenessRiskProxy = deriveBusinessOverviewDebtStalenessRiskProxy(debtOpenSettlementQuality);
const debtDueDateAging = deriveBusinessOverviewDebtDueDateAging({
dueDateResult: input.dueDateAgingResult,
debtAsOfDate: input.debtAsOfDate
});
const inventoryPosition = deriveBusinessOverviewInventoryPosition({
inventoryOnHandResult: input.inventoryOnHandResult,
inventoryAgingResult: input.inventoryAgingResult,
@ -2862,10 +3160,12 @@ function deriveBusinessOverview(input) {
outgoing.rows_with_amount > 0,
Boolean(activityPeriod),
Boolean(taxPosition),
Boolean(accountingFinancialResult),
Boolean(tradingMarginProxy),
Boolean(debtPosition),
Boolean(debtOpenSettlementQuality),
Boolean(debtStalenessRiskProxy),
Boolean(debtDueDateAging),
Boolean(documentActivityProfile),
Boolean(counterpartyProfile),
Boolean(contractUsageProfile),
@ -2879,9 +3179,9 @@ function deriveBusinessOverview(input) {
const netAmount = incoming.total_amount - outgoing.total_amount;
const hasBusinessOverviewProfileSignal = Boolean(documentActivityProfile || counterpartyProfile || contractUsageProfile);
const missingSignalFamilies = [
tradingMarginProxy ? "accounting_profit_margin" : "profit_margin",
accountingFinancialResult ? null : tradingMarginProxy ? "accounting_profit_margin" : "profit_margin",
debtPosition ? null : "debt_position",
debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality",
debtDueDateAging ? null : debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality",
taxPosition ? null : "tax_position",
inventoryPosition
? inventoryStalenessRiskProxy
@ -2894,9 +3194,11 @@ function deriveBusinessOverview(input) {
].filter((item) => Boolean(item));
const missingProofFamilies = buildBusinessOverviewMissingProofFamilies({
missingSignalFamilies,
accountingFinancialResult,
tradingMarginProxy,
debtOpenSettlementQuality,
debtStalenessRiskProxy,
debtDueDateAging,
inventoryPosition,
inventoryTurnoverProxy,
inventoryStalenessRiskProxy,
@ -2915,10 +3217,12 @@ function deriveBusinessOverview(input) {
yearly_breakdown: yearlyBreakdown,
activity_period: activityPeriod,
tax_position: taxPosition,
accounting_financial_result: accountingFinancialResult,
trading_margin_proxy: tradingMarginProxy,
debt_position: debtPosition,
debt_open_settlement_quality: debtOpenSettlementQuality,
debt_staleness_risk_proxy: debtStalenessRiskProxy,
debt_due_date_aging: debtDueDateAging,
inventory_position: inventoryPosition,
inventory_turnover_proxy: inventoryTurnoverProxy,
inventory_staleness_risk_proxy: inventoryStalenessRiskProxy,
@ -2929,9 +3233,9 @@ function deriveBusinessOverview(input) {
checked_signal_count: checkedSignalCount,
missing_signal_families: missingSignalFamilies,
missing_proof_families: missingProofFamilies,
inference_basis: hasBusinessOverviewProfileSignal || inventoryPosition
inference_basis: hasBusinessOverviewProfileSignal || inventoryPosition || accountingFinancialResult
? "business_overview_from_confirmed_1c_multi_family_rows"
: debtOpenSettlementQuality
: debtOpenSettlementQuality || debtDueDateAging
? "business_overview_from_confirmed_1c_multi_family_rows"
: taxPosition && debtPosition
? "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows"
@ -2956,6 +3260,9 @@ function summarizeBusinessOverviewRows(input) {
if (input.taxResult && !input.taxResult.error) {
parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`);
}
if (input.accountingFinancialResultResult && !input.accountingFinancialResultResult.error) {
parts.push(`${input.accountingFinancialResultResult.fetched_rows} accounting financial-result aggregate rows fetched, ${input.accountingFinancialResultResult.matched_rows} matched`);
}
if (input.tradingMarginResult && !input.tradingMarginResult.error) {
parts.push(`${input.tradingMarginResult.fetched_rows} trading-margin document rows fetched, ${input.tradingMarginResult.matched_rows} matched`);
}
@ -2968,6 +3275,9 @@ function summarizeBusinessOverviewRows(input) {
if (input.openContractsResult && !input.openContractsResult.error) {
parts.push(`${input.openContractsResult.fetched_rows} open-contract rows fetched, ${input.openContractsResult.matched_rows} matched`);
}
if (input.dueDateAgingResult && !input.dueDateAgingResult.error) {
parts.push(`${input.dueDateAgingResult.fetched_rows} due-date aging rows fetched, ${input.dueDateAgingResult.matched_rows} matched`);
}
if (input.documentActivityProfileResult && !input.documentActivityProfileResult.error) {
parts.push(`${input.documentActivityProfileResult.fetched_rows} document/account-section profile rows fetched, ${input.documentActivityProfileResult.matched_rows} matched`);
}
@ -3000,11 +3310,29 @@ function buildBusinessOverviewConfirmedFacts(derived) {
}
if (derived.top_customers.length > 0) {
const leader = derived.top_customers[0];
facts.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`);
if ((0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(leader.axis_value)) {
facts.push(`Крупнейший входящий денежный источник в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа это не доказанный клиент или бизнес-выручка.`);
const nonFinancialLeader = derived.top_customers.slice(1).find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value));
if (nonFinancialLeader) {
facts.push(`Крупнейший небанковский входящий контрагент в проверенном срезе: ${nonFinancialLeader.axis_value}${nonFinancialLeader.total_amount_human_ru}.`);
}
}
else {
facts.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`);
}
}
if (derived.top_suppliers.length > 0) {
const leader = derived.top_suppliers[0];
facts.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`);
if ((0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(leader.axis_value)) {
facts.push(`Крупнейший получатель исходящих денег в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора это не доказанный обычный поставщик.`);
const nonFinancialLeader = derived.top_suppliers.slice(1).find((item) => !(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(item.axis_value));
if (nonFinancialLeader) {
facts.push(`Крупнейший небанковский получатель исходящих денег в проверенном срезе: ${nonFinancialLeader.axis_value}${nonFinancialLeader.total_amount_human_ru}.`);
}
}
else {
facts.push(`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`);
}
}
if (derived.yearly_breakdown.length > 0) {
facts.push(`Годовая раскладка операционного денежного потока построена по подтвержденным строкам 1С за ${yearCountHumanRu(derived.yearly_breakdown.length)}.`);
@ -3087,6 +3415,25 @@ function buildBusinessOverviewConfirmedFacts(derived) {
const counterpartyText = proxy.top_contract_counterparty ? ` / ${proxy.top_contract_counterparty}` : "";
facts.push(`Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.`);
}
if (derived.debt_due_date_aging) {
const aging = derived.debt_due_date_aging;
if (aging.evidence_status === "confirmed_overdue") {
const top = aging.top_overdue_items[0];
const topText = top
? ` Самая старая просрочка: ${top.due_date}, ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.`
: "";
facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`);
}
else if (aging.evidence_status === "no_payment_terms_configured") {
facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} проверен по ${aging.rows_with_amount} строкам с суммой: брутто открытых остатков ${aging.gross_open_amount_human_ru}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`);
}
else if (aging.evidence_status === "insufficient_due_date_basis") {
facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} запускался по ${aging.rows_with_payment_terms} строкам с установленным сроком оплаты, но в найденных строках не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`);
}
else {
facts.push(`Due-date aging открытых расчетов на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`);
}
}
if (derived.inventory_position) {
const leader = derived.inventory_position.top_items[0];
const leaderText = leader
@ -3133,6 +3480,9 @@ function buildBusinessOverviewInferredFacts(derived) {
const supplierSharePct = supplierLeader
? percentageOfTotal(supplierLeader.total_amount, derived.outgoing_supplier_payout.total_amount)
: null;
const supplierLeaderIsFinancial = supplierLeader
? (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(supplierLeader.axis_value)
: false;
const strongestIncomingYear = [...derived.yearly_breakdown]
.filter((bucket) => bucket.incoming_total_amount > 0)
.sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0];
@ -3142,9 +3492,13 @@ function buildBusinessOverviewInferredFacts(derived) {
return [
`Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`,
supplierLeader
? supplierSharePct !== null
? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}.`
? supplierLeaderIsFinancial
? supplierSharePct !== null
? `Крупнейший получатель исходящих денег ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). По названию это банк/финансовая организация, поэтому это outgoing cash concentration proxy, а не доказанный vendor-risk по обычному поставщику.`
: `Крупнейший получатель исходящих денег в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому это не доказанный обычный поставщик.`
: supplierSharePct !== null
? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}.`
: null,
strongestIncomingYear
? `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} (${strongestIncomingYear.incoming_total_amount_human_ru}).`
@ -3803,10 +4157,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
let outgoingResult = null;
let lifecycleResult = null;
let taxResult = null;
let accountingFinancialResultResult = null;
let tradingMarginResult = null;
let receivablesResult = null;
let payablesResult = null;
let openContractsResult = null;
let dueDateAgingResult = null;
let documentActivityProfileResult = null;
let counterpartyProfileResult = null;
let contractUsageProfileResult = null;
@ -3816,8 +4172,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
const lifecycleFilters = buildLifecycleFilters(planner);
const profileFilters = buildBusinessOverviewProfileFilters(planner);
const taxFilters = buildBusinessOverviewTaxFilters(planner);
const accountingFinancialResultFilters = buildBusinessOverviewAccountingFinancialResultFilters(planner);
const tradingMarginFilters = buildBusinessOverviewTradingMarginFilters(planner);
const debtFilters = buildBusinessOverviewDebtFilters(planner);
const debtDueDateAgingProbeEnabled = shouldRunDebtDueDateAgingProbe(planner);
const inventoryFilters = buildBusinessOverviewInventoryFilters(planner);
const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date);
const inventoryAsOfDate = toNonEmptyString(inventoryFilters?.as_of_date);
@ -3830,6 +4188,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
const taxSelection = taxFilters
? (0, addressRecipeCatalog_1.selectAddressRecipe)("vat_liability_confirmed_for_tax_period", taxFilters)
: null;
const accountingFinancialResultSelection = accountingFinancialResultFilters
? (0, addressRecipeCatalog_1.selectAddressRecipe)("accounting_financial_result_for_organization", accountingFinancialResultFilters)
: null;
const tradingMarginSelection = tradingMarginFilters
? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_trading_margin_proxy_for_organization", tradingMarginFilters)
: null;
@ -3842,6 +4203,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
const openContractsSelection = debtFilters
? (0, addressRecipeCatalog_1.selectAddressRecipe)("open_contracts_confirmed_as_of_date", debtFilters)
: null;
const dueDateAgingSelection = debtFilters && debtDueDateAgingProbeEnabled
? (0, addressRecipeCatalog_1.selectAddressRecipe)("debt_due_date_aging_for_organization", debtFilters)
: null;
const inventoryOnHandSelection = inventoryFilters
? (0, addressRecipeCatalog_1.selectAddressRecipe)("inventory_on_hand_as_of_date", inventoryFilters)
: null;
@ -3909,6 +4273,16 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available");
pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe");
}
if (accountingFinancialResultSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_selected");
}
else if (!accountingFinancialResultFilters) {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_probe_skipped_without_explicit_period");
}
else {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_not_available");
pushUnique(queryLimitations, "Business overview accounting financial-result probe requires an executable 90/91/99 period-close recipe");
}
if (tradingMarginSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_selected");
}
@ -3939,6 +4313,19 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_not_available");
pushUnique(queryLimitations, "Business overview open-settlement quality probe requires executable open-contracts as-of-date recipe");
}
if (dueDateAgingSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_selected");
}
else if (!debtFilters) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_explicit_as_of_date");
}
else if (!debtDueDateAgingProbeEnabled) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_boundary_need");
}
else {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_not_available");
pushUnique(queryLimitations, "Business overview due-date aging probe requires executable contract payment-term/open-balance recipe");
}
if (inventoryOnHandSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_recipe_selected");
if (inventoryAgingSelection?.selected_recipe) {
@ -3982,6 +4369,14 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
account_scope: taxPlan.account_scope
});
}
if (accountingFinancialResultSelection?.selected_recipe) {
const accountingFinancialResultPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(accountingFinancialResultSelection.selected_recipe, accountingFinancialResultFilters);
accountingFinancialResultResult = await runtimeDeps.executeAddressMcpQuery({
query: accountingFinancialResultPlan.query,
limit: accountingFinancialResultPlan.limit,
account_scope: accountingFinancialResultPlan.account_scope
});
}
if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) {
const receivablesPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(receivablesSelection.selected_recipe, debtFilters);
receivablesResult = await runtimeDeps.executeAddressMcpQuery({
@ -4004,6 +4399,14 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
account_scope: openContractsPlan.account_scope
});
}
if (dueDateAgingSelection?.selected_recipe) {
const dueDateAgingPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(dueDateAgingSelection.selected_recipe, debtFilters);
dueDateAgingResult = await runtimeDeps.executeAddressMcpQuery({
query: dueDateAgingPlan.query,
limit: dueDateAgingPlan.limit,
account_scope: dueDateAgingPlan.account_scope
});
}
if (inventoryOnHandSelection?.selected_recipe) {
const inventoryOnHandPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(inventoryOnHandSelection.selected_recipe, inventoryFilters);
inventoryOnHandResult = await runtimeDeps.executeAddressMcpQuery({
@ -4025,6 +4428,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
if (taxResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, taxResult));
}
if (accountingFinancialResultResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, accountingFinancialResultResult));
}
if (receivablesResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, receivablesResult));
}
@ -4034,6 +4440,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
if (openContractsResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, openContractsResult));
}
if (dueDateAgingResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, dueDateAgingResult));
}
if (inventoryOnHandResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryOnHandResult));
}
@ -4059,6 +4468,13 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
else if (taxResult) {
pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_executed");
}
if (accountingFinancialResultResult?.error) {
pushUnique(queryLimitations, accountingFinancialResultResult.error);
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_error");
}
else if (accountingFinancialResultResult) {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_executed");
}
if (receivablesResult?.error) {
pushUnique(queryLimitations, receivablesResult.error);
pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_error");
@ -4084,6 +4500,13 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
else if (openContractsResult) {
pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_executed");
}
if (dueDateAgingResult?.error) {
pushUnique(queryLimitations, dueDateAgingResult.error);
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_error");
}
else if (dueDateAgingResult) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_executed");
}
if (inventoryOnHandResult?.error) {
pushUnique(queryLimitations, inventoryOnHandResult.error);
pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_error");
@ -4194,10 +4617,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
outgoingResult,
lifecycleResult,
taxResult,
accountingFinancialResultResult,
tradingMarginResult,
receivablesResult,
payablesResult,
openContractsResult,
dueDateAgingResult,
documentActivityProfileResult,
counterpartyProfileResult,
contractUsageProfileResult,
@ -4234,6 +4659,9 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
if (derivedBusinessOverview.tax_position) {
pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows");
}
if (derivedBusinessOverview.accounting_financial_result) {
pushReason(reasonCodes, "pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows");
}
if (derivedBusinessOverview.trading_margin_proxy) {
pushReason(reasonCodes, "pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows");
}
@ -4249,6 +4677,10 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
if (derivedBusinessOverview.debt_staleness_risk_proxy) {
pushReason(reasonCodes, "pilot_derived_business_overview_debt_staleness_risk_proxy_from_confirmed_rows");
}
if (derivedBusinessOverview.debt_due_date_aging) {
pushReason(reasonCodes, "pilot_derived_business_overview_debt_due_date_aging_from_confirmed_rows");
pushReason(reasonCodes, `pilot_derived_business_overview_debt_due_date_aging_${derivedBusinessOverview.debt_due_date_aging.evidence_status}`);
}
if (derivedBusinessOverview.inventory_position) {
pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows");
}
@ -4267,10 +4699,12 @@ async function executeAssistantMcpDiscoveryPilot(planner, deps = DEFAULT_DEPS) {
outgoingResult,
lifecycleResult,
taxResult,
accountingFinancialResultResult,
tradingMarginResult,
receivablesResult,
payablesResult,
openContractsResult,
dueDateAgingResult,
documentActivityProfileResult,
counterpartyProfileResult,
contractUsageProfileResult,

View File

@ -19,7 +19,7 @@ exports.ASSISTANT_MCP_DISCOVERY_PRIMITIVES = [
];
const DEFAULT_DISCOVERY_BUDGET = {
max_probe_count: 3,
max_rows_per_probe: 100
max_rows_per_probe: 200
};
const MAX_PROBE_COUNT = 36;
const MAX_ROWS_PER_PROBE = 500;

View File

@ -2,6 +2,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = void 0;
exports.buildAssistantMcpDiscoveryResponseCandidate = buildAssistantMcpDiscoveryResponseCandidate;
const counterpartyRoleHeuristics_1 = require("./counterpartyRoleHeuristics");
exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_discovery_response_candidate_v1";
function toRecordObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@ -67,7 +68,26 @@ function hasInternalMechanics(value) {
function userFacingLines(values) {
return uniqueStrings(values).filter((line) => !hasInternalMechanics(line));
}
function sanitizeUserFacingMechanics(value) {
return String(value ?? "").replace(/MCP-срез(?:ом|у|е|а)?/giu, (match) => {
const normalized = match.toLowerCase();
if (normalized.endsWith("ом")) {
return "срезом 1С";
}
if (normalized.endsWith("у")) {
return "срезу 1С";
}
if (normalized.endsWith("е")) {
return "срезе 1С";
}
if (normalized.endsWith("а")) {
return "среза 1С";
}
return "срез 1С";
});
}
function localizeLine(value) {
const sanitizedValue = sanitizeUserFacingMechanics(value);
if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки активности в запрошенном срезе.";
}
@ -88,7 +108,7 @@ function localizeLine(value) {
return `В 1С проверены входящие и исходящие денежные строки в запрошенном срезе: ${incoming}, ${outgoing}.`;
}
if (/^Requested period hit the MCP row limit, but the approved monthly recovery probe budget is smaller than the required subperiod count$/i.test(value)) {
return "Запрошенный период уперся в лимит строк MCP; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды.";
return "Запрошенный период достиг лимита строк; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды.";
}
const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i);
if (counterpartyMatch) {
@ -113,10 +133,10 @@ function localizeLine(value) {
}
const movementRowsMatch = value.match(/^1C movement rows were found for counterparty\s+(.+)$/i);
if (movementRowsMatch) {
return `Р1С найдены строки движений по контрагенту ${movementRowsMatch[1]}.`;
return `В 1С найдены строки движений по контрагенту ${movementRowsMatch[1]}.`;
}
if (/^1C movement rows were found for the requested scope$/i.test(value)) {
return "Р1С найдены строки движений по запрошенному контуру.";
return "В 1С найдены строки движений по запрошенному контуру.";
}
const supplierPayoutMatch = value.match(/^1C supplier-payout rows were found for counterparty\s+(.+)$/i);
if (supplierPayoutMatch) {
@ -144,7 +164,7 @@ function localizeLine(value) {
return "Срез документов ограничен только подтвержденными строками документов в проверенном окне.";
}
if (/^Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope$/i.test(value)) {
return "Срез движений ограничен только подтвержденными строками движений в проверенном окне.";
return "Срез движений ограничен только подтвержденными строками движений в проверенном окне.";
}
if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) {
return "Сумма входящих поступлений рассчитана только по подтвержденным строкам поступлений в 1С.";
@ -227,10 +247,10 @@ function localizeLine(value) {
return "Полный срез документов без явно проверенного периода не подтвержден.";
}
if (/^Full movement history outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) {
return "Полный исторический срез движений вне проверенного периода этим поиском не подтвержден.";
return "Полный исторический срез движений вне проверенного периода этим поиском не подтвержден.";
}
if (/^Full movement history is not proven without an explicit checked period$/i.test(value)) {
return "Полный срез движений без явно проверенного периода не подтвержден.";
return "Полный срез движений без явно проверенного периода не подтвержден.";
}
if (/^Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) {
return "Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден.";
@ -245,10 +265,10 @@ function localizeLine(value) {
return "Полный двусторонний денежный поток за все время без явно проверенного периода не подтвержден.";
}
if (/^Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit$/i.test(value)) {
return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк.";
return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк.";
}
if (/^Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit$/i.test(value)) {
return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк хотя бы по одной стороне.";
return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк хотя бы по одной стороне.";
}
if (/^Requested period coverage was recovered through monthly 1C value-flow probes$/i.test(value)) {
return "Покрытие запрошенного периода восстановлено помесячными проверками 1С.";
@ -268,7 +288,7 @@ function localizeLine(value) {
if (/^Complete requested-period coverage for bidirectional value-flow is not proven by the available checked rows$/i.test(value)) {
return "Полное покрытие запрошенного периода по двустороннему денежному потоку не подтверждено доступными проверенными строками.";
}
return value;
return sanitizedValue;
}
function section(title, lines) {
const clean = userFacingLines(lines.map(localizeLine));
@ -345,7 +365,7 @@ function businessOverviewCoverageLimitLine(overview) {
limited.push("исходящие");
}
return limited.length > 0
? `Важно: ${limited.join(" и ")} уперлись в лимит выборки MCP, поэтому это проверенный срез найденных строк, а не гарантированно полный бухгалтерский оборот.`
? `Важно: по направлению ${limited.join(" и ")} проверка достигла лимита строк; это расширенный проверенный срез найденных строк, но не гарантия полного бухгалтерского оборота без отдельной полной выгрузки.`
: null;
}
function businessOverviewYearRowsLine(overview) {
@ -371,6 +391,30 @@ function firstOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") {
const amount = moneyText(first?.[amountKey]);
return label && amount ? `${label}${sentenceAmount(amount) ?? amount}` : null;
}
function firstNonFinancialOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") {
if (!Array.isArray(rows)) {
return null;
}
for (const row of rows) {
const item = toRecordObject(row);
const label = toNonEmptyString(item?.axis_value);
if (!label || (0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(label)) {
continue;
}
const amount = moneyText(item?.[amountKey]);
if (amount) {
return `${label}${sentenceAmount(amount) ?? amount}`;
}
}
return null;
}
function overviewAxisLooksFinancial(row) {
if (!row) {
return false;
}
return (row.counterparty_role_hint === "bank_or_financial_institution" ||
(0, counterpartyRoleHeuristics_1.isLikelyFinancialInstitutionCounterparty)(row.axis_value));
}
function businessOverviewTaxLine(overview) {
const tax = toRecordObject(overview.tax_position);
if (!tax) {
@ -487,7 +531,7 @@ function buildCompactBidirectionalValueFlowReply(entryPoint, draft) {
lines.push(`Основа: ${basis.join("; ")}.`);
}
if (flow.coverage_limited_by_probe_limit === true) {
lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
}
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
@ -610,16 +654,158 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null);
const customerName = toNonEmptyString(topCustomer?.axis_value);
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
const topCustomerLooksFinancial = overviewAxisLooksFinancial(topCustomer);
const nonFinancialCustomer = firstNonFinancialOverviewAxisLabel(topCustomerLooksFinancial ? overview.top_customers : []);
const topCustomerLead = customerName && customerAmount
? `; крупнейший источник входящих денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}`
? topCustomerLooksFinancial
? `; крупнейший входящий денежный источник: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount} (похоже на банк/финорганизацию, не называю это клиентской выручкой без назначения платежа)${nonFinancialCustomer ? `; крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}` : ""}`
: `; крупнейший источник входящих денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}`
: "";
const topSupplierRecord = toRecordObject(Array.isArray(overview.top_suppliers) ? overview.top_suppliers[0] : null);
const topSupplier = firstOverviewAxisLabel(overview.top_suppliers);
const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${topSupplier}` : "";
const topSupplierLooksFinancial = overviewAxisLooksFinancial(topSupplierRecord);
const nonFinancialSupplier = firstNonFinancialOverviewAxisLabel(topSupplierLooksFinancial ? overview.top_suppliers : []);
const topSupplierLead = topSupplier
? topSupplierLooksFinancial
? `; крупнейший получатель исходящих денег: ${topSupplier} (похоже на банк/финорганизацию, не называю это обычным поставщиком без назначения платежа/договора)${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}`
: `; крупнейший получатель исходящих денег: ${topSupplier}`
: "";
const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : "";
const graphReasonCodes = toStringList(graph?.reason_codes);
const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer");
const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary);
const lines = [];
const actionFamily = toNonEmptyString(turnMeaning?.asked_action_family);
const unsupportedFamily = toNonEmptyString(turnMeaning?.unsupported_but_understood_family);
const profitMarginBoundary = actionFamily === "profit_margin_boundary" || unsupportedFamily === "profit_margin_boundary";
const debtDueDateBoundary = actionFamily === "debt_due_date_boundary" || unsupportedFamily === "debt_due_date_boundary";
const vendorRiskBoundary = actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary";
const inventoryReserveBoundary = actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary";
if (profitMarginBoundary) {
const accountingFinancialResult = toRecordObject(overview.accounting_financial_result);
if (accountingFinancialResult) {
const direction = toNonEmptyString(accountingFinancialResult.final_result_direction);
const amount = moneyText(accountingFinancialResult.final_result_amount_human_ru);
const periodScope = toNonEmptyString(accountingFinancialResult.period_scope) ?? period;
const marginPct = typeof accountingFinancialResult.net_margin_to_revenue_pct === "number" &&
Number.isFinite(accountingFinancialResult.net_margin_to_revenue_pct)
? `${accountingFinancialResult.net_margin_to_revenue_pct}%`
: null;
const directionText = direction === "profit"
? "учетная прибыль"
: direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const amountText = amount
? direction === "loss"
? `минус ${amount}`
: amount
: "сумма не распознана";
lines.push(`Коротко: по бухгалтерскому маршруту 90/91/99 за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к выручке 90.01 ${marginPct}` : "; маржа к выручке 90.01 не рассчитана"}.`);
lines.push("Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность.");
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финансовый результат.");
const boundaryLines = userFacingLines([
...toStringList(draft.confirmed_lines),
...toStringList(draft.inference_lines),
...toStringList(draft.unknown_lines)
])
.filter((line) => /(?:прибыл|марж|финанс|p\s*&\s*l|p&l|расход|себестоим|закрыт|profit|margin|financial)/iu.test(line))
.slice(0, 2);
if (boundaryLines.length > 0) {
lines.push(...boundaryLines.map(localizeLine));
}
lines.push("Для точного P&L нужны отдельный маршрут по себестоимости, расходам, закрытию периода и финрезультату; текущий proxy нельзя выдавать за подтвержденную чистую прибыль или маржу.");
if (limitLine) {
lines.push(limitLine);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (debtDueDateBoundary) {
const dueDateAging = toRecordObject(overview.debt_due_date_aging);
if (dueDateAging) {
const status = toNonEmptyString(dueDateAging.evidence_status);
const asOfDate = toNonEmptyString(dueDateAging.as_of_date) ?? "проверенную дату";
const overdueAmount = moneyText(dueDateAging.overdue_amount_human_ru);
const grossAmount = moneyText(dueDateAging.gross_open_amount_human_ru);
const rowsWithPaymentTerms = typeof dueDateAging.rows_with_payment_terms === "number" && Number.isFinite(dueDateAging.rows_with_payment_terms)
? dueDateAging.rows_with_payment_terms
: null;
const rowsWithAmount = typeof dueDateAging.rows_with_amount === "number" && Number.isFinite(dueDateAging.rows_with_amount)
? dueDateAging.rows_with_amount
: null;
if (status === "confirmed_overdue") {
lines.push(`Коротко: на ${asOfDate} подтвержденная просрочка есть: ${overdueAmount ?? "сумма не распознана"} по ${dueDateAging.overdue_rows ?? "найденным"} строкам.`);
lines.push("Основа ответа: открытые расчеты 60/62/76, договорный срок оплаты и дата расчетного документа; это уже due-date route, не старение договора как proxy.");
}
else if (status === "no_payment_terms_configured") {
lines.push(`Коротко: на ${asOfDate} подтвержденной просрочки нет: открытые расчеты проверены${grossAmount ? ` на ${grossAmount}` : ""}, но в найденных договорах срок оплаты не установлен.`);
lines.push(rowsWithAmount !== null
? `Проверено строк с суммой: ${rowsWithAmount}. Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.`
: "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.");
}
else if (status === "insufficient_due_date_basis") {
lines.push(`Коротко: due-date route запущен на ${asOfDate}, но просрочка не подтверждена: по строкам с установленным сроком оплаты не хватило даты расчетного документа.`);
if (rowsWithPaymentTerms !== null) {
lines.push(`Строк с установленным сроком оплаты: ${rowsWithPaymentTerms}; нужен документ-основание с датой для расчета due date.`);
}
}
else {
lines.push(`Коротко: due-date route на ${asOfDate} проверен, подтвержденной просрочки не найдено${rowsWithPaymentTerms !== null ? `; строк с установленным сроком оплаты ${rowsWithPaymentTerms}` : ""}.`);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута.");
lines.push("Проверить нужно отдельно: договоры, сроки оплаты, погашение и закрытие задолженности; без этого нельзя доказать overdue/due-date aging.");
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (vendorRiskBoundary) {
const supplierBasis = topSupplier
? topSupplierLooksFinancial
? `крупнейший получатель исходящих денег: ${topSupplier}; по названию это банк/финансовая организация, поэтому это не доказанная зависимость от обычного поставщика${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}`
: `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${topSupplier}`
: outgoingAmount
? `исходящие платежи/закупочный поток в проверенном срезе: ${outgoingAmount}`
: "есть только ограниченный срез исходящих платежей без полного vendor-risk профиля";
const proxyLabel = topSupplierLooksFinancial ? "outgoing cash concentration proxy" : "procurement concentration proxy";
lines.push(`Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${supplierBasis}.`);
lines.push("Это сигнал концентрации закупок/исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов.");
lines.push("Для точного вывода нужен отдельный reviewed vendor-risk route: поставщики, договорные условия, качество поставок, сроки, доля в закупках и полная структура расходов.");
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (inventoryReserveBoundary) {
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя.");
const boundaryLines = userFacingLines([
...toStringList(draft.unknown_lines),
...toStringList(draft.limitation_lines)
])
.filter((line) => /(?:резерв|неликвид|склад|товар|reserve|obsolete|inventory|stock)/iu.test(line))
.slice(0, 2);
if (boundaryLines.length > 0) {
lines.push(...boundaryLines.map(localizeLine));
}
lines.push("Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; proxy-сигналы нельзя выдавать за доказанный факт резерва.");
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) {
lines.push(`Коротко: по компании ${organizationScope ?? "в выбранном контуре"} ${period} подтвержден денежный срез: получили ${incomingAmount ?? "0 руб."}, исходящие платежи/списания ${outgoingAmount ?? "0 руб."}, ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}${previousCounterpartySummary.lead}; можно утверждать только эти подтвержденные срезы, нельзя называть это чистой прибылью, полным оборотом или доказанной ролью главного клиента/поставщика.`);
lines.push(previousCounterpartySummary.line);
@ -640,7 +826,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (!leaderYear || !leaderAmount) {
return null;
}
lines.push(`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`);
lines.push(`Коротко: в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`);
const netYear = toNonEmptyString(netLeader?.year_bucket);
const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
if (netYear && netYearAmount) {
@ -660,7 +846,9 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
lines.push(`Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`);
lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.');
if (!directMoneyAnswer && customerName && customerAmount) {
lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`);
lines.push(topCustomerLooksFinancial
? `Крупнейший входящий денежный источник в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}. По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой.${nonFinancialCustomer ? ` Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
: `Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`);
}
}
else {
@ -671,10 +859,14 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
`Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`);
}
if (!directMoneyAnswer && topSupplier) {
lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
lines.push(topSupplierLooksFinancial
? `Крупнейший получатель исходящих денег: ${topSupplier}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком.${nonFinancialSupplier ? ` Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
: `Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
}
if (!directMoneyAnswer && (topCustomer || topSupplier)) {
lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
lines.push(topCustomerLooksFinancial || topSupplierLooksFinancial
? "Важно по ролям: текущий денежный срез подтверждает источники и получателей денег, но банковские контрагенты требуют проверки назначения платежа/счетов и не доказывают роль клиента или поставщика."
: "Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
}
if (!directMoneyAnswer) {
lines.push(`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`);

View File

@ -1,12 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0;
exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = void 0;
exports.runAssistantMcpDiscoveryRuntimeBridge = runAssistantMcpDiscoveryRuntimeBridge;
const assistantMcpDiscoveryAnswerAdapter_1 = require("./assistantMcpDiscoveryAnswerAdapter");
const assistantMcpDiscoveryPilotExecutor_1 = require("./assistantMcpDiscoveryPilotExecutor");
const assistantMcpDiscoveryPlanner_1 = require("./assistantMcpDiscoveryPlanner");
exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_bridge_v1";
exports.ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION = "assistant_mcp_discovery_loop_state_v1";
exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION = "assistant_mcp_route_candidate_v1";
function normalizeReasonCode(value) {
const normalized = value
.trim()
@ -58,6 +59,21 @@ function loopStatusFor(bridgeStatus) {
}
return "ready_for_next_hop";
}
function routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily) {
if (bridgeStatus === "blocked" || pilot.pilot_status === "blocked") {
return "blocked";
}
if (bridgeStatus === "needs_clarification" || pilot.pilot_status === "skipped_needs_clarification") {
return "needs_user_scope";
}
if (bridgeStatus === "unsupported" || pilot.pilot_status === "unsupported") {
return "needs_route_enablement";
}
if (missingProofFamily) {
return "needs_route_enablement";
}
return "ready_for_reviewed_execution";
}
function flattenAxes(pilot, source) {
const result = [];
for (const step of pilot.dry_run.execution_steps) {
@ -83,6 +99,119 @@ function entityCandidatesFromPlanner(planner) {
const values = planner.discovery_plan.turn_meaning_ref?.explicit_entity_candidates ?? [];
return uniqueStrings(values);
}
function firstNonEmpty(values) {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) {
return text;
}
}
return null;
}
function routeCandidateProofFamiliesFor(actionFamily, proofExpectation) {
const combined = `${actionFamily ?? ""} ${proofExpectation ?? ""}`.trim().toLowerCase();
const result = [];
const add = (family) => {
if (!result.includes(family)) {
result.push(family);
}
};
if (!combined || combined === "broad_evaluation bounded_inference") {
return result;
}
if (/(?:inventory|stock|warehouse|reserve|liquidation|write[-_ ]?off|obsolete|obsolescence)/iu.test(combined)) {
add("inventory_reserve_liquidation_quality");
}
if (/(?:debt|due[-_ ]?date|overdue|aging|credit[-_ ]?risk)/iu.test(combined)) {
add("debt_due_date_aging_quality");
}
if (/(?:vendor|supplier|procurement|sourcing)/iu.test(combined)) {
add("vendor_risk_procurement_quality");
}
if (/(?:profit|margin|pnl|p&l|financial[-_ ]?result)/iu.test(combined)) {
add("accounting_profit_margin");
}
return result;
}
function routeCandidateMissingProofFamily(planner, pilot) {
if (planner.data_need_graph?.business_fact_family !== "business_overview") {
return null;
}
const wantedFamilies = routeCandidateProofFamiliesFor(planner.data_need_graph?.action_family ?? null, planner.data_need_graph?.proof_expectation ?? null);
if (wantedFamilies.length <= 0) {
return null;
}
const missingProofFamilies = pilot.derived_business_overview?.missing_proof_families ?? [];
return missingProofFamilies.find((item) => wantedFamilies.includes(item.family)) ?? null;
}
function routeCandidateEnablementReason(status, pilot, missingAxes, missingProofFamily) {
if (status === "ready_for_reviewed_execution") {
return null;
}
if (status === "needs_user_scope") {
return missingAxes.length > 0
? `Missing scope axes: ${missingAxes.join(", ")}`
: "Selected chain needs user clarification before MCP execution";
}
if (missingProofFamily) {
return [
`Missing reviewed proof family: ${missingProofFamily.family}`,
`next_required_evidence=${missingProofFamily.next_required_evidence}`,
missingProofFamily.current_supported_evidence
? `current_supported_evidence=${missingProofFamily.current_supported_evidence}`
: null,
`must_not_claim=${missingProofFamily.must_not_claim}`
]
.filter((item) => Boolean(item))
.join("; ");
}
return firstNonEmpty([
...pilot.query_limitations,
...pilot.evidence.unknown_facts,
"Selected chain is not safely executable by the reviewed MCP runtime yet"
]);
}
function routeCandidateNextAction(status) {
if (status === "ready_for_reviewed_execution") {
return "Execute through the reviewed runtime bridge and truth gate.";
}
if (status === "needs_user_scope") {
return "Ask the user for the missing scope axes before MCP execution.";
}
if (status === "needs_route_enablement") {
return "Create or wire a reviewed exact route for the selected chain before treating the fact as answerable.";
}
return "Do not execute until the blocking reason is resolved.";
}
function buildRouteCandidate(planner, pilot, bridgeStatus) {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
const providedAxes = flattenAxes(pilot, "provided_axes");
const missingAxes = plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options");
const missingProofFamily = routeCandidateMissingProofFamily(planner, pilot);
const candidateStatus = routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily);
return {
schema_version: exports.ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
candidate_status: candidateStatus,
selected_chain_id: planner.selected_chain_id,
selected_chain_summary: planner.selected_chain_summary,
nearest_catalog_chain_template: planner.catalog_chain_template_alignment.top_chain_template_match,
catalog_alignment_status: planner.catalog_chain_template_alignment.alignment_status,
business_fact_family: planner.data_need_graph?.business_fact_family ?? null,
action_family: planner.data_need_graph?.action_family ?? null,
proof_expectation: planner.data_need_graph?.proof_expectation ?? null,
required_axes: [...planner.required_axes],
provided_axes: providedAxes,
missing_axes: missingAxes,
executable_now: candidateStatus === "ready_for_reviewed_execution",
enablement_reason: routeCandidateEnablementReason(candidateStatus, pilot, missingAxes, missingProofFamily),
recommended_next_action: routeCandidateNextAction(candidateStatus),
forbidden_overclaim_flags: uniqueStrings([
...(planner.data_need_graph?.forbidden_overclaim_flags ?? []),
...(missingProofFamily ? [missingProofFamily.must_not_claim] : [])
])
};
}
function buildLoopState(planner, pilot, bridgeStatus) {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
return {
@ -120,10 +249,13 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) {
const answerDraft = (0, assistantMcpDiscoveryAnswerAdapter_1.buildAssistantMcpDiscoveryAnswerDraft)(pilot);
const bridgeStatus = bridgeStatusFor(pilot, answerDraft);
const loopState = buildLoopState(planner, pilot, bridgeStatus);
const routeCandidate = buildRouteCandidate(planner, pilot, bridgeStatus);
const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]);
pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`);
pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer");
pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`);
pushReason(reasonCodes, "runtime_bridge_route_candidate_built");
pushReason(reasonCodes, `runtime_bridge_route_candidate_${routeCandidate.candidate_status}`);
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
@ -133,6 +265,7 @@ async function runAssistantMcpDiscoveryRuntimeBridge(input) {
pilot,
answer_draft: answerDraft,
loop_state: loopState,
route_candidate: routeCandidate,
user_facing_response_allowed: bridgeStatus !== "blocked",
business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft),
requires_user_clarification: bridgeStatus === "needs_clarification",

View File

@ -130,6 +130,11 @@ function isGarbageSemanticAnchorCandidate(value) {
"всему",
"всей",
"всем",
"год",
"года",
"году",
"годом",
"годы",
"выводу",
"выводам",
"аудиту",
@ -626,6 +631,30 @@ function hasOrganizationLevelEarningsOverviewSignal(text) {
/(?:\u043a\u0430\u043a\w*|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043c\u044b\b|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u0432\u043e\u043e\u0431\u0449\u0435|(?:19|20)\d{2}|all\s+time|what|which|how\s+much|show|give|company|business|organization|our|we|us)/iu.test(text);
return hasYearRankingCue || hasCompanyEarningsCue || hasCompanyProfitMarginCue;
}
function hasOrganizationLevelProfitMarginBoundaryOverviewSignal(text) {
if (!text) {
return false;
}
const hasProfitMarginCue = /(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit(?:ability)?|margin|financial\s+result)/iu.test(text);
const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043c\u044b\b|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\b(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e|\u043e\u0430\u043e)\b|(?:19|20)\d{2}|company|business|organization|our|we|us)/iu.test(text);
return hasProfitMarginCue && hasCompanyScopeCue;
}
function hasProfitMarginBoundaryFollowupSignal(text) {
if (!text) {
return false;
}
const hasProfitOrResultCue = /(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u0443\u0431\u044b\u0442\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit|loss|margin|financial\s+result)/iu.test(text);
const hasFollowupShape = /(?:\u044d\u0442\u043e|\u0438\u0442\u043e\u0433|\u0438\u0442\u043e\u0433\u043e|\u043f\u043e\u043b\u0443\u0447\w*|\u043a\u043e\u0440\u043e\u0442\w*|\u0432\s+\u0438\u0442\u043e\u0433\u0435|\u043c\u043e\u0436\u043d\u043e\s+(?:\u043b\u0438\s+)?\u0441\u043a\u0430\u0437\u0430\u0442\u044c|is\s+it|result|short|brief)/iu.test(text);
return hasProfitOrResultCue && hasFollowupShape;
}
function hasDebtDueDateBoundaryFollowupSignal(text) {
if (!text) {
return false;
}
const hasDueDateCue = /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447\w*|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437\w*|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\w*|due[-\s]?date|overdue|debt\s+aging|aging)/iu.test(text);
const hasFollowupShape = /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\w*|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|why|short|brief|so)/iu.test(text);
return hasDueDateCue && hasFollowupShape;
}
function hasOrganizationLevelDebtDueDateOverviewSignal(text) {
if (!text) {
return false;
@ -711,6 +740,12 @@ function hasExplicitVatQuestionSignal(text) {
return (/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u0437\u0430|\u043d\u0430|\u043f\u0435\u0440\u0438\u043e\u0434|\u043f\u043e\u0437\u0438\u0446|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043e\u0441\u043d\u043e\u0432\u0430\u043d|\u043d\u0430\u043b\u043e\u0433\u043e\u0432\p{L}*\s+\u0432\u044b\u0432\u043e\u0434|tax\s+period|tax\s+position)/iu.test(text));
}
function hasExplicitVatMovementEvidenceSignal(text) {
if (!/(?:\u043d\u0434\u0441|vat)/iu.test(text)) {
return false;
}
return hasMovementEvidenceFollowupSignal(text);
}
function hasBusinessOverviewSeparateCounterpartySignal(text) {
if (!text) {
return false;
@ -1102,6 +1137,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal);
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) &&
hasBusinessOverviewContinuationSignal(rawText) &&
@ -1114,6 +1150,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
(hasValueFlowSignal(rawText) || hasValueRankingSignal(rawText) || rawBidirectionalValueFlowSignal);
const rawMetadataSignal = !rawLifecycleSignal &&
!rawValueFlowSignal &&
!explicitVatMovementEvidenceSignal &&
!rawReferentialDocumentExclusionSignal &&
hasMetadataSignal(rawText);
const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
@ -1128,7 +1165,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(dateScopeSignalText);
const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText);
const rawDateScope = collectDateScopeFromRawText(dateScopeSignalText);
const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null;
const rawMetadataScopeHint = rawMetadataSignal || explicitVatMovementEvidenceSignal ? metadataScopeHintFromRawText(rawText) : null;
const rawTopicSwitchSignal = hasExplicitTopicSwitchSignal(rawText);
const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null;
const rawScopedEntityCandidate = !predecomposeEntities.counterparty &&
@ -1147,11 +1184,41 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis);
const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family);
const broadBusinessEvaluationUnsupported = unsupported === "broad_business_evaluation";
const businessOverviewSignal = rawBusinessOverviewSignal ||
broadBusinessEvaluationUnsupported ||
const seededBusinessOverviewSignal = broadBusinessEvaluationUnsupported ||
rawDomain === "business_summary" ||
rawDomain === "business_overview" ||
rawAction === "broad_evaluation";
const inventoryReserveBusinessOverviewSignal = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(rawText);
const debtDueDateFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasDebtDueDateBoundaryFollowupSignal(rawText);
const debtDueDateBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) ||
debtDueDateFollowupBusinessOverviewSignal;
const supplierQualityBusinessOverviewSignal = (rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText);
const profitMarginFollowupBusinessOverviewSignal = businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText);
const profitMarginBusinessOverviewSignal = ((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelProfitMarginBoundaryOverviewSignal(rawText)) ||
profitMarginFollowupBusinessOverviewSignal;
const businessOverviewActionFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_boundary"
: debtDueDateBusinessOverviewSignal
? "debt_due_date_boundary"
: supplierQualityBusinessOverviewSignal
? "vendor_risk_procurement_boundary"
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_evaluation";
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_liquidation_boundary"
: debtDueDateBusinessOverviewSignal
? "debt_due_date_boundary"
: supplierQualityBusinessOverviewSignal
? "vendor_risk_procurement_boundary"
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_business_evaluation";
const businessOverviewSignal = rawBusinessOverviewSignal ||
seededBusinessOverviewSignal;
const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText));
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
@ -1176,7 +1243,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization;
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const followupCounterpartyIsMetadataOrganizationScope = Boolean(followupSeed.subjectResolutionOptional &&
followupSeed.counterparty &&
@ -1500,9 +1567,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
? "metadata lane clarification"
: semanticNeedFor({
domain: businessOverviewSignal ? "business_overview" : rawDomain ?? seededDomain,
action: businessOverviewSignal ? "broad_evaluation" : rawAction ?? seededAction,
unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported,
domain: explicitVatMovementEvidenceSignal
? "movements"
: businessOverviewSignal
? "business_overview"
: rawDomain ?? seededDomain,
action: explicitVatMovementEvidenceSignal
? "list_movements"
: businessOverviewSignal
? businessOverviewActionFamily
: rawAction ?? seededAction,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@ -1530,7 +1609,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
followupSeed.discoveryEntity ??
followupSeed.metadataSelectedEntitySet ??
null;
const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) &&
const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable ||
explicitVatMovementEvidenceSignal) &&
!effectiveFollowupCounterparty &&
metadataLaneCarryoverAvailable);
const groundedFollowupEntity = metadataScopedLaneWithoutSubject
@ -1711,17 +1792,19 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? "counterparty_lifecycle"
: valueFlowSignal
? "counterparty_value"
: metadataGroundedMovementLaneApplicable
: explicitVatMovementEvidenceSignal
? "movements"
: metadataGroundedDocumentLaneApplicable
? "documents"
: entityResolutionSignal
? "entity_resolution"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "metadata"
: rawDomain ?? seededDomain,
: metadataGroundedMovementLaneApplicable
? "movements"
: metadataGroundedDocumentLaneApplicable
? "documents"
: entityResolutionSignal
? "entity_resolution"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "metadata"
: rawDomain ?? seededDomain,
asked_action_family: businessOverviewSignal
? "broad_evaluation"
? businessOverviewActionFamily
: lifecycleSignal
? "activity_duration"
: valueFlowSignal
@ -1730,15 +1813,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
: payoutSignal
? "payout"
: rawAction ?? seededAction ?? "turnover"
: metadataGroundedMovementLaneApplicable
: explicitVatMovementEvidenceSignal
? "list_movements"
: metadataGroundedDocumentLaneApplicable
? "list_documents"
: entityResolutionSignal
? "search_business_entity"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? metadataActionFromRawText(rawText) ?? seededAction
: rawAction ?? seededAction,
: metadataGroundedMovementLaneApplicable
? "list_movements"
: metadataGroundedDocumentLaneApplicable
? "list_documents"
: entityResolutionSignal
? "search_business_entity"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? metadataActionFromRawText(rawText) ?? seededAction
: rawAction ?? seededAction,
asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis,
seeded_ranking_need: valueFlowSignal && followupSeed.rankingNeed && !rawEntitySearchOverridesStaleScope
? followupSeed.rankingNeed
@ -1757,7 +1842,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
explicit_date_scope: explicitDateScope,
subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined,
unsupported_but_understood_family: businessOverviewSignal
? "broad_business_evaluation"
? businessOverviewUnsupportedFamily
: unsupported ??
(lifecycleSignal
? "counterparty_lifecycle"
@ -1771,20 +1856,23 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? "movement_evidence"
: metadataGroundedDocumentLaneApplicable
? "document_evidence"
: metadataAmbiguityLaneClarificationApplicable
? "metadata_lane_choice_clarification"
: entityResolutionSignal
? "entity_resolution"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "1c_metadata_surface"
: followupDiscoverySeedApplicable
? seededUnsupported
: null),
: explicitVatMovementEvidenceSignal
? "movement_evidence"
: metadataAmbiguityLaneClarificationApplicable
? "metadata_lane_choice_clarification"
: entityResolutionSignal
? "entity_resolution"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "1c_metadata_surface"
: followupDiscoverySeedApplicable
? seededUnsupported
: null),
stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden ||
businessOverviewSignal ||
unsupported ||
lifecycleSignal ||
valueFlowSignal ||
explicitVatMovementEvidenceSignal ||
metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable ||
metadataAmbiguityLaneClarificationApplicable ||
@ -1841,11 +1929,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
}
const currentTurnValueFlowExactOverrideApplicable = Boolean(valueFlowSignal &&
explicitIntentCandidate &&
rawValueFlowAggregateQuestionSignal &&
(rawValueFlowAggregateQuestionSignal || hasValueRankingSignal(rawText)) &&
semanticDataNeed &&
(entityCandidates.length > 0 || explicitOrganizationScope || openScopeValueFlowWithoutResolvedCounterparty));
const runDiscovery = shouldRunDiscovery({
unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? "broad_business_evaluation"
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@ -1860,6 +1952,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
metadataGroundedDocumentLaneApplicable ||
groundedValueFlowFollowupApplicable,
forceDiscoveryOverExplicitIntent: businessOverviewSignal ||
explicitVatMovementEvidenceSignal ||
Boolean(entityResolutionClarificationCandidate) ||
organizationClarificationFollowupApplicable ||
periodClarificationFollowupApplicable ||
@ -1883,17 +1976,19 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? "followup_context"
: metadataGroundedDocumentLaneApplicable
? "followup_context"
: predecomposeContract
? "predecompose_contract"
: lifecycleSignal
? "raw_text"
: valueFlowSignal
: explicitVatMovementEvidenceSignal
? "raw_text"
: predecomposeContract
? "predecompose_contract"
: lifecycleSignal
? "raw_text"
: entityResolutionSignal
: valueFlowSignal
? "raw_text"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
: entityResolutionSignal
? "raw_text"
: "none";
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "raw_text"
: "none";
if (lifecycleSignal) {
pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected");
}
@ -1903,6 +1998,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (rawMetadataSignal) {
pushReason(reasonCodes, "mcp_discovery_metadata_signal_detected");
}
if (explicitVatMovementEvidenceSignal) {
pushReason(reasonCodes, "mcp_discovery_vat_movement_evidence_signal_detected");
}
if (entityResolutionSignal) {
pushReason(reasonCodes, "mcp_discovery_entity_resolution_signal_detected");
}
@ -2026,6 +2124,12 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
}
if (profitMarginFollowupBusinessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_profit_margin_followup_boundary");
}
if (debtDueDateFollowupBusinessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary");
}
if (explicitVatSuppressesBusinessOverviewContinuation) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
}

View File

@ -313,6 +313,13 @@ function createAssistantRoutePolicy(deps) {
const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return hasRequestCue && hasTemporalCue;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
return /(?<!\p{L})(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e)(?!\p{L})|(?:\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043c\u043f\u0430\u043d|llc|company|organization)/iu.test(normalized);
}
function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
@ -475,6 +482,27 @@ function createAssistantRoutePolicy(deps) {
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
const followupLoopStatus = toNonEmptyString(followupContext?.previous_discovery_loop_status);
const followupLoopSelectedChainId = toNonEmptyString(followupContext?.previous_discovery_loop_selected_chain_id);
const followupLoopPendingAxes = Array.isArray(followupContext?.previous_discovery_loop_pending_axes)
? followupContext.previous_discovery_loop_pending_axes.map((item) => toNonEmptyString(item)).filter(Boolean)
: [];
const currentTurnPredecomposeOrganization = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.entities?.organization) ??
(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_kind) === "organization"
? toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_value)
: null);
const routeCandidateOrganizationClarificationDetected = Boolean(followupContext &&
followupLoopStatus === "awaiting_clarification" &&
followupLoopSelectedChainId &&
followupLoopPendingAxes.includes("organization") &&
(currentTurnPredecomposeOrganization ||
explicitOrganizationClarificationSelection ||
[
rawUserMessage,
repairedRawUserMessage,
effectiveAddressUserMessage,
repairedEffectiveAddressUserMessage
].some((message) => hasOrganizationClarificationTextCue(message))));
const protectedInventoryShortFollowup = Boolean(followupContext &&
(isInventorySelectedObjectIntent(followupPreviousIntent) ||
(followupPreviousIntent === "inventory_on_hand_as_of_date" &&
@ -524,6 +552,7 @@ function createAssistantRoutePolicy(deps) {
"net_value_flow"
].includes(String(toNonEmptyString(assistantTurnMeaning?.asked_action_family) ?? "")) ||
/(?:нетто|сальдо|сколько\s+мы\s+(?:получили|заплатили)|incoming|outgoing)/iu.test(analyticsSample)));
const effectiveGroundedValueFlowFollowupContextDetected = groundedValueFlowFollowupContextDetected || routeCandidateOrganizationClarificationDetected;
const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane &&
[
"address_intent_resolver_detected",
@ -533,14 +562,15 @@ function createAssistantRoutePolicy(deps) {
].includes(String(baseToolGate?.reason ?? ""))) ||
Boolean(baseToolGate?.runAddressLane &&
String(baseToolGate?.reason ?? "") === "followup_context_detected" &&
groundedValueFlowFollowupContextDetected);
effectiveGroundedValueFlowFollowupContextDetected);
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
!baseToolGatePreservesAddressLane &&
!groundedValueFlowFollowupContextDetected &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!organizationClarificationContinuationDetected);
!organizationClarificationContinuationDetected &&
!routeCandidateOrganizationClarificationDetected);
const lastAddressAssistantDebug = sessionItems
? findLastAddressAssistantItem(sessionItems)?.debug ?? null
: null;
@ -583,7 +613,7 @@ function createAssistantRoutePolicy(deps) {
!turnMeaningIntentCandidate &&
!dataScopeMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
const hardMetaMode = resolveHardMetaMode({
dataScopeMetaQuery,
@ -748,7 +778,7 @@ function createAssistantRoutePolicy(deps) {
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
if (unsupportedCurrentTurnMeaningBoundary) {
return {

View File

@ -2123,6 +2123,9 @@ function isAddressLaneDebugPayload(debug) {
if (typeof debug.mcp_call_status === "string" && debug.mcp_call_status.trim().length > 0) {
return true;
}
if (debug.mcp_discovery_response_applied === true && debug.assistant_mcp_discovery_entry_point_v1) {
return true;
}
if (typeof debug.anchor_type === "string" && debug.anchor_type.trim().length > 0) {
return true;
}

View File

@ -120,6 +120,22 @@ function createAssistantTransitionPolicy(deps) {
const samples = [userMessage, alternateMessage].map((item) => normalizeFollowupText(item)).filter(Boolean);
return samples.some((sample) => /(?:итог|summary|резюм|вывод|что\s+(?:мы\s+)?подтверд|что\s+понят|что\s+можно|что\s+нельзя|собери\s+коротк)/iu.test(sample) && /(?:контрагент|группа\s+свк|свк|отдельн)/iu.test(sample));
}
function hasBusinessOverviewBoundaryFollowupCue(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
return false;
}
const hasBoundaryCue = /(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u0443\u0431\u044b\u0442|\u043c\u0430\u0440\u0436|\u0440\u0435\u0437\u0435\u0440\u0432|\u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|due[-\s]?date|overdue|aging|profit|loss|margin|vendor|risk)/iu.test(normalized);
const hasFollowupShape = /(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\u043a|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|\u0438\u0442\u043e\u0433|why|short|brief|so)/iu.test(normalized);
return hasBoundaryCue && hasFollowupShape;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = deps.compactWhitespace(deps.repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
return /(?<!\p{L})(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e)(?!\p{L})|(?:\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043c\u043f\u0430\u043d|llc|company|organization)/iu.test(normalized);
}
function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) {
@ -433,7 +449,11 @@ function createAssistantTransitionPolicy(deps) {
(deps.toNonEmptyString(alternateMessage)
? deps.hasDataRetrievalRequestSignal(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) {
const rawBusinessOverviewBoundaryFollowupCue = hasBusinessOverviewBoundaryFollowupCue(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? hasBusinessOverviewBoundaryFollowupCue(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal && !rawBusinessOverviewBoundaryFollowupCue) {
return null;
}
const assistantTurnMeaning = typeof deps.resolveAssistantTurnMeaning === "function"
@ -492,10 +512,25 @@ function createAssistantTransitionPolicy(deps) {
: false));
const sourceIntentHint = (0, assistantContinuityPolicy_1.readAddressDebugIntent)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryPilotScopeHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopStatusHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopStatus)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopSelectedChainIdHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSelectedChainId)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopPendingAxesHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopPendingAxes)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopProvidedAxesHint = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopProvidedAxes)(carryoverSourceDebug, deps.toNonEmptyString);
const currentTurnPredecomposeOrganization = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.entities?.organization) ??
(deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_kind) === "organization"
? deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_value)
: null);
const mcpDiscoveryOrganizationClarificationContinuation = Boolean(sourceDiscoveryLoopStatusHint === "awaiting_clarification" &&
sourceDiscoveryLoopSelectedChainIdHint &&
sourceDiscoveryLoopPendingAxesHint.includes("organization") &&
(currentTurnPredecomposeOrganization ||
explicitOrganizationClarificationSelection ||
[userMessage, alternateMessage].some((message) => hasOrganizationClarificationTextCue(message))));
const hasValueFlowCarryoverSourceHint = sourceIntentHint === "customer_revenue_and_payments" ||
sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1";
const hasBusinessOverviewCarryoverSourceHint = sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue);
const navigationFocusObjectHint = navigationSessionState.focusObject;
const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) &&
@ -521,20 +556,28 @@ function createAssistantTransitionPolicy(deps) {
const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
: false;
const businessOverviewBoundaryFollowupPrimary = hasBusinessOverviewCarryoverSourceHint && hasBusinessOverviewBoundaryFollowupCue(userMessage);
const businessOverviewBoundaryFollowupAlternate = hasBusinessOverviewCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasBusinessOverviewBoundaryFollowupCue(String(alternateMessage ?? ""))
: false;
const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage);
let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) ||
shortValueFlowRetargetPrimary ||
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal;
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
@ -557,6 +600,7 @@ function createAssistantTransitionPolicy(deps) {
let hasStrongFollowupReference = hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
mcpDiscoveryOrganizationClarificationContinuation ||
hasImplicitContinuationSignal ||
hasSuggestedIntentPivotSignal ||
inventoryShortFollowupPrimary ||
@ -570,6 +614,8 @@ function createAssistantTransitionPolicy(deps) {
Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -579,6 +625,7 @@ function createAssistantTransitionPolicy(deps) {
const hasConcreteFollowupReference = hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
mcpDiscoveryOrganizationClarificationContinuation ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
@ -590,6 +637,8 @@ function createAssistantTransitionPolicy(deps) {
Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -617,6 +666,7 @@ function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation &&
!mcpDiscoveryOrganizationClarificationContinuation &&
!hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal) {
return null;
@ -632,6 +682,7 @@ function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation &&
!mcpDiscoveryOrganizationClarificationContinuation &&
!hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal) {
return null;
@ -650,10 +701,10 @@ function createAssistantTransitionPolicy(deps) {
const sourceDiscoveryMetadataAmbiguityEntitySets = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryMetadataAmbiguityEntitySets)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryEntityResolutionStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityResolutionStatus)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryEntityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityCandidates)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopStatus = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopStatus)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopSelectedChainId = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSelectedChainId)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopPendingAxes = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopPendingAxes)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopProvidedAxes = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopProvidedAxes)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopStatus = sourceDiscoveryLoopStatusHint;
const sourceDiscoveryLoopSelectedChainId = sourceDiscoveryLoopSelectedChainIdHint;
const sourceDiscoveryLoopPendingAxes = sourceDiscoveryLoopPendingAxesHint;
const sourceDiscoveryLoopProvidedAxes = sourceDiscoveryLoopProvidedAxesHint;
const sourceDiscoveryLoopAskedDomainFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopAskedDomainFamily)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopAskedActionFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopAskedActionFamily)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopUnsupportedFamily = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopUnsupportedFamily)(carryoverSourceDebug, deps.toNonEmptyString);
@ -689,6 +740,7 @@ function createAssistantTransitionPolicy(deps) {
explicitIntentFamily &&
sourceIntentFamily !== explicitIntentFamily &&
!hasOrganizationClarificationContinuation &&
!mcpDiscoveryOrganizationClarificationContinuation &&
!hasImplicitContinuationSignal &&
!hasIndexReferenceSignal &&
!hasInventoryRootTemporalFollowupPrimary &&
@ -697,6 +749,8 @@ function createAssistantTransitionPolicy(deps) {
!hasInventoryRootRestatementAlternate &&
!inventoryShortFollowupPrimary &&
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
@ -753,24 +807,29 @@ function createAssistantTransitionPolicy(deps) {
hasSuggestedIntentPivotSignal ||
Boolean(debtRoleSwapPrimary) ||
shortValueFlowRetargetPrimary ||
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary;
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
hasSuggestedIntentPivotSignal ||
Boolean(debtRoleSwapAlternate) ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
hasStrongFollowupReference =
hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
mcpDiscoveryOrganizationClarificationContinuation ||
hasSuggestedIntentPivotSignal ||
hasImplicitContinuationSignal ||
inventoryShortFollowupPrimary ||
@ -782,6 +841,8 @@ function createAssistantTransitionPolicy(deps) {
Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)

View File

@ -0,0 +1,46 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.isLikelyFinancialInstitutionCounterparty = isLikelyFinancialInstitutionCounterparty;
exports.counterpartyRoleHintForName = counterpartyRoleHintForName;
const FINANCIAL_INSTITUTION_PATTERNS = [
/(?:^|[\s"«(,-])банк(?:$|[\s"»),.-])/u,
/сбербанк/u,
/(?:^|[\s"«(,-])сбер(?:$|[\s"»),.-])/u,
/(?:^|[\s"«(,-])втб(?:$|[\s"»),.-])/u,
/альфа[\s-]*банк/u,
/тинькофф/u,
/(?:^|[\s"«(,-])т[\s-]*банк(?:$|[\s"»),.-])/u,
/газпромбанк/u,
/росбанк/u,
/райффайзен/u,
/совкомбанк/u,
/промсвязьбанк/u,
/(?:^|[\s"«(,-])псб(?:$|[\s"»),.-])/u,
/(?:^|[\s"«(,-])мкб(?:$|[\s"»),.-])/u,
/ак[\s-]*барс/u,
/уралсиб/u,
/юникредит/u,
/почта[\s-]*банк/u,
/(?:^|[\s"«(,-])открытие(?:$|[\s"»),.-])/u,
/кредитн(?:ая|ый|ое|ые)\s+организац/u
];
function normalizeCounterpartyRoleText(value) {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[._]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function isLikelyFinancialInstitutionCounterparty(value) {
const normalized = normalizeCounterpartyRoleText(value);
if (!normalized) {
return false;
}
return FINANCIAL_INSTITUTION_PATTERNS.some((pattern) => pattern.test(normalized));
}
function counterpartyRoleHintForName(value) {
return isLikelyFinancialInstitutionCounterparty(value)
? "bank_or_financial_institution"
: "ordinary_counterparty";
}

View File

@ -174,7 +174,7 @@ export const ASSISTANT_MCP_PROXY_URL = (process.env.ASSISTANT_MCP_PROXY_URL ?? "
);
export const ASSISTANT_MCP_CHANNEL = process.env.ASSISTANT_MCP_CHANNEL ?? "default";
export const ASSISTANT_MCP_TIMEOUT_MS = toNumberFlag(process.env.ASSISTANT_MCP_TIMEOUT_MS, 6000);
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 24)));
export const ASSISTANT_MCP_LIVE_LIMIT = Math.max(1, Math.trunc(toNumberFlag(process.env.ASSISTANT_MCP_LIVE_LIMIT, 128)));
export const VAT_PAYABLE_68_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_68_PREFIXES, ["68.02"]);
export const VAT_PAYABLE_19_PREFIXES = toStringListFlag(process.env.VAT_PAYABLE_19_PREFIXES, ["19"]);

View File

@ -2014,7 +2014,6 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase");
const preserveDerivedPeriodWindow =
usesAsOfPrimaryWindow(intent) ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_supplier_stock_overlap_as_of_date";
if (periodWasDerivedHeuristically && !warnings.includes("exact_historical_period_window_requested")) {

View File

@ -2166,7 +2166,11 @@ function hasVatPeriodInspectionBridgeSignal(text: string): boolean {
normalized
);
const forecastOnlyCue = /(?:прогноз|план|примерн|ориентировочн)/iu.test(normalized) && !hasInspectionCue;
return hasPeriodCue && hasInspectionCue && !forecastOnlyCue;
const hasVatMovementInspectionCue =
/(?:покаж|движен|операц|по\s+сч(?:е|ё)т|РїРѕРєР°Р|РґРІРёРен|операС|РїРѕ\s+СЃС(?:Рµ|С)С|show|movement|movements|operation|operations|account)/iu.test(
normalized
);
return hasPeriodCue && (hasInspectionCue || hasVatMovementInspectionCue) && !forecastOnlyCue;
}
function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolution | null {
@ -3034,6 +3038,22 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
};
}
const hasExplicitVatLiabilityPeriodBridge =
/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\b(?:19|20)\d{2}\b|\u0437\u0430\s+(?:\d{4}|\u0433\u043e\u0434|\u043f\u0435\u0440\u0438\u043e\u0434|\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043c\u0435\u0441\u044f\u0446))/iu.test(
text
) &&
/(?:\u043a\u0430\u043a\u043e\u0439|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u043d\u0430\u0447\u0438\u0441\u043b|\u0443\u043f\u043b\u0430\u0447|\u0443\u043f\u043b\u0430\u0442|\u043f\u0440\u043e\u0434\u0430\u0436|\u043f\u043e\u043a\u0443\u043f|\u0432\u044b\u0447\u0435\u0442|\u043a\s+\u0443\u043f\u043b\u0430\u0442|\u043a\s+\u0432\u043e\u0437\u043c\u0435\u0449|\u043f\u043e\u0437\u0438\u0446|liability|payable|charged|paid|sales|purchase|deduction|position)/iu.test(
text
);
if (hasExplicitVatLiabilityPeriodBridge) {
return {
intent: "vat_liability_confirmed_for_tax_period",
confidence: "high",
reasons: ["vat_liability_explicit_period_bridge_signal_detected"]
};
}
const hasLooseVatPayableBridge =
/(?:\u043d\u0434\u0441|vat)/iu.test(text) &&
/(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:(?:\u043d\u0430\u043c|(?:\u043c\u044b\s+)?\u0434\u043e\u043b\u0436\u043d\u044b)\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e|\u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|(?:\u043d\u0430\u043c|\u043c\u044b\s+)?\u043d\u0443\u0436\u043d\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(

View File

@ -207,6 +207,52 @@ const OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
Сумма __ORDER_DIRECTION__
`;
const DEBT_DUE_DATE_AGING_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"DUE_DATE_OPEN_BALANCE" КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт,
"" КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокДт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты,
"debit_open_balance" КАК НаправлениеОстатка
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
__WHERE_DT__
ОБЪЕДИНИТЬ ВСЕ
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
"DUE_DATE_OPEN_BALANCE" КАК Регистратор,
"" КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт,
Остатки.СуммаРазвернутыйОстатокКт КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК Контрагент,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК Договор,
ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК ДокументРасчетов,
ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).Дата КАК ДатаДоговора,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).УстановленСрокОплаты КАК УстановленСрокОплаты,
ВЫРАЗИТЬ(Остатки.Субконто2 КАК Справочник.ДоговорыКонтрагентов).СрокОплаты КАК СрокОплаты,
"credit_open_balance" КАК НаправлениеОстатка
ИЗ
РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки
__WHERE_KT__
УПОРЯДОЧИТЬ ПО
Сумма __ORDER_DIRECTION__
`;
const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = `
ВЫБРАТЬ ПЕРВЫЕ __LIMIT__
__AS_OF_EXPR__ КАК Период,
@ -747,7 +793,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
purpose: "Build customer value ranking and incoming deal profile from bank inflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
default_limit: 200,
account_scope_mode: "preferred",
query_template: "customer_revenue_profile"
},
@ -757,7 +803,7 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
purpose: "Build supplier payout ranking and outgoing deal profile from bank outflow docs",
required_filters: [],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 20,
default_limit: 200,
account_scope_mode: "preferred",
query_template: "supplier_payout_profile"
},
@ -802,6 +848,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "preferred",
query_template: "vat_liability_confirmed_tax_period_profile"
},
{
recipe_id: "address_accounting_financial_result_for_organization_v1",
intent: "accounting_financial_result_for_organization",
purpose: "Build reviewed accounting financial-result aggregate from 90/91/99 period-close movements",
required_filters: ["period_from", "period_to"],
optional_filters: ["organization", "limit", "sort"],
default_limit: 32,
account_scope: ["90", "91", "99"],
account_scope_mode: "strict",
query_template: "accounting_financial_result_profile"
},
{
recipe_id: "address_inventory_on_hand_as_of_date_v1",
intent: "inventory_on_hand_as_of_date",
@ -912,6 +969,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope_mode: "strict",
query_template: "open_contracts_confirmed_as_of_balance_profile"
},
{
recipe_id: "address_debt_due_date_aging_for_organization_v1",
intent: "debt_due_date_aging_for_organization",
purpose: "Check open 60/62/76 settlements against contract payment-term fields and settlement document dates before claiming overdue debt",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 400,
account_scope: ["60", "62", "76"],
account_scope_mode: "strict",
query_template: "debt_due_date_aging_profile"
},
{
recipe_id: "address_contracts_by_counterparty_v1",
intent: "list_contracts_by_counterparty",
@ -1146,6 +1214,37 @@ function buildContractValueWhereClause(filters: AddressFilterSet, fieldPath: str
]);
}
function buildContractReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const contract = typeof filters.contract === "string" ? filters.contract.trim() : "";
if (!contract) {
return null;
}
const contractTokens = Array.from(
new Set(
contract
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
.map((token) => token.trim())
.filter((token) => token.length >= 3)
.filter((token) => !["договор", "дог"].includes(token.toLowerCase()))
)
);
const tokens = contractTokens.length > 0 ? contractTokens : [contract];
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => {
const tokenConditions = tokens.map((token) => {
const escapedToken = toQueryStringLiteral(token);
return `${fieldPath}.Наименование ПОДОБНО "%${escapedToken}%"`;
});
return tokenConditions.length === 1 ? tokenConditions[0] : `(${tokenConditions.join(" И ")})`;
});
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function normalizeAccountTokenForQuery(value: string): string {
const source = String(value ?? "").trim().replace(",", ".");
const match = source.match(/^(\d{2})(?:\.(\d{1,2}))?/);
@ -1251,6 +1350,48 @@ function buildAccountPrefixPredicate(fieldPath: string, prefixes: string[]): str
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildDebtDueDateAgingWhereClause(
filters: AddressFilterSet,
amountFieldPath: string,
accountPredicate: string
): string {
const conditions = [
`${amountFieldPath} > 0`,
`(${accountPredicate})`,
buildOrganizationReferenceCondition(filters, ["Остатки.Организация"]),
buildCounterpartyReferenceCondition(filters, ["Остатки.Субконто1"]),
buildContractReferenceCondition(filters, ["Остатки.Субконто2"])
].filter((item): item is string => Boolean(item));
return `ГДЕ\n ${conditions.join("\n И ")}`;
}
function buildDebtDueDateAgingQuery(filters: AddressFilterSet, resolvedLimit: number): string {
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) ??
"ТЕКУЩАЯДАТА()";
const accountPredicate = buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"]);
return DEBT_DUE_DATE_AGING_QUERY_TEMPLATE
.replaceAll("__LIMIT__", String(resolvedLimit))
.replaceAll("__AS_OF_EXPR__", asOfExpr)
.replaceAll(
"__WHERE_DT__",
buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокДт", accountPredicate)
)
.replaceAll(
"__WHERE_KT__",
buildDebtDueDateAgingWhereClause(filters, "Остатки.СуммаРазвернутыйОстатокКт", accountPredicate)
)
.replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort));
}
function buildInventoryMovementQuery(
filters: AddressFilterSet,
resolvedLimit: number,
@ -1340,6 +1481,179 @@ function buildCounterpartyReferenceCondition(filters: AddressFilterSet, fieldPat
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
const ORGANIZATION_REFERENCE_STOP_WORDS = new Set([
"ооо",
"зао",
"оао",
"ао",
"пао",
"ип",
"на",
"за",
"по",
"конец",
"начало",
"год",
"года",
"период",
"можно",
"точно",
"понять",
"какая",
"какой",
"какие",
"какую",
"компания",
"компании",
"организация",
"организации",
"дебиторка",
"дебиторки",
"кредиторка",
"кредиторки",
"просрочена",
"просроченные",
"просрочка",
"срок",
"оплаты",
"прибыль",
"маржа",
"ндс"
]);
const ORGANIZATION_REFERENCE_BOUNDARY_WORDS = new Set([
"на",
"за",
"конец",
"начало",
"можно",
"точно",
"понять",
"какая",
"какой",
"какие",
"какую",
"дебиторка",
"дебиторки",
"кредиторка",
"кредиторки",
"просрочена",
"просроченные",
"просрочка",
"прибыль",
"маржа",
"ндс"
]);
function organizationReferenceTokens(organization: string): string[] {
const rawTokens = organization
.split(/[^A-Za-zА-Яа-яЁё0-9]+/u)
.map((token) => token.trim())
.filter((token) => token.length > 0);
const boundaryIndex = rawTokens.findIndex((token) => {
const lower = token.toLowerCase();
return /^\d+$/.test(token) || ORGANIZATION_REFERENCE_BOUNDARY_WORDS.has(lower);
});
const scopedTokens = boundaryIndex > 0 ? rawTokens.slice(0, boundaryIndex) : rawTokens;
return Array.from(
new Set(
scopedTokens
.filter((token) => token.length >= 3)
.filter((token) => !/^\d+$/.test(token))
.filter((token) => !ORGANIZATION_REFERENCE_STOP_WORDS.has(token.toLowerCase()))
)
).slice(0, 4);
}
function buildOrganizationReferenceCondition(filters: AddressFilterSet, fieldPaths: string[]): string | null {
const organization = typeof filters.organization === "string" ? filters.organization.trim() : "";
if (!organization) {
return null;
}
const organizationTokens = organizationReferenceTokens(organization);
const tokens = organizationTokens.length > 0 ? organizationTokens : [organization];
const clauses = fieldPaths
.map((fieldPath) => String(fieldPath ?? "").trim())
.filter((fieldPath) => fieldPath.length > 0)
.map((fieldPath) => {
const tokenConditions = tokens.map((token) => {
const escapedToken = toQueryStringLiteral(token);
return `(Организации.Наименование ПОДОБНО "%${escapedToken}%" ИЛИ Организации.НаименованиеПолное ПОДОБНО "%${escapedToken}%")`;
});
const referenceSubquery =
`(ВЫБРАТЬ Организации.Ссылка ИЗ Справочник.Организации КАК Организации ` +
`ГДЕ ${tokenConditions.length === 1 ? tokenConditions[0] : tokenConditions.join(" И ")})`;
return `${fieldPath} В ${referenceSubquery}`;
});
if (clauses.length === 0) {
return null;
}
return clauses.length === 1 ? clauses[0] : `(${clauses.join(" ИЛИ ")})`;
}
function buildAccountingFinancialResultAggregateSelect(
filters: AddressFilterSet,
marker: string,
debitLabel: string,
creditLabel: string,
debitPrefixes: string[],
creditPrefixes: string[]
): string {
const whereClause = buildWhereClause(
filters,
"Движения.Период",
[
debitPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетДт", debitPrefixes) : null,
creditPrefixes.length > 0 ? buildAccountPrefixPredicate("Движения.СчетКт", creditPrefixes) : null,
buildOrganizationReferenceCondition(filters, ["Движения.Организация"])
].filter((item): item is string => Boolean(item))
);
return `
ВЫБРАТЬ
ДАТАВРЕМЯ(2000, 1, 1, 0, 0, 0) КАК Период,
"${marker}" КАК Регистратор,
"${debitLabel}" КАК СчетДт,
"${creditLabel}" КАК СчетКт,
ЕСТЬNULL(СУММА(Движения.Сумма), 0) КАК Сумма,
"" КАК СубконтоДт1,
"" КАК СубконтоДт2,
"" КАК СубконтоДт3,
"" КАК СубконтоКт1,
"" КАК СубконтоКт2,
"" КАК СубконтоКт3,
"" КАК Организация
ИЗ
РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто КАК Движения
${whereClause}`;
}
function buildAccountingFinancialResultQuery(filters: AddressFilterSet): string {
const rows = [
["ACC90_REVENUE_KT", "ANY", "90.01", [], ["90.01"]],
["ACC90_COST_DT", "90.02", "ANY", ["90.02"], []],
["ACC90_SELLING_DT", "90.07", "ANY", ["90.07"], []],
["ACC90_ADMIN_DT", "90.08", "ANY", ["90.08"], []],
["ACC90_RESULT_TO_99_PROFIT", "90.09", "99", ["90.09"], ["99"]],
["ACC90_RESULT_FROM_99_LOSS", "99", "90.09", ["99"], ["90.09"]],
["ACC91_RESULT_TO_99_PROFIT", "91.09", "99", ["91.09"], ["99"]],
["ACC91_RESULT_FROM_99_LOSS", "99", "91.09", ["99"], ["91.09"]],
["ACC99_TO84_PROFIT_TRANSFER", "99", "84", ["99"], ["84"]],
["ACC84_TO99_LOSS_TRANSFER", "84", "99", ["84"], ["99"]]
] as const;
return rows
.map(([marker, debitLabel, creditLabel, debitPrefixes, creditPrefixes]) =>
buildAccountingFinancialResultAggregateSelect(
filters,
marker,
debitLabel,
creditLabel,
[...debitPrefixes],
[...creditPrefixes]
).trim()
)
.join("\nОБЪЕДИНИТЬ ВСЕ\n");
}
function buildInventorySaleDocumentQuery(filters: AddressFilterSet, resolvedLimit: number): string {
const itemCondition = buildInventoryItemReferenceCondition(filters, ["Товары.Номенклатура"]);
return INVENTORY_SALE_DOCUMENTS_QUERY_TEMPLATE
@ -1458,6 +1772,8 @@ function maxLimitForIntent(intent: AddressIntent): number {
intent === "contract_usage_and_value" ||
intent === "vat_payable_forecast" ||
intent === "vat_liability_confirmed_for_tax_period" ||
intent === "accounting_financial_result_for_organization" ||
intent === "debt_due_date_aging_for_organization" ||
intent === "inventory_on_hand_as_of_date" ||
intent === "inventory_purchase_provenance_for_item" ||
intent === "inventory_purchase_documents_for_item" ||
@ -1518,7 +1834,8 @@ export function buildAddressRecipePlan(
recipe.query_template === "counterparty_roles_profile" ||
recipe.query_template === "contract_usage_profile" ||
recipe.query_template === "vat_payable_forecast_profile" ||
recipe.query_template === "vat_liability_confirmed_tax_period_profile";
recipe.query_template === "vat_liability_confirmed_tax_period_profile" ||
recipe.query_template === "accounting_financial_result_profile";
const baseLimit =
typeof filters.limit === "number" && Number.isFinite(filters.limit)
? Math.max(1, Math.min(maxLimit, Math.trunc(filters.limit)))
@ -1659,6 +1976,10 @@ export function buildAddressRecipePlan(
.replaceAll("__WHERE_CLAUSE__", buildManagementWhereClause(filters, "Движения.Период"))
.replaceAll("__PERIOD_TO_EXPR__", periodToExpr);
})()
: recipe.query_template === "accounting_financial_result_profile"
? buildAccountingFinancialResultQuery(filters)
: recipe.query_template === "debt_due_date_aging_profile"
? buildDebtDueDateAgingQuery(filters, resolvedLimit)
: recipe.query_template === "vat_payable_confirmed_as_of_balance_profile"
? (() => {
const asOfExpr =

View File

@ -705,11 +705,19 @@ function detectValueRankingFocus(userMessage: string | null | undefined): ValueR
if (asksTotalMoneyEarned) {
return "total_flow";
}
const hasCounterpartyRankingSubject =
/(?:клиенС|заказСРёРє|РїРѕРєСѓРїР°Сел|РєРѕРЅСрагенС|customer|client|counterpart|\u043a\u043b\u0438\u0435\u043d\u0442|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442)/iu.test(
text
);
const asksExplicitYearBreakdown =
/(?:РїРѕ\s+годам|Р·Р°\s+какие\s+РіРѕРґС|динамик\w*\s+РїРѕ\s+РіРѕРґ|yearly\s+breakdown|by\s+year|\u043f\u043e\s+\u0433\u043e\u0434\u0430\u043c|\u0437\u0430\s+\u043a\u0430\u043a\u0438\u0435\s+\u0433\u043e\u0434\u044b|\u0434\u0438\u043d\u0430\u043c\u0438\u043a\w*\s+\u043f\u043e\s+\u0433\u043e\u0434)/iu.test(
text
);
const asksYearlyRevenueRanking =
/(?:доходн|выручк|оборот|прибыл|деньг|денег|revenue|turnover|income)/iu.test(text) &&
/(?:год|года|годы|year|years|по\s+годам)/iu.test(text) &&
/(?:сам(?:ый|ая|ое|ые)|топ|луч|best|max|наибольш|больше)/iu.test(text);
if (asksYearlyRevenueRanking) {
if (asksYearlyRevenueRanking && (!hasCounterpartyRankingSubject || asksExplicitYearBreakdown)) {
return "top_years_by_total";
}
if (/(?:сам(?:ый|ая|ое|ые)\s+высок[а-яё]*|highest|largest)\s+чек|(?:max\s+check|чек\s+макс)/iu.test(text)) {
@ -3214,7 +3222,7 @@ function composeFactualReplyBody(
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push(
"",
"Покрытие VAT-источников через MCP:",
"Покрытие VAT-источников в 1С:",
`- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`,
@ -3241,7 +3249,7 @@ function composeFactualReplyBody(
}
lines.push("- Сумма прогноза выше рассчитана строго по оборотам 68.02*/19*; прямые VAT-источники показаны для проверки покрытия.");
} else if (vatProbe && vatProbe.status === "error") {
lines.push("", "Покрытие VAT-источников через MCP: probe завершился ошибкой, поэтому использован только базовый контур 68.02*/19*.");
lines.push("", "Покрытие VAT-источников в 1С: дополнительная проверка завершилась ошибкой, поэтому использован только базовый контур 68.02*/19*.");
}
if (!vatActivityDetected) {
@ -3328,13 +3336,16 @@ function composeFactualReplyBody(
options.periodFrom && options.periodTo ? `${formatDateRu(options.periodFrom)}..${formatDateRu(options.periodTo)}` : null;
const formatConfirmedMoney = (value: number): string => (options.useRubCurrency ? formatMoneyRub(value) : formatMoney(value));
const vatProbe = options.vatDirectSourceProbe ?? null;
const organizationLabel = normalizeOrganizationScopeValue(options.organizationHint);
const organizationScopeLabel = organizationLabel ? ` по организации ${organizationLabel}` : "";
const lines = [
`Коротко: подтвержденный НДС к уплате за налоговый период${formatConfirmedMoney(vatToPay)}.`,
`Коротко: подтвержденный НДС к уплате за налоговый период${organizationScopeLabel}${formatConfirmedMoney(vatToPay)}.`,
`Если смотреть на возможный перенос или переплату, получается ${formatConfirmedMoney(carryoverOrOverpayment)}.`,
"Это подтвержденный расчет по регистрам книг продаж и покупок, без surrogate-формулы 68/19.",
"",
"Что вошло в расчет:",
...(organizationLabel ? [`- Организация: ${organizationLabel}.`] : []),
`- Налоговый период расчета: ${periodWindowLabel ?? "не задан (нужен явный период)"}.`,
`- НДС по книге продаж: ${formatConfirmedMoney(salesVat)}.`,
`- НДС по книге покупок (вычеты): ${formatConfirmedMoney(purchaseVat)}.`,
@ -3346,7 +3357,7 @@ function composeFactualReplyBody(
const erroredSources = vatProbe.probedSources.filter((item) => item.status === "error").length;
lines.push(
"",
"Покрытие VAT-источников через MCP:",
"Покрытие VAT-источников в 1С:",
`- Найдено VAT-объектов: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`,
@ -3355,11 +3366,11 @@ function composeFactualReplyBody(
if (vatProbe.errors.length > 0) {
lines.push(`- Ограничения probe: ${vatProbe.errors.slice(0, 2).join("; ")}.`);
}
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; probe использован для контроля полноты VAT-источников.");
lines.push("- Сумма расчета выше получена по книгам продаж/покупок; дополнительная проверка использована для контроля полноты VAT-источников.");
} else if (vatProbe && vatProbe.status === "error") {
lines.push(
"",
"Покрытие VAT-источников через MCP: дополнительный probe недоступен (например, timeout metadata).",
"Покрытие VAT-источников в 1С: дополнительная проверка недоступна, поэтому использован основной бухгалтерский срез.",
"Итоговая сумма НДС выше рассчитана по основному маршруту книг продаж/покупок; probe влияет только на диагностику покрытия."
);
if (vatProbe.errors.length > 0) {
@ -3453,7 +3464,7 @@ function composeFactualReplyBody(
const nonEmptySources = vatProbe.probedSources.filter((item) => item.status === "ok").length;
lines.push(
"",
"Блок 2.1. MCP-проверка VAT-источников",
"Блок 2.1. Проверка VAT-источников в 1С",
`- VAT-объектов в метаданных 1С: ${formatNumberWithDots(vatProbe.objectsTotal)} (документы: ${formatNumberWithDots(vatProbe.documentsTotal)}, регистры: ${formatNumberWithDots(vatProbe.registersTotal)}).`,
`- Пробных прямых источников проверено: ${formatNumberWithDots(vatProbe.probedSources.length)}.`,
`- Источников с движениями до даты среза: ${formatNumberWithDots(nonEmptySources)}.`
@ -3478,8 +3489,8 @@ function composeFactualReplyBody(
} else if (vatProbe && vatProbe.status === "error") {
lines.push(
"",
"Блок 2.1. MCP-проверка VAT-источников",
"- Probe VAT-источников завершился ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."
"Блок 2.1. Проверка VAT-источников в 1С",
"- Дополнительная проверка VAT-источников завершилась ошибкой, поэтому срез подтвержден по доступному бухгалтерскому источнику (68*)."
);
}

View File

@ -148,9 +148,9 @@ export function composeCounterpartyAnalyticsReply(
const includeRoles = focus === "full_profile" || focus === "roles_only";
const directLead =
focus === "suppliers_only"
? `Поставщиков (только supplier-роль): ${supplierOnly}.`
? `Поставщиков с ролью поставщика: ${supplierOnly}.`
: focus === "customers_only"
? `Заказчиков (только customer-роль): ${customerOnly}.`
? `Заказчиков с ролью покупателя: ${customerOnly}.`
: focus === "mixed_only"
? `Контрагентов со смешанной ролью: ${mixedActive}.`
: includeTotal && totalCounterparties > 0
@ -175,9 +175,9 @@ export function composeCounterpartyAnalyticsReply(
if (includeRoles) {
if (resolvedActive > 0 || activeCounterparties > 0) {
lines.push("Роли контрагентов по активности:");
lines.push(`Заказчики (только customer-роль): ${customerOnly}.`);
lines.push(`Поставщики (только supplier-роль): ${supplierOnly}.`);
lines.push("Распределение ролей по активности:");
lines.push(`Заказчики с ролью покупателя: ${customerOnly}.`);
lines.push(`Поставщики с ролью поставщика: ${supplierOnly}.`);
lines.push(`Смешанные (и покупатель, и поставщик): ${mixedActive}.`);
lines.push(`4. Всего активных контрагентов: ${activeCounterparties}.`);
if (otherCounterparties !== null) {
@ -189,10 +189,10 @@ export function composeCounterpartyAnalyticsReply(
}
if (focus === "suppliers_only") {
lines.push(`Поставщиков (только supplier-роль): ${supplierOnly}.`);
lines.push(`Поставщиков с ролью поставщика: ${supplierOnly}.`);
}
if (focus === "customers_only") {
lines.push(`Заказчиков (только customer-роль): ${customerOnly}.`);
lines.push(`Заказчиков с ролью покупателя: ${customerOnly}.`);
}
if (focus === "mixed_only") {
lines.push(`Контрагентов со смешанной ролью: ${mixedActive}.`);
@ -525,6 +525,16 @@ export function composeCounterpartyAnalyticsReply(
const limit = deps.detectRankingLimit(options.userMessage, 20);
const minOpsForAvgCheck = deps.detectMinOpsForAvgCheck(options.userMessage);
const normalizedQuestion = deps.normalizeQuestionText(options.userMessage);
const asksSingleBestCounterparty =
focus === "top_by_total" &&
/(?:какой|кто|which|who|какоР|РєСРѕ)/iu.test(normalizedQuestion) &&
/(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|прин[её]с|highest|most|больСРµ\s+всего|сам(?:СР|ая|РѕРµ|СРµ)|наибол|РїСЂРёРЅ[РµС]СЃ)/iu.test(
normalizedQuestion
) &&
!/(?:\btop\b|топ|рейтинг|список|первые|покажи\s+топ|дай\s+топ|РїРѕРєР°Р\w*\s+СРѕРї|РґР°Р\s+СРѕРї)/iu.test(
normalizedQuestion
);
const effectiveLimit = asksSingleBestCounterparty ? 1 : limit;
const byCounterparty = new Map<string, CounterpartyValuePoint>();
const byYear = new Map<number, CounterpartyYearPoint>();
@ -728,7 +738,7 @@ export function composeCounterpartyAnalyticsReply(
lines.push(
...visible.map(
(item, index) =>
`${index + 1}. ${item.name} | max single: ${item.maxSingle} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`
`${index + 1}. ${item.name} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops}`
)
);
return buildFactualListReply(lines);
@ -786,8 +796,12 @@ export function composeCounterpartyAnalyticsReply(
return buildFactualListReply(lines);
}
const visible = rankedByTotal.slice(0, limit);
const visible = rankedByTotal.slice(0, effectiveLimit);
const singleCandidateOnly = rankedByTotal.length === 1;
const rankingPeriodLabel =
options.periodFrom && options.periodTo
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
: "за доступное время";
const heading = singleCandidateOnly
? isSupplier
? "Найденный поставщик по сумме выплат:"
@ -797,14 +811,17 @@ export function composeCounterpartyAnalyticsReply(
: `Топ-${visible.length} заказчиков по сумме поступлений:`;
const leadingCounterparty = visible[0] ?? null;
lines.unshift(heading);
if (options.periodFrom && options.periodTo) {
lines.push(`Период рейтинга: ${rankingPeriodLabel}.`);
}
if (leadingCounterparty) {
const directAnswerLine = singleCandidateOnly
? isSupplier
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).`
: `Самый доходный клиент ${rankingPeriodLabel} по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`;
lines.unshift(directAnswerLine);
}
lines.push(

View File

@ -1,4 +1,5 @@
import type { AssistantMcpDiscoveryPilotExecutionContract } from "./assistantMcpDiscoveryPilotExecutor";
import { isLikelyFinancialInstitutionCounterparty } from "./counterpartyRoleHeuristics";
export const ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION =
"assistant_mcp_discovery_answer_draft_v1" as const;
@ -26,6 +27,7 @@ export interface AssistantMcpDiscoveryAnswerDraftContract {
}
type BusinessOverview = NonNullable<AssistantMcpDiscoveryPilotExecutionContract["derived_business_overview"]>;
type BusinessOverviewRankedBucket = BusinessOverview["top_customers"][number];
function normalizeReasonCode(value: string): string | null {
const normalized = value
@ -483,6 +485,30 @@ function metadataRouteFamilyLabelRu(
return null;
}
function isInventoryReserveBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "inventory_reserve_boundary" || unsupported === "inventory_reserve_liquidation_boundary";
}
function isProfitMarginBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "profit_margin_boundary" || unsupported === "profit_margin_boundary";
}
function isDebtDueDateBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "debt_due_date_boundary" || unsupported === "debt_due_date_boundary";
}
function isVendorRiskBoundaryTurn(pilot: AssistantMcpDiscoveryPilotExecutionContract): boolean {
const action = pilot.evidence.query_plan.turn_meaning_ref?.asked_action_family;
const unsupported = pilot.evidence.query_plan.turn_meaning_ref?.unsupported_but_understood_family;
return action === "vendor_risk_procurement_boundary" || unsupported === "vendor_risk_procurement_boundary";
}
function businessOverviewInventoryUnknownLabel(overview: BusinessOverview): string {
if (overview.inventory_staleness_risk_proxy) {
return "резервы/списания/ликвидационная стоимость склада";
@ -543,6 +569,81 @@ function inlineBusinessOverviewAmount(value: string): string {
.replace(/[\s.]+$/u, "");
}
function isFinancialInstitutionBucket(bucket: BusinessOverviewRankedBucket | null | undefined): boolean {
if (!bucket) {
return false;
}
return (
bucket.counterparty_role_hint === "bank_or_financial_institution" ||
isLikelyFinancialInstitutionCounterparty(bucket.axis_value)
);
}
function firstNonFinancialInstitutionBucket(
buckets: BusinessOverviewRankedBucket[] | null | undefined
): BusinessOverviewRankedBucket | null {
return (buckets ?? []).find((bucket) => !isFinancialInstitutionBucket(bucket)) ?? null;
}
function rankedBucketAmountLabel(bucket: BusinessOverviewRankedBucket): string {
return `${bucket.axis_value}${bucket.total_amount_human_ru}`;
}
function businessOverviewIncomingLeaderLine(overview: BusinessOverview): string | null {
const leader = overview.top_customers[0];
if (!leader) {
return null;
}
if (!isFinancialInstitutionBucket(leader)) {
return `Самый крупный подтвержденный клиент в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`;
}
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1));
const nonFinancialText = nonFinancial
? ` Крупнейший небанковский входящий контрагент в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.`
: "";
return (
`Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` +
"По названию это банк/финансовая организация, поэтому без проверки назначения платежа не называю это клиентской выручкой или бизнес-заказчиком." +
nonFinancialText
);
}
function businessOverviewOutgoingLeaderLine(overview: BusinessOverview): string | null {
const leader = overview.top_suppliers?.[0];
if (!leader) {
return null;
}
if (!isFinancialInstitutionBucket(leader)) {
return `Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${rankedBucketAmountLabel(leader)}.`;
}
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1));
const nonFinancialText = nonFinancial
? ` Крупнейший небанковский получатель исходящих денег в этом же срезе: ${rankedBucketAmountLabel(nonFinancial)}.`
: "";
return (
`Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}. ` +
"По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком." +
nonFinancialText
);
}
function businessOverviewSupplierBoundaryBasis(overview: BusinessOverview): string {
const leader = overview.top_suppliers?.[0] ?? null;
if (!leader) {
return "есть только общий срез исходящих платежей без надежного vendor-risk профиля";
}
const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount);
if (isFinancialInstitutionBucket(leader)) {
const base = share
? `крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})`
: `крупнейший получатель исходящих денег: ${rankedBucketAmountLabel(leader)}`;
return `${base}; по названию это банк/финансовая организация, поэтому этот факт нельзя считать доказанной зависимостью от одного обычного поставщика`;
}
return share
? `крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенного исходящего потока (${leader.total_amount_human_ru})`
: `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${rankedBucketAmountLabel(leader)}`;
}
function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string | null {
const parts: string[] = [];
if (overview.incoming_customer_revenue.rows_with_amount > 0) {
@ -554,6 +655,24 @@ function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string
if (overview.incoming_customer_revenue.rows_with_amount > 0 || overview.outgoing_supplier_payout.rows_with_amount > 0) {
parts.push(`расчетное операционное нетто ${inlineBusinessOverviewAmount(overview.net_amount_human_ru)}`);
}
if (overview.accounting_financial_result) {
const result = overview.accounting_financial_result;
const direction =
result.final_result_direction === "profit"
? "учетная прибыль"
: result.final_result_direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const amount =
result.final_result_direction === "loss"
? `минус ${inlineBusinessOverviewAmount(result.final_result_amount_human_ru)}`
: inlineBusinessOverviewAmount(result.final_result_amount_human_ru);
const margin =
result.net_margin_to_revenue_pct === null
? "маржа к выручке 90.01 не рассчитана"
: `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`;
parts.push(`${direction} 90/91/99 ${amount}; ${margin}`);
}
const strongestIncomingYear = businessOverviewStrongestIncomingYear(overview);
if (strongestIncomingYear) {
parts.push(
@ -561,10 +680,59 @@ function businessOverviewHeadlineMetricsLine(overview: BusinessOverview): string
);
}
return parts.length > 0
? `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат`
? overview.accounting_financial_result
? `${parts.join("; ")}. Финрезультат ограничен найденными строками 1С и не является внешним аудитом или юридически подтвержденной отчетностью`
: `${parts.join("; ")}. Это operating-flow proxy по найденным строкам, не бухгалтерская прибыль и не финрезультат`
: null;
}
function businessOverviewAccountingFinancialResultText(overview: BusinessOverview): string | null {
const result = overview.accounting_financial_result;
if (!result) {
return null;
}
const direction =
result.final_result_direction === "profit"
? "учетная прибыль"
: result.final_result_direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const signedAmount =
result.final_result_direction === "loss"
? `минус ${result.final_result_amount_human_ru}`
: result.final_result_amount_human_ru;
const marginText =
result.net_margin_to_revenue_pct === null
? "маржа к выручке 90.01 не рассчитана"
: `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`;
const basis =
result.final_transfer_basis === "account_99_to_84_period_close"
? "по закрытию 99 на 84"
: "по закрытию 90/91 на 99";
return `По бухгалтерскому маршруту 90/91/99 за ${result.period_scope} подтвержден ${direction}: ${signedAmount}; ${marginText}. Основа: ${basis}, ${result.period_close_rows_with_amount} строк(и) закрытия периода с суммой. Это учетный финрезультат по найденным строкам 1С, не внешний аудит и не юридически подтвержденная отчетность.`;
}
function businessOverviewDebtDueDateAgingText(overview: BusinessOverview): string | null {
const aging = overview.debt_due_date_aging;
if (!aging) {
return null;
}
if (aging.evidence_status === "confirmed_overdue") {
const top = aging.top_overdue_items?.[0] ?? null;
const topText = top
? ` Самая старая строка: due date ${top.due_date}, просрочка ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.`
: "";
return `Due-date aging на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`;
}
if (aging.evidence_status === "no_payment_terms_configured") {
return `Due-date aging на ${aging.as_of_date} проверен по открытым расчетам: брутто ${aging.gross_open_amount_human_ru}, строк с суммой ${aging.rows_with_amount}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`;
}
if (aging.evidence_status === "insufficient_due_date_basis") {
return `Due-date aging на ${aging.as_of_date} запускался, но по строкам с установленным сроком оплаты не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`;
}
return `Due-date aging на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`;
}
function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpDiscoveryPilotExecutionContract): string {
const askedMonthlyBreakdown =
pilot.derived_bidirectional_value_flow?.aggregation_axis === "month" ||
@ -583,6 +751,35 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
}
if (isBusinessOverviewPilot(pilot) && pilot.derived_business_overview && mode === "confirmed_with_bounded_inference") {
const overview = pilot.derived_business_overview;
if (isProfitMarginBoundaryTurn(pilot)) {
const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview);
if (accountingFinancialResultText) {
return accountingFinancialResultText;
}
return "Нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финрезультат.";
}
if (isDebtDueDateBoundaryTurn(pilot)) {
const dueDateText = businessOverviewDebtDueDateAgingText(overview);
if (dueDateText) {
return dueDateText;
}
return "Нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута по договорам, срокам оплаты и погашению расчетов.";
}
if (isInventoryReserveBoundaryTurn(pilot)) {
const inventoryBasis = overview.inventory_staleness_risk_proxy
? "есть только складской staleness-risk proxy по найденным строкам"
: overview.inventory_position || overview.inventory_turnover_proxy
? "есть только ограниченные складские proxy-сигналы по найденным строкам"
: "нет отдельного складского среза на дату и проверки учетной политики резервов";
return `Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя; ${inventoryBasis}. Можно честно говорить только о необходимости отдельной проверки склада, списаний/резервов и ликвидационной стоимости, не превращая proxy в доказанный факт резерва.`;
}
if (isVendorRiskBoundaryTurn(pilot)) {
const supplierLeader = overview.top_suppliers?.[0] ?? null;
const proxyLabel = isFinancialInstitutionBucket(supplierLeader)
? "outgoing cash concentration proxy"
: "procurement concentration proxy";
return `Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${businessOverviewSupplierBoundaryBasis(overview)}. Это сигнал концентрации исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов.`;
}
const families: string[] = [];
if (
overview.incoming_customer_revenue.rows_with_amount > 0 ||
@ -608,6 +805,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
if (overview.tax_position) {
families.push("НДС-позиция");
}
if (overview.accounting_financial_result) {
families.push("учетный финрезультат 90/91/99");
}
if (overview.trading_margin_proxy) {
families.push("торговый margin proxy");
}
@ -623,6 +823,9 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
if (overview.debt_staleness_risk_proxy) {
families.push("staleness risk proxy открытых расчетов");
}
if (overview.debt_due_date_aging) {
families.push("due-date aging открытых расчетов");
}
if (overview.inventory_position) {
families.push("складской срез на дату");
}
@ -632,20 +835,24 @@ function headlineFor(mode: AssistantMcpDiscoveryAnswerMode, pilot: AssistantMcpD
if (overview.inventory_staleness_risk_proxy) {
families.push("staleness risk proxy склада");
}
const unknownFamilies = [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"];
const unknownFamilies = overview.accounting_financial_result
? ["аудированная/юридически подтвержденная прибыль"]
: [overview.trading_margin_proxy ? "чистая прибыль/точная маржа" : "прибыль/маржа"];
if (!overview.tax_position) {
unknownFamilies.push("НДС");
}
if (!overview.debt_position) {
unknownFamilies.push("долговой срез");
}
unknownFamilies.push(
overview.debt_staleness_risk_proxy
if (!overview.debt_due_date_aging) {
unknownFamilies.push(
overview.debt_staleness_risk_proxy
? "договорные сроки оплаты/due-date просрочка"
: overview.debt_open_settlement_quality
? "due-date просрочка"
: "качество открытых расчетов"
);
);
}
unknownFamilies.push(businessOverviewInventoryUnknownLabel(overview));
const metricLead = businessOverviewHeadlineMetricsLine(overview);
if (metricLead) {
@ -851,9 +1058,16 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract):
claims.push("Do not present a debt-position snapshot as debt aging, overdue debt, or credit-quality analysis.");
claims.push("Do not present open-settlement concentration as contractual due-date aging or confirmed overdue debt.");
claims.push("Do not present business overview debt staleness risk proxy as confirmed overdue debt, contractual delinquency, credit risk, or due-date aging.");
claims.push("Do not claim contractual overdue debt unless the due-date aging route found configured payment terms and enough settlement-date evidence.");
claims.push("Do not present an inventory snapshot or purchase-date aging signal as turnover, obsolescence, liquidation value, or full inventory health.");
claims.push("Do not present business overview inventory turnover proxy as full inventory liquidity, FIFO turnover, obsolescence analysis, or liquidation value.");
claims.push("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value.");
if (
pilot.derived_business_overview?.top_customers?.some(isFinancialInstitutionBucket) ||
pilot.derived_business_overview?.top_suppliers?.some(isFinancialInstitutionBucket)
) {
claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof.");
}
if (pilot.derived_business_overview?.missing_proof_families?.length) {
claims.push("Do not present business overview missing proof families as checked, executed, or confirmed routes.");
}
@ -862,6 +1076,9 @@ function buildMustNotClaim(pilot: AssistantMcpDiscoveryPilotExecutionContract):
if (pilot.derived_ranked_value_flow) {
claims.push("Do not present a bounded ranking as a complete all-time ranking outside the checked period and organization.");
claims.push("Do not imply the top-ranked counterparty is globally final when probe-limit or scope boundaries still exist.");
if (pilot.derived_ranked_value_flow.ranked_values.some(isFinancialInstitutionBucket)) {
claims.push("Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof.");
}
}
if (isDocumentPilot(pilot)) {
claims.push("Do not claim full document history outside the checked period.");
@ -1028,26 +1245,40 @@ function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotEx
return null;
}
const leader = ranking.ranked_values[0];
const leaderLooksFinancial = isFinancialInstitutionBucket(leader);
const organization = ranking.organization_scope ? ` по организации ${ranking.organization_scope}` : "";
const period = ranking.period_scope ? ` за период ${ranking.period_scope}` : " в проверенном окне";
const roleCaveat = leaderLooksFinancial
? ranking.value_flow_direction === "outgoing_supplier_payout"
? " По названию это банк/финансовая организация, поэтому без назначения платежа/договора не называю это обычным поставщиком."
: " По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой или бизнес-заказчиком."
: "";
if (ranking.ranked_values.length === 1) {
const singleLead =
ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один контрагент"
: "В проверенных входящих поступлениях найден один контрагент";
leaderLooksFinancial
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один банковский/финансовый получатель"
: "В проверенных входящих поступлениях найден один банковский/финансовый источник"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "В проверенных исходящих платежах найден один контрагент"
: "В проверенных входящих поступлениях найден один контрагент";
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; сравнение с другими контрагентами может быть неполным."
: " Других контрагентов в этом проверенном срезе не найдено, поэтому это не полноценный сравнительный рейтинг.";
return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${limitCaveat}`;
return `${singleLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${limitCaveat}`;
}
const directionLead =
ranking.ranking_need === "bottom_asc"
leaderLooksFinancial
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
: "Меньше всего денег принёс контрагент"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "Больше всего заплатили контрагенту"
: "Больше всего денег принёс контрагент";
? "Крупнейший получатель исходящих денег"
: "Крупнейший входящий денежный источник"
: ranking.ranking_need === "bottom_asc"
? ranking.value_flow_direction === "outgoing_supplier_payout"
? "Меньше всего заплатили контрагенту"
: "Меньше всего денег принёс контрагент"
: ranking.value_flow_direction === "outgoing_supplier_payout"
? "Больше всего заплатили контрагенту"
: "Больше всего денег принёс контрагент";
const tail = ranking.ranked_values
.slice(1, 3)
.map((bucket) => `${bucket.axis_value}${bucket.total_amount_human_ru}`)
@ -1056,7 +1287,7 @@ function derivedRankedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotEx
const limitCaveat = ranking.coverage_limited_by_probe_limit
? " Лимит строк проверки достигнут; рейтинг может быть неполным."
: "";
return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${trail}${limitCaveat}`;
return `${directionLead} ${leader.axis_value}${organization}${period}: ${leader.total_amount_human_ru} по ${leader.rows_with_amount} строкам с суммой.${roleCaveat}${trail}${limitCaveat}`;
}
function derivedValueFlowConfirmedLine(pilot: AssistantMcpDiscoveryPilotExecutionContract): string | null {
@ -1247,15 +1478,13 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot
`Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket}${strongestIncomingYear.incoming_total_amount_human_ru} по ${strongestIncomingYear.incoming_rows_with_amount} строкам с суммой. Это не бухгалтерская прибыль.`
);
}
const leader = overview.top_customers[0];
if (leader) {
lines.push(`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`);
const incomingLeaderLine = businessOverviewIncomingLeaderLine(overview);
if (incomingLeaderLine) {
lines.push(incomingLeaderLine);
}
const supplierLeader = overview.top_suppliers?.[0];
if (supplierLeader) {
lines.push(
`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}.`
);
const outgoingLeaderLine = businessOverviewOutgoingLeaderLine(overview);
if (outgoingLeaderLine) {
lines.push(outgoingLeaderLine);
}
if (overview.yearly_breakdown?.length) {
lines.push(
@ -1316,6 +1545,12 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot
`НДС-позиция за ${overview.tax_position.period_scope}: книга продаж ${overview.tax_position.sales_vat_amount_human_ru}, книга покупок/вычеты ${overview.tax_position.purchase_vat_amount_human_ru}, нетто ${taxDirection} ${overview.tax_position.net_vat_amount_human_ru}.`
);
}
if (overview.accounting_financial_result) {
const accountingFinancialResultText = businessOverviewAccountingFinancialResultText(overview);
if (accountingFinancialResultText) {
lines.push(accountingFinancialResultText);
}
}
if (overview.trading_margin_proxy) {
const proxy = overview.trading_margin_proxy;
const marginText = proxy.margin_to_revenue_pct === null ? "не рассчитана" : `${proxy.margin_to_revenue_pct}%`;
@ -1359,6 +1594,10 @@ function derivedBusinessOverviewConfirmedLines(pilot: AssistantMcpDiscoveryPilot
`Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.`
);
}
const dueDateText = businessOverviewDebtDueDateAgingText(overview);
if (dueDateText) {
lines.push(dueDateText);
}
if (overview.inventory_position) {
const leader = overview.inventory_position.top_items[0];
const leaderText = leader
@ -1416,6 +1655,13 @@ function businessOverviewCustomerConcentrationLine(overview: BusinessOverview):
return null;
}
const share = percentText(leader.total_amount, overview.incoming_customer_revenue.total_amount);
if (isFinancialInstitutionBucket(leader)) {
const base = share
? `Крупнейший входящий денежный источник ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru})`
: `Крупнейший входящий денежный источник в проверенном срезе: ${rankedBucketAmountLabel(leader)}`;
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_customers.slice(1));
return `${base}. По названию это банк/финансовая организация, поэтому это не доказывает клиентскую выручку или зависимость от клиента.${nonFinancial ? ` Крупнейший небанковский входящий контрагент: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`;
}
return share
? `Концентрация входящего потока: крупнейший подтвержденный клиент ${leader.axis_value} дает около ${share} проверенных входящих поступлений (${leader.total_amount_human_ru}). Это сигнал зависимости от клиента, а не полный customer-risk аудит.`
: `Крупнейший подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`;
@ -1427,6 +1673,13 @@ function businessOverviewSupplierConcentrationLine(overview: BusinessOverview):
return null;
}
const share = percentText(leader.total_amount, overview.outgoing_supplier_payout.total_amount);
if (isFinancialInstitutionBucket(leader)) {
const base = share
? `Концентрация исходящего потока: крупнейший получатель исходящих денег ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru})`
: `Крупнейший получатель исходящих денег в проверенном срезе: ${rankedBucketAmountLabel(leader)}`;
const nonFinancial = firstNonFinancialInstitutionBucket(overview.top_suppliers.slice(1));
return `${base}. По названию это банк/финансовая организация, поэтому это не доказательство зависимости от обычного поставщика без проверки назначения платежа/договора.${nonFinancial ? ` Крупнейший небанковский получатель исходящих денег: ${rankedBucketAmountLabel(nonFinancial)}.` : ""}`;
}
return share
? `Концентрация исходящего потока: крупнейший подтвержденный поставщик/получатель исходящих платежей ${leader.axis_value} держит около ${share} проверенных исходящих платежей (${leader.total_amount_human_ru}). Это сигнал procurement concentration по найденным строкам, а не полный vendor-risk аудит или структура всех расходов.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`;
@ -1474,6 +1727,20 @@ function businessOverviewRiskSynthesisLine(overview: BusinessOverview): string |
: `маржинальность proxy ${overview.trading_margin_proxy.margin_to_revenue_pct}%`;
signals.push(`торговый спред proxy ${overview.trading_margin_proxy.gross_spread_proxy_human_ru}, ${marginText}`);
}
if (overview.accounting_financial_result) {
const result = overview.accounting_financial_result;
const direction =
result.final_result_direction === "profit"
? "учетная прибыль"
: result.final_result_direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const marginText =
result.net_margin_to_revenue_pct === null
? "маржа к выручке 90.01 не рассчитана"
: `маржа к выручке 90.01 ${result.net_margin_to_revenue_pct}%`;
signals.push(`${direction} 90/91/99 ${result.final_result_amount_human_ru}, ${marginText}`);
}
if (overview.debt_position) {
const debtDirection =
overview.debt_position.net_debt_position_direction === "net_receivable"
@ -1495,6 +1762,18 @@ function businessOverviewRiskSynthesisLine(overview: BusinessOverview): string |
`staleness risk proxy открытых расчетов: ${debtStalenessRiskBandRu(overview.debt_staleness_risk_proxy.risk_band)}, возраст ${overview.debt_staleness_risk_proxy.max_contract_age_days} дн., концентрация старейшего крупного договора ${overview.debt_staleness_risk_proxy.top_contract_share_pct}%`
);
}
if (overview.debt_due_date_aging) {
const aging = overview.debt_due_date_aging;
signals.push(
aging.evidence_status === "confirmed_overdue"
? `due-date aging: подтвержденная просрочка ${aging.overdue_amount_human_ru}, строк ${aging.overdue_rows}`
: aging.evidence_status === "no_payment_terms_configured"
? "due-date aging: проверено, но сроки оплаты в договорах не установлены; подтвержденной просрочки нет"
: aging.evidence_status === "insufficient_due_date_basis"
? "due-date aging: не хватило даты расчетного документа для честного расчета просрочки"
: `due-date aging: проверено, подтвержденной просрочки не найдено`
);
}
if (overview.document_activity_profile) {
const topDocument = overview.document_activity_profile.top_document_types[0];
const topSection = overview.document_activity_profile.top_account_sections[0];
@ -1648,6 +1927,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
if (pilot.derived_business_overview?.tax_position) {
pushReason(reasonCodes, "answer_contains_business_overview_tax_position");
}
if (pilot.derived_business_overview?.accounting_financial_result) {
pushReason(reasonCodes, "answer_contains_business_overview_accounting_financial_result");
}
if (pilot.derived_business_overview?.trading_margin_proxy) {
pushReason(reasonCodes, "answer_contains_business_overview_trading_margin_proxy");
}
@ -1678,6 +1960,10 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
if (pilot.derived_business_overview?.debt_staleness_risk_proxy) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_staleness_risk_proxy");
}
if (pilot.derived_business_overview?.debt_due_date_aging) {
pushReason(reasonCodes, "answer_contains_business_overview_debt_due_date_aging");
pushReason(reasonCodes, `answer_contains_business_overview_debt_due_date_aging_${pilot.derived_business_overview.debt_due_date_aging.evidence_status}`);
}
if (pilot.derived_business_overview?.inventory_position) {
pushReason(reasonCodes, "answer_contains_business_overview_inventory_position");
}

View File

@ -1,4 +1,5 @@
import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint";
import type { AssistantMcpRouteCandidateContract } from "./assistantMcpDiscoveryRuntimeBridge";
export interface AssistantMcpDiscoveryDebugAttachmentFields {
assistant_mcp_discovery_entry_point_v1: AssistantMcpDiscoveryRuntimeEntryPointContract | null;
@ -11,6 +12,16 @@ export interface AssistantMcpDiscoveryDebugAttachmentFields {
mcp_discovery_catalog_chain_alignment_status: string | null;
mcp_discovery_catalog_chain_top_match: string | null;
mcp_discovery_catalog_chain_selected_matches_top: boolean;
mcp_discovery_route_candidate_v1: AssistantMcpRouteCandidateContract | null;
mcp_discovery_route_candidate_status: string | null;
mcp_discovery_route_candidate_fact_family: string | null;
mcp_discovery_route_candidate_action_family: string | null;
mcp_discovery_route_candidate_proof_expectation: string | null;
mcp_discovery_route_candidate_missing_axes: string[];
mcp_discovery_route_candidate_provided_axes: string[];
mcp_discovery_route_candidate_executable_now: boolean;
mcp_discovery_route_candidate_enablement_reason: string | null;
mcp_discovery_route_candidate_next_action: string | null;
mcp_discovery_answer_mode: string | null;
mcp_discovery_business_fact_answer_allowed: boolean;
mcp_discovery_user_facing_response_allowed: boolean;
@ -59,6 +70,14 @@ function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcp
);
}
function isRouteCandidateContract(value: unknown): value is AssistantMcpRouteCandidateContract {
const record = toRecordObject(value);
return (
record?.schema_version === "assistant_mcp_route_candidate_v1" &&
record?.policy_owner === "assistantMcpDiscoveryRuntimeBridge"
);
}
function resolveEntryPoint(input: AttachAssistantMcpDiscoveryDebugInput): AssistantMcpDiscoveryRuntimeEntryPointContract | null {
if (isMcpDiscoveryEntryPointContract(input.entryPoint)) {
return input.entryPoint;
@ -77,6 +96,7 @@ export function buildAssistantMcpDiscoveryDebugAttachmentFields(
const bridge = toRecordObject(entryPoint?.bridge);
const planner = toRecordObject(bridge?.planner);
const chainAlignment = toRecordObject(planner?.catalog_chain_template_alignment);
const routeCandidate = isRouteCandidateContract(bridge?.route_candidate) ? bridge.route_candidate : null;
const answerDraft = toRecordObject(bridge?.answer_draft);
return {
@ -90,6 +110,16 @@ export function buildAssistantMcpDiscoveryDebugAttachmentFields(
mcp_discovery_catalog_chain_alignment_status: toNonEmptyString(chainAlignment?.alignment_status),
mcp_discovery_catalog_chain_top_match: toNonEmptyString(chainAlignment?.top_chain_template_match),
mcp_discovery_catalog_chain_selected_matches_top: chainAlignment?.selected_chain_matches_top === true,
mcp_discovery_route_candidate_v1: routeCandidate,
mcp_discovery_route_candidate_status: toNonEmptyString(routeCandidate?.candidate_status),
mcp_discovery_route_candidate_fact_family: toNonEmptyString(routeCandidate?.business_fact_family),
mcp_discovery_route_candidate_action_family: toNonEmptyString(routeCandidate?.action_family),
mcp_discovery_route_candidate_proof_expectation: toNonEmptyString(routeCandidate?.proof_expectation),
mcp_discovery_route_candidate_missing_axes: toStringArray(routeCandidate?.missing_axes),
mcp_discovery_route_candidate_provided_axes: toStringArray(routeCandidate?.provided_axes),
mcp_discovery_route_candidate_executable_now: routeCandidate?.executable_now === true,
mcp_discovery_route_candidate_enablement_reason: toNonEmptyString(routeCandidate?.enablement_reason),
mcp_discovery_route_candidate_next_action: toNonEmptyString(routeCandidate?.recommended_next_action),
mcp_discovery_answer_mode: toNonEmptyString(answerDraft?.answer_mode),
mcp_discovery_business_fact_answer_allowed: bridge?.business_fact_answer_allowed === true,
mcp_discovery_user_facing_response_allowed: bridge?.user_facing_response_allowed === true,

View File

@ -18,6 +18,11 @@ import {
type AssistantMcpDiscoveryProbeResult
} from "./assistantMcpDiscoveryPolicy";
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
import {
counterpartyRoleHintForName,
isLikelyFinancialInstitutionCounterparty,
type CounterpartyRoleHint
} from "./counterpartyRoleHeuristics";
import type { AddressFilterSet, AddressIntent } from "../types/addressQuery";
export const ASSISTANT_MCP_DISCOVERY_PILOT_EXECUTOR_SCHEMA_VERSION =
@ -83,6 +88,7 @@ export interface AssistantMcpDiscoveryRankedValueFlowBucket {
rows_with_amount: number;
total_amount: number;
total_amount_human_ru: string;
counterparty_role_hint?: CounterpartyRoleHint;
}
export interface AssistantMcpDiscoveryDerivedRankedValueFlow {
@ -228,10 +234,12 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverview {
yearly_breakdown: AssistantMcpDiscoveryBusinessOverviewYearBucket[];
activity_period: AssistantMcpDiscoveryDerivedActivityPeriod | null;
tax_position: AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition | null;
accounting_financial_result: AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult | null;
trading_margin_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null;
debt_position: AssistantMcpDiscoveryDerivedBusinessOverviewDebtPosition | null;
debt_open_settlement_quality: AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null;
debt_staleness_risk_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskProxy | null;
debt_due_date_aging: AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging | null;
inventory_position: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null;
inventory_turnover_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryTurnoverProxy | null;
inventory_staleness_risk_proxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryStalenessRiskProxy | null;
@ -264,6 +272,31 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewTaxPosition {
inference_basis: "sales_book_minus_purchase_book_confirmed_1c_vat_rows";
}
export interface AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult {
period_scope: string;
rows_matched: number;
rows_with_amount: number;
sales_revenue_accounting: number;
sales_revenue_accounting_human_ru: string;
cost_of_sales_accounting: number;
cost_of_sales_accounting_human_ru: string;
selling_expenses_accounting: number;
selling_expenses_accounting_human_ru: string;
admin_expenses_accounting: number;
admin_expenses_accounting_human_ru: string;
sales_result_amount: number;
sales_result_amount_human_ru: string;
other_result_amount: number;
other_result_amount_human_ru: string;
final_result_amount: number;
final_result_amount_human_ru: string;
final_result_direction: "profit" | "loss" | "balanced";
net_margin_to_revenue_pct: number | null;
final_transfer_basis: "account_99_to_84_period_close" | "account_90_91_to_99_period_close";
period_close_rows_with_amount: number;
inference_basis: "account_90_91_99_period_close_aggregate_confirmed_1c_rows";
}
export interface AssistantMcpDiscoveryBusinessOverviewTradingItemBucket {
item: string;
sales_revenue: number;
@ -373,6 +406,45 @@ export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskPr
inference_basis: "contract_date_age_and_open_balance_concentration_confirmed_1c_rows";
}
export interface AssistantMcpDiscoveryBusinessOverviewDebtDueDateAgingBucket {
counterparty: string | null;
contract: string | null;
settlement_document: string | null;
document_date: string;
due_date: string;
payment_term_days: number;
overdue_days: number;
amount: number;
amount_human_ru: string;
share_of_overdue_amount_pct: number | null;
}
export interface AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging {
as_of_date: string;
rows_matched: number;
rows_with_amount: number;
gross_open_amount: number;
gross_open_amount_human_ru: string;
rows_with_payment_terms: number;
rows_without_payment_terms: number;
rows_without_document_date: number;
overdue_rows: number;
overdue_amount: number;
overdue_amount_human_ru: string;
not_yet_due_rows: number;
not_yet_due_amount: number;
not_yet_due_amount_human_ru: string;
oldest_due_date: string | null;
max_overdue_days: number | null;
top_overdue_items: AssistantMcpDiscoveryBusinessOverviewDebtDueDateAgingBucket[];
evidence_status:
| "confirmed_overdue"
| "no_overdue_found"
| "no_payment_terms_configured"
| "insufficient_due_date_basis";
inference_basis: "contract_payment_terms_and_settlement_document_dates_from_open_balance_rows";
}
export interface AssistantMcpDiscoveryBusinessOverviewInventoryItemBucket {
item: string;
rows_with_amount: number;
@ -723,6 +795,17 @@ function buildBusinessOverviewDebtFilters(planner: AssistantMcpDiscoveryPlannerC
};
}
function shouldRunDebtDueDateAgingProbe(planner: AssistantMcpDiscoveryPlannerContract): boolean {
const actionFamily = toNonEmptyString(planner.data_need_graph?.action_family);
const turnActionFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.asked_action_family);
const unsupportedFamily = toNonEmptyString(planner.discovery_plan.turn_meaning_ref?.unsupported_but_understood_family);
const proofExpectation = toNonEmptyString(planner.data_need_graph?.proof_expectation);
const combined = [actionFamily, turnActionFamily, unsupportedFamily, proofExpectation]
.filter((item): item is string => Boolean(item))
.join(" ");
return /(?:debt_due_date_boundary|due[-_ ]?date|overdue|aging|просроч|срок\s+оплат|дебиторк|кредиторск)/iu.test(combined);
}
function buildBusinessOverviewInventoryFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet | null {
const meaning = planner.discovery_plan.turn_meaning_ref;
const organization = toNonEmptyString(meaning?.explicit_organization_scope);
@ -756,6 +839,20 @@ function buildBusinessOverviewTradingMarginFilters(planner: AssistantMcpDiscover
};
}
function buildBusinessOverviewAccountingFinancialResultFilters(
planner: AssistantMcpDiscoveryPlannerContract
): AddressFilterSet | null {
const filters = buildBusinessOverviewTradingMarginFilters(planner);
if (!filters) {
return null;
}
return {
...filters,
limit: Math.max(32, planner.discovery_plan.execution_budget.max_rows_per_probe),
sort: "period_asc"
};
}
function buildInventoryExactFilters(planner: AssistantMcpDiscoveryPlannerContract): AddressFilterSet {
const meaning = planner.discovery_plan.turn_meaning_ref;
const subject = firstEntityCandidate(planner);
@ -1359,7 +1456,8 @@ async function executeCoverageAwareValueFlowQuery(input: {
});
executedProbeCount += 1;
probeResults.push(queryResultToProbeResult(input.primitiveId, broadResult));
const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= input.maxRowsPerProbe;
const broadLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, broadRecipePlan.limit));
const broadCoverageLimited = !broadResult.error && broadResult.matched_rows >= broadLimitThreshold;
if (broadResult.error) {
pushUnique(queryLimitations, broadResult.error);
@ -1424,7 +1522,8 @@ async function executeCoverageAwareValueFlowQuery(input: {
pushUnique(queryLimitations, chunkResult.error);
continue;
}
if (chunkResult.matched_rows >= input.maxRowsPerProbe) {
const chunkLimitThreshold = Math.max(1, Math.min(input.maxRowsPerProbe, chunkPlan.limit));
if (chunkResult.matched_rows >= chunkLimitThreshold) {
anyChunkLimited = true;
}
chunkResults.push(chunkResult);
@ -2402,6 +2501,14 @@ function extractContractDateFromText(value: string | null): string | null {
if (!text || !/(?:договор|contract|дог\.)/iu.test(text)) {
return null;
}
return extractAnyDateFromText(text);
}
function extractAnyDateFromText(value: string | null): string | null {
const text = toNonEmptyString(value);
if (!text) {
return null;
}
const isoLikeMatch = text.match(/(\d{4})[-./](\d{1,2})[-./](\d{1,2})/);
if (isoLikeMatch) {
return normalizeDateParts(isoLikeMatch[1], isoLikeMatch[2], isoLikeMatch[3]);
@ -2413,6 +2520,64 @@ function extractContractDateFromText(value: string | null): string | null {
return null;
}
function rowContractDateValue(row: Record<string, unknown>): string | null {
const explicit = rowTextValue(row, ["ДатаДоговора", "ContractDate", "contract_date"]);
return extractAnyDateFromText(explicit) ?? rowOpenSettlementContractStartDateValue(row);
}
function rowSettlementDocumentDateValue(row: Record<string, unknown>): string | null {
const explicit = rowTextValue(row, [
"ДатаДокументаРасчетов",
"SettlementDocumentDate",
"settlement_document_date"
]);
const settlementDocument = rowTextValue(row, [
"ДокументРасчетов",
"SettlementDocument",
"settlement_document"
]);
return extractAnyDateFromText(explicit) ?? extractAnyDateFromText(settlementDocument) ?? extractAnyDateFromText(rowDocumentValue(row));
}
function rowPaymentTermIsSetValue(row: Record<string, unknown>): boolean {
const candidate = rowTextValue(row, ["УстановленСрокОплаты", "PaymentTermIsSet", "payment_term_is_set"]);
if (typeof row["УстановленСрокОплаты"] === "boolean") {
return row["УстановленСрокОплаты"] === true;
}
if (typeof row["PaymentTermIsSet"] === "boolean") {
return row["PaymentTermIsSet"] === true;
}
if (!candidate) {
return false;
}
return /^(?:true|истина|да|yes|1)$/iu.test(candidate.trim());
}
function rowPaymentTermDaysValue(row: Record<string, unknown>): number | null {
const value = rowNumberValue(row, ["СрокОплаты", "PaymentTermDays", "payment_term_days"]);
if (value === null || !Number.isFinite(value) || value <= 0) {
return null;
}
return Math.trunc(value);
}
function addDaysToIsoDate(isoDate: string, days: number): string | null {
const match = isoDate.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match || !Number.isFinite(days)) {
return null;
}
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
date.setUTCDate(date.getUTCDate() + Math.trunc(days));
if (Number.isNaN(date.getTime())) {
return null;
}
return [
String(date.getUTCFullYear()).padStart(4, "0"),
String(date.getUTCMonth() + 1).padStart(2, "0"),
String(date.getUTCDate()).padStart(2, "0")
].join("-");
}
function earlierIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right;
@ -2908,7 +3073,8 @@ function deriveRankedValueFlow(
axis_value: axisValue,
rows_with_amount: bucket.rows_with_amount,
total_amount: bucket.total_amount,
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount)
total_amount_human_ru: formatAmountHumanRu(bucket.total_amount),
counterparty_role_hint: counterpartyRoleHintForName(axisValue)
}))
.sort((left, right) => {
const amountDelta = right.total_amount - left.total_amount;
@ -3097,6 +3263,116 @@ function deriveBusinessOverviewTaxPosition(
};
}
function accountingFinancialResultMarkerAmount(
result: AddressMcpQueryExecutorResult,
marker: string
): number {
let total = 0;
for (const row of result.rows) {
if (String(rowDocumentValue(row) ?? "") !== marker) {
continue;
}
const amount = rowAmountValue(row);
if (amount !== null && Number.isFinite(amount)) {
total += amount;
}
}
return total;
}
function accountingFinancialResultNonZeroCount(
values: number[]
): number {
return values.filter((value) => Math.abs(value) > 0).length;
}
function deriveBusinessOverviewAccountingFinancialResult(
result: AddressMcpQueryExecutorResult | null,
periodScope: string | null
): AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult | null {
if (!result || result.error || result.matched_rows <= 0 || !periodScope) {
return null;
}
const salesRevenueAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_REVENUE_KT");
const costOfSalesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_COST_DT");
const sellingExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_SELLING_DT");
const adminExpensesAccounting = accountingFinancialResultMarkerAmount(result, "ACC90_ADMIN_DT");
const salesProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_TO_99_PROFIT");
const salesLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC90_RESULT_FROM_99_LOSS");
const otherProfitTo99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_TO_99_PROFIT");
const otherLossFrom99 = accountingFinancialResultMarkerAmount(result, "ACC91_RESULT_FROM_99_LOSS");
const profitTransferTo84 = accountingFinancialResultMarkerAmount(result, "ACC99_TO84_PROFIT_TRANSFER");
const lossTransferFrom84 = accountingFinancialResultMarkerAmount(result, "ACC84_TO99_LOSS_TRANSFER");
const amountSignals = [
salesRevenueAccounting,
costOfSalesAccounting,
sellingExpensesAccounting,
adminExpensesAccounting,
salesProfitTo99,
salesLossFrom99,
otherProfitTo99,
otherLossFrom99,
profitTransferTo84,
lossTransferFrom84
];
const rowsWithAmount = accountingFinancialResultNonZeroCount(amountSignals);
if (rowsWithAmount <= 0) {
return null;
}
const salesResultAmount = salesProfitTo99 - salesLossFrom99;
const otherResultAmount = otherProfitTo99 - otherLossFrom99;
const hasFinalTransfer = profitTransferTo84 > 0 || lossTransferFrom84 > 0;
const finalResultAmount = hasFinalTransfer
? profitTransferTo84 - lossTransferFrom84
: salesResultAmount + otherResultAmount;
const finalResultDirection =
finalResultAmount > 0
? "profit"
: finalResultAmount < 0
? "loss"
: "balanced";
const netMarginToRevenuePct =
salesRevenueAccounting > 0 ? percentageOfTotal(finalResultAmount, salesRevenueAccounting) : null;
const periodCloseRowsWithAmount = accountingFinancialResultNonZeroCount([
salesProfitTo99,
salesLossFrom99,
otherProfitTo99,
otherLossFrom99,
profitTransferTo84,
lossTransferFrom84
]);
return {
period_scope: periodScope,
rows_matched: result.matched_rows,
rows_with_amount: rowsWithAmount,
sales_revenue_accounting: salesRevenueAccounting,
sales_revenue_accounting_human_ru: formatAmountHumanRu(salesRevenueAccounting),
cost_of_sales_accounting: costOfSalesAccounting,
cost_of_sales_accounting_human_ru: formatAmountHumanRu(costOfSalesAccounting),
selling_expenses_accounting: sellingExpensesAccounting,
selling_expenses_accounting_human_ru: formatAmountHumanRu(sellingExpensesAccounting),
admin_expenses_accounting: adminExpensesAccounting,
admin_expenses_accounting_human_ru: formatAmountHumanRu(adminExpensesAccounting),
sales_result_amount: salesResultAmount,
sales_result_amount_human_ru: formatAmountHumanRu(Math.abs(salesResultAmount)),
other_result_amount: otherResultAmount,
other_result_amount_human_ru: formatAmountHumanRu(Math.abs(otherResultAmount)),
final_result_amount: finalResultAmount,
final_result_amount_human_ru: formatAmountHumanRu(Math.abs(finalResultAmount)),
final_result_direction: finalResultDirection,
net_margin_to_revenue_pct: netMarginToRevenuePct,
final_transfer_basis: hasFinalTransfer
? "account_99_to_84_period_close"
: "account_90_91_to_99_period_close",
period_close_rows_with_amount: periodCloseRowsWithAmount,
inference_basis: "account_90_91_99_period_close_aggregate_confirmed_1c_rows"
};
}
function deriveBusinessOverviewTradingMarginProxy(
result: AddressMcpQueryExecutorResult | null,
periodScope: string | null
@ -3505,6 +3781,135 @@ function deriveBusinessOverviewDebtStalenessRiskProxy(
};
}
function deriveBusinessOverviewDebtDueDateAging(input: {
dueDateResult: AddressMcpQueryExecutorResult | null;
debtAsOfDate: string | null;
}): AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging | null {
if (!input.debtAsOfDate || !input.dueDateResult || input.dueDateResult.error || input.dueDateResult.matched_rows <= 0) {
return null;
}
const overdueItems: Array<Omit<AssistantMcpDiscoveryBusinessOverviewDebtDueDateAgingBucket, "share_of_overdue_amount_pct">> = [];
let rowsWithAmount = 0;
let grossOpenAmount = 0;
let rowsWithPaymentTerms = 0;
let rowsWithoutPaymentTerms = 0;
let rowsWithoutDocumentDate = 0;
let overdueAmount = 0;
let notYetDueAmount = 0;
let notYetDueRows = 0;
for (const row of input.dueDateResult.rows) {
const amount = rowAmountValue(row);
if (amount === null) {
continue;
}
const absAmount = Math.abs(amount);
if (absAmount <= 0) {
continue;
}
rowsWithAmount += 1;
grossOpenAmount += absAmount;
const paymentTermIsSet = rowPaymentTermIsSetValue(row);
const paymentTermDays = rowPaymentTermDaysValue(row);
if (!paymentTermIsSet || paymentTermDays === null) {
rowsWithoutPaymentTerms += 1;
continue;
}
rowsWithPaymentTerms += 1;
const documentDate = rowSettlementDocumentDateValue(row) ?? rowContractDateValue(row);
if (!documentDate) {
rowsWithoutDocumentDate += 1;
continue;
}
const dueDate = addDaysToIsoDate(documentDate, paymentTermDays);
if (!dueDate) {
rowsWithoutDocumentDate += 1;
continue;
}
const overdueDays = dueDate < input.debtAsOfDate ? daysBetweenIsoDates(dueDate, input.debtAsOfDate) : null;
if (overdueDays !== null && overdueDays > 0) {
overdueAmount += absAmount;
overdueItems.push({
counterparty: rowCounterpartyValue(row),
contract: rowContractValue(row),
settlement_document: rowTextValue(row, [
"ДокументРасчетов",
"SettlementDocument",
"settlement_document"
]) ?? rowDocumentValue(row),
document_date: documentDate,
due_date: dueDate,
payment_term_days: paymentTermDays,
overdue_days: overdueDays,
amount: absAmount,
amount_human_ru: formatAmountHumanRu(absAmount)
});
} else {
notYetDueRows += 1;
notYetDueAmount += absAmount;
}
}
if (rowsWithAmount <= 0) {
return null;
}
const topOverdueItems = overdueItems
.sort((left, right) => {
const daysDelta = right.overdue_days - left.overdue_days;
if (daysDelta !== 0) {
return daysDelta;
}
const amountDelta = right.amount - left.amount;
return amountDelta !== 0 ? amountDelta : String(left.contract ?? "").localeCompare(String(right.contract ?? ""), "ru");
})
.slice(0, 5)
.map((item) => ({
...item,
share_of_overdue_amount_pct: percentageOfTotal(item.amount, overdueAmount)
}));
const oldestDueDate = overdueItems
.map((item) => item.due_date)
.sort()[0] ?? null;
const maxOverdueDays = overdueItems.reduce<number | null>(
(max, item) => max === null ? item.overdue_days : Math.max(max, item.overdue_days),
null
);
const evidenceStatus: AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging["evidence_status"] =
overdueItems.length > 0
? "confirmed_overdue"
: rowsWithPaymentTerms <= 0
? "no_payment_terms_configured"
: rowsWithoutDocumentDate >= rowsWithPaymentTerms
? "insufficient_due_date_basis"
: "no_overdue_found";
return {
as_of_date: input.debtAsOfDate,
rows_matched: input.dueDateResult.matched_rows,
rows_with_amount: rowsWithAmount,
gross_open_amount: grossOpenAmount,
gross_open_amount_human_ru: formatAmountHumanRu(grossOpenAmount),
rows_with_payment_terms: rowsWithPaymentTerms,
rows_without_payment_terms: rowsWithoutPaymentTerms,
rows_without_document_date: rowsWithoutDocumentDate,
overdue_rows: overdueItems.length,
overdue_amount: overdueAmount,
overdue_amount_human_ru: formatAmountHumanRu(overdueAmount),
not_yet_due_rows: notYetDueRows,
not_yet_due_amount: notYetDueAmount,
not_yet_due_amount_human_ru: formatAmountHumanRu(notYetDueAmount),
oldest_due_date: oldestDueDate,
max_overdue_days: maxOverdueDays,
top_overdue_items: topOverdueItems,
evidence_status: evidenceStatus,
inference_basis: "contract_payment_terms_and_settlement_document_dates_from_open_balance_rows"
};
}
function debtStalenessRiskBandRu(
riskBand: AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskProxy["risk_band"]
): string {
@ -3737,9 +4142,11 @@ function inventoryStalenessRiskBandRu(
function buildBusinessOverviewMissingProofFamilies(input: {
missingSignalFamilies: string[];
accountingFinancialResult: AssistantMcpDiscoveryDerivedBusinessOverviewAccountingFinancialResult | null;
tradingMarginProxy: AssistantMcpDiscoveryDerivedBusinessOverviewTradingMarginProxy | null;
debtOpenSettlementQuality: AssistantMcpDiscoveryDerivedBusinessOverviewDebtOpenSettlementQuality | null;
debtStalenessRiskProxy: AssistantMcpDiscoveryDerivedBusinessOverviewDebtStalenessRiskProxy | null;
debtDueDateAging: AssistantMcpDiscoveryDerivedBusinessOverviewDebtDueDateAging | null;
inventoryPosition: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryPosition | null;
inventoryTurnoverProxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryTurnoverProxy | null;
inventoryStalenessRiskProxy: AssistantMcpDiscoveryDerivedBusinessOverviewInventoryStalenessRiskProxy | null;
@ -3753,7 +4160,7 @@ function buildBusinessOverviewMissingProofFamilies(input: {
}
};
if (missing.has("profit_margin") || missing.has("accounting_profit_margin")) {
if ((missing.has("profit_margin") || missing.has("accounting_profit_margin")) && !input.accountingFinancialResult) {
pushUnique({
family: "accounting_profit_margin",
current_status: input.tradingMarginProxy ? "proxy_only_currently" : "reviewed_route_not_wired",
@ -3765,7 +4172,7 @@ function buildBusinessOverviewMissingProofFamilies(input: {
});
}
if (missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) {
if ((missing.has("debt_due_date_aging_quality") || missing.has("debt_open_settlement_quality")) && !input.debtDueDateAging) {
pushUnique({
family: "debt_due_date_aging_quality",
current_status: input.debtStalenessRiskProxy
@ -3826,10 +4233,12 @@ function deriveBusinessOverview(input: {
outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null;
lifecycleResult: AddressMcpQueryExecutorResult | null;
taxResult: AddressMcpQueryExecutorResult | null;
accountingFinancialResultResult: AddressMcpQueryExecutorResult | null;
tradingMarginResult: AddressMcpQueryExecutorResult | null;
receivablesResult: AddressMcpQueryExecutorResult | null;
payablesResult: AddressMcpQueryExecutorResult | null;
openContractsResult: AddressMcpQueryExecutorResult | null;
dueDateAgingResult: AddressMcpQueryExecutorResult | null;
documentActivityProfileResult: AddressMcpQueryExecutorResult | null;
counterpartyProfileResult: AddressMcpQueryExecutorResult | null;
contractUsageProfileResult: AddressMcpQueryExecutorResult | null;
@ -3860,6 +4269,10 @@ function deriveBusinessOverview(input: {
});
const activityPeriod = deriveActivityPeriod(input.lifecycleResult);
const taxPosition = deriveBusinessOverviewTaxPosition(input.taxResult, input.periodScope);
const accountingFinancialResult = deriveBusinessOverviewAccountingFinancialResult(
input.accountingFinancialResultResult,
input.periodScope
);
const tradingMarginProxy = deriveBusinessOverviewTradingMarginProxy(input.tradingMarginResult, input.periodScope);
const debtPosition = deriveBusinessOverviewDebtPosition({
receivablesResult: input.receivablesResult,
@ -3883,6 +4296,10 @@ function deriveBusinessOverview(input: {
input.periodScope
);
const debtStalenessRiskProxy = deriveBusinessOverviewDebtStalenessRiskProxy(debtOpenSettlementQuality);
const debtDueDateAging = deriveBusinessOverviewDebtDueDateAging({
dueDateResult: input.dueDateAgingResult,
debtAsOfDate: input.debtAsOfDate
});
const inventoryPosition = deriveBusinessOverviewInventoryPosition({
inventoryOnHandResult: input.inventoryOnHandResult,
inventoryAgingResult: input.inventoryAgingResult,
@ -3901,10 +4318,12 @@ function deriveBusinessOverview(input: {
outgoing.rows_with_amount > 0,
Boolean(activityPeriod),
Boolean(taxPosition),
Boolean(accountingFinancialResult),
Boolean(tradingMarginProxy),
Boolean(debtPosition),
Boolean(debtOpenSettlementQuality),
Boolean(debtStalenessRiskProxy),
Boolean(debtDueDateAging),
Boolean(documentActivityProfile),
Boolean(counterpartyProfile),
Boolean(contractUsageProfile),
@ -3921,9 +4340,9 @@ function deriveBusinessOverview(input: {
documentActivityProfile || counterpartyProfile || contractUsageProfile
);
const missingSignalFamilies = [
tradingMarginProxy ? "accounting_profit_margin" : "profit_margin",
accountingFinancialResult ? null : tradingMarginProxy ? "accounting_profit_margin" : "profit_margin",
debtPosition ? null : "debt_position",
debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality",
debtDueDateAging ? null : debtOpenSettlementQuality ? "debt_due_date_aging_quality" : "debt_open_settlement_quality",
taxPosition ? null : "tax_position",
inventoryPosition
? inventoryStalenessRiskProxy
@ -3936,9 +4355,11 @@ function deriveBusinessOverview(input: {
].filter((item): item is string => Boolean(item));
const missingProofFamilies = buildBusinessOverviewMissingProofFamilies({
missingSignalFamilies,
accountingFinancialResult,
tradingMarginProxy,
debtOpenSettlementQuality,
debtStalenessRiskProxy,
debtDueDateAging,
inventoryPosition,
inventoryTurnoverProxy,
inventoryStalenessRiskProxy,
@ -3957,10 +4378,12 @@ function deriveBusinessOverview(input: {
yearly_breakdown: yearlyBreakdown,
activity_period: activityPeriod,
tax_position: taxPosition,
accounting_financial_result: accountingFinancialResult,
trading_margin_proxy: tradingMarginProxy,
debt_position: debtPosition,
debt_open_settlement_quality: debtOpenSettlementQuality,
debt_staleness_risk_proxy: debtStalenessRiskProxy,
debt_due_date_aging: debtDueDateAging,
inventory_position: inventoryPosition,
inventory_turnover_proxy: inventoryTurnoverProxy,
inventory_staleness_risk_proxy: inventoryStalenessRiskProxy,
@ -3973,9 +4396,9 @@ function deriveBusinessOverview(input: {
missing_signal_families: missingSignalFamilies,
missing_proof_families: missingProofFamilies,
inference_basis:
hasBusinessOverviewProfileSignal || inventoryPosition
hasBusinessOverviewProfileSignal || inventoryPosition || accountingFinancialResult
? "business_overview_from_confirmed_1c_multi_family_rows"
: debtOpenSettlementQuality
: debtOpenSettlementQuality || debtDueDateAging
? "business_overview_from_confirmed_1c_multi_family_rows"
: taxPosition && debtPosition
? "business_overview_from_confirmed_1c_money_activity_tax_and_debt_rows"
@ -3992,10 +4415,12 @@ function summarizeBusinessOverviewRows(input: {
outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null;
lifecycleResult: AddressMcpQueryExecutorResult | null;
taxResult: AddressMcpQueryExecutorResult | null;
accountingFinancialResultResult: AddressMcpQueryExecutorResult | null;
tradingMarginResult: AddressMcpQueryExecutorResult | null;
receivablesResult: AddressMcpQueryExecutorResult | null;
payablesResult: AddressMcpQueryExecutorResult | null;
openContractsResult: AddressMcpQueryExecutorResult | null;
dueDateAgingResult: AddressMcpQueryExecutorResult | null;
documentActivityProfileResult: AddressMcpQueryExecutorResult | null;
counterpartyProfileResult: AddressMcpQueryExecutorResult | null;
contractUsageProfileResult: AddressMcpQueryExecutorResult | null;
@ -4015,6 +4440,9 @@ function summarizeBusinessOverviewRows(input: {
if (input.taxResult && !input.taxResult.error) {
parts.push(`${input.taxResult.fetched_rows} VAT/tax rows fetched, ${input.taxResult.matched_rows} matched`);
}
if (input.accountingFinancialResultResult && !input.accountingFinancialResultResult.error) {
parts.push(`${input.accountingFinancialResultResult.fetched_rows} accounting financial-result aggregate rows fetched, ${input.accountingFinancialResultResult.matched_rows} matched`);
}
if (input.tradingMarginResult && !input.tradingMarginResult.error) {
parts.push(`${input.tradingMarginResult.fetched_rows} trading-margin document rows fetched, ${input.tradingMarginResult.matched_rows} matched`);
}
@ -4027,6 +4455,9 @@ function summarizeBusinessOverviewRows(input: {
if (input.openContractsResult && !input.openContractsResult.error) {
parts.push(`${input.openContractsResult.fetched_rows} open-contract rows fetched, ${input.openContractsResult.matched_rows} matched`);
}
if (input.dueDateAgingResult && !input.dueDateAgingResult.error) {
parts.push(`${input.dueDateAgingResult.fetched_rows} due-date aging rows fetched, ${input.dueDateAgingResult.matched_rows} matched`);
}
if (input.documentActivityProfileResult && !input.documentActivityProfileResult.error) {
parts.push(`${input.documentActivityProfileResult.fetched_rows} document/account-section profile rows fetched, ${input.documentActivityProfileResult.matched_rows} matched`);
}
@ -4064,15 +4495,39 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv
}
if (derived.top_customers.length > 0) {
const leader = derived.top_customers[0];
facts.push(
`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`
);
if (isLikelyFinancialInstitutionCounterparty(leader.axis_value)) {
facts.push(
`Крупнейший входящий денежный источник в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа это не доказанный клиент или бизнес-выручка.`
);
const nonFinancialLeader = derived.top_customers.slice(1).find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value));
if (nonFinancialLeader) {
facts.push(
`Крупнейший небанковский входящий контрагент в проверенном срезе: ${nonFinancialLeader.axis_value}${nonFinancialLeader.total_amount_human_ru}.`
);
}
} else {
facts.push(
`Самый крупный подтвержденный клиент в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`
);
}
}
if (derived.top_suppliers.length > 0) {
const leader = derived.top_suppliers[0];
facts.push(
`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`
);
if (isLikelyFinancialInstitutionCounterparty(leader.axis_value)) {
facts.push(
`Крупнейший получатель исходящих денег в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора это не доказанный обычный поставщик.`
);
const nonFinancialLeader = derived.top_suppliers.slice(1).find((item) => !isLikelyFinancialInstitutionCounterparty(item.axis_value));
if (nonFinancialLeader) {
facts.push(
`Крупнейший небанковский получатель исходящих денег в проверенном срезе: ${nonFinancialLeader.axis_value}${nonFinancialLeader.total_amount_human_ru}.`
);
}
} else {
facts.push(
`Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${leader.axis_value}${leader.total_amount_human_ru}.`
);
}
}
if (derived.yearly_breakdown.length > 0) {
facts.push(
@ -4179,6 +4634,30 @@ function buildBusinessOverviewConfirmedFacts(derived: AssistantMcpDiscoveryDeriv
`Staleness risk proxy открытых расчетов на ${proxy.as_of_date}: самый старый договорный сигнал ${proxy.oldest_contract_start_date}, возраст ${proxy.max_contract_age_days} дн.; старейший крупный договор ${proxy.top_contract}${counterpartyText} держит ${proxy.top_contract_amount_human_ru} (${proxy.top_contract_share_pct}% брутто открытых остатков); оценка ${debtStalenessRiskBandRu(proxy.risk_band)}. Это не подтвержденная просрочка, не кредитный риск и не due-date aging.`
);
}
if (derived.debt_due_date_aging) {
const aging = derived.debt_due_date_aging;
if (aging.evidence_status === "confirmed_overdue") {
const top = aging.top_overdue_items[0];
const topText = top
? ` Самая старая просрочка: ${top.due_date}, ${top.overdue_days} дн., ${top.amount_human_ru}${top.contract ? ` по договору ${top.contract}` : ""}.`
: "";
facts.push(
`Due-date aging открытых расчетов на ${aging.as_of_date} проверен по срокам оплаты договоров и датам расчетных документов: подтверждено просроченных строк ${aging.overdue_rows}, сумма ${aging.overdue_amount_human_ru}.${topText}`
);
} else if (aging.evidence_status === "no_payment_terms_configured") {
facts.push(
`Due-date aging открытых расчетов на ${aging.as_of_date} проверен по ${aging.rows_with_amount} строкам с суммой: брутто открытых остатков ${aging.gross_open_amount_human_ru}, но в проверенных договорах срок оплаты не установлен. Подтвержденной просрочки по договорным срокам оплаты нет.`
);
} else if (aging.evidence_status === "insufficient_due_date_basis") {
facts.push(
`Due-date aging открытых расчетов на ${aging.as_of_date} запускался по ${aging.rows_with_payment_terms} строкам с установленным сроком оплаты, но в найденных строках не хватило даты расчетного документа для честного расчета due date. Просрочка не подтверждена.`
);
} else {
facts.push(
`Due-date aging открытых расчетов на ${aging.as_of_date} проверен: строк с установленным сроком оплаты ${aging.rows_with_payment_terms}, подтвержденной просрочки не найдено; не просрочено по расчету ${aging.not_yet_due_amount_human_ru}.`
);
}
}
if (derived.inventory_position) {
const leader = derived.inventory_position.top_items[0];
const leaderText = leader
@ -4237,6 +4716,9 @@ function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerive
const supplierSharePct = supplierLeader
? percentageOfTotal(supplierLeader.total_amount, derived.outgoing_supplier_payout.total_amount)
: null;
const supplierLeaderIsFinancial = supplierLeader
? isLikelyFinancialInstitutionCounterparty(supplierLeader.axis_value)
: false;
const strongestIncomingYear = [...derived.yearly_breakdown]
.filter((bucket) => bucket.incoming_total_amount > 0)
.sort((left, right) => right.incoming_total_amount - left.incoming_total_amount || left.year_bucket.localeCompare(right.year_bucket))[0];
@ -4246,9 +4728,13 @@ function buildBusinessOverviewInferredFacts(derived: AssistantMcpDiscoveryDerive
return [
`Расчетное нетто по найденным строкам: ${derived.net_amount_human_ru}; ${direction}.`,
supplierLeader
? supplierSharePct !== null
? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}.`
? supplierLeaderIsFinancial
? supplierSharePct !== null
? `Крупнейший получатель исходящих денег ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). По названию это банк/финансовая организация, поэтому это outgoing cash concentration proxy, а не доказанный vendor-risk по обычному поставщику.`
: `Крупнейший получатель исходящих денег в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}. По названию это банк/финансовая организация, поэтому это не доказанный обычный поставщик.`
: supplierSharePct !== null
? `Крупнейший подтвержденный поставщик/получатель исходящих платежей ${supplierLeader.axis_value} держит около ${supplierSharePct}% проверенного исходящего потока (${supplierLeader.total_amount_human_ru}). Это procurement concentration proxy по найденным строкам, а не полный vendor-risk аудит.`
: `Крупнейший подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: ${supplierLeader.axis_value}${supplierLeader.total_amount_human_ru}.`
: null,
strongestIncomingYear
? `Самый сильный год по подтвержденным входящим поступлениям: ${strongestIncomingYear.year_bucket} (${strongestIncomingYear.incoming_total_amount_human_ru}).`
@ -5032,10 +5518,12 @@ export async function executeAssistantMcpDiscoveryPilot(
let outgoingResult: AssistantMcpDiscoveryCoverageAwareQueryResult | null = null;
let lifecycleResult: AddressMcpQueryExecutorResult | null = null;
let taxResult: AddressMcpQueryExecutorResult | null = null;
let accountingFinancialResultResult: AddressMcpQueryExecutorResult | null = null;
let tradingMarginResult: AddressMcpQueryExecutorResult | null = null;
let receivablesResult: AddressMcpQueryExecutorResult | null = null;
let payablesResult: AddressMcpQueryExecutorResult | null = null;
let openContractsResult: AddressMcpQueryExecutorResult | null = null;
let dueDateAgingResult: AddressMcpQueryExecutorResult | null = null;
let documentActivityProfileResult: AddressMcpQueryExecutorResult | null = null;
let counterpartyProfileResult: AddressMcpQueryExecutorResult | null = null;
let contractUsageProfileResult: AddressMcpQueryExecutorResult | null = null;
@ -5045,8 +5533,10 @@ export async function executeAssistantMcpDiscoveryPilot(
const lifecycleFilters = buildLifecycleFilters(planner);
const profileFilters = buildBusinessOverviewProfileFilters(planner);
const taxFilters = buildBusinessOverviewTaxFilters(planner);
const accountingFinancialResultFilters = buildBusinessOverviewAccountingFinancialResultFilters(planner);
const tradingMarginFilters = buildBusinessOverviewTradingMarginFilters(planner);
const debtFilters = buildBusinessOverviewDebtFilters(planner);
const debtDueDateAgingProbeEnabled = shouldRunDebtDueDateAgingProbe(planner);
const inventoryFilters = buildBusinessOverviewInventoryFilters(planner);
const debtAsOfDate = toNonEmptyString(debtFilters?.as_of_date);
const inventoryAsOfDate = toNonEmptyString(inventoryFilters?.as_of_date);
@ -5059,6 +5549,9 @@ export async function executeAssistantMcpDiscoveryPilot(
const taxSelection = taxFilters
? selectAddressRecipe("vat_liability_confirmed_for_tax_period", taxFilters)
: null;
const accountingFinancialResultSelection = accountingFinancialResultFilters
? selectAddressRecipe("accounting_financial_result_for_organization", accountingFinancialResultFilters)
: null;
const tradingMarginSelection = tradingMarginFilters
? selectAddressRecipe("inventory_trading_margin_proxy_for_organization", tradingMarginFilters)
: null;
@ -5071,6 +5564,9 @@ export async function executeAssistantMcpDiscoveryPilot(
const openContractsSelection = debtFilters
? selectAddressRecipe("open_contracts_confirmed_as_of_date", debtFilters)
: null;
const dueDateAgingSelection = debtFilters && debtDueDateAgingProbeEnabled
? selectAddressRecipe("debt_due_date_aging_for_organization", debtFilters)
: null;
const inventoryOnHandSelection = inventoryFilters
? selectAddressRecipe("inventory_on_hand_as_of_date", inventoryFilters)
: null;
@ -5135,6 +5631,14 @@ export async function executeAssistantMcpDiscoveryPilot(
pushReason(reasonCodes, "pilot_business_overview_tax_recipe_not_available");
pushUnique(queryLimitations, "Business overview VAT/tax probe requires an executable tax-period recipe");
}
if (accountingFinancialResultSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_selected");
} else if (!accountingFinancialResultFilters) {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_probe_skipped_without_explicit_period");
} else {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_recipe_not_available");
pushUnique(queryLimitations, "Business overview accounting financial-result probe requires an executable 90/91/99 period-close recipe");
}
if (tradingMarginSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_trading_margin_recipe_selected");
} else if (!tradingMarginFilters) {
@ -5159,6 +5663,16 @@ export async function executeAssistantMcpDiscoveryPilot(
pushReason(reasonCodes, "pilot_business_overview_open_contracts_recipe_not_available");
pushUnique(queryLimitations, "Business overview open-settlement quality probe requires executable open-contracts as-of-date recipe");
}
if (dueDateAgingSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_selected");
} else if (!debtFilters) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_explicit_as_of_date");
} else if (!debtDueDateAgingProbeEnabled) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_probe_skipped_without_boundary_need");
} else {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_recipe_not_available");
pushUnique(queryLimitations, "Business overview due-date aging probe requires executable contract payment-term/open-balance recipe");
}
if (inventoryOnHandSelection?.selected_recipe) {
pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_recipe_selected");
if (inventoryAgingSelection?.selected_recipe) {
@ -5200,6 +5714,17 @@ export async function executeAssistantMcpDiscoveryPilot(
account_scope: taxPlan.account_scope
});
}
if (accountingFinancialResultSelection?.selected_recipe) {
const accountingFinancialResultPlan = buildAddressRecipePlan(
accountingFinancialResultSelection.selected_recipe,
accountingFinancialResultFilters!
);
accountingFinancialResultResult = await runtimeDeps.executeAddressMcpQuery({
query: accountingFinancialResultPlan.query,
limit: accountingFinancialResultPlan.limit,
account_scope: accountingFinancialResultPlan.account_scope
});
}
if (receivablesSelection?.selected_recipe && payablesSelection?.selected_recipe) {
const receivablesPlan = buildAddressRecipePlan(receivablesSelection.selected_recipe, debtFilters!);
receivablesResult = await runtimeDeps.executeAddressMcpQuery({
@ -5222,6 +5747,14 @@ export async function executeAssistantMcpDiscoveryPilot(
account_scope: openContractsPlan.account_scope
});
}
if (dueDateAgingSelection?.selected_recipe) {
const dueDateAgingPlan = buildAddressRecipePlan(dueDateAgingSelection.selected_recipe, debtFilters!);
dueDateAgingResult = await runtimeDeps.executeAddressMcpQuery({
query: dueDateAgingPlan.query,
limit: dueDateAgingPlan.limit,
account_scope: dueDateAgingPlan.account_scope
});
}
if (inventoryOnHandSelection?.selected_recipe) {
const inventoryOnHandPlan = buildAddressRecipePlan(inventoryOnHandSelection.selected_recipe, inventoryFilters!);
inventoryOnHandResult = await runtimeDeps.executeAddressMcpQuery({
@ -5243,6 +5776,9 @@ export async function executeAssistantMcpDiscoveryPilot(
if (taxResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, taxResult));
}
if (accountingFinancialResultResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, accountingFinancialResultResult));
}
if (receivablesResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, receivablesResult));
}
@ -5252,6 +5788,9 @@ export async function executeAssistantMcpDiscoveryPilot(
if (openContractsResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, openContractsResult));
}
if (dueDateAgingResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, dueDateAgingResult));
}
if (inventoryOnHandResult) {
probeResults.push(queryResultToProbeResult(step.primitive_id, inventoryOnHandResult));
}
@ -5276,6 +5815,12 @@ export async function executeAssistantMcpDiscoveryPilot(
} else if (taxResult) {
pushReason(reasonCodes, "pilot_business_overview_tax_query_mcp_executed");
}
if (accountingFinancialResultResult?.error) {
pushUnique(queryLimitations, accountingFinancialResultResult.error);
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_error");
} else if (accountingFinancialResultResult) {
pushReason(reasonCodes, "pilot_business_overview_accounting_financial_result_query_mcp_executed");
}
if (receivablesResult?.error) {
pushUnique(queryLimitations, receivablesResult.error);
pushReason(reasonCodes, "pilot_business_overview_receivables_query_mcp_error");
@ -5300,6 +5845,12 @@ export async function executeAssistantMcpDiscoveryPilot(
} else if (openContractsResult) {
pushReason(reasonCodes, "pilot_business_overview_open_contracts_query_mcp_executed");
}
if (dueDateAgingResult?.error) {
pushUnique(queryLimitations, dueDateAgingResult.error);
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_error");
} else if (dueDateAgingResult) {
pushReason(reasonCodes, "pilot_business_overview_debt_due_date_aging_query_mcp_executed");
}
if (inventoryOnHandResult?.error) {
pushUnique(queryLimitations, inventoryOnHandResult.error);
pushReason(reasonCodes, "pilot_business_overview_inventory_on_hand_query_mcp_error");
@ -5411,10 +5962,12 @@ export async function executeAssistantMcpDiscoveryPilot(
outgoingResult,
lifecycleResult,
taxResult,
accountingFinancialResultResult,
tradingMarginResult,
receivablesResult,
payablesResult,
openContractsResult,
dueDateAgingResult,
documentActivityProfileResult,
counterpartyProfileResult,
contractUsageProfileResult,
@ -5451,6 +6004,9 @@ export async function executeAssistantMcpDiscoveryPilot(
if (derivedBusinessOverview.tax_position) {
pushReason(reasonCodes, "pilot_derived_business_overview_tax_position_from_confirmed_rows");
}
if (derivedBusinessOverview.accounting_financial_result) {
pushReason(reasonCodes, "pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows");
}
if (derivedBusinessOverview.trading_margin_proxy) {
pushReason(reasonCodes, "pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows");
}
@ -5466,6 +6022,10 @@ export async function executeAssistantMcpDiscoveryPilot(
if (derivedBusinessOverview.debt_staleness_risk_proxy) {
pushReason(reasonCodes, "pilot_derived_business_overview_debt_staleness_risk_proxy_from_confirmed_rows");
}
if (derivedBusinessOverview.debt_due_date_aging) {
pushReason(reasonCodes, "pilot_derived_business_overview_debt_due_date_aging_from_confirmed_rows");
pushReason(reasonCodes, `pilot_derived_business_overview_debt_due_date_aging_${derivedBusinessOverview.debt_due_date_aging.evidence_status}`);
}
if (derivedBusinessOverview.inventory_position) {
pushReason(reasonCodes, "pilot_derived_business_overview_inventory_position_from_confirmed_rows");
}
@ -5484,10 +6044,12 @@ export async function executeAssistantMcpDiscoveryPilot(
outgoingResult,
lifecycleResult,
taxResult,
accountingFinancialResultResult,
tradingMarginResult,
receivablesResult,
payablesResult,
openContractsResult,
dueDateAgingResult,
documentActivityProfileResult,
counterpartyProfileResult,
contractUsageProfileResult,

View File

@ -108,7 +108,7 @@ export interface AssistantMcpDiscoveryEvidenceContract {
const DEFAULT_DISCOVERY_BUDGET: AssistantMcpDiscoveryExecutionBudget = {
max_probe_count: 3,
max_rows_per_probe: 100
max_rows_per_probe: 200
};
const MAX_PROBE_COUNT = 36;

View File

@ -1,4 +1,5 @@
import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint";
import { isLikelyFinancialInstitutionCounterparty } from "./counterpartyRoleHeuristics";
export const ASSISTANT_MCP_DISCOVERY_RESPONSE_CANDIDATE_SCHEMA_VERSION =
"assistant_mcp_discovery_response_candidate_v1" as const;
@ -97,7 +98,27 @@ function userFacingLines(values: string[]): string[] {
return uniqueStrings(values).filter((line) => !hasInternalMechanics(line));
}
function sanitizeUserFacingMechanics(value: string): string {
return String(value ?? "").replace(/MCP-срез(?:ом|у|е|а)?/giu, (match) => {
const normalized = match.toLowerCase();
if (normalized.endsWith("ом")) {
return "срезом 1С";
}
if (normalized.endsWith("у")) {
return "срезу 1С";
}
if (normalized.endsWith("е")) {
return "срезе 1С";
}
if (normalized.endsWith("а")) {
return "среза 1С";
}
return "срез 1С";
});
}
function localizeLine(value: string): string {
const sanitizedValue = sanitizeUserFacingMechanics(value);
if (/^1C activity rows were found for the requested counterparty scope$/i.test(value)) {
return "В 1С найдены строки активности в запрошенном срезе.";
}
@ -126,7 +147,7 @@ function localizeLine(value: string): string {
value
)
) {
return "Запрошенный период уперся в лимит строк MCP; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды.";
return "Запрошенный период достиг лимита строк; доступного бюджета помесячных дозапросов не хватило, чтобы покрыть все подпериоды.";
}
const counterpartyMatch = value.match(/^1C activity rows were found for counterparty\s+(.+)$/i);
if (counterpartyMatch) {
@ -151,10 +172,10 @@ function localizeLine(value: string): string {
}
const movementRowsMatch = value.match(/^1C movement rows were found for counterparty\s+(.+)$/i);
if (movementRowsMatch) {
return `Р1С найдены строки движений по контрагенту ${movementRowsMatch[1]}.`;
return `В 1С найдены строки движений по контрагенту ${movementRowsMatch[1]}.`;
}
if (/^1C movement rows were found for the requested scope$/i.test(value)) {
return "Р1С найдены строки движений по запрошенному контуру.";
return "В 1С найдены строки движений по запрошенному контуру.";
}
const supplierPayoutMatch = value.match(/^1C supplier-payout rows were found for counterparty\s+(.+)$/i);
if (supplierPayoutMatch) {
@ -186,7 +207,7 @@ function localizeLine(value: string): string {
return "Срез документов ограничен только подтвержденными строками документов в проверенном окне.";
}
if (/^Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope$/i.test(value)) {
return "Срез движений ограничен только подтвержденными строками движений в проверенном окне.";
return "Срез движений ограничен только подтвержденными строками движений в проверенном окне.";
}
if (/^Counterparty value-flow total was calculated from confirmed 1C movement rows$/i.test(value)) {
return "Сумма входящих поступлений рассчитана только по подтвержденным строкам поступлений в 1С.";
@ -274,10 +295,10 @@ function localizeLine(value: string): string {
return "Полный срез документов без явно проверенного периода не подтвержден.";
}
if (/^Full movement history outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) {
return "Полный исторический срез движений вне проверенного периода этим поиском не подтвержден.";
return "Полный исторический срез движений вне проверенного периода этим поиском не подтвержден.";
}
if (/^Full movement history is not proven without an explicit checked period$/i.test(value)) {
return "Полный срез движений без явно проверенного периода не подтвержден.";
return "Полный срез движений без явно проверенного периода не подтвержден.";
}
if (/^Full supplier-payout amount outside the checked period is not proven by this MCP discovery pilot$/i.test(value)) {
return "Полный объем исходящих платежей вне проверенного периода этим поиском не подтвержден.";
@ -296,14 +317,14 @@ function localizeLine(value: string): string {
value
)
) {
return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк.";
return "Покрытие запрошенного периода восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк.";
}
if (
/^Requested period coverage for bidirectional value-flow was recovered through monthly 1C side probes after a broad probe hit the row limit$/i.test(
value
)
) {
return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка уперлась в лимит строк хотя бы по одной стороне.";
return "Покрытие запрошенного периода по двустороннему денежному потоку восстановлено помесячными проверками 1С после того, как общая выборка достигла лимита строк хотя бы по одной стороне.";
}
if (/^Requested period coverage was recovered through monthly 1C value-flow probes$/i.test(value)) {
return "Покрытие запрошенного периода восстановлено помесячными проверками 1С.";
@ -323,7 +344,7 @@ function localizeLine(value: string): string {
if (/^Complete requested-period coverage for bidirectional value-flow is not proven by the available checked rows$/i.test(value)) {
return "Полное покрытие запрошенного периода по двустороннему денежному потоку не подтверждено доступными проверенными строками.";
}
return value;
return sanitizedValue;
}
function section(title: string, lines: string[]): string | null {
@ -408,7 +429,7 @@ function businessOverviewCoverageLimitLine(overview: Record<string, unknown>): s
limited.push("исходящие");
}
return limited.length > 0
? `Важно: ${limited.join(" и ")} уперлись в лимит выборки MCP, поэтому это проверенный срез найденных строк, а не гарантированно полный бухгалтерский оборот.`
? `Важно: по направлению ${limited.join(" и ")} проверка достигла лимита строк; это расширенный проверенный срез найденных строк, но не гарантия полного бухгалтерского оборота без отдельной полной выгрузки.`
: null;
}
@ -437,6 +458,34 @@ function firstOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_r
return label && amount ? `${label}${sentenceAmount(amount) ?? amount}` : null;
}
function firstNonFinancialOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_ru"): string | null {
if (!Array.isArray(rows)) {
return null;
}
for (const row of rows) {
const item = toRecordObject(row);
const label = toNonEmptyString(item?.axis_value);
if (!label || isLikelyFinancialInstitutionCounterparty(label)) {
continue;
}
const amount = moneyText(item?.[amountKey]);
if (amount) {
return `${label}${sentenceAmount(amount) ?? amount}`;
}
}
return null;
}
function overviewAxisLooksFinancial(row: Record<string, unknown> | null): boolean {
if (!row) {
return false;
}
return (
row.counterparty_role_hint === "bank_or_financial_institution" ||
isLikelyFinancialInstitutionCounterparty(row.axis_value)
);
}
function businessOverviewTaxLine(overview: Record<string, unknown>): string | null {
const tax = toRecordObject(overview.tax_position);
if (!tax) {
@ -568,7 +617,7 @@ function buildCompactBidirectionalValueFlowReply(
lines.push(`Основа: ${basis.join("; ")}.`);
}
if (flow.coverage_limited_by_probe_limit === true) {
lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
lines.push("Важно: часть проверки достигла лимита строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
}
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
@ -721,17 +770,202 @@ function buildCompactBusinessOverviewReply(
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null);
const customerName = toNonEmptyString(topCustomer?.axis_value);
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
const topCustomerLooksFinancial = overviewAxisLooksFinancial(topCustomer);
const nonFinancialCustomer = firstNonFinancialOverviewAxisLabel(
topCustomerLooksFinancial ? overview.top_customers : []
);
const topCustomerLead =
customerName && customerAmount
? `; крупнейший источник входящих денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}`
? topCustomerLooksFinancial
? `; крупнейший входящий денежный источник: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount} (похоже на банк/финорганизацию, не называю это клиентской выручкой без назначения платежа)${nonFinancialCustomer ? `; крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}` : ""}`
: `; крупнейший источник входящих денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}`
: "";
const topSupplierRecord = toRecordObject(Array.isArray(overview.top_suppliers) ? overview.top_suppliers[0] : null);
const topSupplier = firstOverviewAxisLabel(overview.top_suppliers);
const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${topSupplier}` : "";
const topSupplierLooksFinancial = overviewAxisLooksFinancial(topSupplierRecord);
const nonFinancialSupplier = firstNonFinancialOverviewAxisLabel(
topSupplierLooksFinancial ? overview.top_suppliers : []
);
const topSupplierLead = topSupplier
? topSupplierLooksFinancial
? `; крупнейший получатель исходящих денег: ${topSupplier} (похоже на банк/финорганизацию, не называю это обычным поставщиком без назначения платежа/договора)${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}`
: `; крупнейший получатель исходящих денег: ${topSupplier}`
: "";
const roleBoundaryLead = topCustomer || topSupplier ? "; клиент/поставщик как бизнес-роли этим денежным срезом не подтверждены" : "";
const graphReasonCodes = toStringList(graph?.reason_codes);
const directMoneyAnswer = graphReasonCodes.includes("data_need_graph_business_overview_direct_money_answer");
const crossScopeExecutiveSummary = Boolean(separateSubject && previousCounterpartySummary);
const lines: string[] = [];
const actionFamily = toNonEmptyString(turnMeaning?.asked_action_family);
const unsupportedFamily = toNonEmptyString(turnMeaning?.unsupported_but_understood_family);
const profitMarginBoundary = actionFamily === "profit_margin_boundary" || unsupportedFamily === "profit_margin_boundary";
const debtDueDateBoundary = actionFamily === "debt_due_date_boundary" || unsupportedFamily === "debt_due_date_boundary";
const vendorRiskBoundary =
actionFamily === "vendor_risk_procurement_boundary" || unsupportedFamily === "vendor_risk_procurement_boundary";
const inventoryReserveBoundary =
actionFamily === "inventory_reserve_boundary" || unsupportedFamily === "inventory_reserve_liquidation_boundary";
if (profitMarginBoundary) {
const accountingFinancialResult = toRecordObject(overview.accounting_financial_result);
if (accountingFinancialResult) {
const direction = toNonEmptyString(accountingFinancialResult.final_result_direction);
const amount = moneyText(accountingFinancialResult.final_result_amount_human_ru);
const periodScope = toNonEmptyString(accountingFinancialResult.period_scope) ?? period;
const marginPct =
typeof accountingFinancialResult.net_margin_to_revenue_pct === "number" &&
Number.isFinite(accountingFinancialResult.net_margin_to_revenue_pct)
? `${accountingFinancialResult.net_margin_to_revenue_pct}%`
: null;
const directionText =
direction === "profit"
? "учетная прибыль"
: direction === "loss"
? "учетный убыток"
: "нулевой учетный финрезультат";
const amountText = amount
? direction === "loss"
? `минус ${amount}`
: amount
: "сумма не распознана";
lines.push(
`Коротко: по бухгалтерскому маршруту 90/91/99 за ${periodScope} подтвержден ${directionText}: ${amountText}${marginPct ? `; маржа к выручке 90.01 ${marginPct}` : "; маржа к выручке 90.01 не рассчитана"}.`
);
lines.push(
"Это учетный финрезультат по найденным строкам закрытия периода в 1С, а не внешний аудит и не юридически подтвержденная отчетность."
);
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(
cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финансовый результат."
);
const boundaryLines = userFacingLines([
...toStringList(draft.confirmed_lines),
...toStringList(draft.inference_lines),
...toStringList(draft.unknown_lines)
])
.filter((line) => /(?:прибыл|марж|финанс|p\s*&\s*l|p&l|расход|себестоим|закрыт|profit|margin|financial)/iu.test(line))
.slice(0, 2);
if (boundaryLines.length > 0) {
lines.push(...boundaryLines.map(localizeLine));
}
lines.push(
"Для точного P&L нужны отдельный маршрут по себестоимости, расходам, закрытию периода и финрезультату; текущий proxy нельзя выдавать за подтвержденную чистую прибыль или маржу."
);
if (limitLine) {
lines.push(limitLine);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (debtDueDateBoundary) {
const dueDateAging = toRecordObject(overview.debt_due_date_aging);
if (dueDateAging) {
const status = toNonEmptyString(dueDateAging.evidence_status);
const asOfDate = toNonEmptyString(dueDateAging.as_of_date) ?? "проверенную дату";
const overdueAmount = moneyText(dueDateAging.overdue_amount_human_ru);
const grossAmount = moneyText(dueDateAging.gross_open_amount_human_ru);
const rowsWithPaymentTerms =
typeof dueDateAging.rows_with_payment_terms === "number" && Number.isFinite(dueDateAging.rows_with_payment_terms)
? dueDateAging.rows_with_payment_terms
: null;
const rowsWithAmount =
typeof dueDateAging.rows_with_amount === "number" && Number.isFinite(dueDateAging.rows_with_amount)
? dueDateAging.rows_with_amount
: null;
if (status === "confirmed_overdue") {
lines.push(
`Коротко: на ${asOfDate} подтвержденная просрочка есть: ${overdueAmount ?? "сумма не распознана"} по ${dueDateAging.overdue_rows ?? "найденным"} строкам.`
);
lines.push("Основа ответа: открытые расчеты 60/62/76, договорный срок оплаты и дата расчетного документа; это уже due-date route, не старение договора как proxy.");
} else if (status === "no_payment_terms_configured") {
lines.push(
`Коротко: на ${asOfDate} подтвержденной просрочки нет: открытые расчеты проверены${grossAmount ? ` на ${grossAmount}` : ""}, но в найденных договорах срок оплаты не установлен.`
);
lines.push(
rowsWithAmount !== null
? `Проверено строк с суммой: ${rowsWithAmount}. Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой.`
: "Без установленного срока оплаты нельзя честно назвать эти остатки просрочкой."
);
} else if (status === "insufficient_due_date_basis") {
lines.push(
`Коротко: due-date route запущен на ${asOfDate}, но просрочка не подтверждена: по строкам с установленным сроком оплаты не хватило даты расчетного документа.`
);
if (rowsWithPaymentTerms !== null) {
lines.push(`Строк с установленным сроком оплаты: ${rowsWithPaymentTerms}; нужен документ-основание с датой для расчета due date.`);
}
} else {
lines.push(
`Коротко: due-date route на ${asOfDate} проверен, подтвержденной просрочки не найдено${rowsWithPaymentTerms !== null ? `; строк с установленным сроком оплаты ${rowsWithPaymentTerms}` : ""}.`
);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(
cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: нельзя точно определить, какая дебиторка просрочена, по текущему срезу 1С; есть только debt-quality proxy, но нет проверенного due-date маршрута."
);
lines.push(
"Проверить нужно отдельно: договоры, сроки оплаты, погашение и закрытие задолженности; без этого нельзя доказать overdue/due-date aging."
);
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (vendorRiskBoundary) {
const supplierBasis = topSupplier
? topSupplierLooksFinancial
? `крупнейший получатель исходящих денег: ${topSupplier}; по названию это банк/финансовая организация, поэтому это не доказанная зависимость от обычного поставщика${nonFinancialSupplier ? `; крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}` : ""}`
: `крупнейший подтвержденный поставщик/получатель исходящих платежей: ${topSupplier}`
: outgoingAmount
? `исходящие платежи/закупочный поток в проверенном срезе: ${outgoingAmount}`
: "есть только ограниченный срез исходящих платежей без полного vendor-risk профиля";
const proxyLabel = topSupplierLooksFinancial ? "outgoing cash concentration proxy" : "procurement concentration proxy";
lines.push(
`Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден; есть только ${proxyLabel}: ${supplierBasis}.`
);
lines.push(
"Это сигнал концентрации закупок/исходящих платежей, а не полный аудит надежности поставщиков, условий, качества и структуры всех расходов."
);
lines.push(
"Для точного вывода нужен отдельный reviewed vendor-risk route: поставщики, договорные условия, качество поставок, сроки, доля в закупках и полная структура расходов."
);
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (inventoryReserveBoundary) {
const headline = toNonEmptyString(draft.headline);
const cleanHeadline = headline?.replace(/^Коротко:\s*/iu, "").trim();
lines.push(
cleanHeadline
? `Коротко: ${localizeLine(cleanHeadline)}`
: "Коротко: точно подтвердить резерв под неликвиды по текущим данным нельзя."
);
const boundaryLines = userFacingLines([
...toStringList(draft.unknown_lines),
...toStringList(draft.limitation_lines)
])
.filter((line) => /(?:резерв|неликвид|склад|товар|reserve|obsolete|inventory|stock)/iu.test(line))
.slice(0, 2);
if (boundaryLines.length > 0) {
lines.push(...boundaryLines.map(localizeLine));
}
lines.push(
"Проверить нужно отдельно: складской срез на дату, учетную политику резервов, списания и ликвидационную стоимость; proxy-сигналы нельзя выдавать за доказанный факт резерва."
);
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (crossScopeExecutiveSummary && separateSubject && previousCounterpartySummary && (incomingAmount || outgoingAmount || netAmount)) {
lines.push(
@ -761,7 +995,7 @@ function buildCompactBusinessOverviewReply(
return null;
}
lines.push(
`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`
`Коротко: в доступном проверенном срезе 1С по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`
);
const netYear = toNonEmptyString(netLeader?.year_bucket);
const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
@ -783,7 +1017,11 @@ function buildCompactBusinessOverviewReply(
);
lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.');
if (!directMoneyAnswer && customerName && customerAmount) {
lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`);
lines.push(
topCustomerLooksFinancial
? `Крупнейший входящий денежный источник в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}. По названию это банк/финансовая организация, поэтому без назначения платежа не называю это клиентской выручкой.${nonFinancialCustomer ? ` Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
: `Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`
);
}
} else {
return null;
@ -797,10 +1035,18 @@ function buildCompactBusinessOverviewReply(
}
if (!directMoneyAnswer && topSupplier) {
lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
lines.push(
topSupplierLooksFinancial
? `Крупнейший получатель исходящих денег: ${topSupplier}. По названию это банк/финансовая организация, поэтому без назначения платежа/договора не считаю это обычным поставщиком.${nonFinancialSupplier ? ` Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
: `Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`
);
}
if (!directMoneyAnswer && (topCustomer || topSupplier)) {
lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
lines.push(
topCustomerLooksFinancial || topSupplierLooksFinancial
? "Важно по ролям: текущий денежный срез подтверждает источники и получателей денег, но банковские контрагенты требуют проверки назначения платежа/счетов и не доказывают роль клиента или поставщика."
: "Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль."
);
}
if (!directMoneyAnswer) {
lines.push(

View File

@ -20,6 +20,8 @@ export const ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION =
"assistant_mcp_discovery_runtime_bridge_v1" as const;
export const ASSISTANT_MCP_DISCOVERY_LOOP_STATE_SCHEMA_VERSION =
"assistant_mcp_discovery_loop_state_v1" as const;
export const ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION =
"assistant_mcp_route_candidate_v1" as const;
export type AssistantMcpDiscoveryRuntimeBridgeStatus =
| "answer_draft_ready"
@ -31,6 +33,11 @@ export type AssistantMcpDiscoveryLoopStatus =
| "awaiting_clarification"
| "ready_for_next_hop"
| "blocked";
export type AssistantMcpRouteCandidateStatus =
| "ready_for_reviewed_execution"
| "needs_user_scope"
| "needs_route_enablement"
| "blocked";
export interface AssistantMcpDiscoveryRuntimeBridgeInput {
semanticDataNeed?: string | null;
@ -61,6 +68,26 @@ export interface AssistantMcpDiscoveryLoopStateContract {
explicit_date_scope: string | null;
}
export interface AssistantMcpRouteCandidateContract {
schema_version: typeof ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryRuntimeBridge";
candidate_status: AssistantMcpRouteCandidateStatus;
selected_chain_id: AssistantMcpDiscoveryChainId;
selected_chain_summary: string;
nearest_catalog_chain_template: AssistantMcpDiscoveryPlannerContract["catalog_chain_template_alignment"]["top_chain_template_match"];
catalog_alignment_status: AssistantMcpDiscoveryPlannerContract["catalog_chain_template_alignment"]["alignment_status"];
business_fact_family: string | null;
action_family: string | null;
proof_expectation: string | null;
required_axes: string[];
provided_axes: string[];
missing_axes: string[];
executable_now: boolean;
enablement_reason: string | null;
recommended_next_action: string;
forbidden_overclaim_flags: string[];
}
export interface AssistantMcpDiscoveryRuntimeBridgeContract {
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION;
policy_owner: "assistantMcpDiscoveryRuntimeBridge";
@ -70,6 +97,7 @@ export interface AssistantMcpDiscoveryRuntimeBridgeContract {
pilot: AssistantMcpDiscoveryPilotExecutionContract;
answer_draft: AssistantMcpDiscoveryAnswerDraftContract;
loop_state: AssistantMcpDiscoveryLoopStateContract;
route_candidate: AssistantMcpRouteCandidateContract;
user_facing_response_allowed: boolean;
business_fact_answer_allowed: boolean;
requires_user_clarification: boolean;
@ -138,6 +166,26 @@ function loopStatusFor(
return "ready_for_next_hop";
}
function routeCandidateStatusFor(
bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus,
pilot: AssistantMcpDiscoveryPilotExecutionContract,
missingProofFamily: AssistantMcpDiscoveryBusinessOverviewMissingProofFamily | null
): AssistantMcpRouteCandidateStatus {
if (bridgeStatus === "blocked" || pilot.pilot_status === "blocked") {
return "blocked";
}
if (bridgeStatus === "needs_clarification" || pilot.pilot_status === "skipped_needs_clarification") {
return "needs_user_scope";
}
if (bridgeStatus === "unsupported" || pilot.pilot_status === "unsupported") {
return "needs_route_enablement";
}
if (missingProofFamily) {
return "needs_route_enablement";
}
return "ready_for_reviewed_execution";
}
function flattenAxes(
pilot: AssistantMcpDiscoveryPilotExecutionContract,
source: "provided_axes" | "missing_axis_options"
@ -168,6 +216,144 @@ function entityCandidatesFromPlanner(planner: AssistantMcpDiscoveryPlannerContra
return uniqueStrings(values);
}
function firstNonEmpty(values: Array<string | null | undefined>): string | null {
for (const value of values) {
const text = String(value ?? "").trim();
if (text) {
return text;
}
}
return null;
}
type AssistantMcpDiscoveryBusinessOverviewMissingProofFamily = NonNullable<
AssistantMcpDiscoveryPilotExecutionContract["derived_business_overview"]
>["missing_proof_families"][number];
function routeCandidateProofFamiliesFor(actionFamily: string | null, proofExpectation: string | null): string[] {
const combined = `${actionFamily ?? ""} ${proofExpectation ?? ""}`.trim().toLowerCase();
const result: string[] = [];
const add = (family: string) => {
if (!result.includes(family)) {
result.push(family);
}
};
if (!combined || combined === "broad_evaluation bounded_inference") {
return result;
}
if (/(?:inventory|stock|warehouse|reserve|liquidation|write[-_ ]?off|obsolete|obsolescence)/iu.test(combined)) {
add("inventory_reserve_liquidation_quality");
}
if (/(?:debt|due[-_ ]?date|overdue|aging|credit[-_ ]?risk)/iu.test(combined)) {
add("debt_due_date_aging_quality");
}
if (/(?:vendor|supplier|procurement|sourcing)/iu.test(combined)) {
add("vendor_risk_procurement_quality");
}
if (/(?:profit|margin|pnl|p&l|financial[-_ ]?result)/iu.test(combined)) {
add("accounting_profit_margin");
}
return result;
}
function routeCandidateMissingProofFamily(
planner: AssistantMcpDiscoveryPlannerContract,
pilot: AssistantMcpDiscoveryPilotExecutionContract
): AssistantMcpDiscoveryBusinessOverviewMissingProofFamily | null {
if (planner.data_need_graph?.business_fact_family !== "business_overview") {
return null;
}
const wantedFamilies = routeCandidateProofFamiliesFor(
planner.data_need_graph?.action_family ?? null,
planner.data_need_graph?.proof_expectation ?? null
);
if (wantedFamilies.length <= 0) {
return null;
}
const missingProofFamilies = pilot.derived_business_overview?.missing_proof_families ?? [];
return missingProofFamilies.find((item) => wantedFamilies.includes(item.family)) ?? null;
}
function routeCandidateEnablementReason(
status: AssistantMcpRouteCandidateStatus,
pilot: AssistantMcpDiscoveryPilotExecutionContract,
missingAxes: string[],
missingProofFamily: AssistantMcpDiscoveryBusinessOverviewMissingProofFamily | null
): string | null {
if (status === "ready_for_reviewed_execution") {
return null;
}
if (status === "needs_user_scope") {
return missingAxes.length > 0
? `Missing scope axes: ${missingAxes.join(", ")}`
: "Selected chain needs user clarification before MCP execution";
}
if (missingProofFamily) {
return [
`Missing reviewed proof family: ${missingProofFamily.family}`,
`next_required_evidence=${missingProofFamily.next_required_evidence}`,
missingProofFamily.current_supported_evidence
? `current_supported_evidence=${missingProofFamily.current_supported_evidence}`
: null,
`must_not_claim=${missingProofFamily.must_not_claim}`
]
.filter((item): item is string => Boolean(item))
.join("; ");
}
return firstNonEmpty([
...pilot.query_limitations,
...pilot.evidence.unknown_facts,
"Selected chain is not safely executable by the reviewed MCP runtime yet"
]);
}
function routeCandidateNextAction(status: AssistantMcpRouteCandidateStatus): string {
if (status === "ready_for_reviewed_execution") {
return "Execute through the reviewed runtime bridge and truth gate.";
}
if (status === "needs_user_scope") {
return "Ask the user for the missing scope axes before MCP execution.";
}
if (status === "needs_route_enablement") {
return "Create or wire a reviewed exact route for the selected chain before treating the fact as answerable.";
}
return "Do not execute until the blocking reason is resolved.";
}
function buildRouteCandidate(
planner: AssistantMcpDiscoveryPlannerContract,
pilot: AssistantMcpDiscoveryPilotExecutionContract,
bridgeStatus: AssistantMcpDiscoveryRuntimeBridgeStatus
): AssistantMcpRouteCandidateContract {
const plannerClarificationGaps = planner.discovery_plan.clarification_gaps ?? [];
const providedAxes = flattenAxes(pilot, "provided_axes");
const missingAxes = plannerClarificationGaps.length > 0 ? plannerClarificationGaps : flattenAxes(pilot, "missing_axis_options");
const missingProofFamily = routeCandidateMissingProofFamily(planner, pilot);
const candidateStatus = routeCandidateStatusFor(bridgeStatus, pilot, missingProofFamily);
return {
schema_version: ASSISTANT_MCP_ROUTE_CANDIDATE_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
candidate_status: candidateStatus,
selected_chain_id: planner.selected_chain_id,
selected_chain_summary: planner.selected_chain_summary,
nearest_catalog_chain_template: planner.catalog_chain_template_alignment.top_chain_template_match,
catalog_alignment_status: planner.catalog_chain_template_alignment.alignment_status,
business_fact_family: planner.data_need_graph?.business_fact_family ?? null,
action_family: planner.data_need_graph?.action_family ?? null,
proof_expectation: planner.data_need_graph?.proof_expectation ?? null,
required_axes: [...planner.required_axes],
provided_axes: providedAxes,
missing_axes: missingAxes,
executable_now: candidateStatus === "ready_for_reviewed_execution",
enablement_reason: routeCandidateEnablementReason(candidateStatus, pilot, missingAxes, missingProofFamily),
recommended_next_action: routeCandidateNextAction(candidateStatus),
forbidden_overclaim_flags: uniqueStrings([
...(planner.data_need_graph?.forbidden_overclaim_flags ?? []),
...(missingProofFamily ? [missingProofFamily.must_not_claim] : [])
])
};
}
function buildLoopState(
planner: AssistantMcpDiscoveryPlannerContract,
pilot: AssistantMcpDiscoveryPilotExecutionContract,
@ -215,11 +401,14 @@ export async function runAssistantMcpDiscoveryRuntimeBridge(
const answerDraft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const bridgeStatus = bridgeStatusFor(pilot, answerDraft);
const loopState = buildLoopState(planner, pilot, bridgeStatus);
const routeCandidate = buildRouteCandidate(planner, pilot, bridgeStatus);
const reasonCodes = uniqueStrings([...planner.reason_codes, ...pilot.reason_codes, ...answerDraft.reason_codes]);
pushReason(reasonCodes, `runtime_bridge_status_${bridgeStatus}`);
pushReason(reasonCodes, "runtime_bridge_not_wired_to_hot_assistant_answer");
pushReason(reasonCodes, `runtime_bridge_loop_state_${loopState.loop_status}`);
pushReason(reasonCodes, "runtime_bridge_route_candidate_built");
pushReason(reasonCodes, `runtime_bridge_route_candidate_${routeCandidate.candidate_status}`);
return {
schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_BRIDGE_SCHEMA_VERSION,
@ -230,6 +419,7 @@ export async function runAssistantMcpDiscoveryRuntimeBridge(
pilot,
answer_draft: answerDraft,
loop_state: loopState,
route_candidate: routeCandidate,
user_facing_response_allowed: bridgeStatus !== "blocked",
business_fact_answer_allowed: businessFactAnswerAllowed(answerDraft),
requires_user_clarification: bridgeStatus === "needs_clarification",

View File

@ -180,6 +180,11 @@ function isGarbageSemanticAnchorCandidate(value: string | null): boolean {
"всему",
"всей",
"всем",
"год",
"года",
"году",
"годом",
"годы",
"выводу",
"выводам",
"аудиту",
@ -824,6 +829,51 @@ function hasOrganizationLevelEarningsOverviewSignal(text: string): boolean {
return hasYearRankingCue || hasCompanyEarningsCue || hasCompanyProfitMarginCue;
}
function hasOrganizationLevelProfitMarginBoundaryOverviewSignal(text: string): boolean {
if (!text) {
return false;
}
const hasProfitMarginCue =
/(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit(?:ability)?|margin|financial\s+result)/iu.test(
text
);
const hasCompanyScopeCue =
/(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043c\u044b\b|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u0431\u0438\u0437\u043d\u0435\u0441|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\b(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e|\u043e\u0430\u043e)\b|(?:19|20)\d{2}|company|business|organization|our|we|us)/iu.test(
text
);
return hasProfitMarginCue && hasCompanyScopeCue;
}
function hasProfitMarginBoundaryFollowupSignal(text: string): boolean {
if (!text) {
return false;
}
const hasProfitOrResultCue =
/(?:\u043f\u0440\u0438\u0431\u044b\u043b\w*|\u0443\u0431\u044b\u0442\w*|\u043c\u0430\u0440\u0436\w*|\u0440\u0435\u043d\u0442\u0430\u0431\w*|\u0444\u0438\u043d(?:\u0430\u043d\u0441\w*)?\s*[- ]?\s*\u0440\u0435\u0437\u0443\u043b\u044c\u0442\u0430\u0442|p\s*&\s*l|profit|loss|margin|financial\s+result)/iu.test(
text
);
const hasFollowupShape =
/(?:\u044d\u0442\u043e|\u0438\u0442\u043e\u0433|\u0438\u0442\u043e\u0433\u043e|\u043f\u043e\u043b\u0443\u0447\w*|\u043a\u043e\u0440\u043e\u0442\w*|\u0432\s+\u0438\u0442\u043e\u0433\u0435|\u043c\u043e\u0436\u043d\u043e\s+(?:\u043b\u0438\s+)?\u0441\u043a\u0430\u0437\u0430\u0442\u044c|is\s+it|result|short|brief)/iu.test(
text
);
return hasProfitOrResultCue && hasFollowupShape;
}
function hasDebtDueDateBoundaryFollowupSignal(text: string): boolean {
if (!text) {
return false;
}
const hasDueDateCue =
/(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447\w*|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437\w*|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\w*|due[-\s]?date|overdue|debt\s+aging|aging)/iu.test(
text
);
const hasFollowupShape =
/(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\w*|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|why|short|brief|so)/iu.test(
text
);
return hasDueDateCue && hasFollowupShape;
}
function hasOrganizationLevelDebtDueDateOverviewSignal(text: string): boolean {
if (!text) {
return false;
@ -999,6 +1049,13 @@ function hasExplicitVatQuestionSignal(text: string): boolean {
);
}
function hasExplicitVatMovementEvidenceSignal(text: string): boolean {
if (!/(?:\u043d\u0434\u0441|vat)/iu.test(text)) {
return false;
}
return hasMovementEvidenceFollowupSignal(text);
}
function hasBusinessOverviewSeparateCounterpartySignal(text: string): boolean {
if (!text) {
return false;
@ -1534,6 +1591,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
);
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(
explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal
);
@ -1552,6 +1610,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
const rawMetadataSignal =
!rawLifecycleSignal &&
!rawValueFlowSignal &&
!explicitVatMovementEvidenceSignal &&
!rawReferentialDocumentExclusionSignal &&
hasMetadataSignal(rawText);
const rawEntityResolutionSignal =
@ -1569,7 +1628,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(dateScopeSignalText);
const relativeCurrentDateHintDetected = hasRelativeCurrentDateHint(rawText);
const rawDateScope = collectDateScopeFromRawText(dateScopeSignalText);
const rawMetadataScopeHint = rawMetadataSignal ? metadataScopeHintFromRawText(rawText) : null;
const rawMetadataScopeHint =
rawMetadataSignal || explicitVatMovementEvidenceSignal ? metadataScopeHintFromRawText(rawText) : null;
const rawTopicSwitchSignal = hasExplicitTopicSwitchSignal(rawText);
const rawEntityCandidate = rawEntityResolutionSignal ? rawEntityResolutionCandidate(rawEntitySourceText) : null;
const rawScopedEntityCandidate =
@ -1594,12 +1654,49 @@ export function buildAssistantMcpDiscoveryTurnInput(
const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis);
const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family);
const broadBusinessEvaluationUnsupported = unsupported === "broad_business_evaluation";
const businessOverviewSignal =
rawBusinessOverviewSignal ||
const seededBusinessOverviewSignal =
broadBusinessEvaluationUnsupported ||
rawDomain === "business_summary" ||
rawDomain === "business_overview" ||
rawAction === "broad_evaluation";
const inventoryReserveBusinessOverviewSignal =
(rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(rawText);
const debtDueDateFollowupBusinessOverviewSignal =
businessOverviewContinuationSignal && hasDebtDueDateBoundaryFollowupSignal(rawText);
const debtDueDateBusinessOverviewSignal =
((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelDebtDueDateOverviewSignal(rawText)) ||
debtDueDateFollowupBusinessOverviewSignal;
const supplierQualityBusinessOverviewSignal =
(rawBusinessOverviewSignal || seededBusinessOverviewSignal) && hasOrganizationLevelSupplierQualityOverviewSignal(rawText);
const profitMarginFollowupBusinessOverviewSignal =
businessOverviewContinuationSignal && hasProfitMarginBoundaryFollowupSignal(rawText);
const profitMarginBusinessOverviewSignal =
((rawBusinessOverviewSignal || seededBusinessOverviewSignal) &&
hasOrganizationLevelProfitMarginBoundaryOverviewSignal(rawText)) ||
profitMarginFollowupBusinessOverviewSignal;
const businessOverviewActionFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_boundary"
: debtDueDateBusinessOverviewSignal
? "debt_due_date_boundary"
: supplierQualityBusinessOverviewSignal
? "vendor_risk_procurement_boundary"
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_evaluation";
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_liquidation_boundary"
: debtDueDateBusinessOverviewSignal
? "debt_due_date_boundary"
: supplierQualityBusinessOverviewSignal
? "vendor_risk_procurement_boundary"
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_business_evaluation";
const businessOverviewSignal =
rawBusinessOverviewSignal ||
seededBusinessOverviewSignal;
const businessOverviewSeparateCounterpartySignal = Boolean(
businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)
);
@ -1630,7 +1727,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization;
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
const currentTurnOrganizationScope =
currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const followupCounterpartyIsMetadataOrganizationScope = Boolean(
@ -2029,9 +2126,21 @@ export function buildAssistantMcpDiscoveryTurnInput(
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
? "metadata lane clarification"
: semanticNeedFor({
domain: businessOverviewSignal ? "business_overview" : rawDomain ?? seededDomain,
action: businessOverviewSignal ? "broad_evaluation" : rawAction ?? seededAction,
unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported,
domain: explicitVatMovementEvidenceSignal
? "movements"
: businessOverviewSignal
? "business_overview"
: rawDomain ?? seededDomain,
action: explicitVatMovementEvidenceSignal
? "list_movements"
: businessOverviewSignal
? businessOverviewActionFamily
: rawAction ?? seededAction,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@ -2065,7 +2174,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
followupSeed.metadataSelectedEntitySet ??
null;
const metadataScopedLaneWithoutSubject = Boolean(
(metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) &&
(metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable ||
explicitVatMovementEvidenceSignal) &&
!effectiveFollowupCounterparty &&
metadataLaneCarryoverAvailable
);
@ -2278,11 +2389,13 @@ export function buildAssistantMcpDiscoveryTurnInput(
const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = {
asked_domain_family:
businessOverviewSignal
? "business_overview"
: lifecycleSignal
? "business_overview"
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? "counterparty_value"
: explicitVatMovementEvidenceSignal
? "movements"
: metadataGroundedMovementLaneApplicable
? "movements"
: metadataGroundedDocumentLaneApplicable
@ -2293,7 +2406,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
? "metadata"
: rawDomain ?? seededDomain,
asked_action_family: businessOverviewSignal
? "broad_evaluation"
? businessOverviewActionFamily
: lifecycleSignal
? "activity_duration"
: valueFlowSignal
@ -2302,6 +2415,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
: payoutSignal
? "payout"
: rawAction ?? seededAction ?? "turnover"
: explicitVatMovementEvidenceSignal
? "list_movements"
: metadataGroundedMovementLaneApplicable
? "list_movements"
: metadataGroundedDocumentLaneApplicable
@ -2334,7 +2449,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined,
unsupported_but_understood_family:
businessOverviewSignal
? "broad_business_evaluation"
? businessOverviewUnsupportedFamily
: unsupported ??
(lifecycleSignal
? "counterparty_lifecycle"
@ -2346,8 +2461,10 @@ export function buildAssistantMcpDiscoveryTurnInput(
: seededUnsupported ?? "counterparty_value_or_turnover"
: metadataGroundedMovementLaneApplicable
? "movement_evidence"
: metadataGroundedDocumentLaneApplicable
: metadataGroundedDocumentLaneApplicable
? "document_evidence"
: explicitVatMovementEvidenceSignal
? "movement_evidence"
: metadataAmbiguityLaneClarificationApplicable
? "metadata_lane_choice_clarification"
: entityResolutionSignal
@ -2363,6 +2480,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
unsupported ||
lifecycleSignal ||
valueFlowSignal ||
explicitVatMovementEvidenceSignal ||
metadataGroundedMovementLaneApplicable ||
metadataGroundedDocumentLaneApplicable ||
metadataAmbiguityLaneClarificationApplicable ||
@ -2423,13 +2541,17 @@ export function buildAssistantMcpDiscoveryTurnInput(
const currentTurnValueFlowExactOverrideApplicable = Boolean(
valueFlowSignal &&
explicitIntentCandidate &&
rawValueFlowAggregateQuestionSignal &&
(rawValueFlowAggregateQuestionSignal || hasValueRankingSignal(rawText)) &&
semanticDataNeed &&
(entityCandidates.length > 0 || explicitOrganizationScope || openScopeValueFlowWithoutResolvedCounterparty)
);
const runDiscovery = shouldRunDiscovery({
unsupported: businessOverviewSignal ? "broad_business_evaluation" : unsupported ?? seededUnsupported,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? "broad_business_evaluation"
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@ -2446,6 +2568,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
groundedValueFlowFollowupApplicable,
forceDiscoveryOverExplicitIntent:
businessOverviewSignal ||
explicitVatMovementEvidenceSignal ||
Boolean(entityResolutionClarificationCandidate) ||
organizationClarificationFollowupApplicable ||
periodClarificationFollowupApplicable ||
@ -2469,6 +2592,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
? "followup_context"
: metadataGroundedDocumentLaneApplicable
? "followup_context"
: explicitVatMovementEvidenceSignal
? "raw_text"
: predecomposeContract
? "predecompose_contract"
: lifecycleSignal
@ -2490,6 +2615,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (rawMetadataSignal) {
pushReason(reasonCodes, "mcp_discovery_metadata_signal_detected");
}
if (explicitVatMovementEvidenceSignal) {
pushReason(reasonCodes, "mcp_discovery_vat_movement_evidence_signal_detected");
}
if (entityResolutionSignal) {
pushReason(reasonCodes, "mcp_discovery_entity_resolution_signal_detected");
}
@ -2613,6 +2741,12 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
}
if (profitMarginFollowupBusinessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_profit_margin_followup_boundary");
}
if (debtDueDateFollowupBusinessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_debt_due_date_followup_boundary");
}
if (explicitVatSuppressesBusinessOverviewContinuation) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
}

View File

@ -395,6 +395,13 @@ export function createAssistantRoutePolicy(deps) {
const hasTemporalCue = /(?:на\s+эту\s+же\s+дат[ауеы]|на\s+тот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр|\b(?:19|20)\d{2}\b)/iu.test(normalized);
return hasRequestCue && hasTemporalCue;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
return false;
}
return /(?<!\p{L})(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e)(?!\p{L})|(?:\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043c\u043f\u0430\u043d|llc|company|organization)/iu.test(normalized);
}
function resolveAssistantOrchestrationDecision(input) {
const rawUserMessage = String(input?.rawUserMessage ?? input?.userMessage ?? "");
const effectiveAddressUserMessage = String(input?.effectiveAddressUserMessage ?? rawUserMessage);
@ -559,6 +566,27 @@ export function createAssistantRoutePolicy(deps) {
const followupPreviousFilters = followupContext?.previous_filters && typeof followupContext.previous_filters === "object"
? followupContext.previous_filters
: null;
const followupLoopStatus = toNonEmptyString(followupContext?.previous_discovery_loop_status);
const followupLoopSelectedChainId = toNonEmptyString(followupContext?.previous_discovery_loop_selected_chain_id);
const followupLoopPendingAxes = Array.isArray(followupContext?.previous_discovery_loop_pending_axes)
? followupContext.previous_discovery_loop_pending_axes.map((item) => toNonEmptyString(item)).filter(Boolean)
: [];
const currentTurnPredecomposeOrganization = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.entities?.organization) ??
(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_kind) === "organization"
? toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_value)
: null);
const routeCandidateOrganizationClarificationDetected = Boolean(followupContext &&
followupLoopStatus === "awaiting_clarification" &&
followupLoopSelectedChainId &&
followupLoopPendingAxes.includes("organization") &&
(currentTurnPredecomposeOrganization ||
explicitOrganizationClarificationSelection ||
[
rawUserMessage,
repairedRawUserMessage,
effectiveAddressUserMessage,
repairedEffectiveAddressUserMessage
].some((message) => hasOrganizationClarificationTextCue(message))));
const protectedInventoryShortFollowup = Boolean(followupContext &&
(isInventorySelectedObjectIntent(followupPreviousIntent) ||
(followupPreviousIntent === "inventory_on_hand_as_of_date" &&
@ -608,6 +636,8 @@ export function createAssistantRoutePolicy(deps) {
"net_value_flow"
].includes(String(toNonEmptyString(assistantTurnMeaning?.asked_action_family) ?? "")) ||
/(?:нетто|сальдо|сколько\s+мы\s+(?:получили|заплатили)|incoming|outgoing)/iu.test(analyticsSample)));
const effectiveGroundedValueFlowFollowupContextDetected =
groundedValueFlowFollowupContextDetected || routeCandidateOrganizationClarificationDetected;
const baseToolGatePreservesAddressLane = Boolean(baseToolGate?.runAddressLane &&
[
"address_intent_resolver_detected",
@ -617,14 +647,15 @@ export function createAssistantRoutePolicy(deps) {
].includes(String(baseToolGate?.reason ?? ""))) ||
Boolean(baseToolGate?.runAddressLane &&
String(baseToolGate?.reason ?? "") === "followup_context_detected" &&
groundedValueFlowFollowupContextDetected);
effectiveGroundedValueFlowFollowupContextDetected);
const nonDomainQueryIndexed = Boolean(!llmFirstAddressCandidate &&
deterministicNonDomainGuard &&
(llmFirstUnsupportedCandidate || llmContractMode === null) &&
!baseToolGatePreservesAddressLane &&
!groundedValueFlowFollowupContextDetected &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!protectedInventoryShortFollowup &&
!organizationClarificationContinuationDetected);
!organizationClarificationContinuationDetected &&
!routeCandidateOrganizationClarificationDetected);
const lastAddressAssistantDebug = sessionItems
? findLastAddressAssistantItem(sessionItems)?.debug ?? null
: null;
@ -668,7 +699,7 @@ export function createAssistantRoutePolicy(deps) {
!turnMeaningIntentCandidate &&
!dataScopeMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
const hardMetaMode = resolveHardMetaMode({
dataScopeMetaQuery,
@ -834,7 +865,7 @@ export function createAssistantRoutePolicy(deps) {
!dataScopeMetaQuery &&
!capabilityMetaQuery &&
!dangerOrCoercionSignal &&
!groundedValueFlowFollowupContextDetected &&
!effectiveGroundedValueFlowFollowupContextDetected &&
!organizationClarificationContinuationDetected);
if (unsupportedCurrentTurnMeaningBoundary) {
return {

View File

@ -2079,6 +2079,9 @@ function isAddressLaneDebugPayload(debug) {
if (typeof debug.mcp_call_status === "string" && debug.mcp_call_status.trim().length > 0) {
return true;
}
if (debug.mcp_discovery_response_applied === true && debug.assistant_mcp_discovery_entry_point_v1) {
return true;
}
if (typeof debug.anchor_type === "string" && debug.anchor_type.trim().length > 0) {
return true;
}

View File

@ -208,6 +208,34 @@ export function createAssistantTransitionPolicy(deps) {
);
}
function hasBusinessOverviewBoundaryFollowupCue(text) {
const normalized = normalizeFollowupText(text);
if (!normalized) {
return false;
}
const hasBoundaryCue =
/(?:\u043f\u0440\u043e\u0441\u0440\u043e\u0447|\u0441\u0440\u043e\u043a\w*\s+\u043e\u043f\u043b\u0430\u0442|\u0434\u043e\u043a\u0430\u0437|\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434|\u043f\u0440\u0438\u0431\u044b\u043b|\u0443\u0431\u044b\u0442|\u043c\u0430\u0440\u0436|\u0440\u0435\u0437\u0435\u0440\u0432|\u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|due[-\s]?date|overdue|aging|profit|loss|margin|vendor|risk)/iu.test(
normalized
);
const hasFollowupShape =
/(?:\u0442\u043e\s+\u0435\u0441\u0442\u044c|\u043f\u043e\u0447\u0435\u043c\u0443|\u043a\u043e\u0440\u043e\u0442\u043a|\u043d\u0435\u043b\u044c\u0437\u044f|\u043c\u043e\u0436\u043d\u043e\s+\u043b\u0438|\u0437\u043d\u0430\u0447\u0438\u0442|\u0432\u044b\u0445\u043e\u0434\u0438\u0442|\u0438\u0442\u043e\u0433|why|short|brief|so)/iu.test(
normalized
);
return hasBoundaryCue && hasFollowupShape;
}
function hasOrganizationClarificationTextCue(text) {
const normalized = deps.compactWhitespace(
deps.repairAddressMojibake(String(text ?? "")).toLowerCase()
);
if (!normalized) {
return false;
}
return /(?<!\p{L})(?:\u043e\u043e\u043e|\u0438\u043f|\u0430\u043e|\u043f\u0430\u043e|\u0437\u0430\u043e)(?!\p{L})|(?:\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043c\u043f\u0430\u043d|llc|company|organization)/iu.test(
normalized
);
}
function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) {
@ -584,7 +612,12 @@ export function createAssistantTransitionPolicy(deps) {
(deps.toNonEmptyString(alternateMessage)
? deps.hasDataRetrievalRequestSignal(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal) {
const rawBusinessOverviewBoundaryFollowupCue =
hasBusinessOverviewBoundaryFollowupCue(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
? hasBusinessOverviewBoundaryFollowupCue(String(alternateMessage ?? ""))
: false);
if (rawCapabilityMetaQuery && !rawDataRetrievalSignal && !rawBusinessOverviewBoundaryFollowupCue) {
return null;
}
const assistantTurnMeaning =
@ -660,11 +693,42 @@ export function createAssistantTransitionPolicy(deps) {
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopStatusHint = readAssistantMcpDiscoveryLoopStatus(
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopSelectedChainIdHint = readAssistantMcpDiscoveryLoopSelectedChainId(
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopPendingAxesHint = readAssistantMcpDiscoveryLoopPendingAxes(
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopProvidedAxesHint = readAssistantMcpDiscoveryLoopProvidedAxes(
carryoverSourceDebug,
deps.toNonEmptyString
);
const currentTurnPredecomposeOrganization =
deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.entities?.organization) ??
(deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_kind) === "organization"
? deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.semantics?.anchor_value)
: null);
const mcpDiscoveryOrganizationClarificationContinuation = Boolean(
sourceDiscoveryLoopStatusHint === "awaiting_clarification" &&
sourceDiscoveryLoopSelectedChainIdHint &&
sourceDiscoveryLoopPendingAxesHint.includes("organization") &&
(currentTurnPredecomposeOrganization ||
explicitOrganizationClarificationSelection ||
[userMessage, alternateMessage].some((message) => hasOrganizationClarificationTextCue(message)))
);
const hasValueFlowCarryoverSourceHint =
sourceIntentHint === "customer_revenue_and_payments" ||
sourceDiscoveryPilotScopeHint === "counterparty_value_flow_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_supplier_payout_query_movements_v1" ||
sourceDiscoveryPilotScopeHint === "counterparty_bidirectional_value_flow_query_movements_v1";
const hasBusinessOverviewCarryoverSourceHint =
sourceDiscoveryPilotScopeHint === "business_overview_route_template_v1";
const navigationSessionState = resolveNavigationSessionContextState(
addressNavigationState,
deps.toNonEmptyString,
@ -706,21 +770,31 @@ export function createAssistantTransitionPolicy(deps) {
hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
: false;
const businessOverviewBoundaryFollowupPrimary =
hasBusinessOverviewCarryoverSourceHint && hasBusinessOverviewBoundaryFollowupCue(userMessage);
const businessOverviewBoundaryFollowupAlternate =
hasBusinessOverviewCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasBusinessOverviewBoundaryFollowupCue(String(alternateMessage ?? ""))
: false;
const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage);
let hasPrimaryFollowupSignal =
deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) ||
shortValueFlowRetargetPrimary ||
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal;
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal
explicitSummaryBundleReuseSignal ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
@ -760,6 +834,7 @@ export function createAssistantTransitionPolicy(deps) {
hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
mcpDiscoveryOrganizationClarificationContinuation ||
hasImplicitContinuationSignal ||
hasSuggestedIntentPivotSignal ||
inventoryShortFollowupPrimary ||
@ -773,6 +848,8 @@ export function createAssistantTransitionPolicy(deps) {
Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -783,6 +860,7 @@ export function createAssistantTransitionPolicy(deps) {
hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
mcpDiscoveryOrganizationClarificationContinuation ||
inventoryShortFollowupPrimary ||
inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary ||
@ -794,6 +872,8 @@ export function createAssistantTransitionPolicy(deps) {
Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)
@ -826,6 +906,7 @@ export function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation &&
!mcpDiscoveryOrganizationClarificationContinuation &&
!hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal
) {
@ -843,6 +924,7 @@ export function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation &&
!mcpDiscoveryOrganizationClarificationContinuation &&
!hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal
) {
@ -884,19 +966,10 @@ export function createAssistantTransitionPolicy(deps) {
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopStatus = readAssistantMcpDiscoveryLoopStatus(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryLoopSelectedChainId = readAssistantMcpDiscoveryLoopSelectedChainId(
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopPendingAxes = readAssistantMcpDiscoveryLoopPendingAxes(
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopProvidedAxes = readAssistantMcpDiscoveryLoopProvidedAxes(
carryoverSourceDebug,
deps.toNonEmptyString
);
const sourceDiscoveryLoopStatus = sourceDiscoveryLoopStatusHint;
const sourceDiscoveryLoopSelectedChainId = sourceDiscoveryLoopSelectedChainIdHint;
const sourceDiscoveryLoopPendingAxes = sourceDiscoveryLoopPendingAxesHint;
const sourceDiscoveryLoopProvidedAxes = sourceDiscoveryLoopProvidedAxesHint;
const sourceDiscoveryLoopAskedDomainFamily = readAssistantMcpDiscoveryLoopAskedDomainFamily(
carryoverSourceDebug,
deps.toNonEmptyString
@ -959,6 +1032,7 @@ export function createAssistantTransitionPolicy(deps) {
explicitIntentFamily &&
sourceIntentFamily !== explicitIntentFamily &&
!hasOrganizationClarificationContinuation &&
!mcpDiscoveryOrganizationClarificationContinuation &&
!hasImplicitContinuationSignal &&
!hasIndexReferenceSignal &&
!hasInventoryRootTemporalFollowupPrimary &&
@ -967,6 +1041,8 @@ export function createAssistantTransitionPolicy(deps) {
!hasInventoryRootRestatementAlternate &&
!inventoryShortFollowupPrimary &&
!inventoryShortFollowupAlternate &&
!businessOverviewBoundaryFollowupPrimary &&
!businessOverviewBoundaryFollowupAlternate &&
!foreignAccountingPivotOverInventory &&
!deps.hasFollowupMarker(userMessage) &&
!deps.hasReferentialPointer(userMessage) &&
@ -1027,24 +1103,29 @@ export function createAssistantTransitionPolicy(deps) {
hasSuggestedIntentPivotSignal ||
Boolean(debtRoleSwapPrimary) ||
shortValueFlowRetargetPrimary ||
businessOverviewBoundaryFollowupPrimary ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary;
hasInventoryRootTemporalFollowupPrimary ||
mcpDiscoveryOrganizationClarificationContinuation;
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) ||
hasSuggestedIntentPivotSignal ||
Boolean(debtRoleSwapAlternate) ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupAlternate ||
inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate
hasInventoryRootTemporalFollowupAlternate ||
mcpDiscoveryOrganizationClarificationContinuation
: false;
hasStrongFollowupReference =
hasPrimaryIndexReferenceSignal ||
hasAlternateIndexReferenceSignal ||
hasOrganizationClarificationContinuation ||
mcpDiscoveryOrganizationClarificationContinuation ||
hasSuggestedIntentPivotSignal ||
hasImplicitContinuationSignal ||
inventoryShortFollowupPrimary ||
@ -1056,6 +1137,8 @@ export function createAssistantTransitionPolicy(deps) {
Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate ||
businessOverviewBoundaryFollowupPrimary ||
businessOverviewBoundaryFollowupAlternate ||
deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) ||
(deps.toNonEmptyString(alternateMessage)

View File

@ -0,0 +1,47 @@
export type CounterpartyRoleHint = "ordinary_counterparty" | "bank_or_financial_institution";
const FINANCIAL_INSTITUTION_PATTERNS: RegExp[] = [
/(?:^|[\s"«(,-])банк(?:$|[\s"»),.-])/u,
/сбербанк/u,
/(?:^|[\s"«(,-])сбер(?:$|[\s"»),.-])/u,
/(?:^|[\s"«(,-])втб(?:$|[\s"»),.-])/u,
/альфа[\s-]*банк/u,
/тинькофф/u,
/(?:^|[\s"«(,-])т[\s-]*банк(?:$|[\s"»),.-])/u,
/газпромбанк/u,
/росбанк/u,
/райффайзен/u,
/совкомбанк/u,
/промсвязьбанк/u,
/(?:^|[\s"«(,-])псб(?:$|[\s"»),.-])/u,
/(?:^|[\s"«(,-])мкб(?:$|[\s"»),.-])/u,
/ак[\s-]*барс/u,
/уралсиб/u,
/юникредит/u,
/почта[\s-]*банк/u,
/(?:^|[\s"«(,-])открытие(?:$|[\s"»),.-])/u,
/кредитн(?:ая|ый|ое|ые)\s+организац/u
];
function normalizeCounterpartyRoleText(value: unknown): string {
return String(value ?? "")
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[._]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
export function isLikelyFinancialInstitutionCounterparty(value: unknown): boolean {
const normalized = normalizeCounterpartyRoleText(value);
if (!normalized) {
return false;
}
return FINANCIAL_INSTITUTION_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function counterpartyRoleHintForName(value: unknown): CounterpartyRoleHint {
return isLikelyFinancialInstitutionCounterparty(value)
? "bank_or_financial_institution"
: "ordinary_counterparty";
}

View File

@ -18,6 +18,8 @@ export type AddressIntent =
| "vat_payable_forecast"
| "vat_liability_confirmed_for_tax_period"
| "vat_payable_confirmed_as_of_date"
| "accounting_financial_result_for_organization"
| "debt_due_date_aging_for_organization"
| "open_contracts_confirmed_as_of_date"
| "list_contracts_by_counterparty"
| "list_open_contracts"
@ -189,6 +191,8 @@ export interface AddressRecipeDefinition {
| "vat_payable_forecast_profile"
| "vat_liability_confirmed_tax_period_profile"
| "vat_payable_confirmed_as_of_balance_profile"
| "accounting_financial_result_profile"
| "debt_due_date_aging_profile"
| "open_contracts_confirmed_as_of_balance_profile"
| "payables_confirmed_as_of_balance_profile"
| "receivables_confirmed_as_of_balance_profile"

View File

@ -8,6 +8,24 @@ describe("addressIntentResolver regression bridges", () => {
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
});
it("detects VAT movement inspection wording with an explicit year", () => {
const result = resolveAddressIntent(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f \u043f\u043e \u041d\u0414\u0421 \u0437\u0430 2020 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
);
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result.reasons).toContain("vat_period_inspection_bridge_signal_detected");
});
it("detects canonical VAT charged-or-paid wording with an explicit year", () => {
const result = resolveAddressIntent(
"\u041a\u0430\u043a\u043e\u0439 \u041d\u0414\u0421 \u0431\u044b\u043b \u043d\u0430\u0447\u0438\u0441\u043b\u0435\u043d \u0438\u043b\u0438 \u0443\u043f\u043b\u0430\u0447\u0435\u043d \u0432 2020 \u0433\u043e\u0434\u0443 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441?"
);
expect(result.intent).toBe("vat_liability_confirmed_for_tax_period");
expect(result.reasons).toContain("vat_liability_explicit_period_bridge_signal_detected");
});
it("detects payables snapshot wording in plain human form", () => {
const result = resolveAddressIntent("мы должны комуто денег на сегодня?");

View File

@ -1336,9 +1336,9 @@ describe("address compose stage utf8 headers", () => {
{ userMessage: "Сколько у нас заказчиков, поставщиков и смешанных контрагентов?" }
);
expect(reply.text).toContain("Роли контрагентов по активности:");
expect(reply.text).toContain("Заказчики (только customer-роль): 122.");
expect(reply.text).toContain("Поставщики (только supplier-роль): 71.");
expect(reply.text).toContain("Распределение ролей по активности:");
expect(reply.text).toContain("Заказчики с ролью покупателя: 122.");
expect(reply.text).toContain("Поставщики с ролью поставщика: 71.");
expect(reply.text).toContain("Смешанные (и покупатель, и поставщик): 23.");
expect(reply.text).not.toContain("Всего уникальных контрагентов в базе");
});
@ -1391,7 +1391,7 @@ describe("address compose stage utf8 headers", () => {
{ userMessage: "скока поставщиков в базе" }
);
expect(reply.text).toContain("Поставщиков (только supplier-роль): 71.");
expect(reply.text).toContain("Поставщиков с ролью поставщика: 71.");
expect(reply.text).not.toContain("Роли контрагентов по активности:");
expect(reply.text).not.toContain("Всего уникальных контрагентов в базе");
});
@ -1444,7 +1444,7 @@ describe("address compose stage utf8 headers", () => {
{ userMessage: "скок клиентов" }
);
expect(reply.text).toContain("Заказчиков (только customer-роль): 122.");
expect(reply.text).toContain("Заказчиков с ролью покупателя: 122.");
expect(reply.text).not.toContain("Роли контрагентов по активности:");
expect(reply.text).not.toContain("Всего уникальных контрагентов в базе");
});
@ -1711,7 +1711,7 @@ describe("address compose stage utf8 headers", () => {
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("по максимальной сумме одной входящей операции");
expect(reply.text).toContain("1. Клиент Б | max single: 1200");
expect(reply.text).toContain("1. Клиент Б | максимальная разовая сумма: 1.200,00 ₽");
});
it("renders supplier payout list by operations count", () => {
@ -2033,7 +2033,7 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Покрытие VAT-источников через MCP");
expect(reply.text).toContain("Покрытие VAT-источников в 1С");
expect(reply.text).toContain("Найдено VAT-объектов: 5");
expect(reply.text).toContain("РегистрНакопления.НДСПродажи");
});
@ -2063,12 +2063,14 @@ describe("address compose stage utf8 headers", () => {
userMessage: "сколько платить ндс в налоговую за декабрь 2019",
periodFrom: "2019-10-01",
periodTo: "2019-12-31",
organizationHint: "ООО Альтернатива Плюс",
useRubCurrency: true
}
);
expect(reply.responseType).toBe("FACTUAL_SUMMARY");
expect(reply.text).toContain("Коротко: подтвержденный НДС к уплате за налоговый период");
expect(reply.text).toContain("Коротко: подтвержденный НДС к уплате за налоговый период по организации ООО Альтернатива Плюс");
expect(reply.text).toContain("- Организация: ООО Альтернатива Плюс.");
expect(reply.text).toContain("50.000,00 ₽");
expect(reply.semantics?.result_mode).toBe("confirmed_balance");
expect(reply.semantics?.balance_confirmed).toBe(true);
@ -2214,7 +2216,7 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Блок 2.1. MCP-проверка VAT-источников");
expect(reply.text).toContain("Блок 2.1. Проверка VAT-источников в 1С");
expect(reply.text).toContain("VAT-объектов в метаданных 1С: 3");
expect(reply.text).toContain("Источников с движениями до даты среза: 1");
expect(reply.text).toContain("РегистрНакопления.НДСНачисленный");
@ -2247,7 +2249,7 @@ describe("address compose stage utf8 headers", () => {
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Probe VAT-источников завершился ошибкой");
expect(reply.text).toContain("Дополнительная проверка VAT-источников завершилась ошибкой");
});
});
@ -5099,13 +5101,13 @@ describe("address recipe catalog counterparty filtering", () => {
expect(aging.extracted_filters.limit).toBeUndefined();
});
it("selects customer value recipe and keeps top-20 default", () => {
it("selects customer value recipe and keeps expanded top-200 default", () => {
const selected = selectAddressRecipe("customer_revenue_and_payments", {});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {});
expect(plan.recipe.recipe_id).toBe("address_customer_revenue_and_payments_v1");
expect(plan.limit).toBe(20);
expect(plan.limit).toBe(200);
expect(plan.query).toContain("ПоступлениеНаРасчетныйСчет");
expect(plan.query).toContain("БанкПоступление.ДоговорКонтрагента");
});
@ -5138,13 +5140,13 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.limit).toBe(1000);
});
it("selects supplier payouts recipe and keeps top-20 default", () => {
it("selects supplier payouts recipe and keeps expanded top-200 default", () => {
const selected = selectAddressRecipe("supplier_payouts_profile", {});
expect(selected.selected_recipe).toBeTruthy();
const plan = buildAddressRecipePlan(selected.selected_recipe!, {});
expect(plan.recipe.recipe_id).toBe("address_supplier_payouts_profile_v1");
expect(plan.limit).toBe(20);
expect(plan.limit).toBe(200);
expect(plan.query).toContain("СписаниеСРасчетногоСчета");
expect(plan.query).toContain("БанкСписание.ДоговорКонтрагента");
});
@ -5343,6 +5345,26 @@ describe("address recipe catalog counterparty filtering", () => {
expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 2) = \"60\"");
});
it("builds debt due-date aging query without carrying noisy organization suffix", () => {
const selected = selectAddressRecipe("debt_due_date_aging_for_organization", {
as_of_date: "2020-12-31",
organization: "ООО Альтернатива Плюс на конец 2020 можно точно понять"
});
expect(selected.selected_recipe?.recipe_id).toBe("address_debt_due_date_aging_for_organization_v1");
const plan = buildAddressRecipePlan(selected.selected_recipe!, {
as_of_date: "2020-12-31",
organization: "ООО Альтернатива Плюс на конец 2020 можно точно понять"
});
expect(plan.query).toContain("УстановленСрокОплаты");
expect(plan.query).toContain('Наименование ПОДОБНО "%Альтернатива%"');
expect(plan.query).toContain('Наименование ПОДОБНО "%Плюс%"');
expect(plan.query).not.toContain('Наименование ПОДОБНО "%конец%"');
expect(plan.query).not.toContain('Наименование ПОДОБНО "%2020%"');
expect(plan.query).not.toContain('Наименование ПОДОБНО "%можно%"');
expect(plan.query).not.toContain('Наименование ПОДОБНО "%понять%"');
});
it("injects account condition into movements query for account snapshot", () => {
const filters = extractAddressFilters(
"Какой остаток по счету 60 на дату 2020-07-31",

View File

@ -180,6 +180,76 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.must_not_claim).toContain("Do not present the confirmed movement rows as a complete movement universe.");
});
it("does not present bank-like ranked value-flow leaders as ordinary customers", () => {
const draft = buildAssistantMcpDiscoveryAnswerDraft({
pilot_status: "executed",
pilot_scope: "counterparty_ranked_value_flow_query_movements_v1",
dry_run: false,
mcp_execution_performed: true,
executed_primitives: ["query_movements"],
skipped_primitives: [],
probe_results: [],
evidence: {
evidence_status: "confirmed",
answer_permission: "confirmed_answer",
confirmed_facts: [],
inferred_facts: [],
unknown_facts: [],
query_limitations: [],
reason_codes: [],
query_plan: {}
},
source_rows_summary: "2 MCP movement rows fetched, 2 matched ranking scope",
derived_metadata_surface: null,
derived_entity_resolution: null,
derived_activity_period: null,
derived_value_flow: null,
derived_bidirectional_value_flow: null,
derived_business_overview: null,
derived_ranked_value_flow: {
organization_scope: "ООО Альтернатива Плюс",
period_scope: "2020",
value_flow_direction: "incoming_customer_revenue",
ranking_need: "top_desc",
aggregation_axis: "counterparty",
coverage_limited_by_probe_limit: false,
ranked_values: [
{
axis_value: "СБЕРБАНК, ПАО",
total_amount: 12792194.31,
total_amount_human_ru: "12 792 194,31 руб.",
rows_with_amount: 3,
rows_matched: 3,
first_movement_date: "2020-01-15",
latest_movement_date: "2020-12-20",
counterparty_role_hint: "bank_or_financial_institution"
},
{
axis_value: "Группа СВК",
total_amount: 12093465,
total_amount_human_ru: "12 093 465 руб.",
rows_with_amount: 2,
rows_matched: 2,
first_movement_date: "2020-02-15",
latest_movement_date: "2020-11-10",
counterparty_role_hint: "ordinary_counterparty"
}
],
inference_basis: "confirmed_1c_movement_rows_grouped_by_counterparty"
},
query_limitations: [],
reason_codes: ["pilot_derived_ranked_value_flow_from_confirmed_rows"]
} as any);
const confirmed = draft.confirmed_lines.join("\n");
expect(confirmed).toContain("Крупнейший входящий денежный источник СБЕРБАНК, ПАО");
expect(confirmed).toContain("не называю это клиентской выручкой");
expect(confirmed).not.toContain("Больше всего денег принёс контрагент СБЕРБАНК");
expect(draft.must_not_claim).toContain(
"Do not present bank-like counterparties as ordinary customers, suppliers, revenue, procurement dependency, or business quality evidence without payment-purpose/contract proof."
);
});
it("turns business overview multi-probe evidence into an analyst-safe draft", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
@ -355,6 +425,7 @@ describe("assistant MCP discovery answer adapter", () => {
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" },
@ -429,6 +500,7 @@ describe("assistant MCP discovery answer adapter", () => {
{ rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] },
{ rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А" }
@ -528,6 +600,7 @@ describe("assistant MCP discovery answer adapter", () => {
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" },
@ -576,6 +649,73 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.must_not_claim).toContain("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value.");
});
it("answers inventory reserve boundary questions direct-first before broad overview detail", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "business_overview",
action_family: "inventory_reserve_boundary",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "bounded_inference",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "probe_coverage", "explain_evidence_basis"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"],
reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"]
},
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "inventory_reserve_boundary",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020",
unsupported_but_understood_family: "inventory_reserve_liquidation_boundary"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(
planner,
buildSequentialDeps([
{ rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] },
{ rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" }
]
},
{
rows: [
{ Period: "2020-01-10T00:00:00", Amount: 200000, Quantity: 8, Item: "Товар А" }
]
},
{ rows: [{ Period: "2020-01-15T00:00:00", Registrator: "Поступление 1" }] },
{
rows: [
{ Period: "2020-03-01T00:00:00", Amount: 600000, Item: "Товар А", Counterparty: "Клиент А", AccountKt: "41.01" },
{ Period: "2020-02-01T00:00:00", Amount: 240000, Item: "Товар А", Counterparty: "Поставщик А", AccountDt: "41.01" }
]
}
])
);
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
expect(draft.headline).toContain(
"\u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432"
);
expect(draft.headline).toContain("\u043d\u0435\u043b\u044c\u0437\u044f");
expect(draft.headline).toContain("staleness-risk proxy");
expect(draft.headline).not.toContain("бизнес-обзор");
expect(draft.must_not_claim).toContain("Do not present business overview inventory staleness risk proxy as confirmed obsolete stock, reserve, write-off, or liquidation value.");
});
it("renders metadata-scoped movement all-time follow-up as an all-time bounded answer", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
@ -796,7 +936,9 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(draft.confirmed_lines).toHaveLength(1);
expect(userText).toContain("\u0411\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0451\u0441 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442");
expect(userText).toContain("Крупнейший входящий денежный источник");
expect(userText).toContain("не называю это клиентской выручкой");
expect(userText).not.toContain("\u0411\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0451\u0441 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442 \u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a");
expect(userText).toContain("\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441");
expect(userText).not.toContain("1C incoming value-flow");
expect(userText).not.toContain("Full ranking outside");
@ -1409,7 +1551,7 @@ describe("assistant MCP discovery answer adapter", () => {
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
}
});
const broadRows = Array.from({ length: 100 }, (_, index) => ({
const broadRows = Array.from({ length: 200 }, (_, index) => ({
Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`,
Amount: 10,
Counterparty: "SVK"

View File

@ -26,6 +26,25 @@ function entryPointContract(overrides: Record<string, unknown> = {}) {
selected_chain_matches_top: true
}
},
route_candidate: {
schema_version: "assistant_mcp_route_candidate_v1",
policy_owner: "assistantMcpDiscoveryRuntimeBridge",
candidate_status: "ready_for_reviewed_execution",
selected_chain_id: "value_flow_ranking",
selected_chain_summary: "Rank value flow",
nearest_catalog_chain_template: "value_flow_ranking",
catalog_alignment_status: "selected_matches_top",
business_fact_family: "value_flow",
action_family: "turnover",
proof_expectation: "coverage_checked_fact",
required_axes: ["organization", "period"],
provided_axes: ["organization", "period"],
missing_axes: [],
executable_now: true,
enablement_reason: null,
recommended_next_action: "Execute through the reviewed runtime bridge and truth gate.",
forbidden_overclaim_flags: ["no_unchecked_fact_totals"]
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
@ -54,6 +73,15 @@ describe("assistant MCP discovery debug attachment", () => {
expect(debug.mcp_discovery_catalog_chain_alignment_status).toBe("selected_matches_top");
expect(debug.mcp_discovery_catalog_chain_top_match).toBe("value_flow_ranking");
expect(debug.mcp_discovery_catalog_chain_selected_matches_top).toBe(true);
expect(debug.mcp_discovery_route_candidate_status).toBe("ready_for_reviewed_execution");
expect(debug.mcp_discovery_route_candidate_fact_family).toBe("value_flow");
expect(debug.mcp_discovery_route_candidate_action_family).toBe("turnover");
expect(debug.mcp_discovery_route_candidate_missing_axes).toEqual([]);
expect(debug.mcp_discovery_route_candidate_provided_axes).toEqual(["organization", "period"]);
expect(debug.mcp_discovery_route_candidate_executable_now).toBe(true);
expect(debug.mcp_discovery_route_candidate_next_action).toBe(
"Execute through the reviewed runtime bridge and truth gate."
);
expect(debug.mcp_discovery_answer_mode).toBe("confirmed_with_bounded_inference");
expect(debug.mcp_discovery_business_fact_answer_allowed).toBe(true);
expect(debug.mcp_discovery_user_facing_response_allowed).toBe(true);
@ -76,6 +104,11 @@ describe("assistant MCP discovery debug attachment", () => {
expect(debug.mcp_discovery_catalog_chain_alignment_status).toBeNull();
expect(debug.mcp_discovery_catalog_chain_top_match).toBeNull();
expect(debug.mcp_discovery_catalog_chain_selected_matches_top).toBe(false);
expect(debug.mcp_discovery_route_candidate_v1).toBeNull();
expect(debug.mcp_discovery_route_candidate_status).toBeNull();
expect(debug.mcp_discovery_route_candidate_missing_axes).toEqual([]);
expect(debug.mcp_discovery_route_candidate_provided_axes).toEqual([]);
expect(debug.mcp_discovery_route_candidate_executable_now).toBe(false);
expect(debug.mcp_discovery_answer_mode).toBeNull();
expect(debug.mcp_discovery_business_fact_answer_allowed).toBe(false);
expect(debug.mcp_discovery_user_facing_response_allowed).toBe(false);

View File

@ -293,6 +293,80 @@ describe("assistant MCP discovery pilot executor", () => {
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(6);
});
it("marks bank-like counterparties in business-overview rankings before evidence wording", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "business_overview",
action_family: "broad_evaluation",
aggregation_need: null,
time_scope_need: "all_time_scope",
comparison_need: null,
ranking_need: null,
proof_expectation: "bounded_inference",
clarification_gaps: [],
decomposition_candidates: [
"collect_scoped_movements",
"aggregate_checked_amounts",
"aggregate_ranked_axis_values",
"fetch_supporting_documents",
"probe_coverage",
"explain_evidence_basis"
],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_profit_or_margin_claim_without_evidence"],
reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"]
},
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс"
}
});
const deps = buildSequentialDeps([
{
rows: [
{ Period: "2020-01-15T00:00:00", Amount: 1200000, Counterparty: "СБЕРБАНК, ПАО" },
{ Period: "2020-02-15T00:00:00", Amount: 800000, Counterparty: "Группа СВК" }
]
},
{
rows: [
{ Period: "2020-01-20T00:00:00", Amount: 650000, Counterparty: "СБЕРБАНК, ПАО" },
{ Period: "2020-03-20T00:00:00", Amount: 50000, Counterparty: "ООО Поставщик" }
]
},
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] }
]);
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
expect(result.derived_business_overview?.top_customers[0]).toMatchObject({
axis_value: "СБЕРБАНК, ПАО",
counterparty_role_hint: "bank_or_financial_institution"
});
expect(result.derived_business_overview?.top_customers[1]).toMatchObject({
axis_value: "Группа СВК",
counterparty_role_hint: "ordinary_counterparty"
});
expect(result.derived_business_overview?.top_suppliers[0]).toMatchObject({
axis_value: "СБЕРБАНК, ПАО",
counterparty_role_hint: "bank_or_financial_institution"
});
const confirmedFacts = result.evidence.confirmed_facts.join("\n");
const inferredFacts = result.evidence.inferred_facts.join("\n");
expect(confirmedFacts).toContain("Крупнейший входящий денежный источник");
expect(confirmedFacts).toContain("Крупнейший небанковский входящий контрагент");
expect(confirmedFacts).toContain("Крупнейший получатель исходящих денег");
expect(confirmedFacts).not.toContain("Самый крупный подтвержденный клиент в проверенном срезе: СБЕРБАНК");
expect(confirmedFacts).not.toContain("Самый крупный подтвержденный поставщик/получатель исходящих платежей в проверенном срезе: СБЕРБАНК");
expect(inferredFacts).toContain("outgoing cash concentration proxy");
});
it("adds a checked VAT/tax family to business overview only when an explicit period is available", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
@ -347,6 +421,7 @@ describe("assistant MCP discovery pilot executor", () => {
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Период: "2020-01-15T00:00:00", Регистратор: "Поступление 1" },
@ -402,9 +477,9 @@ describe("assistant MCP discovery pilot executor", () => {
expect(result.reason_codes).toContain("pilot_derived_business_overview_tax_position_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_business_overview_trading_margin_query_mcp_executed");
expect(result.reason_codes).toContain("pilot_derived_business_overview_trading_margin_proxy_from_confirmed_rows");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(13);
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14);
const taxCall = deps.executeAddressMcpQuery.mock.calls[2]?.[0];
const tradingMarginCall = deps.executeAddressMcpQuery.mock.calls[9]?.[0];
const tradingMarginCall = deps.executeAddressMcpQuery.mock.calls[10]?.[0];
expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПродаж");
expect(String(taxCall?.query ?? "")).toContain("НДСЗаписиКнигиПокупок");
expect(String(tradingMarginCall?.query ?? "")).toContain("Документ.РеализацияТоваровУслуг.Товары");
@ -453,6 +528,7 @@ describe("assistant MCP discovery pilot executor", () => {
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-03-01T00:00:00", Amount: 100000, Item: "Товар А", Counterparty: "Клиент А", AccountKt: "41.01" },
@ -510,6 +586,7 @@ describe("assistant MCP discovery pilot executor", () => {
{ rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Клиент А" }] },
{ rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Поставщик А" }] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 70000, Counterparty: "Клиент А" },
@ -602,15 +679,107 @@ describe("assistant MCP discovery pilot executor", () => {
expect(result.reason_codes).toContain("pilot_derived_business_overview_open_settlement_quality_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_age_signal_from_contract_dates");
expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_staleness_risk_proxy_from_confirmed_rows");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(13);
const receivablesCall = deps.executeAddressMcpQuery.mock.calls[3]?.[0];
const payablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0];
const openContractsCall = deps.executeAddressMcpQuery.mock.calls[5]?.[0];
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14);
const receivablesCall = deps.executeAddressMcpQuery.mock.calls[4]?.[0];
const payablesCall = deps.executeAddressMcpQuery.mock.calls[5]?.[0];
const openContractsCall = deps.executeAddressMcpQuery.mock.calls[6]?.[0];
expect(String(receivablesCall?.query ?? "")).toContain("62");
expect(String(payablesCall?.query ?? "")).toContain("60");
expect(String(openContractsCall?.query ?? "")).toContain("СуммаРазвернутыйОстатокКт");
});
it("checks debt due-date aging with contract payment terms before claiming overdue debt", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "business_overview",
action_family: "debt_due_date_boundary",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "due_date_aging",
clarification_gaps: [],
decomposition_candidates: [
"collect_scoped_movements",
"aggregate_checked_amounts",
"aggregate_ranked_axis_values",
"fetch_supporting_documents",
"probe_coverage",
"explain_evidence_basis"
],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_overdue_claim"],
reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"]
},
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "debt_due_date_boundary",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
}
});
const deps = buildSequentialDeps([
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А от 10.02.2019" }
]
},
{
rows: [
{
Period: "2020-12-31T00:00:00",
Amount: 100000,
Counterparty: "Клиент А",
Contract: "Договор А от 10.02.2019",
ДокументРасчетов: "Реализация товаров от 10.03.2020",
УстановленСрокОплаты: false,
СрокОплаты: 0
}
]
},
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] }
]);
const result = await executeAssistantMcpDiscoveryPilot(planner, deps);
expect(result.derived_business_overview?.debt_due_date_aging).toMatchObject({
as_of_date: "2020-12-31",
rows_with_amount: 1,
gross_open_amount: 100000,
rows_with_payment_terms: 0,
rows_without_payment_terms: 1,
overdue_rows: 0,
evidence_status: "no_payment_terms_configured"
});
expect(result.derived_business_overview?.missing_signal_families).not.toContain("debt_due_date_aging_quality");
expect(result.derived_business_overview?.missing_proof_families.map((item) => item.family)).not.toContain(
"debt_due_date_aging_quality"
);
expect(result.evidence.confirmed_facts.join("\n")).toContain("срок оплаты не установлен");
expect(result.evidence.confirmed_facts.join("\n")).toContain("Подтвержденной просрочки");
expect(result.evidence.unknown_facts.join("\n")).not.toContain("due-date aging этим бизнес-обзором не подтверждены");
expect(result.reason_codes).toContain("pilot_business_overview_debt_due_date_aging_query_mcp_executed");
expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_due_date_aging_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_derived_business_overview_debt_due_date_aging_no_payment_terms_configured");
const dueDateCall = deps.executeAddressMcpQuery.mock.calls[7]?.[0];
expect(String(dueDateCall?.query ?? "")).toContain("УстановленСрокОплаты");
expect(String(dueDateCall?.query ?? "")).toContain("СрокОплаты");
});
it("adds a checked inventory-position family to business overview only as an as-of-date snapshot", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
@ -650,6 +819,7 @@ describe("assistant MCP discovery pilot executor", () => {
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Товар А" },
@ -729,8 +899,8 @@ describe("assistant MCP discovery pilot executor", () => {
expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_position_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_turnover_proxy_from_confirmed_rows");
expect(result.reason_codes).toContain("pilot_derived_business_overview_inventory_staleness_risk_proxy_from_confirmed_rows");
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(13);
const inventoryCall = deps.executeAddressMcpQuery.mock.calls[6]?.[0];
expect(deps.executeAddressMcpQuery).toHaveBeenCalledTimes(14);
const inventoryCall = deps.executeAddressMcpQuery.mock.calls[7]?.[0];
expect(inventoryCall?.account_scope).toContain("41.01");
});
@ -1335,7 +1505,7 @@ describe("assistant MCP discovery pilot executor", () => {
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
}
});
const rows = Array.from({ length: 100 }, (_, index) => ({
const rows = Array.from({ length: 200 }, (_, index) => ({
Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`,
Amount: 10,
Counterparty: "SVK"
@ -1359,7 +1529,7 @@ describe("assistant MCP discovery pilot executor", () => {
unsupported_but_understood_family: "counterparty_payouts_or_outflow"
}
});
const broadRows = Array.from({ length: 100 }, (_, index) => ({
const broadRows = Array.from({ length: 200 }, (_, index) => ({
Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`,
Amount: 10,
Counterparty: "SVK"
@ -1560,7 +1730,7 @@ describe("assistant MCP discovery pilot executor", () => {
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
}
});
const outgoingBroadRows = Array.from({ length: 100 }, (_, index) => ({
const outgoingBroadRows = Array.from({ length: 200 }, (_, index) => ({
Period: `2020-01-${String((index % 28) + 1).padStart(2, "0")}T00:00:00`,
Amount: 10,
Counterparty: "SVK"

View File

@ -44,6 +44,177 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("primitive");
});
it("keeps inventory reserve boundary answers direct instead of compacting into a money overview", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "inventory_reserve_boundary",
unsupported_but_understood_family: "inventory_reserve_liquidation_boundary"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
period_scope: null,
incoming_customer_revenue: {
total_amount_human_ru: "157 192 981,43 руб.",
coverage_limited_by_probe_limit: true
},
outgoing_supplier_payout: {
total_amount_human_ru: "35 439 044,74 руб.",
coverage_limited_by_probe_limit: true
},
net_amount_human_ru: "121 753 936,69 руб.",
net_direction: "net_incoming"
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline:
"\u041a\u043e\u0440\u043e\u0442\u043a\u043e: \u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432 \u043f\u043e\u0434 \u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434\u044b \u043f\u043e \u0442\u0435\u043a\u0443\u0449\u0438\u043c \u0434\u0430\u043d\u043d\u044b\u043c \u043d\u0435\u043b\u044c\u0437\u044f.",
confirmed_lines: ["Денежный обзор здесь не является ответом на складской резерв."],
inference_lines: [],
unknown_lines: [
"\u0420\u0435\u0437\u0435\u0440\u0432\u044b, \u0441\u043f\u0438\u0441\u0430\u043d\u0438\u044f \u0438 \u043b\u0438\u043a\u0432\u0438\u0434\u0430\u0446\u0438\u043e\u043d\u043d\u0430\u044f \u0441\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c \u0441\u043a\u043b\u0430\u0434\u0430 \u043d\u0435 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u044b."
],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain(
"\u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432"
);
expect(candidate.reply_text).toContain("\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0443\u0436\u043d\u043e");
expect(candidate.reply_text).not.toContain("157 192 981");
expect(candidate.reply_text).not.toContain("лимит");
});
it("keeps profit/margin boundary answers direct instead of compacting into a money overview", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
unsupported_but_understood_family: "profit_margin_boundary"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
incoming_customer_revenue: {
total_amount_human_ru: "47 628 853,03 руб."
},
outgoing_supplier_payout: {
total_amount_human_ru: "19 568 878,06 руб."
},
net_amount_human_ru: "28 059 974,97 руб.",
net_direction: "net_incoming"
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline:
"Коротко: нельзя точно подтвердить чистую прибыль и маржу по текущему срезу 1С; есть только bounded operating-flow/trading-margin proxy, не P&L и не бухгалтерский финансовый результат.",
confirmed_lines: ["Денежный обзор здесь не является ответом на чистую прибыль."],
inference_lines: [],
unknown_lines: ["Для точного P&L нужны себестоимость, расходы и закрытие периода."],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("нельзя точно подтвердить чистую прибыль");
expect(candidate.reply_text).toContain("P&L");
expect(candidate.reply_text).toContain("себестоимости");
expect(candidate.reply_text).not.toContain("47 628 853");
});
it("keeps vendor-risk boundary answers direct instead of compacting into a money overview", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "vendor_risk_procurement_boundary",
unsupported_but_understood_family: "vendor_risk_procurement_boundary"
},
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: ["data_need_graph_family_business_overview"]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
outgoing_supplier_payout: {
total_amount_human_ru: "19 568 878,06 руб."
},
top_suppliers: [
{
axis_value: "СБЕРБАНК, ПАО",
total_amount_human_ru: "6 653 022,52 руб."
}
]
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Коротко: точный риск зависимости от одного поставщика по текущим данным не подтвержден.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: ["Vendor-risk route не подключен."],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("риск зависимости");
expect(candidate.reply_text).toContain("outgoing cash concentration proxy");
expect(candidate.reply_text).toContain("банк/финансовая организация");
expect(candidate.reply_text).toContain("не доказанная зависимость от обычного поставщика");
expect(candidate.reply_text).not.toContain("крупнейший подтвержденный поставщик/получатель исходящих платежей: СБЕРБАНК");
expect(candidate.reply_text).not.toContain("операционное нетто");
});
it("uses a compact direct answer for business-overview top year questions", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -114,13 +285,15 @@ describe("assistant MCP discovery response candidate", () => {
})
);
expect(candidate.reply_text).toContain("в доступном проверенном MCP-срезе");
expect(candidate.reply_text).toContain("в доступном проверенном срезе 1С");
expect(candidate.reply_text).toContain("лидирует 2015");
expect(candidate.reply_text).toContain("2015");
expect(candidate.reply_text).toContain("136 723 459,73 руб.");
expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности");
expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль");
expect(candidate.reply_text).toContain("лимит выборки MCP");
expect(candidate.reply_text).toContain("проверка достигла лимита строк");
expect(candidate.reply_text).not.toContain("лимит выборки MCP");
expect(candidate.reply_text).not.toContain("MCP-срез");
expect(candidate.reply_text).not.toContain("Что подтверждено:");
expect(candidate.reply_text).not.toContain("Профиль операционной активности");
});
@ -197,6 +370,76 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("Складской срез");
});
it("does not present bank-like incoming leaders as ordinary client revenue", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
data_need_graph: {
business_fact_family: "business_overview",
ranking_need: null,
reason_codes: [
"data_need_graph_family_business_overview",
"data_need_graph_business_overview_direct_money_answer"
]
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "business_overview_route_template_v1",
derived_business_overview: {
period_scope: "2020",
incoming_customer_revenue: {
total_amount_human_ru: "24 885 659,31 руб.",
coverage_limited_by_probe_limit: false
},
outgoing_supplier_payout: {
total_amount_human_ru: "19 568 878,06 руб.",
coverage_limited_by_probe_limit: false
},
net_amount_human_ru: "5 316 781,25 руб.",
net_direction: "net_incoming",
top_customers: [
{
axis_value: "СБЕРБАНК, ПАО",
total_amount_human_ru: "12 792 194,31 руб.",
counterparty_role_hint: "bank_or_financial_institution"
},
{
axis_value: "Группа СВК",
total_amount_human_ru: "12 093 465 руб.",
counterparty_role_hint: "ordinary_counterparty"
}
],
top_suppliers: [],
yearly_breakdown: []
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Ограниченный бизнес-обзор.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
expect(firstLine).toContain("крупнейший входящий денежный источник: СБЕРБАНК, ПАО");
expect(firstLine).toContain("не называю это клиентской выручкой");
expect(firstLine).toContain("крупнейший небанковский входящий контрагент: Группа СВК");
expect(candidate.reply_text).not.toContain("крупнейший источник входящих денег: СБЕРБАНК");
expect(candidate.reply_text).not.toContain("Самый крупный подтвержденный клиент");
});
it("mentions separate counterparty scope in company plus counterparty business summaries", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
@ -607,7 +850,7 @@ describe("assistant MCP discovery response candidate", () => {
requires_user_clarification: false,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "По данным 1С найдены строки исходящих платежей/списаний; сумму можно называть только в рамках проверенного периода и найденных строк.",
headline: "По данным 1С найдены строки исходящих платежей/списаний; сумму можно называть только в рамках проверенного периода и найденных строк.",
confirmed_lines: ["1C supplier-payout rows were found for counterparty SVK"],
inference_lines: [
"Requested period coverage was recovered through monthly 1C value-flow probes after the broad probe hit the row limit"
@ -661,7 +904,7 @@ describe("assistant MCP discovery response candidate", () => {
requires_user_clarification: false,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "По данным 1С найдены строки движений; ответ ограничен проверенным периодом и найденными строками.",
headline: "По данным 1С найдены строки движений; ответ ограничен проверенным периодом и найденными строками.",
confirmed_lines: ["1C movement rows were found for counterparty SVK"],
inference_lines: ["Counterparty movement evidence is limited to confirmed 1C movement rows in the checked scope"],
unknown_lines: ["Full movement history outside the checked period is not proven by this MCP discovery pilot"],
@ -672,9 +915,9 @@ describe("assistant MCP discovery response candidate", () => {
})
);
expect(candidate.reply_text).toContain("Р1С найдены строки движений по контрагенту SVK.");
expect(candidate.reply_text).toContain("Срез движений ограничен только подтвержденными строками движений");
expect(candidate.reply_text).toContain("Полный исторический срез движений вне проверенного периода этим поиском не подтвержден.");
expect(candidate.reply_text).toContain("В 1С найдены строки движений по контрагенту SVK.");
expect(candidate.reply_text).toContain("Срез движений ограничен только подтвержденными строками движений");
expect(candidate.reply_text).toContain("Полный исторический срез движений вне проверенного периода этим поиском не подтвержден.");
expect(candidate.reply_text).not.toContain("1C movement rows were found");
});
@ -841,9 +1084,8 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).toContain(
"\u0412 1\u0421 \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u043d\u044b \u0432\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0438 \u0438\u0441\u0445\u043e\u0434\u044f\u0449\u0438\u0435 \u0434\u0435\u043d\u0435\u0436\u043d\u044b\u0435 \u0441\u0442\u0440\u043e\u043a\u0438 \u0432 \u0437\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u043e\u043c \u0441\u0440\u0435\u0437\u0435"
);
expect(candidate.reply_text).toContain(
"\u0417\u0430\u043f\u0440\u043e\u0448\u0435\u043d\u043d\u044b\u0439 \u043f\u0435\u0440\u0438\u043e\u0434 \u0443\u043f\u0435\u0440\u0441\u044f \u0432 \u043b\u0438\u043c\u0438\u0442 \u0441\u0442\u0440\u043e\u043a MCP"
);
expect(candidate.reply_text).toContain("Запрошенный период достиг лимита строк");
expect(candidate.reply_text).not.toContain("уперся в лимит строк MCP");
expect(candidate.reply_text).not.toContain(
"\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0441\u043a\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0443\u0440\u0443"
);

View File

@ -621,7 +621,7 @@ describe("assistant MCP discovery response policy", () => {
answer_mode: "confirmed_with_bounded_inference",
headline: "Рейтинг по контрагентам построен по подтвержденным строкам 1С.",
confirmed_lines: [
"Больше всего денег принёс контрагент СБЕРБАНК, ПАО по организации ООО Альтернатива Плюс за период 2020: 12 792 194,31 руб. по 9 строкам с суммой."
"Крупнейший входящий денежный источник — СБЕРБАНК, ПАО по организации ООО Альтернатива Плюс за период 2020: 12 792 194,31 руб. по 9 строкам с суммой. Это банк/финансовая организация; без назначения платежа или договора не считаю это обычным клиентом или выручкой."
],
inference_lines: [
"Рейтинг по контрагентам по организации ООО Альтернатива Плюс за период 2020 рассчитан только по подтвержденным строкам 1С."
@ -639,6 +639,7 @@ describe("assistant MCP discovery response policy", () => {
expect(result.decision).toBe("apply_candidate");
expect(result.reply_text).toContain("ООО Альтернатива Плюс");
expect(result.reply_text).toContain("2020");
expect(result.reply_text).toContain("не считаю это обычным клиентом");
expect(result.reason_codes).toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_aligned_factual_address_reply");
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_keep_factual_address_continuation_target");

View File

@ -37,6 +37,22 @@ function buildBidirectionalDeps(
};
}
function buildSequentialDeps(results: Array<{ rows: Array<Record<string, unknown>>; error?: string | null }>) {
const executeAddressMcpQuery = vi.fn(async () => {
const next = results.shift() ?? { rows: [] };
const rows = next.rows;
const error = next.error ?? null;
return {
fetched_rows: rows.length,
matched_rows: error ? 0 : rows.length,
raw_rows: rows,
rows: error ? [] : rows,
error
};
});
return { executeAddressMcpQuery };
}
describe("assistant MCP discovery runtime bridge", () => {
it("composes planner, pilot executor, and answer draft without wiring the hot runtime", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
@ -150,8 +166,24 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(result.loop_state.catalog_chain_template_matches[0]).toBe("value_flow_ranking");
expect(result.loop_state.catalog_chain_template_alignment.alignment_status).toBe("selected_matches_top");
expect(result.loop_state.catalog_chain_template_alignment.selected_chain_matches_top).toBe(true);
expect(result.route_candidate).toMatchObject({
schema_version: "assistant_mcp_route_candidate_v1",
candidate_status: "needs_user_scope",
selected_chain_id: "value_flow_ranking",
nearest_catalog_chain_template: "value_flow_ranking",
catalog_alignment_status: "selected_matches_top",
business_fact_family: "value_flow",
action_family: "turnover",
executable_now: false
});
expect(result.route_candidate.missing_axes).toContain("organization");
expect(result.route_candidate.provided_axes).toContain("aggregate_axis");
expect(result.route_candidate.recommended_next_action).toBe(
"Ask the user for the missing scope axes before MCP execution."
);
expect(result.reason_codes).toContain("planner_selected_chain_matches_catalog_top");
expect(result.reason_codes).toContain("runtime_bridge_loop_state_awaiting_clarification");
expect(result.reason_codes).toContain("runtime_bridge_route_candidate_needs_user_scope");
});
it("produces a bounded ranked value-flow answer when period and organization are known", async () => {
@ -192,6 +224,12 @@ describe("assistant MCP discovery runtime bridge", () => {
axis_value: "СВК-А",
total_amount: 2100
});
expect(result.route_candidate).toMatchObject({
candidate_status: "ready_for_reviewed_execution",
selected_chain_id: "value_flow_ranking",
executable_now: true,
enablement_reason: null
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("СВК-А");
});
@ -437,6 +475,288 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(userFacing).not.toContain("MCP discovery pilot");
});
it("marks exact business-overview proof gaps as route enablement instead of reviewed execution", async () => {
const deps = buildSequentialDeps([
{ rows: [{ Period: "2020-01-15T00:00:00", Amount: 120000, Counterparty: "Client A" }] },
{ rows: [{ Period: "2020-01-20T00:00:00", Amount: 50000, Counterparty: "Supplier A" }] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 250000, Quantity: 10, Item: "Widget A" },
{ Period: "2020-12-31T00:00:00", Amount: 50000, Quantity: 5, Item: "Widget B" }
]
},
{
rows: [
{ Period: "2020-01-10T00:00:00", Amount: 200000, Quantity: 8, Item: "Widget A" },
{ Period: "2020-11-01T00:00:00", Amount: 50000, Quantity: 2, Item: "Widget B" }
]
},
{
rows: [
{ Period: "2020-01-15T00:00:00", Registrator: "Purchase 1" },
{ Period: "2020-12-15T00:00:00", Registrator: "Purchase 2" }
]
},
{
rows: [
{ Period: "2020-03-01T00:00:00", Amount: 600000, Item: "Widget A", Counterparty: "Client A", AccountKt: "41.01" },
{ Period: "2020-02-01T00:00:00", Amount: 240000, Item: "Widget A", Counterparty: "Supplier A", AccountDt: "41.01" }
]
}
]);
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "business_overview",
action_family: "inventory_reserve_boundary",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "bounded_inference",
clarification_gaps: [],
decomposition_candidates: [
"collect_scoped_movements",
"aggregate_checked_amounts",
"fetch_supporting_documents",
"probe_coverage",
"explain_evidence_basis"
],
forbidden_overclaim_flags: [
"no_raw_model_claims",
"no_unchecked_fact_totals",
"no_unchecked_business_health_claim"
],
reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"]
},
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "inventory_reserve_boundary",
explicit_organization_scope: "OOO Alternative Plus",
explicit_date_scope: "2020"
},
deps
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.route_candidate).toMatchObject({
candidate_status: "needs_route_enablement",
selected_chain_id: "business_overview",
business_fact_family: "business_overview",
action_family: "inventory_reserve_boundary",
executable_now: false
});
expect(result.route_candidate.enablement_reason).toContain("inventory_reserve_liquidation_quality");
expect(result.route_candidate.enablement_reason).toContain(
"reviewed_inventory_quality_route_with_reserves_writeoffs_obsolescence_and_liquidation_value"
);
expect(result.route_candidate.forbidden_overclaim_flags).toContain(
"confirmed_obsolete_stock_reserve_writeoff_or_liquidation_value"
);
expect(result.reason_codes).toContain("runtime_bridge_route_candidate_needs_route_enablement");
});
it("promotes profit-margin boundary when accounting 90/91/99 proof is available", async () => {
const deps = buildSequentialDeps([
{ rows: [{ Period: "2020-01-15T00:00:00", Amount: 12012833.72, Counterparty: "Client A" }] },
{ rows: [{ Period: "2020-01-20T00:00:00", Amount: 6430415.34, Counterparty: "Supplier A" }] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Registrator: "ACC90_REVENUE_KT", Amount: 12012833.72 },
{ Period: "2020-12-31T00:00:00", Registrator: "ACC90_COST_DT", Amount: 6430415.34 },
{ Period: "2020-12-31T00:00:00", Registrator: "ACC90_SELLING_DT", Amount: 1715450.75 },
{ Period: "2020-12-31T00:00:00", Registrator: "ACC90_RESULT_TO_99_PROFIT", Amount: 3053486.79 },
{ Period: "2020-12-31T00:00:00", Registrator: "ACC90_RESULT_FROM_99_LOSS", Amount: 1188658.09 },
{ Period: "2020-12-31T00:00:00", Registrator: "ACC91_RESULT_FROM_99_LOSS", Amount: 9001644.55 },
{ Period: "2020-12-31T00:00:00", Registrator: "ACC84_TO99_LOSS_TRANSFER", Amount: 7136815.85 }
]
},
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [{ Period: "2020-01-15T00:00:00", Registrator: "Purchase 1" }] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] }
]);
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "business_overview",
action_family: "profit_margin_boundary",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "bounded_inference",
clarification_gaps: [],
decomposition_candidates: [
"collect_scoped_movements",
"aggregate_checked_amounts",
"fetch_supporting_documents",
"probe_coverage",
"explain_evidence_basis"
],
forbidden_overclaim_flags: [
"no_raw_model_claims",
"no_unchecked_fact_totals",
"no_unchecked_business_health_claim"
],
reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"]
},
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
explicit_organization_scope: "OOO Alternative Plus",
explicit_date_scope: "2020"
},
deps
});
const overview = result.pilot.derived_business_overview;
const missingFamilies = overview?.missing_proof_families.map((item) => item.family) ?? [];
const userFacing = [
result.answer_draft.headline,
...result.answer_draft.confirmed_lines,
...result.answer_draft.inference_lines,
...result.answer_draft.unknown_lines,
...result.answer_draft.limitation_lines,
result.answer_draft.next_step_line ?? ""
].join("\n");
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.route_candidate).toMatchObject({
candidate_status: "ready_for_reviewed_execution",
selected_chain_id: "business_overview",
business_fact_family: "business_overview",
action_family: "profit_margin_boundary",
executable_now: true
});
expect(overview?.accounting_financial_result).toMatchObject({
period_scope: "2020",
final_result_amount: -7136815.85,
final_result_direction: "loss",
final_transfer_basis: "account_99_to_84_period_close",
period_close_rows_with_amount: 4
});
expect(overview?.accounting_financial_result?.net_margin_to_revenue_pct).toBe(-59.41);
expect(missingFamilies).not.toContain("accounting_profit_margin");
expect(userFacing).toContain("90/91/99");
expect(userFacing).toContain("90.01");
expect(userFacing).toContain("7 136 815,85");
expect(userFacing).not.toContain("operating-flow/trading-margin proxy");
expect(result.reason_codes).toContain("pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows");
expect(result.reason_codes).toContain("answer_contains_business_overview_accounting_financial_result");
expect(result.reason_codes).toContain("runtime_bridge_route_candidate_ready_for_reviewed_execution");
});
it("promotes debt due-date boundary after reviewed payment-term route returns a checked negative", async () => {
const deps = buildSequentialDeps([
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{
rows: [
{ Period: "2020-12-31T00:00:00", Amount: 100000, Counterparty: "Клиент А", Contract: "Договор А" }
]
},
{
rows: [
{
Period: "2020-12-31T00:00:00",
Amount: 100000,
Counterparty: "Клиент А",
Contract: "Договор А",
ДокументРасчетов: "Реализация товаров от 10.03.2020",
УстановленСрокОплаты: false,
СрокОплаты: 0
}
]
},
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] },
{ rows: [] }
]);
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "business_overview",
action_family: "debt_due_date_boundary",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: null,
proof_expectation: "due_date_aging",
clarification_gaps: [],
decomposition_candidates: [
"collect_scoped_movements",
"aggregate_checked_amounts",
"fetch_supporting_documents",
"probe_coverage",
"explain_evidence_basis"
],
forbidden_overclaim_flags: [
"no_raw_model_claims",
"no_unchecked_overdue_claim"
],
reason_codes: ["data_need_graph_built", "data_need_graph_family_business_overview"]
},
turnMeaning: {
asked_domain_family: "business_overview",
asked_action_family: "debt_due_date_boundary",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020"
},
deps
});
const userFacing = [
result.answer_draft.headline,
...result.answer_draft.confirmed_lines,
...result.answer_draft.unknown_lines
].join("\n");
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.route_candidate).toMatchObject({
candidate_status: "ready_for_reviewed_execution",
selected_chain_id: "business_overview",
business_fact_family: "business_overview",
action_family: "debt_due_date_boundary",
executable_now: true
});
expect(result.pilot.derived_business_overview?.debt_due_date_aging).toMatchObject({
evidence_status: "no_payment_terms_configured",
rows_without_payment_terms: 1,
overdue_rows: 0
});
expect(userFacing).toContain("срок оплаты не установлен");
expect(userFacing).toContain("Подтвержденной просрочки");
expect(result.reason_codes).toContain("runtime_bridge_route_candidate_ready_for_reviewed_execution");
expect(result.reason_codes).toContain("answer_contains_business_overview_debt_due_date_aging_no_payment_terms_configured");
});
it("bridges selected-item inventory provenance templates through exact document evidence", async () => {
const deps = buildDeps([
{
@ -711,5 +1031,15 @@ describe("assistant MCP discovery runtime bridge", () => {
});
expect(result.loop_state.pending_axes).toEqual(["organization", "period"]);
expect(result.loop_state.explicit_entity_candidates).toEqual([]);
expect(result.route_candidate).toMatchObject({
candidate_status: "needs_user_scope",
selected_chain_id: "movement_evidence",
business_fact_family: "movement_evidence",
action_family: "list_movements",
proof_expectation: "clarification_required",
executable_now: false
});
expect(result.route_candidate.missing_axes).toEqual(["organization", "period"]);
expect(result.route_candidate.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals");
});
});

View File

@ -1505,6 +1505,36 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
});
it("forces discovery over an exact ranking intent when organization scope is missing", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u043a\u0430\u043a\u043e\u0439 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442 \u043f\u0440\u0438\u043d\u0435\u0441 \u0431\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u0437\u0430 2020 \u0433\u043e\u0434?",
assistantTurnMeaning: {
asked_domain_family: "counterparty",
asked_action_family: "counterparty_value_or_turnover",
explicit_intent_candidate: "customer_revenue_and_payments",
explicit_entity_candidates: [{ type: "counterparty", value: "\u0433\u043e\u0434\u0443", source: "current_turn_loose_entity_tail" }],
explicit_date_scope: "2020",
stale_replay_forbidden: false
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("counterparty value-flow evidence");
expect(result.data_need_graph?.business_fact_family).toBe("value_flow");
expect(result.data_need_graph?.ranking_need).toBe("top_desc");
expect(result.data_need_graph?.clarification_gaps).toEqual(["organization"]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "counterparty_value_or_turnover",
explicit_date_scope: "2020",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
});
it("routes broad business evaluation into business overview discovery without metadata drift", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
@ -1536,6 +1566,46 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
});
it("keeps inventory reserve questions as a bounded boundary check instead of generic overview", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u041c\u043e\u0436\u043d\u043e \u043b\u0438 \u043f\u043e \u044d\u0442\u0438\u043c \u0434\u0430\u043d\u043d\u044b\u043c \u0442\u043e\u0447\u043d\u043e \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u0440\u0435\u0437\u0435\u0440\u0432 \u043f\u043e\u0434 \u043d\u0435\u043b\u0438\u043a\u0432\u0438\u0434\u044b \u043d\u0430 \u0441\u043a\u043b\u0430\u0434\u0435?",
assistantTurnMeaning: {
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.data_need_graph?.subject_candidates).toEqual([]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "inventory_reserve_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "inventory_reserve_liquidation_boundary",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate");
expect(result.reason_codes).toContain("mcp_discovery_data_need_graph_built");
});
it("lets raw business-overview wording override stale exact turnover meaning", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
@ -2170,10 +2240,10 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
asked_action_family: "profit_margin_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "broad_business_evaluation",
unsupported_but_understood_family: "profit_margin_boundary",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate");
@ -2181,6 +2251,23 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
});
it("routes legal-entity profit and margin wording to the missing P&L proof-family boundary", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "по ООО Альтернатива Плюс за 2020 можно точно сказать чистую прибыль и маржу?"
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
explicit_organization_scope: "ООО Альтернатива Плюс",
explicit_date_scope: "2020",
unsupported_but_understood_family: "profit_margin_boundary"
});
});
it("routes organization-level overdue debt wording to business overview instead of exact receivables recipes", () => {
const orgName = "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
@ -2201,10 +2288,10 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
asked_action_family: "debt_due_date_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "broad_business_evaluation",
unsupported_but_understood_family: "debt_due_date_boundary",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate");
@ -2232,10 +2319,10 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
asked_action_family: "inventory_reserve_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "broad_business_evaluation",
unsupported_but_understood_family: "inventory_reserve_liquidation_boundary",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate");
@ -2263,10 +2350,10 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
asked_action_family: "vendor_risk_procurement_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "broad_business_evaluation",
unsupported_but_understood_family: "vendor_risk_procurement_boundary",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_broad_business_evaluation_route_candidate");
@ -2708,6 +2795,42 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.data_need_graph?.clarification_gaps).toEqual(["period"]);
});
it("routes explicit VAT movement wording to metadata-scoped movement evidence over exact VAT aggregate", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u0432\u0438\u0436\u0435\u043d\u0438\u044f \u043f\u043e \u041d\u0414\u0421 \u0437\u0430 2020 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
assistantTurnMeaning: {
explicit_intent_candidate: "vat_liability_confirmed_for_tax_period"
},
predecomposeContract: {
entities: { organization: orgName },
period: {
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("movement evidence");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "movements",
asked_action_family: "list_movements",
metadata_scope_hint: "\u041d\u0414\u0421",
subject_resolution_optional: true,
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "movement_evidence",
stale_replay_forbidden: true
});
expect(result.data_need_graph?.business_fact_family).toBe("movement_evidence");
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
expect(result.reason_codes).toContain("mcp_discovery_vat_movement_evidence_signal_detected");
});
it("pivots a metadata-scoped subjectless movement retrieval into documents without inventing a counterparty", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
@ -2867,6 +2990,62 @@ describe("assistant MCP discovery turn input adapter", () => {
});
});
it("prefers semantic organization over noisy raw organization tail for debt due-date overview", () => {
const orgName = "ООО Альтернатива Плюс";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "по ООО Альтернатива Плюс на конец 2020 можно точно понять, какая дебиторка просрочена?",
predecomposeContract: {
entities: { counterparty: orgName, organization: orgName },
period: { period_from: "2020-01-01", period_to: "2020-12-31" }
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "debt_due_date_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "debt_due_date_boundary",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
});
it("routes debt due-date boundary follow-up through the business overview lane", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0442\u043e \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0441\u0440\u043e\u0447\u043a\u0443 \u0434\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0435\u043b\u044c\u0437\u044f, \u043a\u043e\u0440\u043e\u0442\u043a\u043e \u043f\u043e\u0447\u0435\u043c\u0443?",
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "debt_due_date_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "debt_due_date_boundary",
stale_replay_forbidden: true
});
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
expect(result.reason_codes).toContain("mcp_discovery_business_overview_debt_due_date_followup_boundary");
});
it("lets raw metadata scope override stale document evidence subject on topic switch", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
@ -2946,6 +3125,44 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
});
it("keeps short profit/loss follow-up on the accounting profit-margin boundary", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0430 \u044d\u0442\u043e \u043f\u0440\u0438\u0431\u044b\u043b\u044c \u0438\u043b\u0438 \u0443\u0431\u044b\u0442\u043e\u043a, \u043a\u043e\u0440\u043e\u0442\u043a\u043e?",
assistantTurnMeaning: {
asked_domain_family: "unknown",
asked_action_family: "unknown"
},
followupContext: {
previous_discovery_pilot_scope: "business_overview_route_template_v1",
previous_filters: {
organization: orgName,
period_from: "2020-01-01",
period_to: "2020-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation");
expect(result.data_need_graph?.business_fact_family).toBe("business_overview");
expect(result.data_need_graph?.subject_candidates).toEqual([]);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "business_overview",
asked_action_family: "profit_margin_boundary",
explicit_organization_scope: orgName,
explicit_date_scope: "2020",
unsupported_but_understood_family: "profit_margin_boundary",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).toContain("mcp_discovery_business_overview_continuation_from_followup_context");
expect(result.reason_codes).toContain("mcp_discovery_business_overview_profit_margin_followup_boundary");
});
it("keeps detailed money-breakdown follow-up in business overview without pseudo counterparty anchors", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";

View File

@ -557,6 +557,45 @@ describe("assistantRoutePolicy", () => {
expect(decision.orchestrationContract?.followup_context_detected).toBe(false);
});
it("keeps plain organization clarification in address lane for pending route-candidate scope", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const policy = buildPolicy({
resolveAddressToolGateDecision: undefined
});
const decision = policy.resolveAssistantOrchestrationDecision({
rawUserMessage: orgName,
effectiveAddressUserMessage: orgName,
followupContext: {
previous_intent: "customer_revenue_and_payments",
target_intent: "customer_revenue_and_payments",
previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1",
previous_discovery_loop_status: "awaiting_clarification",
previous_discovery_loop_selected_chain_id: "value_flow_ranking",
previous_discovery_loop_pending_axes: ["organization"],
previous_discovery_loop_asked_domain_family: "counterparty_value",
previous_discovery_loop_asked_action_family: "turnover",
previous_discovery_loop_unsupported_family: "counterparty_value_or_turnover"
},
llmPreDecomposeMeta: {
predecomposeContract: {
mode: "unsupported",
intent: "unknown",
entities: { organization: orgName },
semantics: { anchor_kind: "organization", anchor_value: orgName }
}
},
useMock: false
});
expect(decision.runAddressLane).toBe(true);
expect(decision.toolGateReason).toBe("followup_context_detected");
expect(decision.livingMode).toBe("address_data");
expect(decision.livingReason).toBe("address_lane_triggered");
expect(decision.orchestrationContract?.hard_meta_mode).toBeNull();
});
it("does not turn short entity follow-up into organization switch just because scope already has an active company", () => {
const policy = buildPolicy({
resolveAddressToolGateDecision: undefined,

View File

@ -1206,6 +1206,67 @@ describe("assistantTransitionPolicy", () => {
period_to: "2020-12-31"
});
});
it("carries business overview boundary context through short capability-shaped follow-up", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => null,
shouldHandleAsAssistantCapabilityMetaQuery: () => true,
hasDataRetrievalRequestSignal: () => false,
hasAddressFollowupContextSignal: () => false
});
const organization = "ООО Альтернатива Плюс";
const carryover = policy.resolveAddressFollowupCarryoverContext(
"то есть просрочку доказать нельзя, коротко почему?",
[
{
role: "assistant",
text: "На 2020-12-31 подтвержденной просрочки нет: в договорах срок оплаты не установлен.",
debug: {
execution_lane: "living_chat",
mcp_discovery_response_applied: true,
assistant_active_organization: organization,
assistant_mcp_discovery_entry_point_v1: {
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
entry_status: "bridge_executed",
turn_input: {
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "debt_due_date_boundary",
unsupported_but_understood_family: "debt_due_date_boundary",
explicit_organization_scope: organization,
explicit_date_scope: "2020"
}
},
bridge: {
bridge_status: "answer_draft_ready",
business_fact_answer_allowed: true,
pilot: {
pilot_scope: "business_overview_route_template_v1"
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference"
}
}
}
}
}
],
null,
null,
null
);
expect(carryover?.followupSelectionMode).toBe("carry_previous_intent");
expect(carryover?.followupContext?.previous_discovery_pilot_scope).toBe(
"business_overview_route_template_v1"
);
expect(carryover?.followupContext?.previous_filters).toMatchObject({
organization,
period_from: "2020-01-01",
period_to: "2020-12-31"
});
});
it("carries resolved entity candidates from grounded entity-resolution discovery into followup context", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => null,
@ -1506,6 +1567,67 @@ describe("assistantTransitionPolicy", () => {
);
});
it("treats a plain organization reply as continuation of a pending route-candidate organization scope", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const policy = buildPolicy({
findLastAddressAssistantItem: () => ({
role: "assistant",
text: "\u041d\u0443\u0436\u043d\u043e \u0443\u0442\u043e\u0447\u043d\u0438\u0442\u044c \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044e.",
debug: {
execution_lane: "living_chat",
detected_intent: "customer_revenue_and_payments",
mcp_discovery_response_applied: true,
assistant_mcp_discovery_entry_point_v1: {
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
entry_status: "bridge_executed",
bridge: {
bridge_status: "needs_clarification",
business_fact_answer_allowed: false,
pilot: {
pilot_scope: "counterparty_value_flow_query_movements_v1"
},
loop_state: {
schema_version: "assistant_mcp_discovery_loop_state_v1",
loop_status: "awaiting_clarification",
selected_chain_id: "value_flow_ranking",
pilot_scope: "counterparty_value_flow_query_movements_v1",
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
unsupported_but_understood_family: "counterparty_value_or_turnover",
ranking_need: "top_desc",
pending_axes: ["organization"],
provided_axes: ["aggregate_axis", "amount", "coverage_target"],
explicit_date_scope: "2020"
}
}
}
}
}),
hasAddressFollowupContextSignal: () => false
});
const carryover = policy.resolveAddressFollowupCarryoverContext(
orgName,
[],
null,
{
predecomposeContract: {
mode: "unsupported",
intent: "unknown",
entities: { organization: orgName },
semantics: { anchor_kind: "organization", anchor_value: orgName }
}
},
null
);
expect(carryover?.followupContext?.previous_discovery_loop_status).toBe("awaiting_clarification");
expect(carryover?.followupContext?.previous_discovery_loop_selected_chain_id).toBe("value_flow_ranking");
expect(carryover?.followupContext?.previous_discovery_loop_pending_axes).toEqual(["organization"]);
expect(carryover?.followupContext?.target_intent).toBe("customer_revenue_and_payments");
});
it("carries grounded metadata downstream route hints into followup context", () => {
const policy = buildPolicy({
findLastAddressAssistantItem: () => null,

View File

@ -114,6 +114,40 @@ describe("counterparty analytics reply builders", () => {
expect(reply.text).not.toContain("Топ-");
});
it("does not mistake a year period in top-counterparty wording for a top-year question", () => {
const reply = composeFactualReply(
"customer_revenue_and_payments",
[
{
period: "2020-03-01T00:00:00Z",
registrator: "Поступление 1",
account_dt: "",
account_kt: "",
amount: 500,
analytics: ["Клиент А", "Договор А-1"]
},
{
period: "2020-03-02T00:00:00Z",
registrator: "Поступление 2",
account_dt: "",
account_kt: "",
amount: 1200,
analytics: ["Клиент Б", "Договор Б-1"]
}
],
{
userMessage: "какой контрагент принес больше всего денег за 2020 год?",
periodFrom: "2020-01-01",
periodTo: "2020-12-31"
}
);
expect(reply.responseType).toBe("FACTUAL_LIST");
expect(reply.text).toContain("Клиент Б");
expect(reply.text).not.toContain("Клиент А");
expect(reply.text).not.toContain("1. 2020 |");
});
it("explains organization activity age as 1C activity rather than legal age", () => {
const reply = composeFactualReply(
"counterparty_activity_lifecycle",