Укрепить семантику бизнес-ответов адресного контура

This commit is contained in:
dctouch 2026-05-09 21:48:17 +03:00
parent f86cb8e886
commit dfbfe26501
48 changed files with 2644 additions and 176 deletions

View File

@ -84,6 +84,12 @@
"expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"], "expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"],
"expected_result_modes": ["heuristic_candidates", "confirmed_balance"] "expected_result_modes": ["heuristic_candidates", "confirmed_balance"]
}, },
{
"intent": "open_items_by_counterparty_or_contract",
"expected_selected_recipes": ["address_open_items_by_party_or_contract_v1"],
"expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"],
"expected_result_modes": ["heuristic_candidates", "confirmed_balance"]
},
{ {
"intent": "open_contracts_confirmed_as_of_date", "intent": "open_contracts_confirmed_as_of_date",
"expected_selected_recipes": ["address_open_contracts_confirmed_as_of_date_v1"], "expected_selected_recipes": ["address_open_contracts_confirmed_as_of_date_v1"],

View File

@ -356,6 +356,12 @@ function extractMonthPeriod(text) {
} }
return {}; return {};
} }
function isExactHistoricalPeriodWindow(filters) {
return (typeof filters.period_from === "string" &&
filters.period_from.trim().length > 0 &&
typeof filters.period_to === "string" &&
filters.period_to.trim().length > 0);
}
function extractPeriodRange(text) { function extractPeriodRange(text) {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2); const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) { if (!directMatch) {
@ -710,6 +716,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue) {
if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) {
return true; return true;
} }
if (meaningfulNonGenericTokens.length === 0) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0; return meaningfulTokens.length === 0;
} }
@ -1407,6 +1416,9 @@ function resolveSemanticDateBasisHint(filters, warnings) {
const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0; const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0;
const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0; const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0;
const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0; const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0;
if (warnings.includes("as_of_date_derived_from_exact_historical_period") && (hasPeriodFrom || hasPeriodTo)) {
return hasPeriodFrom && hasPeriodTo ? "period_range" : "period_end";
}
if (hasPeriodFrom && hasPeriodTo) { if (hasPeriodFrom && hasPeriodTo) {
return "period_range"; return "period_range";
} }
@ -1670,6 +1682,14 @@ function extractAddressFilters(userMessage, intent) {
warnings.push("period_derived_from_year_phrase"); warnings.push("period_derived_from_year_phrase");
} }
} }
if (isExactHistoricalPeriodWindow(filters) && !warnings.includes("exact_historical_period_window_requested")) {
const derivedFromHistoricalPhrase = warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase");
if (derivedFromHistoricalPhrase) {
warnings.push("exact_historical_period_window_requested");
}
}
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate; const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) { if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate); const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
@ -1685,10 +1705,12 @@ function extractAddressFilters(userMessage, intent) {
} }
} }
const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase"); const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase");
const yearPeriodWasDerived = warnings.includes("period_derived_from_year_phrase") || warnings.includes("period_derived_from_year_range_phrase");
if (intent === "vat_liability_confirmed_for_tax_period" && if (intent === "vat_liability_confirmed_for_tax_period" &&
!periodRange.period_from && !periodRange.period_from &&
!periodRange.period_to && !periodRange.period_to &&
!monthPeriodWasDerived) { !monthPeriodWasDerived &&
!yearPeriodWasDerived) {
const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null;
if (periodToForQuarter) { if (periodToForQuarter) {
const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter);
@ -1711,7 +1733,12 @@ function extractAddressFilters(userMessage, intent) {
const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") || const periodWasDerivedHeuristically = warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase"); warnings.includes("period_derived_from_year_phrase");
const preserveDerivedPeriodWindow = intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; 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")) {
warnings.push("exact_historical_period_window_requested");
}
if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) { if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) {
delete filters.period_from; delete filters.period_from;
delete filters.period_to; delete filters.period_to;
@ -1738,6 +1765,14 @@ function extractAddressFilters(userMessage, intent) {
if (filters.period_to) { if (filters.period_to) {
filters.as_of_date = filters.period_to; filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to"); warnings.push("as_of_date_derived_from_period_to");
if (warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase")) {
warnings.push("as_of_date_derived_from_exact_historical_period");
if (!warnings.includes("exact_historical_period_window_requested")) {
warnings.push("exact_historical_period_window_requested");
}
}
} }
else if (shouldDefaultAsOfDateToToday(intent)) { else if (shouldDefaultAsOfDateToToday(intent)) {
filters.as_of_date = new Date().toISOString().slice(0, 10); filters.as_of_date = new Date().toISOString().slice(0, 10);

View File

@ -1724,6 +1724,10 @@ function resolveUnicodeAddressIntentBridge(text) {
]).has(byAnchorToken); ]).has(byAnchorToken);
const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized); const hasMoneyCue = /(?:деньг|денег|выручк|доход|оборот|заработ|прин[её]с|чек|ликвидн|revenue|turnover|money)/iu.test(normalized);
const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized); const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(normalized);
const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
if (hasInventoryPurchaseToSaleDocumentChainCue) {
return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected");
}
const hasSelectedObjectProfitabilityCue = /(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test(normalized) && const hasSelectedObjectProfitabilityCue = /(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test(normalized) &&
(/(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|profit|margin)/iu.test(normalized) || (/(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442|\u043f\u0440\u0438\u0431\u044b\u043b|\u043c\u0430\u0440\u0436|profit|margin)/iu.test(normalized) ||
(/(?:\u043f\u0440\u043e\u0434\u0430\u0436|sale)/iu.test(normalized) && (/(?:\u043f\u0440\u043e\u0434\u0430\u0436|sale)/iu.test(normalized) &&
@ -1731,10 +1735,6 @@ function resolveUnicodeAddressIntentBridge(text) {
if (hasSelectedObjectProfitabilityCue) { if (hasSelectedObjectProfitabilityCue) {
return unicodeBridgeResolution("inventory_profitability_for_item", "high", "unicode_selected_object_profitability_bridge_signal_detected"); return unicodeBridgeResolution("inventory_profitability_for_item", "high", "unicode_selected_object_profitability_bridge_signal_detected");
} }
const hasInventoryPurchaseToSaleDocumentChainCue = /(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(normalized) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
if (hasInventoryPurchaseToSaleDocumentChainCue) {
return unicodeBridgeResolution("inventory_purchase_to_sale_chain", "high", "unicode_inventory_purchase_to_sale_chain_bridge_signal_detected");
}
const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) && const hasOpenItemsAccountCue = /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized); /(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:№|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(normalized);
if (hasOpenItemsAccountCue) { if (hasOpenItemsAccountCue) {

View File

@ -1769,7 +1769,7 @@ function enforceStrictAccountScopeForIntent(plan, intent) {
account_scope_mode: "strict" account_scope_mode: "strict"
}; };
} }
function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) { function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate, warnings = []) {
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date); const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
const periodTo = normalizeAnalysisDateHint(filters.period_to); const periodTo = normalizeAnalysisDateHint(filters.period_to);
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null; const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
@ -1779,8 +1779,10 @@ function resolveExecutionFiltersForConfirmedBalance(filters, analysisDate) {
if (derivedAsOf) { if (derivedAsOf) {
executionFilters.as_of_date = derivedAsOf; executionFilters.as_of_date = derivedAsOf;
} }
if (!warnings.includes("as_of_date_derived_from_exact_historical_period")) {
delete executionFilters.period_from; delete executionFilters.period_from;
delete executionFilters.period_to; delete executionFilters.period_to;
}
const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit) const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(executionFilters.limit)) ? Math.max(1, Math.trunc(executionFilters.limit))
: null; : null;
@ -1952,6 +1954,9 @@ function asksForUnresolvedInventorySupplierLink(userMessage) {
return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? "")); return /(?:\u0431\u0435\u0437\s+\u043f\u043e\u043d\u044f\u0442\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0431\u0435\u0437\s+(?:\u044f\u0432\u043d[^\s]*\s+)?\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\s+\u0438\u043c\u0435\u044e\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0435\u0442\s+\u044f\u0432\u043d[^\s]*\s+\u043f\u0440\u0438\u0432\u044f\u0437\u043a[^\s]*\s+\u043a\s+\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|unresolved\s+supplier\s+link)/iu.test(String(userMessage ?? ""));
} }
function canAutoBroadenPeriodWindow(intent, filters) { function canAutoBroadenPeriodWindow(intent, filters) {
if (Array.isArray(filters.warnings) && filters.warnings?.includes("exact_historical_period_window_requested")) {
return false;
}
const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) && const hasRecoverableAsOfOnlyWindow = !hasExplicitPeriodWindow(filters) &&
typeof filters.as_of_date === "string" && typeof filters.as_of_date === "string" &&
filters.as_of_date.trim().length > 0 && filters.as_of_date.trim().length > 0 &&
@ -3001,16 +3006,16 @@ class AddressQueryService {
const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; const confirmedBalanceVatPayableIntent = intent.intent === "vat_payable_confirmed_as_of_date" && requestedResultMode === "confirmed_balance";
const confirmedBalanceInventoryIntent = intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance"; const confirmedBalanceInventoryIntent = intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance";
const payablesConfirmedExecution = confirmedBalancePayablesIntent const payablesConfirmedExecution = confirmedBalancePayablesIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
let executionFilters = inventoryConfirmedExecution?.executionFilters ?? let executionFilters = inventoryConfirmedExecution?.executionFilters ??
payablesConfirmedExecution?.executionFilters ?? payablesConfirmedExecution?.executionFilters ??
@ -4219,6 +4224,7 @@ class AddressQueryService {
!counterpartyItemFlowQuery && !counterpartyItemFlowQuery &&
isDocumentOrBankAnchorIntent(intent.intent) && isDocumentOrBankAnchorIntent(intent.intent) &&
!hasExplicitPeriodWindow(filters.extracted_filters) && !hasExplicitPeriodWindow(filters.extracted_filters) &&
!filters.warnings.some((warning) => warning.startsWith("period_derived_from_")) &&
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) { (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")) {
const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit) const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit)) ? Math.max(1, Math.trunc(filters.extracted_filters.limit))

View File

@ -119,6 +119,10 @@ function truthGateStatusFrom(input) {
return input.truthGateStatusHint; return input.truthGateStatusHint;
} }
const missingRequiredFilters = input.missingRequiredFilters ?? []; const missingRequiredFilters = input.missingRequiredFilters ?? [];
const reasonCodes = input.reasons ?? [];
const heuristicOpenItemsFallback = Boolean(input.intent === "open_items_by_counterparty_or_contract" &&
(reasonCodes.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates") ||
reasonCodes.includes("open_items_account_query_override_to_movements")));
if (input.routeExpectationStatus === "mismatch") { if (input.routeExpectationStatus === "mismatch") {
return "blocked_route_expectation_failure"; return "blocked_route_expectation_failure";
} }
@ -134,6 +138,9 @@ function truthGateStatusFrom(input) {
if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") { if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") {
return "full_confirmed"; return "full_confirmed";
} }
if (heuristicOpenItemsFallback) {
return "partial_supported";
}
if (input.limitedReasonCategory === "empty_match" || if (input.limitedReasonCategory === "empty_match" ||
input.limitedReasonCategory === "recipe_visibility_gap" || input.limitedReasonCategory === "recipe_visibility_gap" ||
input.limitedReasonCategory === "unsupported" || input.limitedReasonCategory === "unsupported" ||

View File

@ -3281,11 +3281,22 @@ function composeFactualReplyBody(intent, rows, options = {}) {
} }
if (intent === "open_items_by_counterparty_or_contract") { if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows); const counterparties = buildCounterpartyRiskAggregate(rows);
const accountLead = typeof options.accountHint === "string" && options.accountHint.trim().length > 0 const accountLabel = typeof options.accountHint === "string" && options.accountHint.trim().length > 0
? `Проверил хвосты по счету ${options.accountHint.trim()}.` ? `по счету ${options.accountHint.trim()}`
: "Собраны открытые позиции по взаиморасчетам."; : "по взаиморасчетам";
const exactBalanceRequested = options.requestedResultMode === "confirmed_balance";
const periodLabel = options.asOfDate
? `на ${formatDateRu(options.asOfDate)}`
: options.periodFrom || options.periodTo
? `за период ${formatDateRu(options.periodFrom ?? "...")}..${formatDateRu(options.periodTo ?? "...")}`
: null;
const lines = [ const lines = [
accountLead, exactBalanceRequested
? `Коротко: точный открытый остаток ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} не подтвержден; ниже только предварительные сигналы по движениям: ${formatNumberWithDots(rows.length)} строк, контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`
: `Коротко: ${accountLabel} найдено ${formatNumberWithDots(rows.length)} строк хвостов/открытых расчетов; контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`,
exactBalanceRequested
? "Это не подтвержденное сальдо и не финальный реестр открытых расчетов: текущий контур видит движения-кандидаты, но не доказывает остаток закрытия."
: "Это shortlist для проверки, а не финальный подтвержденный реестр открытых расчетов.",
`Строк отобрано: ${rows.length}.`, `Строк отобрано: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.` `Контрагентов с сигналом: ${counterparties.length}.`
]; ];
@ -3301,7 +3312,12 @@ function composeFactualReplyBody(intent, rows, options = {}) {
} }
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 || rows.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
}; };
} }
if (intent === "list_contracts_by_counterparty") { if (intent === "list_contracts_by_counterparty") {
@ -3366,7 +3382,7 @@ function composeFactualReplyBody(intent, rows, options = {}) {
? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.` ? `Контрагент: ${counterpartyInline}. Найдено документов: ${rows.length}.`
: `Найдено документов по контрагенту: ${rows.length}.`); : `Найдено документов по контрагенту: ${rows.length}.`);
} }
if (counterpartyLabel) { if (counterpartyLabel && itemFlowQuestion) {
lines.push(`Контрагент: ${counterpartyLabel}`); lines.push(`Контрагент: ${counterpartyLabel}`);
} }
if (itemFlowQuestion) { if (itemFlowQuestion) {
@ -3388,7 +3404,11 @@ function composeFactualReplyBody(intent, rows, options = {}) {
} }
} }
else { else {
lines.push(...formatTopRows(rows, rows.length)); const visibleRows = rows.slice(0, 5);
lines.push(...formatTopRows(visibleRows, visibleRows.length));
if (rows.length > visibleRows.length) {
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length} документов; полный список остается в подтвержденном срезе.`);
}
} }
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",

View File

@ -165,11 +165,18 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"сейчас", "сейчас",
"этому", "этому",
"этомуже", "этомуже",
"этой",
"этойже",
"тому", "тому",
"томуже", "томуже",
"той",
"тойже",
"нему", "нему",
"ней", "ней",
"ним", "ним",
"цепочка",
"цепочке",
"цепочку",
"неуказанному", "неуказанному",
"неуказанный", "неуказанный",
"неуказанная", "неуказанная",

View File

