Усилить answer contract и агентный аудит для phase105

This commit is contained in:
2026-05-21 09:00:08 +03:00
parent 9c86407937
commit bbc257fd6c
34 changed files with 1533 additions and 184 deletions
@@ -365,6 +365,63 @@ function bankOperationEvidenceLine(rows, preferredDirection = null) {
}
return `Основание 1С: ${parts.join("; ")}.`;
}
function classifyBankOperationSemanticBucket(row) {
const text = [
row.registrator,
row.operation_kind,
row.payment_purpose,
row.contract,
row.comment
]
.map((item) => String(item ?? "").toLowerCase())
.join(" ");
if (/(?:комисс|тариф|эквайр|обслуживан)/iu.test(text)) {
return "commission";
}
if (/(?:депозит|кредит|займ|овердрафт|процент|ссуд)/iu.test(text)) {
return "deposit_or_credit";
}
if (/(?:налог|ндс|взнос|бюджет|фнс|пфр|страхов)/iu.test(text)) {
return "tax_or_budget";
}
if (/(?:возврат|перевод|перечислен|переброс|пополн|инкасс|перенос)/iu.test(text)) {
return "transfer_or_return";
}
return "other";
}
function bankOperationSemanticBucketLabel(bucket) {
if (bucket === "commission") {
return "комиссии и банковое обслуживание";
}
if (bucket === "deposit_or_credit") {
return "депозиты, кредиты или проценты";
}
if (bucket === "tax_or_budget") {
return "налоги и бюджетные платежи";
}
if (bucket === "transfer_or_return") {
return "переводы, возвраты или перебросы";
}
return "прочие банковские операции";
}
function summarizeBankOperationSemantics(rows) {
if (rows.length === 0) {
return null;
}
const counts = new Map();
for (const row of rows) {
const bucket = classifyBankOperationSemanticBucket(row);
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
}
const ranked = Array.from(counts.entries())
.sort((left, right) => right[1] - left[1])
.slice(0, 3);
if (ranked.length === 0) {
return null;
}
const parts = ranked.map(([bucket, count]) => `${bankOperationSemanticBucketLabel(bucket)} — ${count}`);
return `По смыслу это скорее финансовый/банковский контур: ${parts.join("; ")}.`;
}
function bankRoleBoundaryLine(userMessage, rows) {
const incomingBoundary = hasBankIncomingRoleBoundaryQuestion(userMessage);
const outgoingBoundary = hasBankOutgoingRoleBoundaryQuestion(userMessage);
@@ -3931,13 +3988,31 @@ function composeFactualReplyBody(intent, rows, options = {}) {
.filter((item) => Boolean(item)));
const counterparty = resolvePreferredCounterpartyDisplayLabel(options.counterpartyHint, rowCounterparties);
const roleBoundary = bankRoleBoundaryLine(options.userMessage, rows);
const visibleRows = rows.slice(0, Math.min(rows.length, 5));
const visibleRows = [...rows]
.sort((left, right) => Math.abs(right.amount ?? 0) - Math.abs(left.amount ?? 0) ||
(String(right.period ?? "").localeCompare(String(left.period ?? ""), "ru")))
.slice(0, Math.min(rows.length, 5));
const semanticSummary = summarizeBankOperationSemantics(rows);
const compactEvidenceRows = visibleRows.map((row, index) => {
const direction = bankOperationDirectionLabel(bankOperationDirection(row));
const amount = formatMoneyRub(row.amount ?? 0);
const period = row.period ? formatDateRu(row.period) : "дата не указана";
const operationKind = String(row.operation_kind ?? "").trim();
const paymentPurpose = String(row.payment_purpose ?? "").trim();
const detail = operationKind || paymentPurpose
? ` | ${[operationKind, paymentPurpose].filter(Boolean).join("; ")}`
: "";
return `${index + 1}. ${period} | ${direction} | ${amount}${detail}`;
});
const lines = [
`Коротко: найдено банковских операций${counterparty ? ` по ${counterparty}` : " по контрагенту"} — ${rows.length}.`,
summarizeBankOperationDirections(rows),
roleBoundary ?? "Показываю подтвержденные банковские операции из текущего среза.",
bankOperationEvidenceLine(rows, preferredBankEvidenceDirection(options.userMessage)),
...formatTopRows(visibleRows, visibleRows.length)
...(semanticSummary ? [semanticSummary] : []),
"Примеры строк 1С:",
...compactEvidenceRows,
"Следующий шаг: могу отдельно разложить назначения платежа, договоры или отделить банковский контур от клиентского/поставщицкого."
];
if (rows.length > visibleRows.length) {
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length}; полный список остается в подтвержденном срезе.`);
@@ -30,6 +30,23 @@ function findFocusedCounterpartyValuePoint(profileRows, counterpartyHint, deps)
}
return profileRows.length === 1 ? profileRows[0] : null;
}
function hasProfitAmbiguityCue(normalizedQuestion) {
return /(?:заработ|прибыл|прибыль|доход|выручк)/iu.test(normalizedQuestion);
}
function buildCashflowBoundaryLine(isSupplier) {
return isSupplier
? "Граница ответа: это подтвержденный денежный поток по поставщику, а не итоговая задолженность."
: "Граница ответа: это подтвержденный денежный поток по поступлениям, а не чистая прибыль.";
}
function buildCashflowNextStepLine(isSupplier, normalizedQuestion) {
if (isSupplier) {
return "Следующий шаг: могу отдельно показать остаток долга, просрочку или расшифровку по документам.";
}
if (hasProfitAmbiguityCue(normalizedQuestion)) {
return "Следующий шаг: могу отдельно проверить чистую прибыль по закрытию 90/91/99.";
}
return "Следующий шаг: могу разложить поток по месяцам, документам или контрагентам.";
}
function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
if (intent === "counterparty_population_and_roles") {
const rowsByMarker = groupRowsByMarker(rows);
@@ -396,6 +413,8 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
/(?:клиент|заказчик|покупател|контрагент|customer|client|counterparty|buyer)/iu.test(normalizedQuestion);
const semanticSingleBestCounterparty = focus === "top_by_total" && hasSingleBestCounterpartyCue && !asksExplicitRankingList;
const effectiveLimit = asksSingleBestCounterparty || semanticSingleBestCounterparty ? 1 : limit;
const cashflowBoundaryLine = buildCashflowBoundaryLine(isSupplier);
const cashflowNextStepLine = buildCashflowNextStepLine(isSupplier, normalizedQuestion);
const byCounterparty = new Map();
const byYear = new Map();
const deals = [];
@@ -490,10 +509,12 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
? `за период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)}`
: "за доступное время";
const directAnswerLine = isSupplier
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям. Это денежный поток по поставщику, а не итоговая задолженность.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям. Это денежный поток от клиента, а не чистая прибыль.`;
? `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным исходящим операциям.`
: `Оборот по ${focusedCounterparty.name} ${periodLabel}: ${deps.formatMoneyRub(focusedCounterparty.total)} по ${focusedCounterparty.ops} подтвержденным входящим операциям.`;
const summaryLines = [
directAnswerLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Контрагент в выборке: ${focusedCounterparty.name}.`,
@@ -511,11 +532,10 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const periodLine = options.periodFrom && options.periodTo
? `За период ${deps.formatDateRu(options.periodFrom)}..${deps.formatDateRu(options.periodTo)} подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`
: `За все доступное время подтверждено ${deps.formatMoneyRub(totalFlow)} ${isSupplier ? "исходящих выплат" : "входящих поступлений"}.`;
const directAnswerLine = isSupplier
? periodLine
: `${periodLine} Это денежный поток от клиентов, а не чистая прибыль.`;
const summaryLines = [
directAnswerLine,
periodLine,
cashflowBoundaryLine,
...(cashflowNextStepLine ? [cashflowNextStepLine] : []),
"",
"Подтверждение:",
`- Операций в выборке: ${totalOperations}.`,
@@ -538,11 +558,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const strongestYear = visible[0];
const directAnswerLine = isSupplier
? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`;
: `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).`;
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (!isSupplier) {
lines.unshift(cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.unshift(cashflowNextStepLine);
}
}
lines.unshift(directAnswerLine);
lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`));
}
@@ -622,11 +648,17 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const directAnswerLine = singleCandidateOnly
? isSupplier
? `В выбранном срезе найден один поставщик: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг; сумма является денежным потоком, а не чистой прибылью.`
: `В выбранном срезе найден один клиент: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это не полноценный сравнительный рейтинг.`
: isSupplier
? `Крупнейший поставщик по подтвержденным выплатам ${rankingPeriodLabel}: ${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);
if (!isSupplier) {
lines.splice(1, 0, cashflowBoundaryLine);
if (cashflowNextStepLine) {
lines.splice(2, 0, cashflowNextStepLine);
}
}
}
lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? item.total / item.ops : 0;
@@ -87,10 +87,9 @@ function composeInventoryReply(intent, rows, options, deps) {
const positions = deps.buildInventoryOnHandAggregate(rows, asOfDate);
const uniqueItems = deps.uniqueStrings(positions.map((item) => item.item));
const uniqueWarehouses = deps.uniqueStrings(positions.map((item) => String(item.warehouse ?? "").trim()).filter((item) => item.length > 0));
const totalQuantity = positions.reduce((sum, item) => sum + item.quantity, 0);
const totalAmount = positions.reduce((sum, item) => sum + item.amount, 0);
const directAnswerLine = positions.length > 0
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций с остатком на ${deps.formatMoneyRub(totalAmount)}.`
? `На ${deps.formatDateRu(asOfDate)} на складе подтверждено ${deps.formatNumberWithDots(positions.length)} позиций на ${deps.formatMoneyRub(totalAmount)}.`
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines = [directAnswerLine];
if (positions.length > 0) {
@@ -115,11 +114,14 @@ function composeInventoryReply(intent, rows, options, deps) {
`Позиции с остатком: ${deps.formatNumberWithDots(positions.length)}.`,
`Уникальных товаров: ${deps.formatNumberWithDots(uniqueItems.length)}.`,
`Уникальных складов: ${deps.formatNumberWithDots(uniqueWarehouses.length)}.`,
`Суммарное количество: ${deps.formatNumberWithDots(totalQuantity, 3)}.`
"Общее количество не свожу в один управленческий показатель, потому что в остатках смешаны разнородные позиции."
]);
if (rows.length !== positions.length) {
lines.push(`- Проверенных строк движения: ${deps.formatNumberWithDots(rows.length)}.`);
}
if (positions.length > 0) {
lines.push("- Следующий шаг: могу раскрыть полный список, разложить остатки по складам или сравнить с другой датой.");
}
return positions.length > 0
? (0, replyContracts_1.buildFactualListReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("strong"))
: (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)("medium"));
@@ -942,6 +942,29 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
return joinBusinessReplyLines(lines);
}
if (rankingNeed) {
const explicitPeriodRankingOverview = period &&
!/(?:все\s+доступное|все\s+время|all\s+time)/iu.test(period) &&
(incomingAmount || outgoingAmount || netAmount);
if (explicitPeriodRankingOverview) {
lines.push(`Коротко: ${organizationPrefix}${period} денежная картина подтверждена по найденным строкам 1С.`);
lines.push(`Деньги: входящие ${incomingAmount ?? "0 руб."}, исходящие ${outgoingAmount ?? "0 руб."}, расчетное операционное нетто ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`);
if (customerName && customerAmount) {
lines.push(topCustomerLooksFinancial
? `Топ входящих: 1. ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}. Это финансовый/банковский контур, не считаю его клиентской выручкой без назначения платежа.${nonFinancialCustomer ? ` 2. Крупнейший небанковский входящий контрагент: ${nonFinancialCustomer}.` : ""}`
: `Крупнейший входящий контрагент: ${customerName} — ${sentenceAmount(customerAmount) ?? customerAmount}.`);
}
if (topSupplier) {
lines.push(topSupplierLooksFinancial
? `Топ исходящих: 1. ${topSupplier}. Это финансовый/банковский контур, не считаю его обычным поставщиком без назначения платежа и договора.${nonFinancialSupplier ? ` 2. Крупнейший небанковский получатель исходящих денег: ${nonFinancialSupplier}.` : ""}`
: `Крупнейший получатель исходящих денег: ${topSupplier}.`);
}
lines.push(`Вывод: по движению денег период ${netDirection}; это не чистая прибыль и не бухгалтерский финрезультат.`);
if (requestedFinancialBoundaryLine) {
lines.push(requestedFinancialBoundaryLine);
}
lines.push("Следующий шаг: могу отдельно посчитать чистую прибыль через закрытие 90/91/99 или разложить этот период по контрагентам.");
return joinBusinessReplyLines(lines);
}
const incomingLeader = strongestIncomingYear(overview);
const canRankYearlyNet = !limitLine;
const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null;
@@ -1139,13 +1139,38 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawEffectiveText = toNonEmptyString(input.effectiveMessage);
const repairedUserText = rawUserText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawUserText) : null;
const repairedEffectiveText = rawEffectiveText ? (0, addressTextRepair_1.repairAddressMojibakeText)(rawEffectiveText) : null;
const rawUserSignalSourceText = repairedUserText ?? rawUserText ?? "";
const rawSignalSourceText = `${repairedUserText ?? rawUserText ?? ""} ${repairedEffectiveText ?? rawEffectiveText ?? ""}`.trim();
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
const rawUserEntitySourceText = rawUserSignalSourceText || rawEntitySourceText;
const rawUserTextOnly = compactLower(rawUserSignalSourceText);
const rawAssistantEntityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates);
const rawUserPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawUserTextOnly);
const rawUserLifecyclePivotTextSignal = !rawUserPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawUserTextOnly);
const rawUserBidirectionalValueFlowPivotTextSignal = !rawUserPrimaryBusinessOverviewSignal &&
!rawUserLifecyclePivotTextSignal &&
hasBidirectionalValueFlowSignal(rawUserTextOnly);
const rawUserScopedEntityCandidate = rawUserSignalSourceText
? rawScopedEntityCandidateFromText(rawUserEntitySourceText)
: null;
const rawUserCounterpartyBidirectionalOverride = Boolean(rawUserBidirectionalValueFlowPivotTextSignal &&
(rawUserScopedEntityCandidate ||
predecomposeEntities.counterparty ||
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate))));
const rawText = compactLower(rawSignalSourceText);
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) && !rawUserCounterpartyBidirectionalOverride;
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatMovementEvidenceSignal = hasExplicitVatMovementEvidenceSignal(rawText);
const rawLifecyclePivotTextSignal = !rawPrimaryBusinessOverviewSignal && hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowPivotTextSignal = !rawPrimaryBusinessOverviewSignal &&
!rawLifecyclePivotTextSignal &&
hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowPivotTextSignal = !rawPrimaryBusinessOverviewSignal &&
!rawLifecyclePivotTextSignal &&
(hasValueFlowSignal(rawText) ||
hasValueRankingSignal(rawText) ||
rawBidirectionalValueFlowPivotTextSignal);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal);
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) &&
hasBusinessOverviewContinuationSignal(rawText) &&
@@ -1163,7 +1188,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
hasMetadataSignal(rawText);
const rawEntityResolutionSignal = !rawLifecycleSignal && !rawValueFlowSignal && !rawMetadataSignal && hasEntityResolutionSignal(rawText);
const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText);
const rawValueFlowAggregateQuestionSignal = rawValueFlowSignal && hasValueFlowAggregateQuestionSignal(rawText);
const rawValueFlowAggregateQuestionSignal = (rawValueFlowSignal || rawValueFlowPivotTextSignal) && hasValueFlowAggregateQuestionSignal(rawText);
const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText);
const rawAllTimeScopeSignal = hasAllTimeScopeHint(rawText);
const dateScopeSignalText = stripNegatedTaxDateScopeClauses(rawText);
@@ -1216,6 +1241,32 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_evaluation";
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
? null
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
const organizationMirrorsPredecomposeCounterpartyForPivot = Boolean(sameScopedName(predecomposeEntities.counterparty, assistantTurnMeaningOrganizationScope) ||
sameScopedName(predecomposeEntities.counterparty, currentTurnOrganizationScope) ||
predecomposeOrganizationMirrorsCounterparty);
const normalizedPredecomposeCounterpartyForPivot = organizationMirrorsPredecomposeCounterpartyForPivot
? null
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const rawExplicitCounterpartyPivotCandidate = rawScopedEntityCandidate ??
rawAssistantEntityCandidates.find((candidate) => !isInvalidEntityCandidate(candidate) &&
!sameScopedName(candidate, currentTurnOrganizationScope)) ??
normalizedPredecomposeCounterpartyForPivot ??
null;
const businessOverviewCounterpartyValueFlowPivot = Boolean(businessOverviewContinuationSignal &&
!rawPrimaryBusinessOverviewSignal &&
rawValueFlowPivotTextSignal &&
rawExplicitCounterpartyPivotCandidate &&
(rawTopicSwitchSignal || rawValueFlowAggregateQuestionSignal));
const businessOverviewUnsupportedFamily = inventoryReserveBusinessOverviewSignal
? "inventory_reserve_liquidation_boundary"
: debtDueDateBusinessOverviewSignal
@@ -1225,8 +1276,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
: profitMarginBusinessOverviewSignal
? "profit_margin_boundary"
: "broad_business_evaluation";
const businessOverviewSignal = rawBusinessOverviewSignal ||
seededBusinessOverviewSignal;
const businessOverviewSignal = !businessOverviewCounterpartyValueFlowPivot &&
(rawBusinessOverviewSignal || seededBusinessOverviewSignal);
const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText));
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
@@ -1244,15 +1295,6 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
hasSimpleMovementLanePivotSignal(rawText) ||
hasMovementEvidenceFollowupSignal(rawText) ||
hasPronounMovementEvidenceFollowupSignal(rawText);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
? null
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = predecomposeEntities.organization ?? rawOrganizationScope;
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const followupCounterpartyIsMetadataOrganizationScope = Boolean(followupSeed.subjectResolutionOptional &&
followupSeed.counterparty &&
(followupSeed.metadataScopeHint ||
@@ -1288,7 +1330,6 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawOpenScopeValueFlowOrganizationSignal = Boolean(rawValueFlowSignal &&
!rawBidirectionalValueFlowSignal &&
explicitOrganizationScopeSignal);
const predecomposeOrganizationMirrorsCounterparty = sameScopedName(predecomposeEntities.counterparty, predecomposeEntities.organization);
const organizationMirrorsPredecomposeCounterparty = Boolean((rawBidirectionalValueFlowSignal ||
hasValueRankingSignal(rawText) ||
rawOpenScopeValueFlowOrganizationSignal ||
@@ -1564,14 +1605,25 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const lifecycleSignal = !businessOverviewSignal && (rawLifecycleSignal || seededDomain === "counterparty_lifecycle");
const bidirectionalValueFlowSignal = !businessOverviewSignal &&
!lifecycleSignal &&
(rawBidirectionalValueFlowSignal || seededAction === "net_value_flow");
((businessOverviewCounterpartyValueFlowPivot
? rawBidirectionalValueFlowPivotTextSignal
: rawBidirectionalValueFlowSignal) ||
seededAction === "net_value_flow");
const valueFlowSignal = !businessOverviewSignal &&
!lifecycleSignal &&
!metadataGroundedMovementLaneApplicable &&
(rawValueFlowSignal || seededDomain === "counterparty_value");
((businessOverviewCounterpartyValueFlowPivot
? rawValueFlowPivotTextSignal
: rawValueFlowSignal) ||
seededDomain === "counterparty_value");
const payoutSignal = valueFlowSignal &&
!bidirectionalValueFlowSignal &&
(rawPayoutSignal || seededAction === "payout");
((businessOverviewCounterpartyValueFlowPivot
? rawValueFlowPivotTextSignal &&
!rawBidirectionalValueFlowPivotTextSignal &&
hasPayoutSignal(rawText)
: rawPayoutSignal) ||
seededAction === "payout");
const semanticDataNeed = metadataAmbiguityLaneClarificationApplicable
? "metadata lane clarification"
: semanticNeedFor({
@@ -1579,17 +1631,37 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? "movements"
: businessOverviewSignal
? "business_overview"
: rawDomain ?? seededDomain,
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? "counterparty_value"
: rawDomain ?? seededDomain,
action: explicitVatMovementEvidenceSignal
? "list_movements"
: businessOverviewSignal
? businessOverviewActionFamily
: rawAction ?? seededAction,
: lifecycleSignal
? "activity_duration"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "net_value_flow"
: payoutSignal
? "payout"
: rawAction ?? seededAction ?? "turnover"
: rawAction ?? seededAction,
unsupported: explicitVatMovementEvidenceSignal
? "movement_evidence"
: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ?? seededUnsupported,
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: unsupported ?? seededUnsupported,
lifecycleSignal,
valueFlowSignal,
metadataSignal: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable,
@@ -1853,16 +1925,16 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
subject_resolution_optional: metadataScopedLaneWithoutSubject || undefined,
unsupported_but_understood_family: businessOverviewSignal
? businessOverviewUnsupportedFamily
: unsupported ??
(lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: metadataGroundedMovementLaneApplicable
: lifecycleSignal
? "counterparty_lifecycle"
: valueFlowSignal
? bidirectionalValueFlowSignal
? "counterparty_bidirectional_value_flow_or_netting"
: payoutSignal
? "counterparty_payouts_or_outflow"
: seededUnsupported ?? "counterparty_value_or_turnover"
: unsupported ??
(metadataGroundedMovementLaneApplicable
? "movement_evidence"
: metadataGroundedDocumentLaneApplicable
? "document_evidence"
@@ -2137,6 +2209,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (businessOverviewSignal) {
pushReason(reasonCodes, "mcp_discovery_broad_business_evaluation_route_candidate");
}
if (businessOverviewCounterpartyValueFlowPivot) {
pushReason(reasonCodes, "mcp_discovery_business_overview_followup_pivoted_to_counterparty_value_flow");
}
if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
}
@@ -133,6 +133,57 @@ function detectCounterpartyTurnoverFamily(text) {
entity
};
}
function detectScopedCounterpartyEntity(text) {
const patterns = [
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+(.+?)(?=$|[,.;:!?]|\s+(?:\u0437\u0430|\u043d\u0430|\u0432|\u0432\u043e|\u043a|\u043f\u043e|\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a|\u043a\u0430\u043a|\u043a\u0430\u043a\u043e\u0435|\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|\u0434\u0435\u043d\u0435\u0433|\u0434\u0435\u043d\u0435\u0436\p{L}*|\u043f\u043b\u0430\u0442[\u0435\u0451]\u0436\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0432\u0445\u043e\u0434\p{L}*)(?=$|[\s,.;:!?]))/iu,
/(?:^|[\s,.;:!?])(?:\u043f\u043e|\u0443|\u0434\u043b\u044f|by|for)\s+([\p{L}\d._-]{2,})(?=$|[\s,.;:!?])/iu
];
const ignored = new Set([
"\u0433\u043e\u0434",
"\u0433\u043e\u0434\u0430",
"\u043f\u0435\u0440\u0438\u043e\u0434",
"\u043f\u0435\u0440\u0438\u043e\u0434\u0430",
"\u043c\u0435\u0441\u044f\u0446",
"\u043c\u0435\u0441\u044f\u0446\u0430",
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b",
"\u043a\u0432\u0430\u0440\u0442\u0430\u043b\u0430",
"\u0434\u0435\u043d\u044c\u0433\u0438",
"\u043d\u0435\u0442\u0442\u043e",
"\u0441\u0430\u043b\u044c\u0434\u043e",
"year",
"period",
"month",
"quarter",
"net"
]);
for (const pattern of patterns) {
const rawEntity = text.match(pattern)?.[1]?.trim() ?? "";
if (!rawEntity) {
continue;
}
const entity = rawEntity.replace(/^["'«»]+|["'«»]+$/gu, "").trim();
if (entity.length >= 2 && !ignored.has(entity)) {
return entity;
}
}
return null;
}
function detectCounterpartyBidirectionalValueFlowFamily(text) {
const hasNetCue = /(?:\u043d\u0435\u0442\u0442\u043e|\u0441\u0430\u043b\u044c\u0434\u043e|net\s+(?:flow|cash|payment)|cash\s+net)/iu.test(text);
const hasIncomingCue = /(?:\u043f\u043e\u043b\u0443\u0447\p{L}*|\u0432\u0445\u043e\u0434\p{L}*|\u043f\u043e\u0441\u0442\u0443\u043f\p{L}*|received|incoming)/iu.test(text);
const hasOutgoingCue = /(?:\u0437\u0430\u043f\u043b\u0430\u0442\p{L}*|\u0438\u0441\u0445\u043e\u0434\p{L}*|\u0441\u043f\u0438\u0441\u0430\u043d\p{L}*|paid|outgoing|payment)/iu.test(text);
if (!(hasNetCue || (hasIncomingCue && hasOutgoingCue))) {
return null;
}
const entity = detectScopedCounterpartyEntity(text);
if (!entity) {
return null;
}
return {
family: "counterparty_bidirectional_value_flow_or_netting",
entity
};
}
function hasExplicitCounterpartyValueObject(text) {
return /(?:\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u043a\u0443\u043f\u0430\u0442\u0435\u043b|\u0437\u0430\u043a\u0430\u0437\u0447\u0438\u043a|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0434\u043e\u0433\u043e\u0432\u043e\u0440|\u043a\u043e\u043d\u0442\u0440\u0430\u043a\u0442|\u0442\u043e\u0432\u0430\u0440|\u043d\u043e\u043c\u0435\u043d\u043a\u043b\u0430\u0442\u0443\u0440|\u0441\u0434\u0435\u043b\u043a|customer|client|counterparty|supplier|vendor|contract|item|product|deal)/iu.test(text);
}
@@ -279,14 +330,14 @@ function detectBroadBusinessEvaluation(text) {
}
return null;
}
function buildEntityCandidates(counterpartyTurnover) {
if (!counterpartyTurnover?.entity) {
function buildEntityCandidates(entityFamily) {
if (!entityFamily?.entity) {
return [];
}
return [
{
type: "counterparty",
value: counterpartyTurnover.entity,
value: entityFamily.entity,
source: "current_turn_loose_entity_tail"
}
];
@@ -299,22 +350,30 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
const effectiveText = normalizeTurnText(effectiveMessage, deps);
const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`);
const supportedIntent = detectSupportedIntent(joinedText, deps);
const counterpartyBidirectionalValueFlow = detectCounterpartyBidirectionalValueFlowFamily(joinedText);
const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText);
const selectedObjectInventoryExact = hasSelectedObjectInventoryExactSignal(joinedText);
const broadBusinessEvaluation = selectedObjectInventoryExact ? null : detectBroadBusinessEvaluation(joinedText);
const broadBusinessEvaluation = selectedObjectInventoryExact || counterpartyBidirectionalValueFlow?.family
? null
: detectBroadBusinessEvaluation(joinedText);
const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps);
const explicitIntentCandidate = broadBusinessEvaluation?.family
? null
: supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null);
const unsupportedFamily = broadBusinessEvaluation?.family
? broadBusinessEvaluation.family
: !explicitIntentCandidate && counterpartyTurnover?.family
? counterpartyTurnover.family
: null;
: !explicitIntentCandidate && counterpartyBidirectionalValueFlow?.family
? counterpartyBidirectionalValueFlow.family
: !explicitIntentCandidate && counterpartyTurnover?.family
? counterpartyTurnover.family
: null;
const reasonCodes = [];
if (supportedIntent?.reason) {
reasonCodes.push(supportedIntent.reason);
}
if (counterpartyBidirectionalValueFlow?.family) {
reasonCodes.push("counterparty_bidirectional_value_flow_current_turn_signal");
}
if (counterpartyTurnover?.family) {
reasonCodes.push("counterparty_turnover_current_turn_signal");
}
@@ -338,32 +397,39 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
? "inventory"
: broadBusinessEvaluation?.family
? "business_summary"
: explicitIntentCandidate?.includes("counterparty")
? "counterparty"
: counterpartyTurnover?.family
: counterpartyBidirectionalValueFlow?.family
? "counterparty_value"
: explicitIntentCandidate?.includes("counterparty")
? "counterparty"
: null;
: counterpartyTurnover?.family
? "counterparty"
: null;
const askedActionFamily = explicitIntentCandidate === "receivables_confirmed_as_of_date" ||
explicitIntentCandidate === "payables_confirmed_as_of_date" ||
explicitIntentCandidate === "inventory_on_hand_as_of_date"
? "confirmed_snapshot"
: broadBusinessEvaluation?.family
? "broad_evaluation"
: explicitIntentCandidate === "customer_revenue_and_payments" ||
explicitIntentCandidate === "supplier_payouts_profile"
? "counterparty_value_or_turnover"
: explicitIntentCandidate === "vat_liability_confirmed_for_tax_period"
? "confirmed_tax_period"
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
? "confirmed_snapshot"
: explicitIntentCandidate === "vat_payable_forecast"
? "forecast"
: explicitIntentCandidate === "list_documents_by_counterparty"
? "list_documents"
: counterpartyTurnover?.family
? "counterparty_value_or_turnover"
: null;
const staleReplayForbidden = Boolean(unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate));
: counterpartyBidirectionalValueFlow?.family
? "net_value_flow"
: explicitIntentCandidate === "customer_revenue_and_payments" ||
explicitIntentCandidate === "supplier_payouts_profile"
? "counterparty_value_or_turnover"
: explicitIntentCandidate === "vat_liability_confirmed_for_tax_period"
? "confirmed_tax_period"
: explicitIntentCandidate === "vat_payable_confirmed_as_of_date"
? "confirmed_snapshot"
: explicitIntentCandidate === "vat_payable_forecast"
? "forecast"
: explicitIntentCandidate === "list_documents_by_counterparty"
? "list_documents"
: counterpartyTurnover?.family
? "counterparty_value_or_turnover"
: null;
const staleReplayForbidden = Boolean(unsupportedFamily ||
broadBusinessEvaluation?.family ||
(counterpartyBidirectionalValueFlow?.entity && !explicitIntentCandidate) ||
(counterpartyTurnover?.entity && !explicitIntentCandidate));
return {
schema_version: "assistant_turn_meaning_v1",
raw_message: rawMessage,
@@ -373,10 +439,13 @@ function createAssistantTurnMeaningPolicy(deps = {}) {
asked_domain_family: askedDomainFamily,
asked_action_family: askedActionFamily,
explicit_intent_candidate: explicitIntentCandidate,
explicit_entity_candidates: broadBusinessEvaluation?.family ? [] : buildEntityCandidates(counterpartyTurnover),
explicit_entity_candidates: broadBusinessEvaluation?.family
? []
: buildEntityCandidates(counterpartyBidirectionalValueFlow ?? counterpartyTurnover),
meaning_confidence: broadBusinessEvaluation?.family
? "medium"
: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"),
: supportedIntent?.confidence ??
(counterpartyBidirectionalValueFlow?.family || counterpartyTurnover?.family ? "medium" : "low"),
intent_override_strength: explicitIntentCandidate
? "explicit_current_turn_intent"
: staleReplayForbidden
+27 -1
View File
@@ -31,6 +31,8 @@ function toRouteHintSummaryV1(normalized) {
}
const ACCOUNT_HINT_PATTERN = /(?:\b(?:account|acct|schet|счет|сч)\s*[:#]?\s*(?:[1-9][0-9](?:[./-][0-9]{1,2})?)|\b(?:19|20|21|23|25|26|28|29|44|51|60|62|68)\b)/i;
const PERIOD_PATTERN = /\b20\d{2}(?:[-./](?:0[1-9]|1[0-2]))?\b/i;
const BIDIRECTIONAL_VALUE_FLOW_PATTERN = /(?:\b(?:receive(?:d)?|received|get|got|incoming|inflow|paid|payment|payments|outgoing|outflow|net|netto|cash\s*flow)\b|получ(?:ить|ил[аи]?|ено|аем|или)|поступ(?:ил[аи]?|ление|ления)|заплат(?:ить|ил[аи]?|или)|оплат(?:ить|ил[аи]?|ы|или)|входящ(?:ий|ие|их)|исходящ(?:ий|ие|их)|нетто|сальдо)/iu;
const COUNTERPARTY_SCOPE_PATTERN = /(?:\b(?:counterparty|supplier|customer|vendor|client|bank)\b|контрагент|поставщик|покупател|клиент|заказчик|банк|сбербанк|по\s+(?:ип|ооо|пао|зао|оао|группа)\b)/iu;
const SYMPTOM_MARKER_PATTERN = /(?:\bsymptom\b|\banomaly\b|\bproblem\b|\bissue\b|\btail\b|\bhanging\b|\bblocked\b|\bincomplete\b|remains?\s+open|not\s+(?:confirmed|observed|resolved|closed)|не\s+(?:подтвержден|закрыт|наблюдается)|хвост|сбой|проблем)/i;
const LIFECYCLE_MARKER_PATTERN = /(?:\blifecycle\b|\bchain\b|\btransition\b|\bstep\b|\btrace\b|цепоч|этап|переход|связк|где\s+разрыв)/i;
const CHAIN_BREAK_PATTERN = /(?:\bbreak\b|\bbroken\b|\bgap\b|missing\s+(?:transition|step|link)|chain\s+break|разрыв|обрыв|нет\s+переход|не\s+дошл|не\s+наблюд)/i;
@@ -54,6 +56,13 @@ exports.ROUTE_DISCIPLINE_RULE_TABLE = [
forbidden_fallback: ["store_canonical", "hybrid_store_plus_live"],
description: "Ranking and period summary queries require analytical batch path."
},
{
query_class: "bidirectional_value_flow",
required_route: "hybrid_store_plus_live",
allowed_fallback: ["no_route"],
forbidden_fallback: ["store_canonical"],
description: "Scoped bidirectional value-flow questions require hybrid evidence path."
},
{
query_class: "symptom_first",
required_route: "hybrid_store_plus_live",
@@ -155,6 +164,17 @@ function hasAmbiguitySignal(fragment, lowerText) {
function hasAccountOrPeriodAnchor(fragment, lowerText) {
return fragment.account_hints.length > 0 || ACCOUNT_HINT_PATTERN.test(lowerText) || PERIOD_PATTERN.test(lowerText);
}
function hasBidirectionalValueFlowSignal(fragment, lowerText) {
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
return false;
}
return BIDIRECTIONAL_VALUE_FLOW_PATTERN.test(lowerText);
}
function hasCounterpartyScopeSignal(fragment, lowerText) {
return (COUNTERPARTY_SCOPE_PATTERN.test(lowerText) ||
fragment.entity_hints.some((hint) => hint.trim().length > 0) ||
fragment.candidate_labels.includes("cross_entity"));
}
function resolveRouteClass(fragment) {
const lowerText = mergedFragmentText(fragment);
const symptomSignal = hasSymptomSignal(fragment, lowerText);
@@ -164,12 +184,17 @@ function resolveRouteClass(fragment) {
const causalSignal = hasCausalSignal(lowerText);
const ambiguitySignal = hasAmbiguitySignal(fragment, lowerText);
const accountOrPeriodAnchor = hasAccountOrPeriodAnchor(fragment, lowerText);
const bidirectionalValueFlowSignal = hasBidirectionalValueFlowSignal(fragment, lowerText);
const counterpartyScopeSignal = hasCounterpartyScopeSignal(fragment, lowerText);
if (fragment.flags.asks_for_exact_object_trace) {
return ROUTE_DISCIPLINE_RULE_MAP.get("exact_object_trace");
}
if (fragment.flags.asks_for_ranking_or_top || fragment.flags.asks_for_period_summary) {
return ROUTE_DISCIPLINE_RULE_MAP.get("ranking_or_period_summary");
}
if (bidirectionalValueFlowSignal && counterpartyScopeSignal) {
return ROUTE_DISCIPLINE_RULE_MAP.get("bidirectional_value_flow");
}
if (ambiguitySignal && (symptomSignal || lifecycleSignal || chainBreakSignal || periodImpactSignal || causalSignal)) {
return ROUTE_DISCIPLINE_RULE_MAP.get("mixed_ambiguity");
}
@@ -205,7 +230,8 @@ function shouldPromoteFromNoRoute(fragment, rule) {
hasLifecycleSignal(fragment, lowerText) ||
hasChainBreakSignal(lowerText) ||
hasPeriodImpactSignal(lowerText) ||
hasCausalSignal(lowerText);
hasCausalSignal(lowerText) ||
(hasBidirectionalValueFlowSignal(fragment, lowerText) && hasCounterpartyScopeSignal(fragment, lowerText));
const hasAnchor = hasAccountOrPeriodAnchor(fragment, lowerText) ||
fragment.candidate_labels.includes("cross_entity") ||
DOMAIN_LEXICAL_ANCHOR_PATTERN.test(lowerText);