@ -93,11 +93,16 @@ function composeInventoryReply(intent, rows, options, deps) {
: `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`; : `На ${deps.formatDateRu(asOfDate)} подтвержденных товарных остатков по счету 41.01 не найдено.`;
const lines = [directAnswerLine]; const lines = [directAnswerLine];
if (positions.length > 0) { if (positions.length > 0) {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", positions.slice(0, 20).map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, { const visiblePositionsLimit = 6;
const visiblePositions = positions.slice(0, visiblePositionsLimit);
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", visiblePositions.map((item, index) => (0, inventoryReplyPresentation_1.formatInventorySnapshotPositionLine)(item, index, {
formatDateRu: deps.formatDateRu, formatDateRu: deps.formatDateRu,
formatNumberWithDots: deps.formatNumberWithDots, formatNumberWithDots: deps.formatNumberWithDots,
formatMoneyRub: deps.formatMoneyRub formatMoneyRub: deps.formatMoneyRub
}))); })));
if (positions.length > visiblePositions.length) {
lines.push(`Показаны первые ${deps.formatNumberWithDots(visiblePositions.length)} из ${deps.formatNumberWithDots(positions.length)} позиций по сумме; полный список можно раскрыть отдельным запросом.`);
}
} }
else { else {
(0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", [ (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Позиции:", [

View File

@ -190,6 +190,7 @@ async function runAssistantLivingChatRuntime(input) {
organization: scopedOrganization, organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug, addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems, sessionItems: input.sessionItems,
userMessage,
toNonEmptyString: input.toNonEmptyString toNonEmptyString: input.toNonEmptyString
}); });
activeOrganization = scopedOrganization ?? activeOrganization; activeOrganization = scopedOrganization ?? activeOrganization;

View File

@ -121,7 +121,7 @@ function timeScopeNeedFor(input) {
if (input.explicitDateScope) { if (input.explicitDateScope) {
return "explicit_period"; return "explicit_period";
} }
if (input.allTimeScopeHint && if ((input.allTimeScopeHint || input.subjectScopedBidirectionalAllTime) &&
(input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence")) { (input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence")) {
return "all_time_scope"; return "all_time_scope";
} }
@ -396,6 +396,10 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
const comparisonNeed = comparisonNeedFor(action); const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed;
const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance);
const subjectScopedBidirectionalAllTime = businessFactFamily === "value_flow" &&
comparisonNeed === "incoming_vs_outgoing" &&
subjectCandidates.length > 0 &&
!explicitDateScope;
const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({ const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({
family: businessFactFamily, family: businessFactFamily,
rawUtterance, rawUtterance,
@ -449,7 +453,8 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
const timeScopeNeed = timeScopeNeedFor({ const timeScopeNeed = timeScopeNeedFor({
family: businessFactFamily, family: businessFactFamily,
explicitDateScope, explicitDateScope,
allTimeScopeHint allTimeScopeHint,
subjectScopedBidirectionalAllTime
}); });
if (timeScopeNeed === "period_required" && !explicitDateScope) { if (timeScopeNeed === "period_required" && !explicitDateScope) {
pushUnique(clarificationGaps, "period"); pushUnique(clarificationGaps, "period");
@ -492,6 +497,9 @@ function buildAssistantMcpDiscoveryDataNeedGraph(input) {
if (allTimeScopeHint) { if (allTimeScopeHint) {
pushReason(reasonCodes, "data_need_graph_all_time_scope_hint"); pushReason(reasonCodes, "data_need_graph_all_time_scope_hint");
} }
if (subjectScopedBidirectionalAllTime) {
pushReason(reasonCodes, "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope");
}
if (businessFactFamily === "business_overview" && !explicitDateScope) { if (businessFactFamily === "business_overview" && !explicitDateScope) {
pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope");
} }

View File

@ -80,6 +80,7 @@ function normalizeTurnMeaning(value) {
const dateScope = toNonEmptyString(value.explicit_date_scope); const dateScope = toNonEmptyString(value.explicit_date_scope);
const unsupported = toNonEmptyString(value.unsupported_but_understood_family); const unsupported = toNonEmptyString(value.unsupported_but_understood_family);
const entities = toStringList(value.explicit_entity_candidates); const entities = toStringList(value.explicit_entity_candidates);
const businessOverviewSeparateEntities = toStringList(value.business_overview_separate_entity_candidates);
const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets); const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets);
if (domain) { if (domain) {
result.asked_domain_family = domain; result.asked_domain_family = domain;
@ -96,6 +97,9 @@ function normalizeTurnMeaning(value) {
if (entities.length > 0) { if (entities.length > 0) {
result.explicit_entity_candidates = entities; result.explicit_entity_candidates = entities;
} }
if (businessOverviewSeparateEntities.length > 0) {
result.business_overview_separate_entity_candidates = businessOverviewSeparateEntities;
}
if (metadataAmbiguityEntitySets.length > 0) { if (metadataAmbiguityEntitySets.length > 0) {
result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets; result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets;
} }

View File

@ -365,18 +365,230 @@ function businessOverviewYearRowsLine(overview) {
const joined = values.join("; "); const joined = values.join("; ");
return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null; return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null;
} }
function firstOverviewAxisLabel(rows, amountKey = "total_amount_human_ru") {
const first = toRecordObject(Array.isArray(rows) ? rows[0] : null);
const label = toNonEmptyString(first?.axis_value);
const amount = moneyText(first?.[amountKey]);
return label && amount ? `${label}${sentenceAmount(amount) ?? amount}` : null;
}
function businessOverviewTaxLine(overview) {
const tax = toRecordObject(overview.tax_position);
if (!tax) {
return null;
}
const salesVat = moneyText(tax.sales_vat_amount_human_ru);
const purchaseVat = moneyText(tax.purchase_vat_amount_human_ru);
const netVat = moneyText(tax.net_vat_amount_human_ru);
if (!salesVat && !purchaseVat && !netVat) {
return null;
}
const direction = tax.net_vat_direction === "vat_to_pay"
? "НДС к уплате"
: tax.net_vat_direction === "vat_to_recover_or_offset"
? "НДС к возмещению/зачету"
: "чистая НДС-позиция";
return `НДС: продажи ${salesVat ?? "0 руб."}, покупки ${purchaseVat ?? "0 руб."}, ${direction} ${sentenceAmount(netVat) ?? netVat ?? "0 руб."}.`;
}
function businessOverviewDebtLine(overview) {
const debt = toRecordObject(overview.debt_position);
if (!debt) {
return null;
}
const receivables = moneyText(toRecordObject(debt.receivables)?.total_amount_human_ru);
const payables = moneyText(toRecordObject(debt.payables)?.total_amount_human_ru);
const net = moneyText(debt.net_debt_position_amount_human_ru);
if (!receivables && !payables && !net) {
return null;
}
const direction = debt.net_debt_position_direction === "net_payable" ? "кредиторка больше дебиторки" : "дебиторка больше кредиторки";
return `Долги: дебиторка ${receivables ?? "0 руб."}, кредиторка ${payables ?? "0 руб."}, нетто ${sentenceAmount(net) ?? net ?? "0 руб."} (${direction}).`;
}
function businessOverviewInventoryLine(overview) {
const inventory = toRecordObject(overview.inventory_position);
if (!inventory) {
return null;
}
const amount = moneyText(inventory.total_amount_human_ru);
const rows = Number(inventory.rows_matched);
const quantity = Number(inventory.total_quantity);
if (!amount && !Number.isFinite(rows)) {
return null;
}
const pieces = [
Number.isFinite(rows) ? `${rows} позиций` : null,
amount ? `на ${sentenceAmount(amount) ?? amount}` : null,
Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null
].filter((item) => Boolean(item));
return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null;
}
function rowCountText(value) {
const count = Number(value);
return Number.isFinite(count) ? String(count) : null;
}
function sideRowsText(side) {
const rowsWithAmount = rowCountText(side?.rows_with_amount);
const rowsMatched = rowCountText(side?.rows_matched);
if (rowsWithAmount && rowsMatched) {
return `${rowsWithAmount} из ${rowsMatched}`;
}
return rowsWithAmount ?? rowsMatched;
}
function sideDateText(side) {
const first = toNonEmptyString(side?.first_movement_date);
const latest = toNonEmptyString(side?.latest_movement_date);
if (first && latest) {
return first === latest ? `дата ${first}` : `даты ${first}..${latest}`;
}
return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null;
}
function bidirectionalNetLabel(direction) {
if (direction === "net_outgoing") {
return "нетто в сторону контрагента";
}
if (direction === "balanced") {
return "нетто около нуля";
}
return "нетто в нашу сторону";
}
function buildCompactBidirectionalValueFlowReply(entryPoint, draft) {
const bridge = toRecordObject(entryPoint.bridge);
const pilot = toRecordObject(bridge?.pilot);
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
if (!flow) {
return null;
}
const incoming = toRecordObject(flow.incoming_customer_revenue);
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
const netAmount = moneyText(flow.net_amount_human_ru);
if (!incomingAmount && !outgoingAmount && !netAmount) {
return null;
}
const counterparty = toNonEmptyString(flow.counterparty) ?? "запрошенному контрагенту";
const period = toNonEmptyString(flow.period_scope);
const periodText = period ? ` за период ${period}` : " в проверенном окне";
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
const netLabel = bidirectionalNetLabel(flow.net_direction);
const lines = [
`Коротко: по контрагенту ${counterparty}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
];
const basis = [];
if (incomingRows) {
basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
}
if (outgoingRows) {
basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
}
if (basis.length > 0) {
lines.push(`Основа: ${basis.join("; ")}.`);
}
if (flow.coverage_limited_by_probe_limit === true) {
lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
}
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
if (fallbackNextStep) {
lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
function compactComparable(value) {
return String(value ?? "")
.toLowerCase()
.replace(/[«»"']/g, "")
.replace(/\s+/g, " ")
.trim();
}
function businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope) {
const candidates = uniqueStrings([
...toStringList(turnMeaning?.business_overview_separate_entity_candidates),
...toStringList(graph?.subject_candidates),
...toStringList(turnMeaning?.explicit_entity_candidates)
]);
const organizationComparable = compactComparable(organizationScope);
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
if (!text) {
continue;
}
const comparable = compactComparable(text);
if (organizationComparable && comparable === organizationComparable) {
continue;
}
return text;
}
return null;
}
function sameBusinessSubject(left, right) {
const leftComparable = compactComparable(left);
const rightComparable = compactComparable(right);
return Boolean(leftComparable && rightComparable && leftComparable === rightComparable);
}
function previousDocumentSummaryLine(bundle, separateSubject) {
if (!bundle || !sameBusinessSubject(toNonEmptyString(bundle.counterparty), separateSubject)) {
return null;
}
const count = Number(bundle.document_count);
if (!Number.isFinite(count) || count <= 0) {
return null;
}
return `документы по цепочке: найдено ${count}`;
}
function buildPreviousCounterpartyValueFlowSummary(flow, separateSubject, documentBundle) {
if (!flow || !separateSubject || !sameBusinessSubject(toNonEmptyString(flow.counterparty), separateSubject)) {
return null;
}
const incoming = toRecordObject(flow.incoming_customer_revenue);
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
const netAmount = moneyText(flow.net_amount_human_ru);
if (!incomingAmount && !outgoingAmount && !netAmount) {
return null;
}
const counterparty = toNonEmptyString(flow.counterparty) ?? separateSubject;
const netLabel = bidirectionalNetLabel(flow.net_direction);
const lead = `; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` +
`${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`;
const basis = [];
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
if (incomingRows) {
basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
}
if (outgoingRows) {
basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
}
const documents = previousDocumentSummaryLine(documentBundle, counterparty);
if (documents) {
basis.push(documents);
}
const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : "";
return {
lead,
line: `Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` +
`заплатили ${outgoingAmount ?? "0 руб."}, расчетное ${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` +
`${basisText} Это не перенос сумм компании на контрагента, а отдельный ранее подтвержденный контрагентский срез.`
};
}
function buildCompactBusinessOverviewReply(entryPoint, draft) { function buildCompactBusinessOverviewReply(entryPoint, draft) {
const turnInput = toRecordObject(entryPoint.turn_input); const turnInput = toRecordObject(entryPoint.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
const graph = toRecordObject(turnInput?.data_need_graph); const graph = toRecordObject(turnInput?.data_need_graph);
const bridge = toRecordObject(entryPoint.bridge); const bridge = toRecordObject(entryPoint.bridge);
const pilot = toRecordObject(bridge?.pilot); const pilot = toRecordObject(bridge?.pilot);
const overview = toRecordObject(pilot?.derived_business_overview); const overview = toRecordObject(pilot?.derived_business_overview);
const graphReasons = readStringArray(graph?.reason_codes);
const isBusinessOverview = toNonEmptyString(graph?.business_fact_family) === "business_overview" || const isBusinessOverview = toNonEmptyString(graph?.business_fact_family) === "business_overview" ||
toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1"; toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1";
const rankingNeed = toNonEmptyString(graph?.ranking_need); const rankingNeed = toNonEmptyString(graph?.ranking_need);
const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer"); if (!isBusinessOverview || !overview) {
if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) {
return null; return null;
} }
const incoming = toRecordObject(overview.incoming_customer_revenue); const incoming = toRecordObject(overview.incoming_customer_revenue);
@ -387,7 +599,38 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто"; const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто";
const period = businessOverviewPeriodText(overview); const period = businessOverviewPeriodText(overview);
const limitLine = businessOverviewCoverageLimitLine(overview); const limitLine = businessOverviewCoverageLimitLine(overview);
const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope);
const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle), separateSubject, toRecordObject(turnMeaning?.previous_counterparty_document_bundle));
const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : "";
const separateSubjectLead = separateSubject
? previousCounterpartySummary?.lead ??
`; по контрагенту ${separateSubject} суммы компании не переношу, это отдельный контур без подтвержденного итога в этой строке`
: "";
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 topCustomerLead = customerName && customerAmount
? `; крупнейший источник входящих денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}`
: "";
const topSupplier = firstOverviewAxisLabel(overview.top_suppliers);
const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${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 lines = [];
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);
lines.push(`Можно утверждать: по компании подтвержден operating-flow proxy по найденным строкам 1С; по ${separateSubject} отдельно подтверждены входящие/исходящие строки, расчетное нетто и документы из предыдущего контрагентского среза.`);
lines.push(`Нельзя утверждать: это не чистая прибыль, не полный бухгалтерский оборот вне проверенного окна и не доказательство, что ${separateSubject} является главным клиентом или поставщиком как бизнес-роль.`);
if (limitLine) {
lines.push(limitLine);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (rankingNeed) { if (rankingNeed) {
const incomingLeader = strongestIncomingYear(overview); const incomingLeader = strongestIncomingYear(overview);
const netLeader = strongestNetYear(overview); const netLeader = strongestNetYear(overview);
@ -397,7 +640,7 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
if (!leaderYear || !leaderAmount) { if (!leaderYear || !leaderAmount) {
return null; return null;
} }
lines.push(`Коротко: самый доходный год в доступном денежном контуре 1С ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.`); lines.push(`Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`);
const netYear = toNonEmptyString(netLeader?.year_bucket); const netYear = toNonEmptyString(netLeader?.year_bucket);
const netYearAmount = moneyText(netLeader?.net_amount_human_ru); const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
if (netYear && netYearAmount) { if (netYear && netYearAmount) {
@ -414,18 +657,54 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) {
} }
} }
else if (incomingAmount || outgoingAmount || netAmount) { else if (incomingAmount || outgoingAmount || netAmount) {
lines.push(`Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.`); lines.push(`Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`);
lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.');
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null); if (!directMoneyAnswer && customerName && customerAmount) {
const customerName = toNonEmptyString(topCustomer?.axis_value);
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
if (customerName && customerAmount) {
lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`); lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`);
} }
} }
else { else {
return null; return null;
} }
if (separateSubject) {
lines.push(previousCounterpartySummary?.line ??
`Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`);
}
if (!directMoneyAnswer && topSupplier) {
lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
}
if (!directMoneyAnswer && (topCustomer || topSupplier)) {
lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
}
if (!directMoneyAnswer) {
lines.push(`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`);
const taxLine = businessOverviewTaxLine(overview);
if (taxLine) {
lines.push(taxLine);
}
const debtLine = businessOverviewDebtLine(overview);
if (debtLine) {
lines.push(debtLine);
}
const inventoryLine = businessOverviewInventoryLine(overview);
if (inventoryLine) {
lines.push(inventoryLine);
}
const missingOverviewFamilies = [];
if (!taxLine) {
missingOverviewFamilies.push("общая НДС/налоговая позиция без отдельного точного расчета");
}
if (!debtLine) {
missingOverviewFamilies.push("долги без даты среза");
}
if (!inventoryLine) {
missingOverviewFamilies.push("склад без даты среза");
}
if (missingOverviewFamilies.length > 0) {
lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`);
}
lines.push("Что нельзя утверждать: чистую прибыль, полноценный финрезультат, юридические бизнес-роли клиентов/поставщиков и общую налоговую позицию без отдельного точного расчета.");
}
if (limitLine) { if (limitLine) {
lines.push(limitLine); lines.push(limitLine);
} }
@ -476,6 +755,10 @@ function buildReplyText(entryPoint, status) {
} }
return null; return null;
} }
const compactBidirectionalValueFlowReply = buildCompactBidirectionalValueFlowReply(entryPoint, draft);
if (compactBidirectionalValueFlowReply) {
return compactBidirectionalValueFlowReply;
}
const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft); const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft);
if (compactBusinessOverviewReply) { if (compactBusinessOverviewReply) {
return compactBusinessOverviewReply; return compactBusinessOverviewReply;

View File

@ -233,6 +233,18 @@ function readStateTransitionReasonCodes(input) {
.map((item) => toNonEmptyString(item)) .map((item) => toNonEmptyString(item))
.filter((item) => Boolean(item)); .filter((item) => Boolean(item));
} }
function hasFullConfirmedTruth(input) {
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
if (truthGateStatus === "full_confirmed") {
return true;
}
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
}
function readStringArray(value) { function readStringArray(value) {
return Array.isArray(value) return Array.isArray(value)
? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item)) ? value.map((item) => toNonEmptyString(item)).filter((item) => Boolean(item))
@ -299,6 +311,12 @@ function hasExactMatchedFactualAddressReply(input, entryPoint) {
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false; return false;
} }
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
return false;
}
}
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
@ -335,16 +353,7 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) {
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false; return false;
} }
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); if (!hasFullConfirmedTruth(input)) {
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
const hasFullConfirmedTruth = truthGateStatus === "full_confirmed" ||
sourceTruthGateStatus === "full_confirmed" ||
(coverageStatus === "full" && groundingStatus === "grounded");
if (!hasFullConfirmedTruth) {
return false; return false;
} }
const truthAnswerShape = readTruthAnswerShape(input); const truthAnswerShape = readTruthAnswerShape(input);
@ -354,6 +363,26 @@ function hasRuntimeAdjustedExactReply(input, entryPoint) {
} }
return readStateTransitionReasonCodes(input).some((reason) => /^intent_adjusted_to_.+_followup_context$/i.test(reason)); return readStateTransitionReasonCodes(input).some((reason) => /^intent_adjusted_to_.+_followup_context$/i.test(reason));
} }
function hasRuntimeMatchedExactReply(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!hasEffectivelyFactualAddressReply(input)) {
return false;
}
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
if (!hasFullConfirmedTruth(input)) {
return false;
}
const reasonCodes = readStateTransitionReasonCodes(input);
return (reasonCodes.some((reason) => reason === "route_expectation_matched") &&
reasonCodes.some((reason) => /(?:confirmed_balance_exact|exact_.+_intent|vat_period_inspection_bridge_signal_detected)/iu.test(reason)));
}
function hasAlignedFactualAddressReply(input, entryPoint) { function hasAlignedFactualAddressReply(input, entryPoint) {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) { if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false; return false;
@ -380,6 +409,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint) {
if (hasRuntimeAdjustedExactReply(input, entryPoint)) { if (hasRuntimeAdjustedExactReply(input, entryPoint)) {
return false; return false;
} }
if (hasRuntimeMatchedExactReply(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint); const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
@ -453,16 +485,7 @@ function hasFullConfirmedFactualAddressReply(input, entryPoint) {
if (hasMetadataDiscoveryPriority(input, entryPoint)) { if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false; return false;
} }
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); return hasFullConfirmedTruth(input);
if (truthGateStatus === "full_confirmed") {
return true;
}
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
} }
function applyAssistantMcpDiscoveryResponsePolicy(input) { function applyAssistantMcpDiscoveryResponsePolicy(input) {
const currentReply = String(input.currentReply ?? ""); const currentReply = String(input.currentReply ?? "");
@ -482,6 +505,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint);
const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint);
const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint);
const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint); const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(input, entryPoint);
@ -534,6 +558,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
if (runtimeAdjustedExactReply) { if (runtimeAdjustedExactReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning"); pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning");
} }
if (runtimeMatchedExactReply) {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning");
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate");
} }
@ -557,6 +584,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) {
!fullConfirmedFactualAddressReply && !fullConfirmedFactualAddressReply &&
!exactMatchedFactualAddressReply && !exactMatchedFactualAddressReply &&
!runtimeAdjustedExactReply && !runtimeAdjustedExactReply &&
!runtimeMatchedExactReply &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime && candidate.eligible_for_future_hot_runtime &&

View File

@ -193,6 +193,9 @@ function pushScopedEntityCandidate(target, value, groundedFollowupEntity) {
isValueFlowPredicateEntityCandidate(text)) { isValueFlowPredicateEntityCandidate(text)) {
return; return;
} }
if (target.some((existing) => sameScopedName(existing, text))) {
return;
}
pushUnique(target, text); pushUnique(target, text);
} }
function canonicalizeEntityResolutionCandidate(value) { function canonicalizeEntityResolutionCandidate(value) {
@ -220,6 +223,19 @@ function compactLower(value) {
function sameScopedName(left, right) { function sameScopedName(left, right) {
return Boolean(left && right && compactLower(left) === compactLower(right)); return Boolean(left && right && compactLower(left) === compactLower(right));
} }
function preferredScopedDisplayName(value, candidates) {
const anchor = toNonEmptyString(value);
if (!anchor) {
return null;
}
for (const candidate of candidates) {
const text = candidateValue(candidate);
if (sameScopedName(text, anchor)) {
return text;
}
}
return anchor;
}
function candidateValue(value) { function candidateValue(value) {
const direct = toNonEmptyString(value); const direct = toNonEmptyString(value);
if (direct && direct !== "[object Object]") { if (direct && direct !== "[object Object]") {
@ -553,7 +569,9 @@ function collectFollowupDiscoverySeed(followupContext) {
metadataSelectedSurfaceObjects: collectEntityCandidates(followupContext?.previous_discovery_metadata_selected_surface_objects), metadataSelectedSurfaceObjects: collectEntityCandidates(followupContext?.previous_discovery_metadata_selected_surface_objects),
metadataRecommendedNextPrimitive: normalizeMetadataRecommendedPrimitive(followupContext?.previous_discovery_metadata_recommended_next_primitive), metadataRecommendedNextPrimitive: normalizeMetadataRecommendedPrimitive(followupContext?.previous_discovery_metadata_recommended_next_primitive),
metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true, metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true,
metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets) metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets),
previousBidirectionalValueFlow: toRecordObject(followupContext?.previous_discovery_bidirectional_value_flow),
previousDocumentSummary: toRecordObject(followupContext?.previous_discovery_document_summary)
}; };
} }
function buildMetadataSurfaceRef(followupSeed) { function buildMetadataSurfaceRef(followupSeed) {
@ -652,8 +670,15 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text) {
const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u043e\u0431\u0449\w*|\u043a\u0430\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0441\u0440\u0435\u0437|\u0430\u043d\u0430\u043b\u0438\u0437|(?:19|20)\d{2}|company|business|organization|overall|our|we|us|show|give|analysis)/iu.test(text); const hasCompanyScopeCue = /(?:\u0443\s+\u043d\u0430\u0441|\u043d\u0430\u0448\w*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d|\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u0431\u0438\u0437\u043d\u0435\u0441|\u0432\s+\u0446\u0435\u043b\u043e\u043c|\u043e\u0431\u0449\w*|\u043a\u0430\u043a\w*|\u043f\u043e\u043a\u0430\u0436|\u0434\u0430\u0439|\u0441\u0440\u0435\u0437|\u0430\u043d\u0430\u043b\u0438\u0437|(?:19|20)\d{2}|company|business|organization|overall|our|we|us|show|give|analysis)/iu.test(text);
return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue; return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue;
} }
function hasCrossScopeExecutiveSummarySignal(text) {
return (/(?:\u0441\u043e\u0431\u0435\u0440\p{L}*\s+(?:\u043a\u043e\u0440\u043e\u0442\u043a\p{L}*\s+)?\u0438\u0442\u043e\u0433|\u044d\u043a\u0437\u0435\u043a\u044c\u044e\u0442\u0438\u0432\p{L}*\s+\u0441\u0430\u043c\u043c\u0430\u0440\u0438|executive\s+summary|final\s+summary)/iu.test(text) &&
/(?:\u0447\u0442\u043e\s+(?:\u043c\u044b\s+)?\u043f\u043e\u0434\u0442\u0432\u0435\u0440\p{L}*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043f\u043e\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|confirmed|company|organization)/iu.test(text) &&
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test(text) &&
/(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test(text));
}
function hasBusinessOverviewSignal(text) { function hasBusinessOverviewSignal(text) {
if (hasOrganizationLevelEarningsOverviewSignal(text) || if (hasCrossScopeExecutiveSummarySignal(text) ||
hasOrganizationLevelEarningsOverviewSignal(text) ||
hasOrganizationLevelDebtPositionOverviewSignal(text) || hasOrganizationLevelDebtPositionOverviewSignal(text) ||
hasOrganizationLevelDebtDueDateOverviewSignal(text) || hasOrganizationLevelDebtDueDateOverviewSignal(text) ||
hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) || hasOrganizationLevelInventoryReserveLiquidationOverviewSignal(text) ||
@ -679,6 +704,34 @@ function hasBusinessOverviewContinuationSignal(text) {
hasFinalSummaryCue || hasFinalSummaryCue ||
hasMoneyBreakdownCue); hasMoneyBreakdownCue);
} }
function hasExplicitVatQuestionSignal(text) {
if (!text) {
return false;
}
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 hasBusinessOverviewSeparateCounterpartySignal(text) {
if (!text) {
return false;
}
return (/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|counterpart(?:y|ies)?)/iu.test(text) &&
/(?:\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|company|organization|\u0438\u0442\u043e\u0433|summary|\u0432\u044b\u0432\u043e\u0434\p{L}*)/iu.test(text));
}
function businessOverviewSeparateCounterpartyCandidateFromText(text) {
const source = (0, addressTextRepair_1.repairAddressMojibakeText)(String(text ?? ""));
const patterns = [
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*)\s+(.+?)(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu,
/(?:\u0434\u043b\u044f|for)\s+([\p{L}\d._-]+(?:\s+[\p{L}\d._-]+){0,3})(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu
];
for (const pattern of patterns) {
const candidate = normalizeFollowupCounterpartyCandidate(source.match(pattern)?.[1]);
if (candidate && !isInvalidEntityCandidate(candidate)) {
return candidate;
}
}
return null;
}
function hasExplicitTopicSwitchSignal(text) { function hasExplicitTopicSwitchSignal(text) {
return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(text); return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(text);
} }
@ -1047,8 +1100,13 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText; const rawEntitySourceText = repairedUserText ?? rawUserText ?? repairedEffectiveText ?? rawEffectiveText ?? rawSignalSourceText;
const rawText = compactLower(rawSignalSourceText); const rawText = compactLower(rawSignalSourceText);
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? ""); const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(repairedUserText ?? rawUserText ?? "");
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText); const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const rawBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) || businessOverviewContinuationSignal; const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal);
const businessOverviewContinuationSignal = hasBusinessOverviewFollowupSeed(followupSeed) &&
hasBusinessOverviewContinuationSignal(rawText) &&
!explicitVatSuppressesBusinessOverviewContinuation;
const rawBusinessOverviewSignal = rawPrimaryBusinessOverviewSignal || businessOverviewContinuationSignal;
const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText); const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowSignal = !rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText); const rawBidirectionalValueFlowSignal = !rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
const rawValueFlowSignal = !rawBusinessOverviewSignal && const rawValueFlowSignal = !rawBusinessOverviewSignal &&
@ -1094,6 +1152,10 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
rawDomain === "business_summary" || rawDomain === "business_summary" ||
rawDomain === "business_overview" || rawDomain === "business_overview" ||
rawAction === "broad_evaluation"; rawAction === "broad_evaluation";
const businessOverviewSeparateCounterpartySignal = Boolean(businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText));
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
: null;
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
const currentTurnDocumentLaneSignal = rawAction === "list_documents"; const currentTurnDocumentLaneSignal = rawAction === "list_documents";
const currentTurnMovementLaneSignal = rawAction === "list_movements"; const currentTurnMovementLaneSignal = rawAction === "list_movements";
@ -1125,6 +1187,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
sameScopedName(followupSeed.counterparty, followupSeed.organization) || sameScopedName(followupSeed.counterparty, followupSeed.organization) ||
sameScopedName(followupSeed.counterparty, currentTurnOrganizationScope))); sameScopedName(followupSeed.counterparty, currentTurnOrganizationScope)));
const businessOverviewSuppressesFollowupCounterparty = Boolean(businessOverviewSignal && const businessOverviewSuppressesFollowupCounterparty = Boolean(businessOverviewSignal &&
!businessOverviewSeparateCounterpartySignal &&
(rawBusinessOverviewSignal || (rawBusinessOverviewSignal ||
businessOverviewContinuationSignal || businessOverviewContinuationSignal ||
broadBusinessEvaluationUnsupported || broadBusinessEvaluationUnsupported ||
@ -1161,7 +1224,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? null ? null
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty); : normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const predecomposeDateScope = collectDateScope(predecomposeContract); const predecomposeDateScope = collectDateScope(predecomposeContract);
const suppressFollowupBusinessOverviewSeed = Boolean(explicitVatSuppressesBusinessOverviewContinuation && hasBusinessOverviewFollowupSeed(followupSeed));
const periodClarificationFollowupApplicable = Boolean(followupSeed.domain && const periodClarificationFollowupApplicable = Boolean(followupSeed.domain &&
!suppressFollowupBusinessOverviewSeed &&
followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopStatus === "awaiting_clarification" &&
followupSeed.loopPendingAxes.includes("period") && followupSeed.loopPendingAxes.includes("period") &&
!rawLifecycleSignal && !rawLifecycleSignal &&
@ -1172,6 +1237,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
relativeCurrentDateHintDetected || relativeCurrentDateHintDetected ||
(predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope)))); (predecomposeDateScope && !isImplicitCurrentDateScope(predecomposeDateScope))));
const followupDiscoverySeedApplicable = Boolean(followupSeed.domain && const followupDiscoverySeedApplicable = Boolean(followupSeed.domain &&
!suppressFollowupBusinessOverviewSeed &&
!rawLifecycleSignal && !rawLifecycleSignal &&
!rawMetadataSignal && !rawMetadataSignal &&
(periodClarificationFollowupApplicable || (periodClarificationFollowupApplicable ||
@ -1499,6 +1565,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity);
} }
pushScopedEntityCandidate(entityCandidates, businessOverviewSeparateCounterpartyCandidate, groundedFollowupEntity);
pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity);
pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity);
if (!groundedFollowupEntity) { if (!groundedFollowupEntity) {
@ -1511,6 +1578,20 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
} }
pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity);
} }
const businessOverviewSeparateCounterpartyDisplayCandidate = businessOverviewSeparateCounterpartySignal
? preferredScopedDisplayName(businessOverviewSeparateCounterpartyCandidate, [
groundedFollowupEntity,
effectiveFollowupCounterparty,
followupSeed.discoveryEntity,
normalizedPredecomposeCounterparty,
rawScopedEntityCandidate,
rawEntityCandidate,
...entityCandidates
])
: null;
const businessOverviewSeparateEntityCandidates = businessOverviewSeparateCounterpartyDisplayCandidate
? [businessOverviewSeparateCounterpartyDisplayCandidate]
: [];
if ((rawMetadataSignal || metadataFollowupSeedApplicable) && if ((rawMetadataSignal || metadataFollowupSeedApplicable) &&
!groundedFollowupEntity && !groundedFollowupEntity &&
!metadataScopedLaneWithoutSubject) { !metadataScopedLaneWithoutSubject) {
@ -1579,6 +1660,8 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
(clarificationLoopStillNeedsPeriod || (clarificationLoopStillNeedsPeriod ||
businessOverviewSignal || businessOverviewSignal ||
openScopeValueFlowWithoutResolvedCounterparty || openScopeValueFlowWithoutResolvedCounterparty ||
valueFlowGroundedDocumentFollowupApplicable ||
valueFlowGroundedMovementFollowupApplicable ||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))); (valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))));
const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal); const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal);
const topicSwitchSuppressesFollowupScope = Boolean(rawTopicSwitchSignal && const topicSwitchSuppressesFollowupScope = Boolean(rawTopicSwitchSignal &&
@ -1604,10 +1687,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)) (suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope))
? null ? null
: followupSeed.dateScope; : followupSeed.dateScope;
const businessOverviewRawYearOverridesPredecomposeAsOf = Boolean(businessOverviewSignal &&
rawDateScope &&
/^\d{4}$/.test(rawDateScope) &&
normalizedPredecomposeDateScope &&
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`));
const explicitDateScope = rawAllTimeScopeSignal const explicitDateScope = rawAllTimeScopeSignal
? null ? null
: normalizedAssistantTurnMeaningDateScope ?? : normalizedAssistantTurnMeaningDateScope ??
normalizedPredecomposeDateScope ?? (businessOverviewRawYearOverridesPredecomposeAsOf ? rawDateScope : normalizedPredecomposeDateScope) ??
rawDateScope ?? rawDateScope ??
normalizedFollowupDateScope; normalizedFollowupDateScope;
const followupDateScopeApplied = Boolean(!rawAllTimeScopeSignal && const followupDateScopeApplied = Boolean(!rawAllTimeScopeSignal &&
@ -1656,6 +1744,11 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? followupSeed.rankingNeed ? followupSeed.rankingNeed
: undefined, : undefined,
explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates, explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates,
business_overview_separate_entity_candidates: businessOverviewSeparateEntityCandidates,
previous_counterparty_value_flow_bundle: businessOverviewSignal && followupSeed.previousBidirectionalValueFlow
? followupSeed.previousBidirectionalValueFlow
: undefined,
previous_counterparty_document_bundle: businessOverviewSignal && followupSeed.previousDocumentSummary ? followupSeed.previousDocumentSummary : undefined,
metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0
? followupSeed.metadataAmbiguityEntitySets ? followupSeed.metadataAmbiguityEntitySets
: undefined, : undefined,
@ -1716,6 +1809,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) {
cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates;
} }
if ((turnMeaning.business_overview_separate_entity_candidates?.length ?? 0) > 0) {
cleanTurnMeaning.business_overview_separate_entity_candidates = turnMeaning.business_overview_separate_entity_candidates;
}
if (toRecordObject(turnMeaning.previous_counterparty_value_flow_bundle)) {
cleanTurnMeaning.previous_counterparty_value_flow_bundle = turnMeaning.previous_counterparty_value_flow_bundle;
}
if (toRecordObject(turnMeaning.previous_counterparty_document_bundle)) {
cleanTurnMeaning.previous_counterparty_document_bundle = turnMeaning.previous_counterparty_document_bundle;
}
if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) {
cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets;
} }
@ -1924,9 +2026,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (businessOverviewContinuationSignal) { if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context"); pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
} }
if (explicitVatSuppressesBusinessOverviewContinuation) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
}
if (businessOverviewSuppressesFollowupCounterparty) { if (businessOverviewSuppressesFollowupCounterparty) {
pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty"); pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty");
} }
if (businessOverviewSeparateCounterpartySignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope");
}
if (businessOverviewSeparateCounterpartyCandidate) {
pushReason(reasonCodes, "mcp_discovery_business_overview_counterparty_from_summary_text");
}
if (businessOverviewRawYearOverridesPredecomposeAsOf) {
pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
}
if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) && if (!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
normalizedPredecomposeCounterparty) { normalizedPredecomposeCounterparty) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose"); pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");
@ -1957,11 +2071,17 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
if (runDiscovery && !hasTurnMeaning) { if (runDiscovery && !hasTurnMeaning) {
pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing"); pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing");
} }
const dataNeedGraphTurnMeaning = businessOverviewSeparateCounterpartySignal && cleanTurnMeaning.explicit_entity_candidates
? {
...cleanTurnMeaning,
explicit_entity_candidates: []
}
: cleanTurnMeaning;
const dataNeedGraph = runDiscovery && hasTurnMeaning const dataNeedGraph = runDiscovery && hasTurnMeaning
? (0, assistantMcpDiscoveryDataNeedGraph_1.buildAssistantMcpDiscoveryDataNeedGraph)({ ? (0, assistantMcpDiscoveryDataNeedGraph_1.buildAssistantMcpDiscoveryDataNeedGraph)({
semanticDataNeed, semanticDataNeed,
rawUtterance: rawSignalSourceText, rawUtterance: rawSignalSourceText,
turnMeaning: cleanTurnMeaning turnMeaning: dataNeedGraphTurnMeaning
}) })
: null; : null;
if (dataNeedGraph) { if (dataNeedGraph) {

View File

@ -171,6 +171,30 @@ function hasSignalAcrossSamples(samples, detector) {
function hasExplicitRecapPromptSignal(samples) { function hasExplicitRecapPromptSignal(samples) {
return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample)); return samples.some((sample) => /(?:что\s+мы\s+.*(?:обсуждали|выяснили)|что\s+уже\s+выяснили|что\s+уже\s+поняли|напомни\s+что\s+мы|executive\s+summary|финальн\w*\s+собери|итогов\w*\s+(?:резюм|summary|вывод)|по\s+всему\s+диалогу|где\s+ответы\s+были\s+подтвержден|где\s+proxy|где\s+прокси|не\s+хватил\w*\s+доказательств|ручн\w*\s+(?:смотр|провер|контрол))/iu.test(sample));
} }
function normalizeMemoryCheckpointSample(value) {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`]/g, "")
.replace(/\s+/g, " ");
}
function hasMemoryCheckpointPromptSignal(samples) {
return samples.some((sample) => {
const text = normalizeMemoryCheckpointSample(sample);
if (!text) {
return false;
}
if (/(?:стартов\w*\s+чек\s+контекст|чек\s+контекста|context\s+check|memory\s+check)/iu.test(text)) {
return true;
}
const hasSelectedStateCue = /(?:выбранн\w*\s+(?:компан|организац|контрагент|объект)|активн\w*\s+(?:компан|организац|контрагент|объект)|selected\s+(?:company|organization|counterparty|object)|active\s+(?:company|organization|counterparty|object))/iu.test(text);
const hasDialogStateCue = /(?:в\s+текущ\w*\s+диалог|в\s+этом\s+диалог|в\s+сессии|контекст(?:е|а)?\s+диалог|current\s+(?:dialog|session|conversation))/iu.test(text);
const hasHonestyCue = /(?:не\s+выдумывай\s+памят|не\s+придумывай\s+памят|скажи\s+честно|если\s+нет|no\s+fabricat|do\s+not\s+invent\s+memory)/iu.test(text);
const asksCurrentSelection = /(?:есть\s+ли\s+уже|есть\s+ли\s+сейчас|что\s+выбрано|кто\s+выбран|какая\s+компан\w*\s+выбран)/iu.test(text);
return (hasSelectedStateCue && hasDialogStateCue) || (hasDialogStateCue && hasHonestyCue) || (asksCurrentSelection && hasHonestyCue);
});
}
function buildInventoryHistoryCapabilityFollowupReply(input) { function buildInventoryHistoryCapabilityFollowupReply(input) {
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
const organization = input.organization ?? contextFacts.organization; const organization = input.organization ?? contextFacts.organization;
@ -545,6 +569,26 @@ function extractBuyerFromSaleTraceAnswer(answerText, itemLabel) {
} }
return null; return null;
} }
function extractRequestedMemorySubject(userMessage) {
const text = String(userMessage ?? "").trim();
if (!text) {
return null;
}
const patterns = [
/памят[ьи]\s+про\s+([^.;!?]+)/iu,
/memory\s+about\s+([^.;!?]+)/iu
];
for (const pattern of patterns) {
const match = text.match(pattern);
const subject = match?.[1]
? match[1].replace(/[«»"'`]/g, "").replace(/\s+/g, " ").trim()
: "";
if (subject.length >= 2 && subject.length <= 80) {
return subject;
}
}
return null;
}
function buildAddressMemoryRecapReply(input) { function buildAddressMemoryRecapReply(input) {
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
const item = contextFacts.item; const item = contextFacts.item;
@ -604,7 +648,14 @@ function buildAddressMemoryRecapReply(input) {
"Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию." "Могу кратко напомнить контекст или сразу продолжить следующий шаг по этому же сценарию."
].join(" "); ].join(" ");
} }
return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; const requestedMemorySubject = extractRequestedMemorySubject(input.userMessage);
const subjectLine = requestedMemorySubject
? ` Память про «${requestedMemorySubject}» в этом диалоге не подтверждена.`
: " Память про конкретную компанию или контрагента в этом диалоге не подтверждена.";
return [
`Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции.${subjectLine}`,
"Чтобы продолжить без выдуманной памяти, назови компанию, контрагента или объект, и я начну новый проверенный контур."
].join(" ");
} }
function buildBroadBusinessEvaluationReply(input) { function buildBroadBusinessEvaluationReply(input) {
const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString);
@ -820,6 +871,7 @@ function createAssistantMemoryRecapPolicy(deps) {
const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal); const historicalCapabilitySignal = hasSignalAcrossSamples(samples, deps.hasHistoricalCapabilityFollowupSignal);
const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal); const memoryRecapSignal = hasSignalAcrossSamples(samples, deps.hasConversationMemoryRecallFollowupSignal);
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples); const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
const memoryCheckpointPromptSignal = hasMemoryCheckpointPromptSignal(samples);
return { return {
contextualHistoricalCapabilityFollowupDetected: Boolean(input.capabilityMetaQuery && contextualHistoricalCapabilityFollowupDetected: Boolean(input.capabilityMetaQuery &&
!input.dataScopeMetaQuery && !input.dataScopeMetaQuery &&
@ -829,9 +881,10 @@ function createAssistantMemoryRecapPolicy(deps) {
contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery && contextualMemoryRecapFollowupDetected: Boolean(!input.dataScopeMetaQuery &&
!input.capabilityMetaQuery && !input.capabilityMetaQuery &&
!input.aggregateBusinessAnalyticsSignal && !input.aggregateBusinessAnalyticsSignal &&
memoryRecapSignal && (memoryCheckpointPromptSignal ||
(memoryRecapSignal &&
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
continuity.hasGroundedAddressContext) continuity.hasGroundedAddressContext)))
}; };
} }
return { return {

View File

@ -116,6 +116,10 @@ function createAssistantTransitionPolicy(deps) {
} }
return null; return null;
} }
function hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage = null) {
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 parseDmyDateToIso(value) { function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/); const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) { if (!match) {
@ -244,6 +248,57 @@ function createAssistantTransitionPolicy(deps) {
} }
return null; return null;
} }
function readMcpDiscoveryBidirectionalValueFlow(debug) {
const entryPoint = debug?.assistant_mcp_discovery_entry_point_v1;
const flow = entryPoint?.bridge?.pilot?.derived_bidirectional_value_flow;
if (!flow || typeof flow !== "object" || Array.isArray(flow)) {
return null;
}
return flow;
}
function readCounterpartyDocumentSummaryFromItem(item) {
const text = deps.toNonEmptyString(item?.text);
if (!text) {
return null;
}
const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
const match = firstLine.match(/Контрагент:\s*([^.\n]+)\.\s*Найдено документов:\s*(\d+)/iu);
if (!match?.[1] || !match?.[2]) {
return null;
}
return {
counterparty: deps.toNonEmptyString(match[1]),
document_count: Number(match[2]),
direct_answer: firstLine
};
}
function findRecentDiscoveryValueFlowBundle(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
const debug = item?.debug;
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
continue;
}
const flow = readMcpDiscoveryBidirectionalValueFlow(debug);
if (flow) {
return flow;
}
}
return null;
}
function findRecentCounterpartyDocumentBundle(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
const summary = readCounterpartyDocumentSummaryFromItem(item);
if (summary) {
return summary;
}
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) { function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
if (sourceIntentHint !== "inventory_purchase_provenance_for_item" && if (sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
!hasInventoryItemFocusHint && !hasInventoryItemFocusHint &&
@ -388,7 +443,8 @@ function createAssistantTransitionPolicy(deps) {
llmPreDecomposeMeta llmPreDecomposeMeta
}) })
: null; : null;
if (assistantTurnMeaning?.stale_replay_forbidden === true) { if (assistantTurnMeaning?.stale_replay_forbidden === true &&
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)) {
return null; return null;
} }
const latestAddressItem = deps.findLastAddressAssistantItem(items); const latestAddressItem = deps.findLastAddressAssistantItem(items);
@ -465,17 +521,20 @@ function createAssistantTransitionPolicy(deps) {
const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) const shortValueFlowRetargetAlternate = hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) ? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
: false; : false;
const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage);
let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) || Boolean(debtRoleSwapPrimary) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
inventoryShortFollowupPrimary || inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge; inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) || Boolean(debtRoleSwapAlternate) ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal
: false; : false;
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
@ -507,6 +566,7 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementPrimary || hasInventoryRootRestatementPrimary ||
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
@ -526,6 +586,7 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementPrimary || hasInventoryRootRestatementPrimary ||
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
@ -556,7 +617,8 @@ function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal && !hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal && !hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation && !hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal) { !hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal) {
return null; return null;
} }
if (!hasPrimaryFollowupSignal && if (!hasPrimaryFollowupSignal &&
@ -570,7 +632,8 @@ function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal && !hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal && !hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation && !hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal) { !hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal) {
return null; return null;
} }
if (!carryoverSourceDebug) { if (!carryoverSourceDebug) {
@ -598,6 +661,8 @@ function createAssistantTransitionPolicy(deps) {
const sourceDiscoveryLoopSubjectResolutionOptional = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSubjectResolutionOptional)(carryoverSourceDebug); const sourceDiscoveryLoopSubjectResolutionOptional = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryLoopSubjectResolutionOptional)(carryoverSourceDebug);
const sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryRankingNeed = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryRankingNeed)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryEntityAmbiguityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityAmbiguityCandidates)(carryoverSourceDebug, deps.toNonEmptyString); const sourceDiscoveryEntityAmbiguityCandidates = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryEntityAmbiguityCandidates)(carryoverSourceDebug, deps.toNonEmptyString);
const sourceDiscoveryBidirectionalValueFlow = readMcpDiscoveryBidirectionalValueFlow(carryoverSourceDebug) ?? findRecentDiscoveryValueFlowBundle(items);
const sourceDiscoveryDocumentSummary = findRecentCounterpartyDocumentBundle(items);
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent;
@ -690,6 +755,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
inventoryShortFollowupPrimary || inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary; hasInventoryRootTemporalFollowupPrimary;
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
@ -698,6 +764,7 @@ function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate hasInventoryRootTemporalFollowupAlternate
: false; : false;
hasStrongFollowupReference = hasStrongFollowupReference =
@ -711,6 +778,7 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate || hasInventoryRootTemporalFollowupAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
@ -859,6 +927,8 @@ function createAssistantTransitionPolicy(deps) {
previous_discovery_metadata_recommended_next_primitive: sourceDiscoveryMetadataRecommendedNextPrimitive ?? undefined, previous_discovery_metadata_recommended_next_primitive: sourceDiscoveryMetadataRecommendedNextPrimitive ?? undefined,
previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined,
previous_discovery_metadata_ambiguity_entity_sets: sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined, previous_discovery_metadata_ambiguity_entity_sets: sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined,
previous_discovery_bidirectional_value_flow: sourceDiscoveryBidirectionalValueFlow ?? undefined,
previous_discovery_document_summary: sourceDiscoveryDocumentSummary ?? undefined,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_context_only: rootScopedPivot || undefined, root_context_only: rootScopedPivot || undefined,
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,

View File

@ -80,21 +80,24 @@ function groundingStatusFrom(debug, input, truthGateStatus) {
} }
function coverageStatusFrom(debug, input, truthGateStatus, groundingStatus) { function coverageStatusFrom(debug, input, truthGateStatus, groundingStatus) {
const explicitCoverageEvidence = (0, addressCoverageEvidencePolicy_1.toAddressCoverageEvidenceContract)(debug.address_coverage_evidence_v1); const explicitCoverageEvidence = (0, addressCoverageEvidencePolicy_1.toAddressCoverageEvidenceContract)(debug.address_coverage_evidence_v1);
if (truthGateStatus === "full_confirmed") {
return "full";
}
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
return "partial";
}
if (truthGateStatus.startsWith("blocked")) { if (truthGateStatus.startsWith("blocked")) {
return "blocked"; return "blocked";
} }
if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") { if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") {
return "blocked"; return "blocked";
} }
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
return "partial";
}
if (explicitCoverageEvidence) { if (explicitCoverageEvidence) {
return explicitCoverageEvidence.coverage_status; return explicitCoverageEvidence.coverage_status;
} }
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
return "partial";
}
if (truthGateStatus === "full_confirmed") {
return "full";
}
const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report);
if (coverageReport) { if (coverageReport) {
const total = asNumber(coverageReport.requirements_total); const total = asNumber(coverageReport.requirements_total);
@ -123,10 +126,16 @@ function truthModeFrom(input) {
if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) { if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) {
return "clarification_required"; return "clarification_required";
} }
if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) { if (input.coverageStatus === "partial") {
return "limited";
}
if (input.truthGateStatus === "full_confirmed" && input.coverageStatus === "full") {
return "confirmed"; return "confirmed";
} }
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") { if (input.coverageStatus === "full" && input.groundingStatus === "grounded") {
return "confirmed";
}
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual") {
return "limited"; return "limited";
} }
return "unsupported"; return "unsupported";
@ -140,6 +149,9 @@ function evidenceGradeFrom(debug, coverageStatus, groundingStatus, truthGateStat
if (isEvidenceGrade(explicit)) { if (isEvidenceGrade(explicit)) {
return explicit; return explicit;
} }
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
return coverageStatus === "partial" ? "medium" : "weak";
}
if (coverageStatus === "blocked") { if (coverageStatus === "blocked") {
return "none"; return "none";
} }

View File

@ -384,6 +384,15 @@ function extractMonthPeriod(text: string): { period_from?: string; period_to?: s
return {}; return {};
} }
function isExactHistoricalPeriodWindow(filters: AddressFilterSet): boolean {
return (
typeof filters.period_from === "string" &&
filters.period_from.trim().length > 0 &&
typeof filters.period_to === "string" &&
filters.period_to.trim().length > 0
);
}
function extractPeriodRange(text: string): { period_from?: string; period_to?: string } { function extractPeriodRange(text: string): { period_from?: string; period_to?: string } {
const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2); const directMatch = text.match(PERIOD_RANGE_PATTERN_1) ?? text.match(PERIOD_RANGE_PATTERN_2);
if (!directMatch) { if (!directMatch) {
@ -810,6 +819,9 @@ function isLowQualityCounterpartyAnchorValue(rawValue: string): boolean {
if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) { if (meaningfulNonGenericTokens.length === 0 && (hasTemporalCue || paymentCue)) {
return true; return true;
} }
if (meaningfulNonGenericTokens.length === 0) {
return true;
}
const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token)); const meaningfulTokens = tokens.filter((token) => isLikelyCounterpartyToken(token));
return meaningfulTokens.length === 0; return meaningfulTokens.length === 0;
} }
@ -1634,6 +1646,9 @@ function resolveSemanticDateBasisHint(filters: AddressFilterSet, warnings: strin
const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0; const hasAsOfDate = typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0;
const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0; const hasPeriodFrom = typeof filters.period_from === "string" && filters.period_from.trim().length > 0;
const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0; const hasPeriodTo = typeof filters.period_to === "string" && filters.period_to.trim().length > 0;
if (warnings.includes("as_of_date_derived_from_exact_historical_period") && (hasPeriodFrom || hasPeriodTo)) {
return hasPeriodFrom && hasPeriodTo ? "period_range" : "period_end";
}
if (hasPeriodFrom && hasPeriodTo) { if (hasPeriodFrom && hasPeriodTo) {
return "period_range"; return "period_range";
} }
@ -1938,6 +1953,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
} }
} }
if (isExactHistoricalPeriodWindow(filters) && !warnings.includes("exact_historical_period_window_requested")) {
const derivedFromHistoricalPhrase =
warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase");
if (derivedFromHistoricalPhrase) {
warnings.push("exact_historical_period_window_requested");
}
}
const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate; const vatAsOfDate = explicitAsOfDateWithCue ?? explicitAsOfDate;
if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) { if (intent === "vat_payable_forecast" && vatAsOfDate && !periodRange.period_from && !periodRange.period_to) {
const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate); const quarterWindow = deriveQuarterWindowForDate(vatAsOfDate);
@ -1954,11 +1979,14 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
} }
} }
const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase"); const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase");
const yearPeriodWasDerived =
warnings.includes("period_derived_from_year_phrase") || warnings.includes("period_derived_from_year_range_phrase");
if ( if (
intent === "vat_liability_confirmed_for_tax_period" && intent === "vat_liability_confirmed_for_tax_period" &&
!periodRange.period_from && !periodRange.period_from &&
!periodRange.period_to && !periodRange.period_to &&
!monthPeriodWasDerived !monthPeriodWasDerived &&
!yearPeriodWasDerived
) { ) {
const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null;
if (periodToForQuarter) { if (periodToForQuarter) {
@ -1986,7 +2014,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
warnings.includes("period_derived_from_year_range_phrase") || warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase"); warnings.includes("period_derived_from_year_phrase");
const preserveDerivedPeriodWindow = const preserveDerivedPeriodWindow =
intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date"; 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")) {
warnings.push("exact_historical_period_window_requested");
}
if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) { if (periodWasDerivedHeuristically && !periodRange.period_from && !periodRange.period_to && !preserveDerivedPeriodWindow) {
delete filters.period_from; delete filters.period_from;
delete filters.period_to; delete filters.period_to;
@ -2016,6 +2049,16 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
if (filters.period_to) { if (filters.period_to) {
filters.as_of_date = filters.period_to; filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to"); warnings.push("as_of_date_derived_from_period_to");
if (
warnings.includes("period_derived_from_month_phrase") ||
warnings.includes("period_derived_from_year_range_phrase") ||
warnings.includes("period_derived_from_year_phrase")
) {
warnings.push("as_of_date_derived_from_exact_historical_period");
if (!warnings.includes("exact_historical_period_window_requested")) {
warnings.push("exact_historical_period_window_requested");
}
}
} else if (shouldDefaultAsOfDateToToday(intent)) { } else if (shouldDefaultAsOfDateToToday(intent)) {
filters.as_of_date = new Date().toISOString().slice(0, 10); filters.as_of_date = new Date().toISOString().slice(0, 10);
warnings.push("as_of_date_defaulted_today"); warnings.push("as_of_date_defaulted_today");

View File

@ -2232,6 +2232,18 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test( const hasRankingCue = /(?:топ|ранк|сам(?:ый|ая|ое|ые)|больше\s+всего|наибольш|крупн|жирн|max|top|rank)/iu.test(
normalized normalized
); );
const hasInventoryPurchaseToSaleDocumentChainCue =
/(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(
normalized
) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
if (hasInventoryPurchaseToSaleDocumentChainCue) {
return unicodeBridgeResolution(
"inventory_purchase_to_sale_chain",
"high",
"unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"
);
}
const hasSelectedObjectProfitabilityCue = const hasSelectedObjectProfitabilityCue =
/(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test( /(?:\u043f\u043e\s+\u0432\u044b\u0431\u0440\u0430\u043d\u043d(?:\u043e\u043c\u0443|\u043e\u0439)\s+(?:\u043e\u0431\u044a\u0435\u043a\u0442\u0443|\u043f\u043e\u0437\u0438\u0446\u0438\u0438)|selected\s+object)/iu.test(
normalized normalized
@ -2247,18 +2259,6 @@ function resolveUnicodeAddressIntentBridge(text: string): AddressIntentResolutio
); );
} }
const hasInventoryPurchaseToSaleDocumentChainCue =
/(?:закупк[а-яё]*[\s\S]{0,80}склад[\s\S]{0,80}продаж|путь\s+товар[а-яё]*[\s\S]{0,80}закуп|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale|->\s*(?:склад|warehouse|stock)\s*->\s*(?:продаж|sale))/iu.test(
normalized
) && /(?:товар|позици|номенклатур|sku|item|product)/iu.test(normalized);
if (hasInventoryPurchaseToSaleDocumentChainCue) {
return unicodeBridgeResolution(
"inventory_purchase_to_sale_chain",
"high",
"unicode_inventory_purchase_to_sale_chain_bridge_signal_detected"
);
}
const hasOpenItemsAccountCue = const hasOpenItemsAccountCue =
/(?:хвост|долг|незакрыт|вис)/iu.test(normalized) && /(?:хвост|долг|незакрыт|вис)/iu.test(normalized) &&
/(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test( /(?:сч(?:е|ё)т(?:а|у|ом|е|ов)?\s*(?:|#)?\s*(?:60|62|76)(?:[.,]\d{1,2})?|\b(?:60|62|76)(?:[.,]\d{1,2})?\b\s*сч(?:е|ё)т)/iu.test(

View File

@ -2194,7 +2194,8 @@ function enforceStrictAccountScopeForIntent(
function resolveExecutionFiltersForConfirmedBalance( function resolveExecutionFiltersForConfirmedBalance(
filters: AddressFilterSet, filters: AddressFilterSet,
analysisDate: string | null analysisDate: string | null,
warnings: string[] = []
): { ): {
executionFilters: AddressFilterSet; executionFilters: AddressFilterSet;
asOfDerived: string | null; asOfDerived: string | null;
@ -2208,8 +2209,10 @@ function resolveExecutionFiltersForConfirmedBalance(
if (derivedAsOf) { if (derivedAsOf) {
executionFilters.as_of_date = derivedAsOf; executionFilters.as_of_date = derivedAsOf;
} }
if (!warnings.includes("as_of_date_derived_from_exact_historical_period")) {
delete executionFilters.period_from; delete executionFilters.period_from;
delete executionFilters.period_to; delete executionFilters.period_to;
}
const limit = const limit =
typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit) typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(executionFilters.limit)) ? Math.max(1, Math.trunc(executionFilters.limit))
@ -2415,6 +2418,9 @@ function asksForUnresolvedInventorySupplierLink(userMessage: string | null | und
} }
function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean { function canAutoBroadenPeriodWindow(intent: AddressIntent, filters: AddressFilterSet): boolean {
if (Array.isArray((filters as { warnings?: unknown }).warnings) && (filters as { warnings?: string[] }).warnings?.includes("exact_historical_period_window_requested")) {
return false;
}
const hasRecoverableAsOfOnlyWindow = const hasRecoverableAsOfOnlyWindow =
!hasExplicitPeriodWindow(filters) && !hasExplicitPeriodWindow(filters) &&
typeof filters.as_of_date === "string" && typeof filters.as_of_date === "string" &&
@ -3713,16 +3719,16 @@ export class AddressQueryService {
intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance"; intent.intent === "inventory_on_hand_as_of_date" && requestedResultMode === "confirmed_balance";
const payablesConfirmedExecution = const payablesConfirmedExecution =
confirmedBalancePayablesIntent confirmedBalancePayablesIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent const vatPayableConfirmedExecution = confirmedBalanceVatPayableIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
const inventoryConfirmedExecution = confirmedBalanceInventoryIntent const inventoryConfirmedExecution = confirmedBalanceInventoryIntent
? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate) ? resolveExecutionFiltersForConfirmedBalance(filters.extracted_filters, analysisDate, filters.warnings)
: null; : null;
let executionFilters = let executionFilters =
inventoryConfirmedExecution?.executionFilters ?? inventoryConfirmedExecution?.executionFilters ??
@ -5145,6 +5151,7 @@ export class AddressQueryService {
!counterpartyItemFlowQuery && !counterpartyItemFlowQuery &&
isDocumentOrBankAnchorIntent(intent.intent) && isDocumentOrBankAnchorIntent(intent.intent) &&
!hasExplicitPeriodWindow(filters.extracted_filters) && !hasExplicitPeriodWindow(filters.extracted_filters) &&
!filters.warnings.some((warning) => warning.startsWith("period_derived_from_")) &&
(anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract") (anchor.anchor_type === "counterparty" || anchor.anchor_type === "contract")
) { ) {
const currentLimit = const currentLimit =

View File

@ -175,6 +175,12 @@ function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTrut
return input.truthGateStatusHint; return input.truthGateStatusHint;
} }
const missingRequiredFilters = input.missingRequiredFilters ?? []; const missingRequiredFilters = input.missingRequiredFilters ?? [];
const reasonCodes = input.reasons ?? [];
const heuristicOpenItemsFallback = Boolean(
input.intent === "open_items_by_counterparty_or_contract" &&
(reasonCodes.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates") ||
reasonCodes.includes("open_items_account_query_override_to_movements"))
);
if (input.routeExpectationStatus === "mismatch") { if (input.routeExpectationStatus === "mismatch") {
return "blocked_route_expectation_failure"; return "blocked_route_expectation_failure";
} }
@ -190,6 +196,9 @@ function truthGateStatusFrom(input: ResolveAddressTruthGateInput): AssistantTrut
if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") { if (input.replyType === "factual" && input.limitedReasonCategory === "empty_match") {
return "full_confirmed"; return "full_confirmed";
} }
if (heuristicOpenItemsFallback) {
return "partial_supported";
}
if ( if (
input.limitedReasonCategory === "empty_match" || input.limitedReasonCategory === "empty_match" ||
input.limitedReasonCategory === "recipe_visibility_gap" || input.limitedReasonCategory === "recipe_visibility_gap" ||

View File

@ -4198,12 +4198,23 @@ function composeFactualReplyBody(
if (intent === "open_items_by_counterparty_or_contract") { if (intent === "open_items_by_counterparty_or_contract") {
const counterparties = buildCounterpartyRiskAggregate(rows); const counterparties = buildCounterpartyRiskAggregate(rows);
const accountLead = const accountLabel =
typeof options.accountHint === "string" && options.accountHint.trim().length > 0 typeof options.accountHint === "string" && options.accountHint.trim().length > 0
? `Проверил хвосты по счету ${options.accountHint.trim()}.` ? `по счету ${options.accountHint.trim()}`
: "Собраны открытые позиции по взаиморасчетам."; : "по взаиморасчетам";
const exactBalanceRequested = options.requestedResultMode === "confirmed_balance";
const periodLabel = options.asOfDate
? `на ${formatDateRu(options.asOfDate)}`
: options.periodFrom || options.periodTo
? `за период ${formatDateRu(options.periodFrom ?? "...")}..${formatDateRu(options.periodTo ?? "...")}`
: null;
const lines = [ const lines = [
accountLead, exactBalanceRequested
? `Коротко: точный открытый остаток ${accountLabel}${periodLabel ? ` ${periodLabel}` : ""} не подтвержден; ниже только предварительные сигналы по движениям: ${formatNumberWithDots(rows.length)} строк, контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`
: `Коротко: ${accountLabel} найдено ${formatNumberWithDots(rows.length)} строк хвостов/открытых расчетов; контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`,
exactBalanceRequested
? "Это не подтвержденное сальдо и не финальный реестр открытых расчетов: текущий контур видит движения-кандидаты, но не доказывает остаток закрытия."
: "Это shortlist для проверки, а не финальный подтвержденный реестр открытых расчетов.",
`Строк отобрано: ${rows.length}.`, `Строк отобрано: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.` `Контрагентов с сигналом: ${counterparties.length}.`
]; ];
@ -4223,7 +4234,12 @@ function composeFactualReplyBody(
} }
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",
text: lines.join("\n") text: lines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 || rows.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
}; };
} }
@ -4310,7 +4326,7 @@ function composeFactualReplyBody(
: `Найдено документов по контрагенту: ${rows.length}.` : `Найдено документов по контрагенту: ${rows.length}.`
); );
} }
if (counterpartyLabel) { if (counterpartyLabel && itemFlowQuestion) {
lines.push(`Контрагент: ${counterpartyLabel}`); lines.push(`Контрагент: ${counterpartyLabel}`);
} }
if (itemFlowQuestion) { if (itemFlowQuestion) {
@ -4330,7 +4346,11 @@ function composeFactualReplyBody(
lines.push(`Показаны первые 12 из ${rows.length} поставок.`); lines.push(`Показаны первые 12 из ${rows.length} поставок.`);
} }
} else { } else {
lines.push(...formatTopRows(rows, rows.length)); const visibleRows = rows.slice(0, 5);
lines.push(...formatTopRows(visibleRows, visibleRows.length));
if (rows.length > visibleRows.length) {
lines.push(`Показаны первые ${visibleRows.length} из ${rows.length} документов; полный список остается в подтвержденном срезе.`);
}
} }
return { return {
responseType: "FACTUAL_LIST", responseType: "FACTUAL_LIST",

View File

@ -259,11 +259,18 @@ const FOLLOWUP_LOW_QUALITY_COUNTERPARTY_TOKENS = new Set([
"сейчас", "сейчас",
"этому", "этому",
"этомуже", "этомуже",
"этой",
"этойже",
"тому", "тому",
"томуже", "томуже",
"той",
"тойже",
"нему", "нему",
"ней", "ней",
"ним", "ним",
"цепочка",
"цепочке",
"цепочку",
"неуказанному", "неуказанному",
"неуказанный", "неуказанный",
"неуказанная", "неуказанная",

View File

@ -182,10 +182,12 @@ export function composeInventoryReply(
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
if (positions.length > 0) { if (positions.length > 0) {
const visiblePositionsLimit = 6;
const visiblePositions = positions.slice(0, visiblePositionsLimit);
appendInventorySection( appendInventorySection(
lines, lines,
"Позиции:", "Позиции:",
positions.slice(0, 20).map((item, index) => visiblePositions.map((item, index) =>
formatInventorySnapshotPositionLine(item, index, { formatInventorySnapshotPositionLine(item, index, {
formatDateRu: deps.formatDateRu, formatDateRu: deps.formatDateRu,
formatNumberWithDots: deps.formatNumberWithDots, formatNumberWithDots: deps.formatNumberWithDots,
@ -193,6 +195,11 @@ export function composeInventoryReply(
}) })
) )
); );
if (positions.length > visiblePositions.length) {
lines.push(
`Показаны первые ${deps.formatNumberWithDots(visiblePositions.length)} из ${deps.formatNumberWithDots(positions.length)} позиций по сумме; полный список можно раскрыть отдельным запросом.`
);
}
} else { } else {
appendInventorySection(lines, "Позиции:", [ appendInventorySection(lines, "Позиции:", [
"- На дату среза товары с ненулевым остатком не найдены." "- На дату среза товары с ненулевым остатком не найдены."

View File

@ -271,6 +271,7 @@ export async function runAssistantLivingChatRuntime(
organization: scopedOrganization, organization: scopedOrganization,
addressDebug: lastMemoryAddressDebug, addressDebug: lastMemoryAddressDebug,
sessionItems: input.sessionItems, sessionItems: input.sessionItems,
userMessage,
toNonEmptyString: input.toNonEmptyString toNonEmptyString: input.toNonEmptyString
}); });
activeOrganization = scopedOrganization ?? activeOrganization; activeOrganization = scopedOrganization ?? activeOrganization;

View File

@ -185,12 +185,13 @@ function timeScopeNeedFor(input: {
family: string | null; family: string | null;
explicitDateScope: string | null; explicitDateScope: string | null;
allTimeScopeHint: boolean; allTimeScopeHint: boolean;
subjectScopedBidirectionalAllTime: boolean;
}): string | null { }): string | null {
if (input.explicitDateScope) { if (input.explicitDateScope) {
return "explicit_period"; return "explicit_period";
} }
if ( if (
input.allTimeScopeHint && (input.allTimeScopeHint || input.subjectScopedBidirectionalAllTime) &&
(input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence") (input.family === "value_flow" || input.family === "movement_evidence" || input.family === "document_evidence")
) { ) {
return "all_time_scope"; return "all_time_scope";
@ -515,6 +516,11 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
const comparisonNeed = comparisonNeedFor(action); const comparisonNeed = comparisonNeedFor(action);
const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed; const rankingNeed = rankingNeedFromRawUtterance(rawUtterance) ?? seededRankingNeed;
const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance); const allTimeScopeHint = hasAllTimeScopeHint(rawUtterance);
const subjectScopedBidirectionalAllTime =
businessFactFamily === "value_flow" &&
comparisonNeed === "incoming_vs_outgoing" &&
subjectCandidates.length > 0 &&
!explicitDateScope;
const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({ const directBusinessOverviewMoneyAnswerHint = hasBusinessOverviewDirectMoneyAnswerHint({
family: businessFactFamily, family: businessFactFamily,
rawUtterance, rawUtterance,
@ -576,7 +582,8 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
const timeScopeNeed = timeScopeNeedFor({ const timeScopeNeed = timeScopeNeedFor({
family: businessFactFamily, family: businessFactFamily,
explicitDateScope, explicitDateScope,
allTimeScopeHint allTimeScopeHint,
subjectScopedBidirectionalAllTime
}); });
if (timeScopeNeed === "period_required" && !explicitDateScope) { if (timeScopeNeed === "period_required" && !explicitDateScope) {
pushUnique(clarificationGaps, "period"); pushUnique(clarificationGaps, "period");
@ -618,6 +625,9 @@ export function buildAssistantMcpDiscoveryDataNeedGraph(
if (allTimeScopeHint) { if (allTimeScopeHint) {
pushReason(reasonCodes, "data_need_graph_all_time_scope_hint"); pushReason(reasonCodes, "data_need_graph_all_time_scope_hint");
} }
if (subjectScopedBidirectionalAllTime) {
pushReason(reasonCodes, "data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope");
}
if (businessFactFamily === "business_overview" && !explicitDateScope) { if (businessFactFamily === "business_overview" && !explicitDateScope) {
pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope"); pushReason(reasonCodes, "data_need_graph_business_overview_defaults_to_all_time_scope");
} }

View File

@ -26,6 +26,9 @@ export interface AssistantMcpDiscoveryTurnMeaningRef {
asked_aggregation_axis?: string | null; asked_aggregation_axis?: string | null;
seeded_ranking_need?: string | null; seeded_ranking_need?: string | null;
explicit_entity_candidates?: string[]; explicit_entity_candidates?: string[];
business_overview_separate_entity_candidates?: string[];
previous_counterparty_value_flow_bundle?: Record<string, unknown> | null;
previous_counterparty_document_bundle?: Record<string, unknown> | null;
metadata_ambiguity_entity_sets?: string[]; metadata_ambiguity_entity_sets?: string[];
metadata_scope_hint?: string | null; metadata_scope_hint?: string | null;
explicit_organization_scope?: string | null; explicit_organization_scope?: string | null;
@ -177,6 +180,7 @@ function normalizeTurnMeaning(
const dateScope = toNonEmptyString(value.explicit_date_scope); const dateScope = toNonEmptyString(value.explicit_date_scope);
const unsupported = toNonEmptyString(value.unsupported_but_understood_family); const unsupported = toNonEmptyString(value.unsupported_but_understood_family);
const entities = toStringList(value.explicit_entity_candidates); const entities = toStringList(value.explicit_entity_candidates);
const businessOverviewSeparateEntities = toStringList(value.business_overview_separate_entity_candidates);
const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets); const metadataAmbiguityEntitySets = toStringList(value.metadata_ambiguity_entity_sets);
if (domain) { if (domain) {
result.asked_domain_family = domain; result.asked_domain_family = domain;
@ -193,6 +197,9 @@ function normalizeTurnMeaning(
if (entities.length > 0) { if (entities.length > 0) {
result.explicit_entity_candidates = entities; result.explicit_entity_candidates = entities;
} }
if (businessOverviewSeparateEntities.length > 0) {
result.business_overview_separate_entity_candidates = businessOverviewSeparateEntities;
}
if (metadataAmbiguityEntitySets.length > 0) { if (metadataAmbiguityEntitySets.length > 0) {
result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets; result.metadata_ambiguity_entity_sets = metadataAmbiguityEntitySets;
} }

View File

@ -430,22 +430,271 @@ function businessOverviewYearRowsLine(overview: Record<string, unknown>): string
return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null; return values.length > 0 ? `По годам: ${sentenceAmount(joined) ?? joined}.` : null;
} }
function firstOverviewAxisLabel(rows: unknown, amountKey = "total_amount_human_ru"): string | null {
const first = toRecordObject(Array.isArray(rows) ? rows[0] : null);
const label = toNonEmptyString(first?.axis_value);
const amount = moneyText(first?.[amountKey]);
return label && amount ? `${label}${sentenceAmount(amount) ?? amount}` : null;
}
function businessOverviewTaxLine(overview: Record<string, unknown>): string | null {
const tax = toRecordObject(overview.tax_position);
if (!tax) {
return null;
}
const salesVat = moneyText(tax.sales_vat_amount_human_ru);
const purchaseVat = moneyText(tax.purchase_vat_amount_human_ru);
const netVat = moneyText(tax.net_vat_amount_human_ru);
if (!salesVat && !purchaseVat && !netVat) {
return null;
}
const direction =
tax.net_vat_direction === "vat_to_pay"
? "НДС к уплате"
: tax.net_vat_direction === "vat_to_recover_or_offset"
? "НДС к возмещению/зачету"
: "чистая НДС-позиция";
return `НДС: продажи ${salesVat ?? "0 руб."}, покупки ${purchaseVat ?? "0 руб."}, ${direction} ${sentenceAmount(netVat) ?? netVat ?? "0 руб."}.`;
}
function businessOverviewDebtLine(overview: Record<string, unknown>): string | null {
const debt = toRecordObject(overview.debt_position);
if (!debt) {
return null;
}
const receivables = moneyText(toRecordObject(debt.receivables)?.total_amount_human_ru);
const payables = moneyText(toRecordObject(debt.payables)?.total_amount_human_ru);
const net = moneyText(debt.net_debt_position_amount_human_ru);
if (!receivables && !payables && !net) {
return null;
}
const direction =
debt.net_debt_position_direction === "net_payable" ? "кредиторка больше дебиторки" : "дебиторка больше кредиторки";
return `Долги: дебиторка ${receivables ?? "0 руб."}, кредиторка ${payables ?? "0 руб."}, нетто ${sentenceAmount(net) ?? net ?? "0 руб."} (${direction}).`;
}
function businessOverviewInventoryLine(overview: Record<string, unknown>): string | null {
const inventory = toRecordObject(overview.inventory_position);
if (!inventory) {
return null;
}
const amount = moneyText(inventory.total_amount_human_ru);
const rows = Number(inventory.rows_matched);
const quantity = Number(inventory.total_quantity);
if (!amount && !Number.isFinite(rows)) {
return null;
}
const pieces = [
Number.isFinite(rows) ? `${rows} позиций` : null,
amount ? `на ${sentenceAmount(amount) ?? amount}` : null,
Number.isFinite(quantity) && quantity > 0 ? `количество ${quantity}` : null
].filter((item): item is string => Boolean(item));
return pieces.length > 0 ? `Склад: ${pieces.join(", ")}.` : null;
}
function rowCountText(value: unknown): string | null {
const count = Number(value);
return Number.isFinite(count) ? String(count) : null;
}
function sideRowsText(side: Record<string, unknown> | null): string | null {
const rowsWithAmount = rowCountText(side?.rows_with_amount);
const rowsMatched = rowCountText(side?.rows_matched);
if (rowsWithAmount && rowsMatched) {
return `${rowsWithAmount} из ${rowsMatched}`;
}
return rowsWithAmount ?? rowsMatched;
}
function sideDateText(side: Record<string, unknown> | null): string | null {
const first = toNonEmptyString(side?.first_movement_date);
const latest = toNonEmptyString(side?.latest_movement_date);
if (first && latest) {
return first === latest ? `дата ${first}` : `даты ${first}..${latest}`;
}
return first ? `первая дата ${first}` : latest ? `последняя дата ${latest}` : null;
}
function bidirectionalNetLabel(direction: unknown): string {
if (direction === "net_outgoing") {
return "нетто в сторону контрагента";
}
if (direction === "balanced") {
return "нетто около нуля";
}
return "нетто в нашу сторону";
}
function buildCompactBidirectionalValueFlowReply(
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract,
draft: Record<string, unknown>
): string | null {
const bridge = toRecordObject(entryPoint.bridge);
const pilot = toRecordObject(bridge?.pilot);
const flow = toRecordObject(pilot?.derived_bidirectional_value_flow);
if (!flow) {
return null;
}
const incoming = toRecordObject(flow.incoming_customer_revenue);
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
const netAmount = moneyText(flow.net_amount_human_ru);
if (!incomingAmount && !outgoingAmount && !netAmount) {
return null;
}
const counterparty = toNonEmptyString(flow.counterparty) ?? "запрошенному контрагенту";
const period = toNonEmptyString(flow.period_scope);
const periodText = period ? ` за период ${period}` : " в проверенном окне";
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
const netLabel = bidirectionalNetLabel(flow.net_direction);
const lines = [
`Коротко: по контрагенту ${counterparty}${periodText} по найденным строкам 1С получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}; расчетное ${netLabel}: ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.`
];
const basis: string[] = [];
if (incomingRows) {
basis.push(`входящих строк с суммой ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
}
if (outgoingRows) {
basis.push(`исходящих строк с суммой ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
}
if (basis.length > 0) {
lines.push(`Основа: ${basis.join("; ")}.`);
}
if (flow.coverage_limited_by_probe_limit === true) {
lines.push("Важно: часть проверки уперлась в лимит строк, поэтому это проверенный срез найденных движений, а не гарантия полного периода.");
}
lines.push("Метод: нетто рассчитано как подтвержденные входящие строки 1С минус подтвержденные исходящие строки; это не полное бухгалтерское сальдо вне проверенного окна.");
const fallbackNextStep = toNonEmptyString(draft.next_step_line);
if (fallbackNextStep) {
lines.push(`Следующий шаг: ${localizeLine(fallbackNextStep)}`);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
function compactComparable(value: string | null): string {
return String(value ?? "")
.toLowerCase()
.replace(/[«»"']/g, "")
.replace(/\s+/g, " ")
.trim();
}
function businessOverviewSeparateSubjectLabel(
graph: Record<string, unknown> | null,
turnMeaning: Record<string, unknown> | null,
organizationScope: string | null
): string | null {
const candidates = uniqueStrings([
...toStringList(turnMeaning?.business_overview_separate_entity_candidates),
...toStringList(graph?.subject_candidates),
...toStringList(turnMeaning?.explicit_entity_candidates)
]);
const organizationComparable = compactComparable(organizationScope);
for (const candidate of candidates) {
const text = toNonEmptyString(candidate);
if (!text) {
continue;
}
const comparable = compactComparable(text);
if (organizationComparable && comparable === organizationComparable) {
continue;
}
return text;
}
return null;
}
function sameBusinessSubject(left: string | null, right: string | null): boolean {
const leftComparable = compactComparable(left);
const rightComparable = compactComparable(right);
return Boolean(leftComparable && rightComparable && leftComparable === rightComparable);
}
function previousDocumentSummaryLine(
bundle: Record<string, unknown> | null,
separateSubject: string | null
): string | null {
if (!bundle || !sameBusinessSubject(toNonEmptyString(bundle.counterparty), separateSubject)) {
return null;
}
const count = Number(bundle.document_count);
if (!Number.isFinite(count) || count <= 0) {
return null;
}
return `документы по цепочке: найдено ${count}`;
}
function buildPreviousCounterpartyValueFlowSummary(
flow: Record<string, unknown> | null,
separateSubject: string | null,
documentBundle: Record<string, unknown> | null
): { lead: string; line: string } | null {
if (!flow || !separateSubject || !sameBusinessSubject(toNonEmptyString(flow.counterparty), separateSubject)) {
return null;
}
const incoming = toRecordObject(flow.incoming_customer_revenue);
const outgoing = toRecordObject(flow.outgoing_supplier_payout);
const incomingAmount = moneyText(incoming?.total_amount_human_ru);
const outgoingAmount = moneyText(outgoing?.total_amount_human_ru);
const netAmount = moneyText(flow.net_amount_human_ru);
if (!incomingAmount && !outgoingAmount && !netAmount) {
return null;
}
const counterparty = toNonEmptyString(flow.counterparty) ?? separateSubject;
const netLabel = bidirectionalNetLabel(flow.net_direction);
const lead =
`; отдельно по ${counterparty}: получили ${incomingAmount ?? "0 руб."}, заплатили ${outgoingAmount ?? "0 руб."}, ` +
`${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}`;
const basis: string[] = [];
const incomingRows = sideRowsText(incoming);
const outgoingRows = sideRowsText(outgoing);
const incomingDates = sideDateText(incoming);
const outgoingDates = sideDateText(outgoing);
if (incomingRows) {
basis.push(`входящие строки ${incomingRows}${incomingDates ? ` (${incomingDates})` : ""}`);
}
if (outgoingRows) {
basis.push(`исходящие строки ${outgoingRows}${outgoingDates ? ` (${outgoingDates})` : ""}`);
}
const documents = previousDocumentSummaryLine(documentBundle, counterparty);
if (documents) {
basis.push(documents);
}
const basisText = basis.length > 0 ? ` Основа: ${basis.join("; ")}.` : "";
return {
lead,
line:
`Отдельно по контрагенту ${counterparty}: подтверждено получили ${incomingAmount ?? "0 руб."}, ` +
`заплатили ${outgoingAmount ?? "0 руб."}, расчетное ${netLabel} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб."}.` +
`${basisText} Это не перенос сумм компании на контрагента, а отдельный ранее подтвержденный контрагентский срез.`
};
}
function buildCompactBusinessOverviewReply( function buildCompactBusinessOverviewReply(
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract,
draft: Record<string, unknown> draft: Record<string, unknown>
): string | null { ): string | null {
const turnInput = toRecordObject(entryPoint.turn_input); const turnInput = toRecordObject(entryPoint.turn_input);
const turnMeaning = toRecordObject(turnInput?.turn_meaning_ref);
const graph = toRecordObject(turnInput?.data_need_graph); const graph = toRecordObject(turnInput?.data_need_graph);
const bridge = toRecordObject(entryPoint.bridge); const bridge = toRecordObject(entryPoint.bridge);
const pilot = toRecordObject(bridge?.pilot); const pilot = toRecordObject(bridge?.pilot);
const overview = toRecordObject(pilot?.derived_business_overview); const overview = toRecordObject(pilot?.derived_business_overview);
const graphReasons = readStringArray(graph?.reason_codes);
const isBusinessOverview = const isBusinessOverview =
toNonEmptyString(graph?.business_fact_family) === "business_overview" || toNonEmptyString(graph?.business_fact_family) === "business_overview" ||
toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1"; toNonEmptyString(pilot?.pilot_scope) === "business_overview_route_template_v1";
const rankingNeed = toNonEmptyString(graph?.ranking_need); const rankingNeed = toNonEmptyString(graph?.ranking_need);
const directMoneyAnswer = graphReasons.includes("data_need_graph_business_overview_direct_money_answer"); if (!isBusinessOverview || !overview) {
if (!isBusinessOverview || !overview || (!rankingNeed && !directMoneyAnswer)) {
return null; return null;
} }
@ -457,8 +706,51 @@ function buildCompactBusinessOverviewReply(
const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто"; const netDirection = overview.net_direction === "net_outgoing" ? "операционное нетто в минус" : "расчетное операционное нетто";
const period = businessOverviewPeriodText(overview); const period = businessOverviewPeriodText(overview);
const limitLine = businessOverviewCoverageLimitLine(overview); const limitLine = businessOverviewCoverageLimitLine(overview);
const organizationScope = toNonEmptyString(turnMeaning?.explicit_organization_scope);
const separateSubject = businessOverviewSeparateSubjectLabel(graph, turnMeaning, organizationScope);
const previousCounterpartySummary = buildPreviousCounterpartyValueFlowSummary(
toRecordObject(turnMeaning?.previous_counterparty_value_flow_bundle),
separateSubject,
toRecordObject(turnMeaning?.previous_counterparty_document_bundle)
);
const organizationPrefix = organizationScope ? `по компании ${organizationScope} ` : "";
const separateSubjectLead = separateSubject
? previousCounterpartySummary?.lead ??
`; по контрагенту ${separateSubject} суммы компании не переношу, это отдельный контур без подтвержденного итога в этой строке`
: "";
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 topCustomerLead =
customerName && customerAmount
? `; крупнейший источник входящих денег: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}`
: "";
const topSupplier = firstOverviewAxisLabel(overview.top_suppliers);
const topSupplierLead = topSupplier ? `; крупнейший получатель исходящих денег: ${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 lines: string[] = [];
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);
lines.push(
`Можно утверждать: по компании подтвержден operating-flow proxy по найденным строкам 1С; по ${separateSubject} отдельно подтверждены входящие/исходящие строки, расчетное нетто и документы из предыдущего контрагентского среза.`
);
lines.push(
`Нельзя утверждать: это не чистая прибыль, не полный бухгалтерский оборот вне проверенного окна и не доказательство, что ${separateSubject} является главным клиентом или поставщиком как бизнес-роль.`
);
if (limitLine) {
lines.push(limitLine);
}
const reply = lines.join("\n").trim();
return reply.length > 0 && !hasInternalMechanics(reply) ? reply : null;
}
if (rankingNeed) { if (rankingNeed) {
const incomingLeader = strongestIncomingYear(overview); const incomingLeader = strongestIncomingYear(overview);
const netLeader = strongestNetYear(overview); const netLeader = strongestNetYear(overview);
@ -469,7 +761,7 @@ function buildCompactBusinessOverviewReply(
return null; return null;
} }
lines.push( lines.push(
`Коротко: самый доходный год в доступном денежном контуре 1С ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}.` `Коротко: в доступном проверенном MCP-срезе по входящим денежным строкам лидирует ${leaderYear}: ${leaderAmount}${Number.isFinite(leaderRows) && leaderRows > 0 ? ` по ${leaderRows} строкам с суммой` : ""}; это не полный бухгалтерский рейтинг доходности.`
); );
const netYear = toNonEmptyString(netLeader?.year_bucket); const netYear = toNonEmptyString(netLeader?.year_bucket);
const netYearAmount = moneyText(netLeader?.net_amount_human_ru); const netYearAmount = moneyText(netLeader?.net_amount_human_ru);
@ -487,19 +779,62 @@ function buildCompactBusinessOverviewReply(
} }
} else if (incomingAmount || outgoingAmount || netAmount) { } else if (incomingAmount || outgoingAmount || netAmount) {
lines.push( lines.push(
`Коротко: ${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}.` `Коротко: ${organizationPrefix}${period} по подтвержденным строкам 1С получили ${incomingAmount ?? "0 руб."}; исходящие платежи/списания ${outgoingAmount ?? "0 руб."}; ${netDirection} ${sentenceAmount(netAmount) ?? netAmount ?? "0 руб"}${topCustomerLead}${topSupplierLead}${roleBoundaryLead}${separateSubjectLead}.`
); );
lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.'); lines.push('Метод: "заработали" здесь считаю как денежный operating-flow proxy по 1С; это не чистая прибыль и не финрезультат.');
const topCustomer = toRecordObject(Array.isArray(overview.top_customers) ? overview.top_customers[0] : null); if (!directMoneyAnswer && customerName && customerAmount) {
const customerName = toNonEmptyString(topCustomer?.axis_value);
const customerAmount = moneyText(topCustomer?.total_amount_human_ru);
if (customerName && customerAmount) {
lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`); lines.push(`Крупнейший подтвержденный источник входящих денег в этом срезе: ${customerName}${sentenceAmount(customerAmount) ?? customerAmount}.`);
} }
} else { } else {
return null; return null;
} }
if (separateSubject) {
lines.push(
previousCounterpartySummary?.line ??
`Отдельно по контрагенту ${separateSubject}: этот итог не переносит суммы компании на контрагента. Можно утверждать только разделение контура; нельзя делать вывод о выручке, долге или прибыльности ${separateSubject} без отдельного контрагентского среза документов и движений.`
);
}
if (!directMoneyAnswer && topSupplier) {
lines.push(`Крупнейший подтвержденный получатель исходящих денег: ${topSupplier}.`);
}
if (!directMoneyAnswer && (topCustomer || topSupplier)) {
lines.push("Важно по ролям: текущий денежный срез подтверждает денежные источники и получателей, но не доказывает, что это главный клиент или главный поставщик как бизнес-роль.");
}
if (!directMoneyAnswer) {
lines.push(
`Что подтверждено: денежный срез по компании${organizationScope ? ` ${organizationScope}` : ""}${period ? ` ${period}` : ""}${topCustomer ? ", крупнейший источник входящих денег" : ""}${topSupplier ? ", крупнейший получатель исходящих денег" : ""}.`
);
const taxLine = businessOverviewTaxLine(overview);
if (taxLine) {
lines.push(taxLine);
}
const debtLine = businessOverviewDebtLine(overview);
if (debtLine) {
lines.push(debtLine);
}
const inventoryLine = businessOverviewInventoryLine(overview);
if (inventoryLine) {
lines.push(inventoryLine);
}
const missingOverviewFamilies: string[] = [];
if (!taxLine) {
missingOverviewFamilies.push("общая НДС/налоговая позиция без отдельного точного расчета");
}
if (!debtLine) {
missingOverviewFamilies.push("долги без даты среза");
}
if (!inventoryLine) {
missingOverviewFamilies.push("склад без даты среза");
}
if (missingOverviewFamilies.length > 0) {
lines.push(`Что не подтверждено в этом срезе: ${missingOverviewFamilies.join(", ")}.`);
}
lines.push(
"Что нельзя утверждать: чистую прибыль, полноценный финрезультат, юридические бизнес-роли клиентов/поставщиков и общую налоговую позицию без отдельного точного расчета."
);
}
if (limitLine) { if (limitLine) {
lines.push(limitLine); lines.push(limitLine);
} }
@ -556,6 +891,11 @@ function buildReplyText(entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContra
return null; return null;
} }
const compactBidirectionalValueFlowReply = buildCompactBidirectionalValueFlowReply(entryPoint, draft);
if (compactBidirectionalValueFlowReply) {
return compactBidirectionalValueFlowReply;
}
const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft); const compactBusinessOverviewReply = buildCompactBusinessOverviewReply(entryPoint, draft);
if (compactBusinessOverviewReply) { if (compactBusinessOverviewReply) {
return compactBusinessOverviewReply; return compactBusinessOverviewReply;

View File

@ -344,6 +344,19 @@ function readStateTransitionReasonCodes(input: ApplyAssistantMcpDiscoveryRespons
.filter((item): item is string => Boolean(item)); .filter((item): item is string => Boolean(item));
} }
function hasFullConfirmedTruth(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean {
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status);
if (truthGateStatus === "full_confirmed") {
return true;
}
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
}
function readStringArray(value: unknown): string[] { function readStringArray(value: unknown): string[] {
return Array.isArray(value) return Array.isArray(value)
? value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item)) ? value.map((item) => toNonEmptyString(item)).filter((item): item is string => Boolean(item))
@ -424,6 +437,12 @@ function hasExactMatchedFactualAddressReply(
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false; return false;
} }
if (hasSemanticConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
if (!(isMetadataDiscoveryTurn(entryPoint) && isInventoryExactAddressIntent(detectedIntent))) {
return false;
}
}
const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status); const mcpCallStatus = toNonEmptyString(input.addressRuntimeMeta?.mcp_call_status);
const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode); const truthMode = toNonEmptyString(input.addressRuntimeMeta?.truth_mode);
const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe); const selectedRecipe = toNonEmptyString(input.addressRuntimeMeta?.selected_recipe);
@ -472,17 +491,7 @@ function hasRuntimeAdjustedExactReply(
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) { if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false; return false;
} }
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); if (!hasFullConfirmedTruth(input)) {
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
const hasFullConfirmedTruth =
truthGateStatus === "full_confirmed" ||
sourceTruthGateStatus === "full_confirmed" ||
(coverageStatus === "full" && groundingStatus === "grounded");
if (!hasFullConfirmedTruth) {
return false; return false;
} }
const truthAnswerShape = readTruthAnswerShape(input); const truthAnswerShape = readTruthAnswerShape(input);
@ -495,6 +504,32 @@ function hasRuntimeAdjustedExactReply(
); );
} }
function hasRuntimeMatchedExactReply(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
): boolean {
if (!isDiscoveryReadyAddressCandidate(input, entryPoint)) {
return false;
}
if (!hasEffectivelyFactualAddressReply(input)) {
return false;
}
if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false;
}
if (hasEvidenceLaneConflictWithDiscoveryTurnMeaning(input, entryPoint)) {
return false;
}
if (!hasFullConfirmedTruth(input)) {
return false;
}
const reasonCodes = readStateTransitionReasonCodes(input);
return (
reasonCodes.some((reason) => reason === "route_expectation_matched") &&
reasonCodes.some((reason) => /(?:confirmed_balance_exact|exact_.+_intent|vat_period_inspection_bridge_signal_detected)/iu.test(reason))
);
}
function hasAlignedFactualAddressReply( function hasAlignedFactualAddressReply(
input: ApplyAssistantMcpDiscoveryResponsePolicyInput, input: ApplyAssistantMcpDiscoveryResponsePolicyInput,
entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null
@ -528,6 +563,9 @@ function hasSemanticConflictWithDiscoveryTurnMeaning(
if (hasRuntimeAdjustedExactReply(input, entryPoint)) { if (hasRuntimeAdjustedExactReply(input, entryPoint)) {
return false; return false;
} }
if (hasRuntimeMatchedExactReply(input, entryPoint)) {
return false;
}
const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent); const detectedIntent = toNonEmptyString(input.addressRuntimeMeta?.detected_intent);
const turnMeaning = readDiscoveryTurnMeaning(entryPoint); const turnMeaning = readDiscoveryTurnMeaning(entryPoint);
const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family); const askedDomain = toNonEmptyString(turnMeaning?.asked_domain_family);
@ -619,16 +657,7 @@ function hasFullConfirmedFactualAddressReply(
if (hasMetadataDiscoveryPriority(input, entryPoint)) { if (hasMetadataDiscoveryPriority(input, entryPoint)) {
return false; return false;
} }
const truthGateStatus = toNonEmptyString(input.addressRuntimeMeta?.truth_gate_contract_status); return hasFullConfirmedTruth(input);
if (truthGateStatus === "full_confirmed") {
return true;
}
const truthAnswerPolicy = toRecordObject(input.addressRuntimeMeta?.assistant_truth_answer_policy_v1);
const truthGate = toRecordObject(truthAnswerPolicy?.truth_gate);
const sourceTruthGateStatus = toNonEmptyString(truthGate?.source_truth_gate_status);
const coverageStatus = toNonEmptyString(truthGate?.coverage_status);
const groundingStatus = toNonEmptyString(truthGate?.grounding_status);
return sourceTruthGateStatus === "full_confirmed" || (coverageStatus === "full" && groundingStatus === "grounded");
} }
export function applyAssistantMcpDiscoveryResponsePolicy( export function applyAssistantMcpDiscoveryResponsePolicy(
@ -652,6 +681,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint); const fullConfirmedFactualAddressReply = hasFullConfirmedFactualAddressReply(input, entryPoint);
const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint); const exactMatchedFactualAddressReply = hasExactMatchedFactualAddressReply(input, entryPoint);
const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint); const runtimeAdjustedExactReply = hasRuntimeAdjustedExactReply(input, entryPoint);
const runtimeMatchedExactReply = hasRuntimeMatchedExactReply(input, entryPoint);
const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint); const openScopeValueFlowDiscoveryPriority = hasOpenScopeValueFlowDiscoveryPriority(input, entryPoint);
const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint); const metadataDiscoveryPriority = hasMetadataDiscoveryPriority(input, entryPoint);
const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning( const valueFlowActionConflictWithDiscoveryTurnMeaning = hasValueFlowActionConflictWithDiscoveryTurnMeaning(
@ -714,6 +744,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
"mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning" "mcp_discovery_response_policy_keep_runtime_adjusted_exact_reply_over_stale_discovery_turn_meaning"
); );
} }
if (runtimeMatchedExactReply) {
pushReason(
reasonCodes,
"mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning"
);
}
if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") {
pushReason( pushReason(
reasonCodes, reasonCodes,
@ -742,6 +778,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy(
!fullConfirmedFactualAddressReply && !fullConfirmedFactualAddressReply &&
!exactMatchedFactualAddressReply && !exactMatchedFactualAddressReply &&
!runtimeAdjustedExactReply && !runtimeAdjustedExactReply &&
!runtimeMatchedExactReply &&
!(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") &&
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
candidate.eligible_for_future_hot_runtime && candidate.eligible_for_future_hot_runtime &&

View File

@ -259,6 +259,9 @@ function pushScopedEntityCandidate(
) { ) {
return; return;
} }
if (target.some((existing) => sameScopedName(existing, text))) {
return;
}
pushUnique(target, text); pushUnique(target, text);
} }
@ -291,6 +294,20 @@ function sameScopedName(left: string | null, right: string | null): boolean {
return Boolean(left && right && compactLower(left) === compactLower(right)); return Boolean(left && right && compactLower(left) === compactLower(right));
} }
function preferredScopedDisplayName(value: string | null, candidates: unknown[]): string | null {
const anchor = toNonEmptyString(value);
if (!anchor) {
return null;
}
for (const candidate of candidates) {
const text = candidateValue(candidate);
if (sameScopedName(text, anchor)) {
return text;
}
}
return anchor;
}
function candidateValue(value: unknown): string | null { function candidateValue(value: unknown): string | null {
const direct = toNonEmptyString(value); const direct = toNonEmptyString(value);
if (direct && direct !== "[object Object]") { if (direct && direct !== "[object Object]") {
@ -612,6 +629,8 @@ function collectFollowupDiscoverySeed(followupContext: Record<string, unknown> |
metadataRecommendedNextPrimitive: AssistantMcpDiscoveryMetadataRecommendedPrimitive | null; metadataRecommendedNextPrimitive: AssistantMcpDiscoveryMetadataRecommendedPrimitive | null;
metadataAmbiguityDetected: boolean; metadataAmbiguityDetected: boolean;
metadataAmbiguityEntitySets: string[]; metadataAmbiguityEntitySets: string[];
previousBidirectionalValueFlow: Record<string, unknown> | null;
previousDocumentSummary: Record<string, unknown> | null;
} { } {
const previousFilters = toRecordObject(followupContext?.previous_filters); const previousFilters = toRecordObject(followupContext?.previous_filters);
const rootFilters = toRecordObject(followupContext?.root_filters); const rootFilters = toRecordObject(followupContext?.root_filters);
@ -717,7 +736,9 @@ function collectFollowupDiscoverySeed(followupContext: Record<string, unknown> |
followupContext?.previous_discovery_metadata_recommended_next_primitive followupContext?.previous_discovery_metadata_recommended_next_primitive
), ),
metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true, metadataAmbiguityDetected: followupContext?.previous_discovery_metadata_ambiguity_detected === true,
metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets) metadataAmbiguityEntitySets: collectEntityCandidates(followupContext?.previous_discovery_metadata_ambiguity_entity_sets),
previousBidirectionalValueFlow: toRecordObject(followupContext?.previous_discovery_bidirectional_value_flow),
previousDocumentSummary: toRecordObject(followupContext?.previous_discovery_document_summary)
}; };
} }
@ -896,8 +917,26 @@ function hasOrganizationLevelSupplierQualityOverviewSignal(text: string): boolea
return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue; return hasSupplierScopeCue && hasSupplierQualityCue && hasCompanyScopeCue;
} }
function hasCrossScopeExecutiveSummarySignal(text: string): boolean {
return (
/(?:\u0441\u043e\u0431\u0435\u0440\p{L}*\s+(?:\u043a\u043e\u0440\u043e\u0442\u043a\p{L}*\s+)?\u0438\u0442\u043e\u0433|\u044d\u043a\u0437\u0435\u043a\u044c\u044e\u0442\u0438\u0432\p{L}*\s+\u0441\u0430\u043c\u043c\u0430\u0440\u0438|executive\s+summary|final\s+summary)/iu.test(
text
) &&
/(?:\u0447\u0442\u043e\s+(?:\u043c\u044b\s+)?\u043f\u043e\u0434\u0442\u0432\u0435\u0440\p{L}*|\u043f\u043e\s+\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043f\u043e\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|confirmed|company|organization)/iu.test(
text
) &&
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u0433\u0440\u0443\u043f\u043f\p{L}*\s+\u0441\u0432\u043a|\u0441\u0432\u043a|counterpart(?:y|ies)?)/iu.test(
text
) &&
/(?:\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\p{L}*|\u0447\u0442\u043e\s+\u043d\u0435\u043b\u044c\u0437\p{L}*|\u0432\u044b\u0432\u043e\u0434\p{L}*|allowed|forbidden|cannot|can\s+say)/iu.test(
text
)
);
}
function hasBusinessOverviewSignal(text: string): boolean { function hasBusinessOverviewSignal(text: string): boolean {
if ( if (
hasCrossScopeExecutiveSummarySignal(text) ||
hasOrganizationLevelEarningsOverviewSignal(text) || hasOrganizationLevelEarningsOverviewSignal(text) ||
hasOrganizationLevelDebtPositionOverviewSignal(text) || hasOrganizationLevelDebtPositionOverviewSignal(text) ||
hasOrganizationLevelDebtDueDateOverviewSignal(text) || hasOrganizationLevelDebtDueDateOverviewSignal(text) ||
@ -948,6 +987,43 @@ function hasBusinessOverviewContinuationSignal(text: string): boolean {
); );
} }
function hasExplicitVatQuestionSignal(text: string): boolean {
if (!text) {
return false;
}
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 hasBusinessOverviewSeparateCounterpartySignal(text: string): boolean {
if (!text) {
return false;
}
return (
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*|counterpart(?:y|ies)?)/iu.test(text) &&
/(?:\u043a\u043e\u043c\u043f\u0430\u043d\p{L}*|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\p{L}*|company|organization|\u0438\u0442\u043e\u0433|summary|\u0432\u044b\u0432\u043e\u0434\p{L}*)/iu.test(text)
);
}
function businessOverviewSeparateCounterpartyCandidateFromText(text: string): string | null {
const source = repairAddressMojibakeText(String(text ?? ""));
const patterns = [
/(?:\u043e\u0442\u0434\u0435\u043b\u044c\u043d\p{L}*\s+\u043f\u043e|\u043f\u043e\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\p{L}*)\s+(.+?)(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu,
/(?:\u0434\u043b\u044f|for)\s+([\p{L}\d._-]+(?:\s+[\p{L}\d._-]+){0,3})(?:[,.;:!?]|\s+\u043a\u0430\u043a\u0438\p{L}*\b|\s+\u0447\u0442\u043e\b|$)/iu
];
for (const pattern of patterns) {
const candidate = normalizeFollowupCounterpartyCandidate(source.match(pattern)?.[1]);
if (candidate && !isInvalidEntityCandidate(candidate)) {
return candidate;
}
}
return null;
}
function hasExplicitTopicSwitchSignal(text: string): boolean { function hasExplicitTopicSwitchSignal(text: string): boolean {
return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test( return /(?:^|\s)(?:\u0442\u0435\u043f\u0435\u0440\u044c|\u0430\s+\u0442\u0435\u043f\u0435\u0440\u044c|\u0434\u0430\u043b\u044c\u0448\u0435|\u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e|\u043f\u0435\u0440\u0435\u0439\u0434[\u0451\u0435]\u043c|\u0441\u043c\u0435\u043d\u0438\u043c\s+\u0442\u0435\u043c\u0443|\u0432\u0435\u0440\u043d[\u0451\u0435]\u043c\u0441\u044f\s+\u043a|now|next|switch\s+to)(?:\s|$)/iu.test(
text text
@ -1456,9 +1532,16 @@ export function buildAssistantMcpDiscoveryTurnInput(
const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal( const rawReferentialDocumentExclusionSignal = hasReferentialDocumentExclusionFollowupSignal(
repairedUserText ?? rawUserText ?? "" repairedUserText ?? rawUserText ?? ""
); );
const rawPrimaryBusinessOverviewSignal = hasBusinessOverviewSignal(rawText);
const explicitVatQuestionSignal = hasExplicitVatQuestionSignal(rawText);
const explicitVatSuppressesBusinessOverviewContinuation = Boolean(
explicitVatQuestionSignal && !rawPrimaryBusinessOverviewSignal
);
const businessOverviewContinuationSignal = const businessOverviewContinuationSignal =
hasBusinessOverviewFollowupSeed(followupSeed) && hasBusinessOverviewContinuationSignal(rawText); hasBusinessOverviewFollowupSeed(followupSeed) &&
const rawBusinessOverviewSignal = hasBusinessOverviewSignal(rawText) || businessOverviewContinuationSignal; hasBusinessOverviewContinuationSignal(rawText) &&
!explicitVatSuppressesBusinessOverviewContinuation;
const rawBusinessOverviewSignal = rawPrimaryBusinessOverviewSignal || businessOverviewContinuationSignal;
const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText); const rawLifecycleSignal = !rawBusinessOverviewSignal && hasLifecycleSignal(rawText);
const rawBidirectionalValueFlowSignal = const rawBidirectionalValueFlowSignal =
!rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText); !rawBusinessOverviewSignal && !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText);
@ -1517,6 +1600,12 @@ export function buildAssistantMcpDiscoveryTurnInput(
rawDomain === "business_summary" || rawDomain === "business_summary" ||
rawDomain === "business_overview" || rawDomain === "business_overview" ||
rawAction === "broad_evaluation"; rawAction === "broad_evaluation";
const businessOverviewSeparateCounterpartySignal = Boolean(
businessOverviewSignal && hasBusinessOverviewSeparateCounterpartySignal(rawText)
);
const businessOverviewSeparateCounterpartyCandidate = businessOverviewSeparateCounterpartySignal
? businessOverviewSeparateCounterpartyCandidateFromText(rawText)
: null;
const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate);
const currentTurnDocumentLaneSignal = rawAction === "list_documents"; const currentTurnDocumentLaneSignal = rawAction === "list_documents";
const currentTurnMovementLaneSignal = rawAction === "list_movements"; const currentTurnMovementLaneSignal = rawAction === "list_movements";
@ -1556,6 +1645,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
); );
const businessOverviewSuppressesFollowupCounterparty = Boolean( const businessOverviewSuppressesFollowupCounterparty = Boolean(
businessOverviewSignal && businessOverviewSignal &&
!businessOverviewSeparateCounterpartySignal &&
(rawBusinessOverviewSignal || (rawBusinessOverviewSignal ||
businessOverviewContinuationSignal || businessOverviewContinuationSignal ||
broadBusinessEvaluationUnsupported || broadBusinessEvaluationUnsupported ||
@ -1604,8 +1694,12 @@ export function buildAssistantMcpDiscoveryTurnInput(
? null ? null
: normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty); : normalizeFollowupCounterpartyCandidate(predecomposeEntities.counterparty);
const predecomposeDateScope = collectDateScope(predecomposeContract); const predecomposeDateScope = collectDateScope(predecomposeContract);
const suppressFollowupBusinessOverviewSeed = Boolean(
explicitVatSuppressesBusinessOverviewContinuation && hasBusinessOverviewFollowupSeed(followupSeed)
);
const periodClarificationFollowupApplicable = Boolean( const periodClarificationFollowupApplicable = Boolean(
followupSeed.domain && followupSeed.domain &&
!suppressFollowupBusinessOverviewSeed &&
followupSeed.loopStatus === "awaiting_clarification" && followupSeed.loopStatus === "awaiting_clarification" &&
followupSeed.loopPendingAxes.includes("period") && followupSeed.loopPendingAxes.includes("period") &&
!rawLifecycleSignal && !rawLifecycleSignal &&
@ -1618,6 +1712,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
); );
const followupDiscoverySeedApplicable = Boolean( const followupDiscoverySeedApplicable = Boolean(
followupSeed.domain && followupSeed.domain &&
!suppressFollowupBusinessOverviewSeed &&
!rawLifecycleSignal && !rawLifecycleSignal &&
!rawMetadataSignal && !rawMetadataSignal &&
(periodClarificationFollowupApplicable || (periodClarificationFollowupApplicable ||
@ -2005,6 +2100,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) { for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, candidate, groundedFollowupEntity);
} }
pushScopedEntityCandidate(entityCandidates, businessOverviewSeparateCounterpartyCandidate, groundedFollowupEntity);
pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, normalizedPredecomposeCounterparty, groundedFollowupEntity);
pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, rawScopedEntityCandidate, groundedFollowupEntity);
if (!groundedFollowupEntity) { if (!groundedFollowupEntity) {
@ -2017,6 +2113,20 @@ export function buildAssistantMcpDiscoveryTurnInput(
} }
pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity); pushScopedEntityCandidate(entityCandidates, rawEntityCandidate, groundedFollowupEntity);
} }
const businessOverviewSeparateCounterpartyDisplayCandidate = businessOverviewSeparateCounterpartySignal
? preferredScopedDisplayName(businessOverviewSeparateCounterpartyCandidate, [
groundedFollowupEntity,
effectiveFollowupCounterparty,
followupSeed.discoveryEntity,
normalizedPredecomposeCounterparty,
rawScopedEntityCandidate,
rawEntityCandidate,
...entityCandidates
])
: null;
const businessOverviewSeparateEntityCandidates = businessOverviewSeparateCounterpartyDisplayCandidate
? [businessOverviewSeparateCounterpartyDisplayCandidate]
: [];
if ( if (
(rawMetadataSignal || metadataFollowupSeedApplicable) && (rawMetadataSignal || metadataFollowupSeedApplicable) &&
!groundedFollowupEntity && !groundedFollowupEntity &&
@ -2107,6 +2217,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
(clarificationLoopStillNeedsPeriod || (clarificationLoopStillNeedsPeriod ||
businessOverviewSignal || businessOverviewSignal ||
openScopeValueFlowWithoutResolvedCounterparty || openScopeValueFlowWithoutResolvedCounterparty ||
valueFlowGroundedDocumentFollowupApplicable ||
valueFlowGroundedMovementFollowupApplicable ||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))) (valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))
); );
const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal); const suppressNegatedTaxOnlyDateScope = Boolean(businessOverviewSignal && negatedTaxDateScopeOnlySignal);
@ -2138,11 +2250,18 @@ export function buildAssistantMcpDiscoveryTurnInput(
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)) (suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope))
? null ? null
: followupSeed.dateScope; : followupSeed.dateScope;
const businessOverviewRawYearOverridesPredecomposeAsOf = Boolean(
businessOverviewSignal &&
rawDateScope &&
/^\d{4}$/.test(rawDateScope) &&
normalizedPredecomposeDateScope &&
normalizedPredecomposeDateScope.startsWith(`${rawDateScope}-`)
);
const explicitDateScope = const explicitDateScope =
rawAllTimeScopeSignal rawAllTimeScopeSignal
? null ? null
: normalizedAssistantTurnMeaningDateScope ?? : normalizedAssistantTurnMeaningDateScope ??
normalizedPredecomposeDateScope ?? (businessOverviewRawYearOverridesPredecomposeAsOf ? rawDateScope : normalizedPredecomposeDateScope) ??
rawDateScope ?? rawDateScope ??
normalizedFollowupDateScope; normalizedFollowupDateScope;
const followupDateScopeApplied = Boolean( const followupDateScopeApplied = Boolean(
@ -2198,6 +2317,13 @@ export function buildAssistantMcpDiscoveryTurnInput(
? followupSeed.rankingNeed ? followupSeed.rankingNeed
: undefined, : undefined,
explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates, explicit_entity_candidates: businessOverviewSignal ? [] : entityCandidates,
business_overview_separate_entity_candidates: businessOverviewSeparateEntityCandidates,
previous_counterparty_value_flow_bundle:
businessOverviewSignal && followupSeed.previousBidirectionalValueFlow
? followupSeed.previousBidirectionalValueFlow
: undefined,
previous_counterparty_document_bundle:
businessOverviewSignal && followupSeed.previousDocumentSummary ? followupSeed.previousDocumentSummary : undefined,
metadata_ambiguity_entity_sets: metadata_ambiguity_entity_sets:
metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0 metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0
? followupSeed.metadataAmbiguityEntitySets ? followupSeed.metadataAmbiguityEntitySets
@ -2263,6 +2389,15 @@ export function buildAssistantMcpDiscoveryTurnInput(
if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) { if ((turnMeaning.explicit_entity_candidates?.length ?? 0) > 0) {
cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates; cleanTurnMeaning.explicit_entity_candidates = turnMeaning.explicit_entity_candidates;
} }
if ((turnMeaning.business_overview_separate_entity_candidates?.length ?? 0) > 0) {
cleanTurnMeaning.business_overview_separate_entity_candidates = turnMeaning.business_overview_separate_entity_candidates;
}
if (toRecordObject(turnMeaning.previous_counterparty_value_flow_bundle)) {
cleanTurnMeaning.previous_counterparty_value_flow_bundle = turnMeaning.previous_counterparty_value_flow_bundle;
}
if (toRecordObject(turnMeaning.previous_counterparty_document_bundle)) {
cleanTurnMeaning.previous_counterparty_document_bundle = turnMeaning.previous_counterparty_document_bundle;
}
if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) { if ((turnMeaning.metadata_ambiguity_entity_sets?.length ?? 0) > 0) {
cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets; cleanTurnMeaning.metadata_ambiguity_entity_sets = turnMeaning.metadata_ambiguity_entity_sets;
} }
@ -2478,9 +2613,21 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (businessOverviewContinuationSignal) { if (businessOverviewContinuationSignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context"); pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_from_followup_context");
} }
if (explicitVatSuppressesBusinessOverviewContinuation) {
pushReason(reasonCodes, "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question");
}
if (businessOverviewSuppressesFollowupCounterparty) { if (businessOverviewSuppressesFollowupCounterparty) {
pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty"); pushReason(reasonCodes, "mcp_discovery_business_overview_suppressed_stale_counterparty");
} }
if (businessOverviewSeparateCounterpartySignal) {
pushReason(reasonCodes, "mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope");
}
if (businessOverviewSeparateCounterpartyCandidate) {
pushReason(reasonCodes, "mcp_discovery_business_overview_counterparty_from_summary_text");
}
if (businessOverviewRawYearOverridesPredecomposeAsOf) {
pushReason(reasonCodes, "mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
}
if ( if (
!(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) && !(valueFlowOrganizationStaysScope && normalizedPredecomposeCounterparty === explicitOrganizationScope) &&
normalizedPredecomposeCounterparty normalizedPredecomposeCounterparty
@ -2515,12 +2662,19 @@ export function buildAssistantMcpDiscoveryTurnInput(
if (runDiscovery && !hasTurnMeaning) { if (runDiscovery && !hasTurnMeaning) {
pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing"); pushReason(reasonCodes, "mcp_discovery_turn_meaning_missing");
} }
const dataNeedGraphTurnMeaning =
businessOverviewSeparateCounterpartySignal && cleanTurnMeaning.explicit_entity_candidates
? {
...cleanTurnMeaning,
explicit_entity_candidates: []
}
: cleanTurnMeaning;
const dataNeedGraph = const dataNeedGraph =
runDiscovery && hasTurnMeaning runDiscovery && hasTurnMeaning
? buildAssistantMcpDiscoveryDataNeedGraph({ ? buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed, semanticDataNeed,
rawUtterance: rawSignalSourceText, rawUtterance: rawSignalSourceText,
turnMeaning: cleanTurnMeaning turnMeaning: dataNeedGraphTurnMeaning
}) })
: null; : null;
if (dataNeedGraph) { if (dataNeedGraph) {

View File

@ -236,6 +236,36 @@ function hasExplicitRecapPromptSignal(samples: string[]): boolean {
); );
} }
function normalizeMemoryCheckpointSample(value: unknown): string {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`]/g, "")
.replace(/\s+/g, " ");
}
function hasMemoryCheckpointPromptSignal(samples: string[]): boolean {
return samples.some((sample) => {
const text = normalizeMemoryCheckpointSample(sample);
if (!text) {
return false;
}
if (/(?:стартов\w*\s+чек\s+контекст|чек\s+контекста|context\s+check|memory\s+check)/iu.test(text)) {
return true;
}
const hasSelectedStateCue =
/(?:выбранн\w*\s+(?:компан|организац|контрагент|объект)|активн\w*\s+(?:компан|организац|контрагент|объект)|selected\s+(?:company|organization|counterparty|object)|active\s+(?:company|organization|counterparty|object))/iu.test(text);
const hasDialogStateCue =
/(?:в\s+текущ\w*\s+диалог|в\s+этом\s+диалог|в\s+сессии|контекст(?:е|а)?\s+диалог|current\s+(?:dialog|session|conversation))/iu.test(text);
const hasHonestyCue =
/(?:не\s+выдумывай\s+памят|не\s+придумывай\s+памят|скажи\s+честно|если\s+нет|no\s+fabricat|do\s+not\s+invent\s+memory)/iu.test(text);
const asksCurrentSelection =
/(?:есть\s+ли\s+уже|есть\s+ли\s+сейчас|что\s+выбрано|кто\s+выбран|какая\s+компан\w*\s+выбран)/iu.test(text);
return (hasSelectedStateCue && hasDialogStateCue) || (hasDialogStateCue && hasHonestyCue) || (asksCurrentSelection && hasHonestyCue);
});
}
export function buildInventoryHistoryCapabilityFollowupReply(input: { export function buildInventoryHistoryCapabilityFollowupReply(input: {
organization: string | null; organization: string | null;
addressDebug: Record<string, unknown> | null; addressDebug: Record<string, unknown> | null;
@ -713,10 +743,32 @@ function extractBuyerFromSaleTraceAnswer(
return null; return null;
} }
function extractRequestedMemorySubject(userMessage: unknown): string | null {
const text = String(userMessage ?? "").trim();
if (!text) {
return null;
}
const patterns = [
/памят[ьи]\s+про\s+([^.;!?]+)/iu,
/memory\s+about\s+([^.;!?]+)/iu
];
for (const pattern of patterns) {
const match = text.match(pattern);
const subject = match?.[1]
? match[1].replace(/[«»"'`]/g, "").replace(/\s+/g, " ").trim()
: "";
if (subject.length >= 2 && subject.length <= 80) {
return subject;
}
}
return null;
}
export function buildAddressMemoryRecapReply(input: { export function buildAddressMemoryRecapReply(input: {
organization: string | null; organization: string | null;
addressDebug: Record<string, unknown> | null; addressDebug: Record<string, unknown> | null;
sessionItems?: unknown[]; sessionItems?: unknown[];
userMessage?: unknown;
toNonEmptyString: (value: unknown) => string | null; toNonEmptyString: (value: unknown) => string | null;
}): string { }): string {
const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString); const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString);
@ -782,7 +834,14 @@ export function buildAddressMemoryRecapReply(input: {
].join(" "); ].join(" ");
} }
return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; const requestedMemorySubject = extractRequestedMemorySubject(input.userMessage);
const subjectLine = requestedMemorySubject
? ` Память про «${requestedMemorySubject}» в этом диалоге не подтверждена.`
: " Память про конкретную компанию или контрагента в этом диалоге не подтверждена.";
return [
`Коротко: в текущем диалоге я не вижу выбранной компании, контрагента или позиции.${subjectLine}`,
"Чтобы продолжить без выдуманной памяти, назови компанию, контрагента или объект, и я начну новый проверенный контур."
].join(" ");
} }
export function buildBroadBusinessEvaluationReply(input: { export function buildBroadBusinessEvaluationReply(input: {
@ -1055,6 +1114,7 @@ export function createAssistantMemoryRecapPolicy(
deps.hasConversationMemoryRecallFollowupSignal deps.hasConversationMemoryRecallFollowupSignal
); );
const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples); const explicitRecapPromptSignal = hasExplicitRecapPromptSignal(samples);
const memoryCheckpointPromptSignal = hasMemoryCheckpointPromptSignal(samples);
return { return {
contextualHistoricalCapabilityFollowupDetected: Boolean( contextualHistoricalCapabilityFollowupDetected: Boolean(
input.capabilityMetaQuery && input.capabilityMetaQuery &&
@ -1067,9 +1127,10 @@ export function createAssistantMemoryRecapPolicy(
!input.dataScopeMetaQuery && !input.dataScopeMetaQuery &&
!input.capabilityMetaQuery && !input.capabilityMetaQuery &&
!input.aggregateBusinessAnalyticsSignal && !input.aggregateBusinessAnalyticsSignal &&
memoryRecapSignal && (memoryCheckpointPromptSignal ||
(memoryRecapSignal &&
(explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) && (explicitRecapPromptSignal || (!input.dataRetrievalSignal && !input.strongDataSignal)) &&
continuity.hasGroundedAddressContext continuity.hasGroundedAddressContext))
) )
}; };
} }

View File

@ -198,6 +198,16 @@ export function createAssistantTransitionPolicy(deps) {
return null; return null;
} }
function hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage = null) {
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 parseDmyDateToIso(value) { function parseDmyDateToIso(value) {
const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/); const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) { if (!match) {
@ -341,6 +351,61 @@ export function createAssistantTransitionPolicy(deps) {
return null; return null;
} }
function readMcpDiscoveryBidirectionalValueFlow(debug) {
const entryPoint = debug?.assistant_mcp_discovery_entry_point_v1;
const flow = entryPoint?.bridge?.pilot?.derived_bidirectional_value_flow;
if (!flow || typeof flow !== "object" || Array.isArray(flow)) {
return null;
}
return flow;
}
function readCounterpartyDocumentSummaryFromItem(item) {
const text = deps.toNonEmptyString(item?.text);
if (!text) {
return null;
}
const firstLine = text.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
const match = firstLine.match(/Контрагент:\s*([^.\n]+)\.\s*Найдено документов:\s*(\d+)/iu);
if (!match?.[1] || !match?.[2]) {
return null;
}
return {
counterparty: deps.toNonEmptyString(match[1]),
document_count: Number(match[2]),
direct_answer: firstLine
};
}
function findRecentDiscoveryValueFlowBundle(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
const debug = item?.debug;
if (!item || item.role !== "assistant" || !debug || typeof debug !== "object") {
continue;
}
const flow = readMcpDiscoveryBidirectionalValueFlow(debug);
if (flow) {
return flow;
}
}
return null;
}
function findRecentCounterpartyDocumentBundle(items) {
for (let index = Array.isArray(items) ? items.length - 1 : -1; index >= 0; index -= 1) {
const item = items[index];
if (!item || item.role !== "assistant" || !item.debug || typeof item.debug !== "object") {
continue;
}
const summary = readCounterpartyDocumentSummaryFromItem(item);
if (summary) {
return summary;
}
}
return null;
}
function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) { function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) {
if ( if (
sourceIntentHint !== "inventory_purchase_provenance_for_item" && sourceIntentHint !== "inventory_purchase_provenance_for_item" &&
@ -530,7 +595,10 @@ export function createAssistantTransitionPolicy(deps) {
llmPreDecomposeMeta llmPreDecomposeMeta
}) })
: null; : null;
if (assistantTurnMeaning?.stale_replay_forbidden === true) { if (
assistantTurnMeaning?.stale_replay_forbidden === true &&
!hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage)
) {
return null; return null;
} }
const latestAddressItem = deps.findLastAddressAssistantItem(items); const latestAddressItem = deps.findLastAddressAssistantItem(items);
@ -638,18 +706,21 @@ export function createAssistantTransitionPolicy(deps) {
hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage) hasValueFlowCarryoverSourceHint && deps.toNonEmptyString(alternateMessage)
? hasShortValueFlowRetargetCue(String(alternateMessage ?? "")) ? hasShortValueFlowRetargetCue(String(alternateMessage ?? ""))
: false; : false;
const explicitSummaryBundleReuseSignal = hasExplicitSummaryBundleReuseSignal(userMessage, alternateMessage);
let hasPrimaryFollowupSignal = let hasPrimaryFollowupSignal =
deps.hasAddressFollowupContextSignal(userMessage) || deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) || Boolean(debtRoleSwapPrimary) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
inventoryShortFollowupPrimary || inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge; inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) || Boolean(debtRoleSwapAlternate) ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal
: false; : false;
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
@ -698,6 +769,7 @@ export function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementPrimary || hasInventoryRootRestatementPrimary ||
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
@ -718,6 +790,7 @@ export function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementPrimary || hasInventoryRootRestatementPrimary ||
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
@ -753,7 +826,8 @@ export function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal && !hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal && !hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation && !hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal !hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal
) { ) {
return null; return null;
} }
@ -769,7 +843,8 @@ export function createAssistantTransitionPolicy(deps) {
!hasImplicitContinuationSignal && !hasImplicitContinuationSignal &&
!hasSuggestedIntentPivotSignal && !hasSuggestedIntentPivotSignal &&
!hasOrganizationClarificationContinuation && !hasOrganizationClarificationContinuation &&
!hasIndexReferenceSignal !hasIndexReferenceSignal &&
!explicitSummaryBundleReuseSignal
) { ) {
return null; return null;
} }
@ -848,6 +923,9 @@ export function createAssistantTransitionPolicy(deps) {
carryoverSourceDebug, carryoverSourceDebug,
deps.toNonEmptyString deps.toNonEmptyString
); );
const sourceDiscoveryBidirectionalValueFlow =
readMcpDiscoveryBidirectionalValueFlow(carryoverSourceDebug) ?? findRecentDiscoveryValueFlowBundle(items);
const sourceDiscoveryDocumentSummary = findRecentCounterpartyDocumentBundle(items);
const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
const llmSelectedObjectScopeDetected = const llmSelectedObjectScopeDetected =
llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true;
@ -951,6 +1029,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
inventoryShortFollowupPrimary || inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupPrimary; hasInventoryRootTemporalFollowupPrimary;
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
@ -959,6 +1038,7 @@ export function createAssistantTransitionPolicy(deps) {
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
hasInventoryRootTemporalFollowupAlternate hasInventoryRootTemporalFollowupAlternate
: false; : false;
hasStrongFollowupReference = hasStrongFollowupReference =
@ -972,6 +1052,7 @@ export function createAssistantTransitionPolicy(deps) {
hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate || hasInventoryRootTemporalFollowupAlternate ||
inventoryPurchaseDateVatBridge || inventoryPurchaseDateVatBridge ||
explicitSummaryBundleReuseSignal ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
shortValueFlowRetargetPrimary || shortValueFlowRetargetPrimary ||
shortValueFlowRetargetAlternate || shortValueFlowRetargetAlternate ||
@ -1220,6 +1301,8 @@ export function createAssistantTransitionPolicy(deps) {
previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined, previous_discovery_metadata_ambiguity_detected: sourceDiscoveryMetadataAmbiguityDetected || undefined,
previous_discovery_metadata_ambiguity_entity_sets: previous_discovery_metadata_ambiguity_entity_sets:
sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined, sourceDiscoveryMetadataAmbiguityEntitySets.length > 0 ? sourceDiscoveryMetadataAmbiguityEntitySets : undefined,
previous_discovery_bidirectional_value_flow: sourceDiscoveryBidirectionalValueFlow ?? undefined,
previous_discovery_document_summary: sourceDiscoveryDocumentSummary ?? undefined,
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined,
root_context_only: rootScopedPivot || undefined, root_context_only: rootScopedPivot || undefined,
root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined,

View File

@ -125,21 +125,24 @@ function coverageStatusFrom(
groundingStatus: AssistantGroundingStatus groundingStatus: AssistantGroundingStatus
): AssistantCoverageStatus { ): AssistantCoverageStatus {
const explicitCoverageEvidence = toAddressCoverageEvidenceContract(debug.address_coverage_evidence_v1); const explicitCoverageEvidence = toAddressCoverageEvidenceContract(debug.address_coverage_evidence_v1);
if (truthGateStatus === "full_confirmed") {
return "full";
}
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
return "partial";
}
if (truthGateStatus.startsWith("blocked")) { if (truthGateStatus.startsWith("blocked")) {
return "blocked"; return "blocked";
} }
if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") { if (toStringList(debug.missing_required_filters).length > 0 || groundingStatus === "route_mismatch_blocked" || groundingStatus === "no_grounded_answer") {
return "blocked"; return "blocked";
} }
if (truthGateStatus === "partial_supported" || truthGateStatus === "limited_temporal_or_contextual") {
return "partial";
}
if (explicitCoverageEvidence) { if (explicitCoverageEvidence) {
return explicitCoverageEvidence.coverage_status; return explicitCoverageEvidence.coverage_status;
} }
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
return "partial";
}
if (truthGateStatus === "full_confirmed") {
return "full";
}
const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report); const coverageReport = toRecordObject(input.coverageReport) ?? toRecordObject(debug.coverage_report);
if (coverageReport) { if (coverageReport) {
@ -176,10 +179,16 @@ function truthModeFrom(input: {
if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) { if (input.truthGateStatus === "blocked_missing_anchor" || toStringList(input.debug.missing_required_filters).length > 0) {
return "clarification_required"; return "clarification_required";
} }
if (input.truthGateStatus === "full_confirmed" || (input.coverageStatus === "full" && input.groundingStatus === "grounded")) { if (input.coverageStatus === "partial") {
return "limited";
}
if (input.truthGateStatus === "full_confirmed" && input.coverageStatus === "full") {
return "confirmed"; return "confirmed";
} }
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual" || input.coverageStatus === "partial") { if (input.coverageStatus === "full" && input.groundingStatus === "grounded") {
return "confirmed";
}
if (input.truthGateStatus === "partial_supported" || input.truthGateStatus === "limited_temporal_or_contextual") {
return "limited"; return "limited";
} }
return "unsupported"; return "unsupported";
@ -199,6 +208,9 @@ function evidenceGradeFrom(
if (isEvidenceGrade(explicit)) { if (isEvidenceGrade(explicit)) {
return explicit; return explicit;
} }
if (debug.balance_confirmed === false || toNonEmptyString(debug.result_mode) === "heuristic_candidates") {
return coverageStatus === "partial" ? "medium" : "weak";
}
if (coverageStatus === "blocked") { if (coverageStatus === "blocked") {
return "none"; return "none";
} }

View File

@ -255,6 +255,28 @@ describe("counterparty shipment item flow and open-items routing", () => {
expect(reply.text).not.toContain("Контрагент: Группа. Найдено документов"); expect(reply.text).not.toContain("Контрагент: Группа. Найдено документов");
}); });
it("keeps document follow-up answer compact for larger counterparty lists", () => {
const rows = Array.from({ length: 7 }, (_, index) => ({
period: `2021-11-${String(index + 1).padStart(2, "0")}T12:00:00Z`,
registrator: `Документ ${index + 1}`,
account_dt: "0",
account_kt: "0",
amount: 1000 + index,
analytics: ["Группа СВК", "Договор № 1-ПМ/2020"],
organization: "ООО Альтернатива Плюс"
}));
const reply = composeFactualReply("list_documents_by_counterparty", rows, {
counterpartyHint: "Группа СВК"
});
expect(reply.text).toContain("Контрагент: Группа СВК. Найдено документов: 7.");
expect(reply.text).toContain("Показаны первые 5 из 7 документов");
expect(reply.text).toContain("Документ 5");
expect(reply.text).not.toContain("Документ 6");
expect(reply.text.split("\n").filter((line) => line.startsWith("Контрагент:")).length).toBe(1);
});
it("keeps current resolved counterparty label over stale follow-up anchor during short retarget", async () => { it("keeps current resolved counterparty label over stale follow-up anchor during short retarget", async () => {
executeAddressMcpQueryMock executeAddressMcpQueryMock
.mockResolvedValueOnce({ .mockResolvedValueOnce({
@ -427,6 +449,14 @@ describe("counterparty shipment item flow and open-items routing", () => {
expect(result?.response_type).toBe("FACTUAL_LIST"); expect(result?.response_type).toBe("FACTUAL_LIST");
expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract"); expect(result?.debug.detected_intent).toBe("open_items_by_counterparty_or_contract");
expect(String(result?.reply_text ?? "")).toContain("счету 60"); expect(String(result?.reply_text ?? "")).toContain("счету 60");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("Коротко:");
expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("точный открытый остаток");
expect(String(result?.reply_text ?? "")).toContain("не подтвержден");
expect(String(result?.reply_text ?? "")).toContain("предварительные сигналы");
expect(result?.debug.address_coverage_evidence_v1?.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.address_coverage_evidence_v1?.result_mode).toBe("heuristic_candidates");
expect(result?.debug.address_coverage_evidence_v1?.coverage_status).toBe("partial");
expect(result?.debug.address_coverage_evidence_v1?.balance_confirmed).toBe(false);
const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? ""); const query = String(executeAddressMcpQueryMock.mock.calls[0]?.[0]?.query ?? "");
expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто"); expect(query).toContain("РегистрБухгалтерии.Хозрасчетный.ДвиженияССубконто");

View File

@ -14,6 +14,29 @@ describe("address filter extractor regressions", () => {
expect(extracted.warnings).toContain("period_derived_from_month_phrase"); expect(extracted.warnings).toContain("period_derived_from_month_phrase");
expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability");
}); });
it("keeps explicit year window for confirmed VAT tax-period intent", () => {
const extracted = extractAddressFilters(
"\u0447\u0442\u043e \u0441 \u043d\u0434\u0441 \u0437\u0430 2020 \u0433\u043e\u0434 \u043f\u043e \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
"vat_liability_confirmed_for_tax_period"
);
expect(extracted.extracted_filters.period_from).toBe("2020-01-01");
expect(extracted.extracted_filters.period_to).toBe("2020-12-31");
expect(extracted.warnings).toContain("period_derived_from_year_phrase");
expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability");
});
it("drops pronoun-only counterparty anchors for chain follow-ups", () => {
const extracted = extractAddressFilters(
"\u043f\u043e\u043a\u0430\u0436\u0438 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u044b \u043f\u043e \u044d\u0442\u043e\u0439 \u0446\u0435\u043f\u043e\u0447\u043a\u0435",
"list_documents_by_counterparty"
);
expect(extracted.extracted_filters.counterparty).toBeUndefined();
expect(extracted.warnings).toContain("counterparty_anchor_dropped_low_quality");
});
it("extracts a compact counterparty tail for customer revenue profile", () => { it("extracts a compact counterparty tail for customer revenue profile", () => {
const extracted = extractAddressFilters( const extracted = extractAddressFilters(
"\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a", "\u043a\u0430\u043a\u043e\u0439 \u043e\u0431\u043e\u0440\u043e\u0442 \u0431\u044b\u043b \u0441\u0432\u043a",

View File

@ -291,4 +291,27 @@ describe("address follow-up temporal regressions", () => {
expect(movements?.filters.extracted_filters.counterparty).toBe("Группа СВК"); expect(movements?.filters.extracted_filters.counterparty).toBe("Группа СВК");
expect(movements?.baseReasons).toContain("counterparty_from_followup_context"); expect(movements?.baseReasons).toContain("counterparty_from_followup_context");
}); });
it("replaces pronoun chain anchors from counterparty follow-up context", () => {
const followupContext = {
previous_intent: "customer_revenue_and_payments" as const,
target_intent: "list_documents_by_counterparty" as const,
previous_filters: {
organization: "ООО Альтернатива Плюс",
counterparty: "Группа СВК"
},
previous_anchor_type: "counterparty" as const,
previous_anchor_value: "Группа СВК",
resolved_counterparty_from_display: true
};
const documents = runAddressDecomposeStage(
"покажи документы по этой цепочке и не смешивай Группа СВК с организацией ООО Альтернатива Плюс",
followupContext
);
expect(documents?.intent.intent).toBe("list_documents_by_counterparty");
expect(documents?.filters.extracted_filters.counterparty).toBe("Группа СВК");
expect(documents?.baseReasons).toContain("counterparty_from_followup_context");
});
}); });

View File

@ -67,6 +67,17 @@ describe("address route expectations contract", () => {
expect(audit.reason).toBe("route_expectation_matched"); expect(audit.reason).toBe("route_expectation_matched");
}); });
it("matches open-items route as a supported factual route", () => {
const audit = evaluateAddressRouteExpectation({
intent: "open_items_by_counterparty_or_contract",
selectedRecipe: "address_open_items_by_party_or_contract_v1",
requestedResultMode: "confirmed_balance",
resultMode: "confirmed_balance"
});
expect(audit.status).toBe("matched");
expect(audit.reason).toBe("route_expectation_matched");
});
it("detects selected recipe mismatch", () => { it("detects selected recipe mismatch", () => {
const audit = evaluateAddressRouteExpectation({ const audit = evaluateAddressRouteExpectation({
intent: "payables_confirmed_as_of_date", intent: "payables_confirmed_as_of_date",

View File

@ -25,6 +25,32 @@ describe("address truth gate policy", () => {
expect(gate.reason_codes).toContain("limited_category_empty_match"); expect(gate.reason_codes).toContain("limited_category_empty_match");
}); });
it("keeps open-items movement fallback partial even when heuristic rows are found", () => {
const gate = resolveAddressTruthGate({
intent: "open_items_by_counterparty_or_contract",
filters: {
account: "60",
period_from: "2020-08-01",
period_to: "2020-08-31",
organization: "ООО Альтернатива Плюс"
},
selectedRecipe: "address_open_items_by_party_or_contract_v1",
rowsMatched: 8,
runtimeReadiness: "LIVE_QUERYABLE_WITH_LIMITS",
reasons: [
"confirmed_balance_unavailable_fallback_to_heuristic_candidates",
"open_items_account_query_override_to_movements"
],
routeExpectationStatus: "matched",
replyType: "factual"
});
expect(gate.truth_gate_status).toBe("partial_supported");
expect(gate.carryover_eligibility).toBe("root_only");
expect(gate.blocked_or_limited_explanation).toBe("evidence_or_coverage_is_partial");
expect(gate.reason_codes).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
});
it("keeps selected-item limited answers object-scoped", () => { it("keeps selected-item limited answers object-scoped", () => {
const gate = resolveAddressTruthGate({ const gate = resolveAddressTruthGate({
intent: "inventory_sale_trace_for_item", intent: "inventory_sale_trace_for_item",

View File

@ -468,6 +468,26 @@ describe("assistant living chat runtime adapter", () => {
expect(executeLlmChat).not.toHaveBeenCalled(); expect(executeLlmChat).not.toHaveBeenCalled();
}); });
it("builds honest memory checkpoint reply when there is no selected context", async () => {
const executeLlmChat = vi.fn(async () => "raw-llm");
const input = buildRuntimeInput({
userMessage:
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
modeDecision: { mode: "chat", reason: "memory_recap_followup_detected" },
sessionItems: [],
executeLlmChat
});
const output = await runAssistantLivingChatRuntime(input);
expect(output.handled).toBe(true);
expect(output.chatText).toContain("не вижу выбранной компании");
expect(output.chatText).toContain("Группа СВК");
expect(output.chatText).toContain("не подтверждена");
expect(output.debug?.living_chat_response_source).toBe("deterministic_memory_recap_contract");
expect(executeLlmChat).not.toHaveBeenCalled();
});
it("builds deterministic memory recap for prior grounded MCP discovery counterparty context", async () => { it("builds deterministic memory recap for prior grounded MCP discovery counterparty context", async () => {
const executeLlmChat = vi.fn(async () => "raw-llm"); const executeLlmChat = vi.fn(async () => "raw-llm");
const input = buildRuntimeInput({ const input = buildRuntimeInput({

View File

@ -1007,6 +1007,39 @@ describe("assistant orchestration contract", () => {
expect(decision.livingReason).toBe("memory_recap_followup_detected"); expect(decision.livingReason).toBe("memory_recap_followup_detected");
}); });
it("routes startup memory checkpoint without selected context to deterministic chat", () => {
const question =
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.";
const decision = resolveAssistantOrchestrationDecision({
rawUserMessage: question,
effectiveAddressUserMessage: question,
followupContext: null,
llmPreDecomposeMeta: {
applied: true,
llmCanonicalCandidateDetected: true,
predecomposeContract: {
mode: "unsupported",
mode_confidence: "low",
intent: "customer_revenue_and_payments",
intent_confidence: "high"
},
semanticExtractionContract: {
valid: true,
apply_canonical_recommended: true,
reason_codes: ["unsupported_low_confidence_contract"]
}
} as any,
sessionItems: [],
useMock: false
} as any);
expect(decision.runAddressLane).toBe(false);
expect(decision.toolGateDecision).toBe("skip_address_lane");
expect(decision.toolGateReason).toBe("memory_recap_followup_detected");
expect(decision.livingMode).toBe("chat");
expect(decision.livingReason).toBe("memory_recap_followup_detected");
});
it("keeps documentary inventory chain verification in address lane for supported exact intent", () => { it("keeps documentary inventory chain verification in address lane for supported exact intent", () => {
const question = const question =
"Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы"; "Есть ли документально подтвержденная цепочка: поставщик Гамма-мебель, ООО -> товар Шкаф картотечный 1000*400*2100 -> покупатель Департамент капитального ремонта города Москвы";

View File

@ -32,6 +32,34 @@ describe("assistant MCP discovery data need graph", () => {
expect(result.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals"); expect(result.forbidden_overclaim_flags).toContain("no_unchecked_fact_totals");
}); });
it("defaults explicit-counterparty bidirectional value-flow without period to bounded all-time scope", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "counterparty value-flow evidence",
rawUtterance: "how much money passed with SVK, incoming and outgoing?",
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["SVK"]
}
});
expect(result.business_fact_family).toBe("value_flow");
expect(result.comparison_need).toBe("incoming_vs_outgoing");
expect(result.time_scope_need).toBe("all_time_scope");
expect(result.clarification_gaps).toEqual([]);
expect(result.proof_expectation).toBe("coverage_checked_fact");
expect(result.decomposition_candidates).toEqual([
"resolve_entity_reference",
"collect_incoming_movements",
"collect_outgoing_movements",
"aggregate_checked_amounts",
"probe_coverage"
]);
expect(result.reason_codes).toContain(
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
);
});
it("marks metadata lane choice as a clarification-required graph", () => { it("marks metadata lane choice as a clarification-required graph", () => {
const result = buildAssistantMcpDiscoveryDataNeedGraph({ const result = buildAssistantMcpDiscoveryDataNeedGraph({
semanticDataNeed: "metadata lane clarification", semanticDataNeed: "metadata lane clarification",

View File

@ -1011,6 +1011,45 @@ describe("assistant MCP discovery planner", () => {
expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_comparison"); expect(result.reason_codes).toContain("planner_instantiated_catalog_chain_template_value_flow_comparison");
}); });
it("keeps explicit-counterparty bidirectional comparison executable over bounded all-time scope", () => {
const result = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: ["SVK"],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "all_time_scope",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["resolve_entity_reference", "collect_incoming_movements", "collect_outgoing_movements", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: [
"data_need_graph_built",
"data_need_graph_comparison_incoming_vs_outgoing",
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["SVK"],
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
}
});
expect(result.planner_status).toBe("ready_for_execution");
expect(result.selected_chain_id).toBe("value_flow_comparison");
expect(result.proposed_primitives).toEqual(["resolve_entity_reference", "query_movements", "probe_coverage"]);
expect(result.required_axes).toEqual(["counterparty", "all_time_scope", "amount", "coverage_target"]);
expect(result.catalog_review.review_status).toBe("catalog_compatible");
expect(result.discovery_plan.clarification_gaps).toEqual([]);
expect(result.reason_codes).toContain("planner_ready_for_guarded_mcp_execution");
});
it("builds an inference-safe lifecycle plan with evidence explanation", () => { it("builds an inference-safe lifecycle plan with evidence explanation", () => {
const result = planAssistantMcpDiscovery({ const result = planAssistantMcpDiscovery({
turnMeaning: { turnMeaning: {

View File

@ -114,9 +114,11 @@ describe("assistant MCP discovery response candidate", () => {
}) })
); );
expect(candidate.reply_text).toContain("самый доходный год"); expect(candidate.reply_text).toContain("в доступном проверенном MCP-срезе");
expect(candidate.reply_text).toContain("лидирует 2015");
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("136 723 459,73 руб.");
expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности");
expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль"); expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль");
expect(candidate.reply_text).toContain("лимит выборки MCP"); expect(candidate.reply_text).toContain("лимит выборки MCP");
expect(candidate.reply_text).not.toContain("Что подтверждено:"); expect(candidate.reply_text).not.toContain("Что подтверждено:");
@ -162,6 +164,12 @@ describe("assistant MCP discovery response candidate", () => {
total_amount_human_ru: "11 536 836,23 руб." total_amount_human_ru: "11 536 836,23 руб."
} }
], ],
top_suppliers: [
{
axis_value: "ООО Поставщик",
total_amount_human_ru: "2 200 000 руб."
}
],
yearly_breakdown: [] yearly_breakdown: []
} }
}, },
@ -181,12 +189,219 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).toContain("за 2017"); expect(candidate.reply_text).toContain("за 2017");
expect(candidate.reply_text).toContain("получили 16 932 063,96 руб."); expect(candidate.reply_text).toContain("получили 16 932 063,96 руб.");
expect(candidate.reply_text).toContain("исходящие платежи/списания 4 458 027,05 руб."); expect(candidate.reply_text).toContain("исходящие платежи/списания 4 458 027,05 руб.");
expect(candidate.reply_text).toContain("12 474 036,91 руб."); expect(candidate.reply_text).toContain("12 474 036,91 руб");
expect(candidate.reply_text?.split("\n")[0]).toContain("крупнейший источник входящих денег: ГКУ УКРиС");
expect(candidate.reply_text?.split("\n")[0]).toContain("крупнейший получатель исходящих денег: ООО Поставщик");
expect(candidate.reply_text).toContain("денежный operating-flow proxy"); expect(candidate.reply_text).toContain("денежный operating-flow proxy");
expect(candidate.reply_text).not.toContain("Что можно сказать только как вывод:"); expect(candidate.reply_text).not.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({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс",
business_overview_separate_entity_candidates: ["Группа СВК"]
},
data_need_graph: {
business_fact_family: "business_overview",
subject_candidates: [],
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",
top_customers: [],
yearly_breakdown: []
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Company summary.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("по компании ООО Альтернатива Плюс");
expect(candidate.reply_text).toContain("Группа СВК");
expect(candidate.reply_text?.split("\n")[0]).toContain("суммы компании не переношу");
expect(candidate.reply_text).toContain("нельзя делать вывод о выручке, долге или прибыльности");
expect(candidate.reply_text).toContain("без отдельного контрагентского среза");
});
it("adds missing proof boundaries for broad all-time business overviews", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс"
},
data_need_graph: {
business_fact_family: "business_overview",
subject_candidates: [],
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",
top_customers: [],
top_suppliers: [],
yearly_breakdown: []
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Company summary.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
expect(candidate.reply_text).toContain("Что не подтверждено в этом срезе");
expect(candidate.reply_text).toContain("НДС");
expect(candidate.reply_text).toContain("долги");
expect(candidate.reply_text).toContain("склад");
expect(candidate.reply_text).not.toContain("capability_id");
});
it("reuses previous counterparty value-flow bundle in company plus counterparty summaries", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
turn_input: {
adapter_status: "ready",
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
explicit_organization_scope: "ООО Альтернатива Плюс",
business_overview_separate_entity_candidates: ["Группа СВК"],
previous_counterparty_value_flow_bundle: {
counterparty: "Группа СВК",
incoming_customer_revenue: {
total_amount_human_ru: "20 653 490 руб.",
rows_with_amount: 26,
rows_matched: 26,
first_movement_date: "2020-07-27",
latest_movement_date: "2021-11-10"
},
outgoing_supplier_payout: {
total_amount_human_ru: "2 129 651 руб.",
rows_with_amount: 1,
rows_matched: 1,
first_movement_date: "2022-01-20",
latest_movement_date: "2022-01-20"
},
net_amount_human_ru: "18 523 839 руб.",
net_direction: "net_incoming"
},
previous_counterparty_document_bundle: {
counterparty: "Группа СВК",
document_count: 19
}
},
data_need_graph: {
business_fact_family: "business_overview",
subject_candidates: [],
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 руб." },
outgoing_supplier_payout: { total_amount_human_ru: "35 439 044,74 руб." },
net_amount_human_ru: "121 753 936,69 руб.",
net_direction: "net_incoming",
top_customers: [],
yearly_breakdown: []
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Company summary.",
confirmed_lines: [],
inference_lines: [],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
expect(firstLine).toContain("отдельно по Группа СВК: получили 20 653 490 руб.");
expect(firstLine).toContain("можно утверждать только эти подтвержденные срезы");
expect(firstLine).toContain("нельзя называть это чистой прибылью");
expect(candidate.reply_text).toContain("Отдельно по контрагенту Группа СВК: подтверждено получили 20 653 490 руб.");
expect(candidate.reply_text).toContain("заплатили 2 129 651 руб.");
expect(candidate.reply_text).toContain("Можно утверждать:");
expect(candidate.reply_text).toContain("Нельзя утверждать:");
expect(candidate.reply_text).toContain("документы по цепочке: найдено 19");
expect(candidate.reply_text).toContain("ранее подтвержденный контрагентский срез");
});
it("localizes value-flow evidence without leaking pilot mechanics", () => { it("localizes value-flow evidence without leaking pilot mechanics", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate( const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({ entryPoint({
@ -294,6 +509,63 @@ describe("assistant MCP discovery response candidate", () => {
expect(candidate.reply_text).not.toContain("query_movements"); expect(candidate.reply_text).not.toContain("query_movements");
}); });
it("uses a compact direct first line for derived bidirectional value-flow totals", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
pilot: {
pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
derived_bidirectional_value_flow: {
counterparty: "SVK",
period_scope: "2020",
net_amount_human_ru: "8 500,50 руб.",
net_direction: "net_incoming",
coverage_limited_by_probe_limit: false,
incoming_customer_revenue: {
rows_matched: 2,
rows_with_amount: 2,
total_amount_human_ru: "12 500,50 руб.",
first_movement_date: "2020-01-15",
latest_movement_date: "2020-02-20"
},
outgoing_supplier_payout: {
rows_matched: 1,
rows_with_amount: 1,
total_amount_human_ru: "4 000 руб.",
first_movement_date: "2020-03-10",
latest_movement_date: "2020-03-10"
}
}
},
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "По данным 1С найдены строки входящих и исходящих денежных движений.",
confirmed_lines: ["1C bidirectional value-flow rows were checked for counterparty SVK: incoming=found, outgoing=found"],
inference_lines: ["Counterparty net value-flow was calculated as incoming confirmed 1C rows minus outgoing confirmed 1C rows"],
unknown_lines: [],
limitation_lines: [],
next_step_line: null
}
}
})
);
const firstLine = candidate.reply_text?.split("\n")[0] ?? "";
expect(firstLine).toContain("Коротко:");
expect(firstLine).toContain("SVK");
expect(firstLine).toContain("получили 12 500,50 руб.");
expect(firstLine).toContain("заплатили 4 000 руб.");
expect(firstLine).toContain("нетто в нашу сторону: 8 500,50 руб.");
expect(candidate.reply_text).toContain("Основа:");
expect(candidate.reply_text).not.toContain("Что подтверждено");
expect(candidate.reply_text).not.toContain("pilot_");
expect(candidate.reply_text).not.toContain("query_movements");
});
it("keeps monthly breakdown lines user-facing and localizes monthly inference basis", () => { it("keeps monthly breakdown lines user-facing and localizes monthly inference basis", () => {
const candidate = buildAssistantMcpDiscoveryResponseCandidate( const candidate = buildAssistantMcpDiscoveryResponseCandidate(
entryPoint({ entryPoint({

View File

@ -135,6 +135,69 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate"); expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_not_discovery_ready_address_candidate");
}); });
it("lets a grounded business overview candidate override a semantically wrong exact address recipe", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Supplier and stock overlap was confirmed for 2020.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "inventory_supplier_stock_overlap_as_of_date",
selected_recipe: "address_inventory_supplier_stock_overlap_as_of_date_v1",
mcp_call_status: "matched_non_empty",
truth_mode: "confirmed",
capability_binding_status: "bound",
capability_binding_violations: [],
answer_shape_contract: {
reply_type: "factual",
capability_contract_id: "inventory_inventory_supplier_stock_overlap_as_of_date"
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
explicit_organization_scope: "OOO Alternative Plus",
explicit_date_scope: "2020"
},
data_need_graph: {
business_fact_family: "business_overview",
clarification_gaps: []
}
},
bridge: {
bridge_status: "answer_draft_ready",
user_facing_response_allowed: true,
business_fact_answer_allowed: true,
requires_user_clarification: false,
answer_draft: {
answer_mode: "confirmed_with_bounded_inference",
headline: "Business overview was assembled from confirmed 1C evidence.",
confirmed_lines: [
"Incoming customer money flow: 200000.00 RUB.",
"Outgoing supplier payouts: 150000.00 RUB."
],
inference_lines: ["Net confirmed cash-flow spread is +50000.00 RUB; this is not profit."],
unknown_lines: ["Profit and formal margin are not confirmed by this overview."],
limitation_lines: ["The overview is limited to checked 1C rows."],
next_step_line: "Check profit, VAT, debt quality, and inventory liquidity next."
}
}
})
}
});
expect(result.applied).toBe(true);
expect(result.decision).toBe("apply_candidate");
expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded");
expect(result.reply_text).toContain("Business overview");
expect(result.reply_text).toContain("Incoming customer money flow");
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_exact_matched_factual_address_reply");
});
it("overrides exact inbound value-flow replies when the discovery turn meaning asks for payouts", () => { it("overrides exact inbound value-flow replies when the discovery turn meaning asks for payouts", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({ const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Incoming turnover by SVK: 12 224 925.00 rub.", currentReply: "Incoming turnover by SVK: 12 224 925.00 rub.",
@ -744,6 +807,65 @@ describe("assistant MCP discovery response policy", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override"); expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
}); });
it("keeps runtime-matched exact VAT replies over a stale business overview discovery seed", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "Short: confirmed VAT for 2020 is based on checked VAT rows.",
currentReplySource: "address_query_runtime_v1",
currentReplyType: "factual",
addressRuntimeMeta: {
detected_intent: "vat_liability_confirmed_for_tax_period",
selected_recipe: "address_vat_liability_confirmed_tax_period_v1",
mcp_call_status: "matched_non_empty",
truth_mode: "confirmed",
capability_binding_status: "bound",
capability_binding_violations: [],
truth_gate_contract_status: "full_confirmed",
assistant_truth_answer_policy_v1: {
truth_gate: {
coverage_status: "full",
grounding_status: "grounded",
source_truth_gate_status: "full_confirmed"
},
answer_shape: {
reply_type: "factual",
capability_contract_id: "confirmed_vat_liability_for_tax_period"
}
},
assistant_state_transition_v1: {
reason_codes: [
"root_followup_continue_previous",
"route_expectation_matched",
"vat_period_inspection_bridge_signal_detected",
"confirmed_balance_exact_vat_tax_period_intent"
]
},
assistant_mcp_discovery_entry_point_v1: entryPoint({
turn_input: {
adapter_status: "ready",
should_run_discovery: true,
turn_meaning_ref: {
asked_domain_family: "business_overview",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation"
},
data_need_graph: {
business_fact_family: "business_overview",
clarification_gaps: []
}
}
})
}
});
expect(result.applied).toBe(false);
expect(result.decision).toBe("keep_current_reply");
expect(result.reply_text).toContain("confirmed VAT");
expect(result.reason_codes).toContain(
"mcp_discovery_response_policy_keep_runtime_matched_exact_reply_over_stale_discovery_turn_meaning"
);
expect(result.reason_codes).not.toContain("mcp_discovery_response_policy_semantic_conflict_allows_candidate_override");
});
it("keeps address lane answers when discovery was not requested for the current turn", () => { it("keeps address lane answers when discovery was not requested for the current turn", () => {
const result = applyAssistantMcpDiscoveryResponsePolicy({ const result = applyAssistantMcpDiscoveryResponsePolicy({
currentReply: "supported exact route answer", currentReply: "supported exact route answer",

View File

@ -279,6 +279,66 @@ describe("assistant MCP discovery runtime bridge", () => {
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("заплатили"); expect(result.answer_draft.confirmed_lines.join("\n")).toContain("заплатили");
}); });
it("executes explicit-counterparty bidirectional comparison without period as bounded all-time scope", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: ["SVK"],
business_fact_family: "value_flow",
action_family: "net_value_flow",
aggregation_need: null,
time_scope_need: "all_time_scope",
comparison_need: "incoming_vs_outgoing",
ranking_need: null,
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: [
"resolve_entity_reference",
"collect_incoming_movements",
"collect_outgoing_movements",
"probe_coverage"
],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: [
"data_need_graph_built",
"data_need_graph_comparison_incoming_vs_outgoing",
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["SVK"],
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting"
},
deps: buildBidirectionalDeps(
[
{ Period: "2020-01-10T00:00:00", Amount: 3200, Counterparty: "SVK" },
{ Period: "2021-04-11T00:00:00", Amount: 1800, Counterparty: "SVK" }
],
[{ Period: "2022-02-12T00:00:00", Amount: 1400, Counterparty: "SVK" }]
)
});
expect(result.bridge_status).toBe("answer_draft_ready");
expect(result.requires_user_clarification).toBe(false);
expect(result.business_fact_answer_allowed).toBe(true);
expect(result.planner.selected_chain_id).toBe("value_flow_comparison");
expect(result.planner.required_axes).toContain("all_time_scope");
expect(result.pilot.mcp_execution_performed).toBe(true);
expect(result.pilot.derived_bidirectional_value_flow).toMatchObject({
period_scope: null,
incoming_customer_revenue: {
total_amount: 5000
},
outgoing_supplier_payout: {
total_amount: 1400
}
});
expect(result.answer_draft.confirmed_lines.join("\n")).toContain("SVK");
});
it("keeps document-ready plans bounded when the pilot finds no confirmed rows", async () => { it("keeps document-ready plans bounded when the pilot finds no confirmed rows", async () => {
const result = await runAssistantMcpDiscoveryRuntimeBridge({ const result = await runAssistantMcpDiscoveryRuntimeBridge({
turnMeaning: { turnMeaning: {

View File

@ -133,6 +133,39 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected"); expect(result.reason_codes).not.toContain("mcp_discovery_payout_signal_detected");
}); });
it("keeps explicit counterparty money-flow basis questions executable without requiring a period", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "How much money passed with SVK, incoming and outgoing, and what documents or movements prove it?",
assistantTurnMeaning: {
asked_domain_family: "counterparty",
asked_action_family: "list_documents",
explicit_intent_candidate: "list_documents_by_counterparty",
explicit_entity_candidates: [{ value: "SVK" }]
},
predecomposeContract: {
entities: { counterparty: "SVK" },
period: { scope: "unspecified", period_from: null, period_to: null }
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "counterparty_value",
asked_action_family: "net_value_flow",
explicit_entity_candidates: ["SVK"],
unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
expect(result.data_need_graph?.time_scope_need).toBe("all_time_scope");
expect(result.data_need_graph?.clarification_gaps).toEqual([]);
expect(result.data_need_graph?.reason_codes).toContain(
"data_need_graph_subject_bidirectional_value_flow_defaults_to_all_time_scope"
);
expect(result.reason_codes).toContain("mcp_discovery_bidirectional_value_flow_signal_detected");
});
it("extracts compact scoped counterparty from net follow-up wording when LLM entities are empty", () => { it("extracts compact scoped counterparty from net follow-up wording when LLM entities are empty", () => {
const orgName = "ООО Альтернатива Плюс"; const orgName = "ООО Альтернатива Плюс";
const result = buildAssistantMcpDiscoveryTurnInput({ const result = buildAssistantMcpDiscoveryTurnInput({
@ -938,6 +971,39 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context"); expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context");
}); });
it("does not leak implicit current date into document follow-up after all-time bidirectional value-flow", () => {
const orgName = "ООО Альтернатива Плюс";
const counterpartyName = "Группа СВК";
const today = new Date().toISOString().slice(0, 10);
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "а покажи документы по этой цепочке",
followupContext: {
previous_discovery_pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1",
previous_filters: {
counterparty: counterpartyName,
organization: orgName,
as_of_date: today
},
previous_anchor_type: "counterparty",
previous_anchor_value: counterpartyName
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.turn_meaning_ref).toMatchObject({
asked_domain_family: "documents",
asked_action_family: "list_documents",
explicit_entity_candidates: [counterpartyName],
explicit_organization_scope: orgName,
unsupported_but_understood_family: "document_evidence",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
expect(result.reason_codes).toContain("mcp_discovery_value_flow_grounded_document_followup");
expect(result.reason_codes).not.toContain("mcp_discovery_date_scope_from_followup_context");
});
it("seeds short metadata follow-up from prior metadata discovery context", () => { it("seeds short metadata follow-up from prior metadata discovery context", () => {
const result = buildAssistantMcpDiscoveryTurnInput({ const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "а по регистрам?", userMessage: "а по регистрам?",
@ -1531,6 +1597,40 @@ describe("assistant MCP discovery turn input adapter", () => {
}); });
}); });
it("keeps a raw business-overview year over a predecompose as-of date derived from that year", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0414\u0430\u0439 \u0432\u0437\u0440\u043e\u0441\u043b\u044b\u0439 \u0431\u0438\u0437\u043d\u0435\u0441-\u043e\u0431\u0437\u043e\u0440 \u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441 \u0437\u0430 2020 \u0433\u043e\u0434: \u0434\u0435\u043d\u044c\u0433\u0438, \u041d\u0414\u0421, \u0434\u043e\u043b\u0433\u0438, \u0441\u043a\u043b\u0430\u0434.",
assistantTurnMeaning: {
asked_domain_family: "business_summary",
asked_action_family: "broad_evaluation",
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
},
predecomposeContract: {
entities: {
organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
},
period: {
scope: "as_of",
period_from: "2020-01-01",
period_to: "2020-12-31",
as_of_date: "2020-12-31",
has_explicit_period: true
}
}
});
expect(result.adapter_status).toBe("ready");
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",
explicit_date_scope: "2020"
});
expect(result.reason_codes).toContain("mcp_discovery_business_overview_raw_year_overrode_predecompose_as_of_scope");
});
it("keeps all-time business overview from reusing a negated VAT period as active scope", () => { it("keeps all-time business overview from reusing a negated VAT period as active scope", () => {
const result = buildAssistantMcpDiscoveryTurnInput({ const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: userMessage:
@ -2937,7 +3037,7 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context"); expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
}); });
it("keeps VAT-position follow-up inside business overview instead of stale inventory position", () => { it("lets an explicit VAT follow-up stay on the exact VAT route instead of stale business overview", () => {
const orgName = const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"; "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const result = buildAssistantMcpDiscoveryTurnInput({ const result = buildAssistantMcpDiscoveryTurnInput({
@ -2957,21 +3057,16 @@ describe("assistant MCP discovery turn input adapter", () => {
} }
}); });
expect(result.adapter_status).toBe("ready"); expect(result.adapter_status).toBe("not_applicable");
expect(result.should_run_discovery).toBe(true); expect(result.should_run_discovery).toBe(false);
expect(result.semantic_data_need).toBe("business overview evidence with bounded analyst interpretation"); expect(result.semantic_data_need).toBeNull();
expect(result.data_need_graph?.business_fact_family).toBe("business_overview"); expect(result.data_need_graph).toBeNull();
expect(result.data_need_graph?.subject_candidates).toEqual([]); expect(result.turn_meaning_ref).toBeNull();
expect(result.turn_meaning_ref).toMatchObject({ expect(result.reason_codes).toContain(
asked_domain_family: "business_overview", "mcp_discovery_business_overview_continuation_suppressed_by_explicit_vat_question"
asked_action_family: "broad_evaluation", );
explicit_organization_scope: orgName, expect(result.reason_codes).toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
explicit_date_scope: "2020", expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_continuation_from_followup_context");
unsupported_but_understood_family: "broad_business_evaluation",
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");
}); });
it("routes business overview final-summary wording to the overview lane without document pseudo subject", () => { it("routes business overview final-summary wording to the overview lane without document pseudo subject", () => {
@ -3012,4 +3107,108 @@ describe("assistant MCP discovery turn input adapter", () => {
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined(); 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_continuation_from_followup_context");
}); });
it("routes cross-scope executive summary over stale document carryover and keeps prior bundles", () => {
const orgName =
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441";
const counterpartyName = "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a";
const valueFlowBundle = {
counterparty: counterpartyName,
incoming_total: 20653490,
outgoing_total: 2129651,
net_amount: 18523839
};
const documentBundle = {
counterparty: counterpartyName,
document_count: 19,
direct_answer:
"\u041a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442: \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a. \u041d\u0430\u0439\u0434\u0435\u043d\u043e \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u043e\u0432: 19."
};
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0421\u043e\u0431\u0435\u0440\u0438 \u043a\u043e\u0440\u043e\u0442\u043a\u0438\u0439 \u0438\u0442\u043e\u0433: \u0447\u0442\u043e \u043c\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u043b\u0438 \u043f\u043e \u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438, \u0447\u0442\u043e \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u043f\u043e \u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a, \u043a\u0430\u043a\u0438\u0435 \u0432\u044b\u0432\u043e\u0434\u044b \u043c\u043e\u0436\u043d\u043e \u0434\u0435\u043b\u0430\u0442\u044c \u0438 \u043a\u0430\u043a\u0438\u0435 \u043d\u0435\u043b\u044c\u0437\u044f.",
assistantTurnMeaning: {
asked_domain_family: "counterparty",
asked_action_family: "list_documents",
explicit_intent_candidate: "list_documents_by_counterparty"
},
followupContext: {
previous_discovery_pilot_scope: "counterparty_document_evidence_query_documents_v1",
previous_intent: "list_documents_by_counterparty",
target_intent: "list_documents_by_counterparty",
previous_filters: {
organization: orgName,
counterparty: counterpartyName,
as_of_date: "2026-05-09"
},
previous_anchor_type: "counterparty",
previous_anchor_value: counterpartyName,
previous_discovery_bidirectional_value_flow: valueFlowBundle,
previous_discovery_document_summary: documentBundle
}
});
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: "broad_evaluation",
business_overview_separate_entity_candidates: [counterpartyName],
previous_counterparty_value_flow_bundle: valueFlowBundle,
previous_counterparty_document_bundle: documentBundle,
explicit_organization_scope: orgName,
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).toContain(
"mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope"
);
expect(result.reason_codes).not.toContain("mcp_discovery_not_applicable_for_supported_exact_turn");
});
it("preserves explicit counterparty scope for company plus counterparty business summaries", () => {
const orgName = "ООО Альтернатива Плюс";
const counterpartyName = "Группа СВК";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"Собери короткий итог: что мы подтвердили по компании, что отдельно по Группа СВК, какие выводы можно делать и какие нельзя.",
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: "counterparty_document_evidence_query_documents_v1",
previous_filters: {
organization: orgName,
counterparty: counterpartyName
},
previous_anchor_type: "counterparty",
previous_anchor_value: counterpartyName
}
});
expect(result.adapter_status).toBe("ready");
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: "broad_evaluation",
business_overview_separate_entity_candidates: [counterpartyName],
explicit_organization_scope: orgName,
unsupported_but_understood_family: "broad_business_evaluation",
stale_replay_forbidden: true
});
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.reason_codes).toContain(
"mcp_discovery_business_overview_preserved_explicit_counterparty_summary_scope"
);
expect(result.reason_codes).not.toContain("mcp_discovery_business_overview_suppressed_stale_counterparty");
});
}); });

View File

@ -69,6 +69,27 @@ describe("assistantMemoryRecapPolicy", () => {
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true); expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
}); });
it("detects startup memory checkpoint without prior grounded context", () => {
const signals = policy.resolveRouteMemorySignals({
rawUserMessage:
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
repairedRawUserMessage: "",
effectiveAddressUserMessage: "",
repairedEffectiveAddressUserMessage: "",
dataScopeMetaQuery: false,
capabilityMetaQuery: false,
dataRetrievalSignal: false,
strongDataSignal: true,
aggregateBusinessAnalyticsSignal: false,
lastGroundedAddressDebug: null,
hasPriorAddressDebug: false,
sessionItems: []
});
expect(signals.contextualHistoricalCapabilityFollowupDetected).toBe(false);
expect(signals.contextualMemoryRecapFollowupDetected).toBe(true);
});
it("treats explicit recap wording over selected-object phrasing as memory follow-up even when data cues are present", () => { it("treats explicit recap wording over selected-object phrasing as memory follow-up even when data cues are present", () => {
const signals = policy.resolveRouteMemorySignals({ const signals = policy.resolveRouteMemorySignals({
rawUserMessage: "а ты помнишь, что мы по этой позиции уже выяснили?", rawUserMessage: "а ты помнишь, что мы по этой позиции уже выяснили?",
@ -324,6 +345,25 @@ describe("assistantMemoryRecapPolicy", () => {
expect(reply).toContain("подняли документы закупки"); expect(reply).toContain("подняли документы закупки");
}); });
it("honestly reports empty memory when startup checkpoint has no selected context", () => {
const reply = buildAddressMemoryRecapReply({
organization: null,
addressDebug: null,
sessionItems: [],
userMessage:
"Сделай короткий стартовый чек контекста: есть ли уже выбранная компания или контрагент в текущем диалоге; если нет, скажи честно и не выдумывай память про Группа СВК.",
toNonEmptyString: (value: unknown) => {
const text = String(value ?? "").trim();
return text.length > 0 ? text : null;
}
});
expect(reply).toContain("Коротко: в текущем диалоге я не вижу выбранной компании");
expect(reply).toContain("Группа СВК");
expect(reply).toContain("не подтверждена");
expect(reply).not.toContain("Да, помню предыдущий адресный контур");
});
it("resolves grounded answer inspection from shared memory context", () => { it("resolves grounded answer inspection from shared memory context", () => {
const context = resolveAssistantLivingChatMemoryContext({ const context = resolveAssistantLivingChatMemoryContext({
modeDecisionReason: "answer_inspection_followup_detected", modeDecisionReason: "answer_inspection_followup_detected",

View File

@ -131,6 +131,85 @@ describe("assistant truth answer policy runtime adapter", () => {
expect(policy.answer_shape.may_power_followup).toBe(true); expect(policy.answer_shape.may_power_followup).toBe(true);
}); });
it("downgrades stale full-confirmed truth gates when coverage evidence is only heuristic", () => {
const policy = resolveAssistantTruthAnswerPolicyRuntime({
addressDebug: {
capability_id: "address_open_items_by_counterparty_or_contract",
rows_matched: 8,
answer_grounding_check: {
status: "grounded",
reasons: ["confirmed_balance_unavailable_fallback_to_heuristic_candidates"]
},
address_coverage_evidence_v1: {
schema_version: "address_coverage_evidence_v1",
policy_owner: "addressCoverageEvidencePolicy",
requested_result_mode: "confirmed_balance",
result_mode: "heuristic_candidates",
evidence_strength: "medium",
balance_confirmed: false,
as_of_date_basis: "period_range",
coverage_status: "partial",
evidence_basis: "heuristic_candidates",
reason_codes: [
"coverage_status_partial",
"result_mode_heuristic_candidates",
"balance_confirmed_false"
]
},
address_truth_gate_v1: {
schema_version: "address_truth_gate_v1",
policy_owner: "addressTruthGatePolicy",
truth_gate_status: "full_confirmed",
carryover_eligibility: "none",
limited_reason_category: null,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
reason_codes: ["stale_full_confirmed_shadow"],
blocked_or_limited_explanation: null
}
},
replyType: "factual"
});
expect(policy.truth_gate.coverage_status).toBe("partial");
expect(policy.truth_gate.truth_mode).toBe("limited");
expect(policy.truth_gate.evidence_grade).toBe("medium");
expect(policy.truth_gate.reason_codes).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
expect(policy.answer_shape.answer_shape).toBe("limited_with_reason");
expect(policy.answer_shape.must_include_limitation).toBe(true);
});
it("downgrades stale full-confirmed truth gates from top-level heuristic result metadata", () => {
const policy = resolveAssistantTruthAnswerPolicyRuntime({
addressDebug: {
capability_id: "address_open_items_by_counterparty_or_contract",
rows_matched: 8,
result_mode: "heuristic_candidates",
evidence_strength: "medium",
balance_confirmed: false,
answer_grounding_check: {
status: "grounded",
reasons: ["confirmed_balance_unavailable_fallback_to_heuristic_candidates"]
},
address_truth_gate_v1: {
schema_version: "address_truth_gate_v1",
policy_owner: "addressTruthGatePolicy",
truth_gate_status: "full_confirmed",
carryover_eligibility: "none",
limited_reason_category: null,
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
reason_codes: [],
blocked_or_limited_explanation: null
}
},
replyType: "factual"
});
expect(policy.truth_gate.coverage_status).toBe("partial");
expect(policy.truth_gate.truth_mode).toBe("limited");
expect(policy.truth_gate.evidence_grade).toBe("medium");
expect(policy.answer_shape.answer_shape).toBe("limited_with_reason");
});
it("keeps explicit temporal-limited factual answers limited in the truth contract", () => { it("keeps explicit temporal-limited factual answers limited in the truth contract", () => {
const policy = resolveAssistantTruthAnswerPolicyRuntime({ const policy = resolveAssistantTruthAnswerPolicyRuntime({
addressDebug: { addressDebug: {