ДОМЕНЫ - ВОПРОСЫ - Этап 4: точный маршрут confirmed payables на дату без эвристического фолбэка

This commit is contained in:
2026-04-12 14:14:03 +03:00
parent 1b2ee93176
commit fbd156e58e
19 changed files with 1303 additions and 162 deletions
@@ -745,6 +745,9 @@ function requiredFiltersByIntent(intent) {
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
if (intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty") {
@@ -756,7 +759,9 @@ function requiredFiltersByIntent(intent) {
return [];
}
function usesAsOfPrimaryWindow(intent) {
return intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts";
return (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date");
}
function extractAddressFilters(userMessage, intent) {
const rawText = String(userMessage ?? "").trim();
@@ -916,7 +921,10 @@ function extractAddressFilters(userMessage, intent) {
// - explicit as_of has priority;
// - else use period_to boundary when provided;
// - else default to today.
if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance") && !filters.as_of_date) {
if ((intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date") &&
!filters.as_of_date) {
if (filters.period_to) {
filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to");
@@ -1225,11 +1225,12 @@ function resolveAddressIntent(userMessage) {
}
if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return {
intent: "list_payables_counterparties",
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",
confidence: "high",
reasons
};
+202 -54
View File
@@ -10,6 +10,7 @@ const composeStage_1 = require("./address_runtime/composeStage");
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"];
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1";
const ADDRESS_ANCHOR_RECOVERY_LIMIT = 1000;
const ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT = 200;
const COUNTERPARTY_CATALOG_LOOKUP_LIMIT = 1000;
const COUNTERPARTY_CATALOG_CACHE_TTL_MS = 120_000;
const PARTY_ANCHOR_STOPWORDS = new Set([
@@ -345,6 +346,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent, filters) {
intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_receivables_counterparties");
}
async function resolveCounterpartyViaCatalog(anchorRaw) {
@@ -628,6 +630,7 @@ function parseIsoDateUtcTimestamp(value) {
function isCounterpartyRiskIntent(intent) {
return (intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract");
}
@@ -638,7 +641,9 @@ function isHeuristicCandidatesIntent(intent) {
intent === "open_items_by_counterparty_or_contract");
}
function isConfirmedBalanceIntent(intent) {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
return (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date");
}
function resolveAsOfDateBasis(filters) {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
@@ -702,11 +707,12 @@ function deriveAddressResultSemantics(input) {
};
}
if (isConfirmedBalanceIntent(input.intent)) {
const balanceConfirmed = input.responseType !== "LIMITED_WITH_REASON";
return {
requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true,
balance_confirmed: balanceConfirmed,
as_of_date_basis: asOfDateBasis ?? "period_end"
};
}
@@ -717,6 +723,62 @@ function deriveAddressResultSemantics(input) {
}
return {};
}
function mergeAddressResultSemantics(base, override) {
if (!override) {
return base;
}
return {
...base,
...(override.result_mode ? { result_mode: override.result_mode } : {}),
...(override.evidence_strength ? { evidence_strength: override.evidence_strength } : {}),
...(typeof override.balance_confirmed === "boolean" ? { balance_confirmed: override.balance_confirmed } : {})
};
}
function withConfirmedBalanceFallbackReason(reasons, requestedResultMode, semantics, baseResultMode) {
if (requestedResultMode !== "confirmed_balance") {
return reasons;
}
const effectiveResultMode = semantics?.result_mode ?? baseResultMode;
if (effectiveResultMode !== "heuristic_candidates") {
return reasons;
}
if (reasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
return reasons;
}
return [...reasons, "confirmed_balance_unavailable_fallback_to_heuristic_candidates"];
}
function enforceStrictAccountScopeForIntent(plan, intent) {
if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") {
return plan;
}
return {
...plan,
account_scope_mode: "strict"
};
}
function resolveExecutionFiltersForPayablesConfirmedBalance(filters, analysisDate) {
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
const executionFilters = {
...filters
};
if (derivedAsOf) {
executionFilters.as_of_date = derivedAsOf;
}
delete executionFilters.period_from;
delete executionFilters.period_to;
const limit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(executionFilters.limit))
: null;
if (limit === null || limit < ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT) {
executionFilters.limit = Math.max(ADDRESS_CONFIRMED_PAYABLES_MIN_LIMIT, limit ?? 0);
}
return {
executionFilters,
asOfDerived: derivedAsOf
};
}
function resolveFutureGuardReferenceDate(analysisDate, filters) {
if (analysisDate) {
return analysisDate;
@@ -1124,6 +1186,9 @@ function buildLimitedOffers(input) {
if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
}
else if (input.intent === "payables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
}
else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
}
@@ -1165,7 +1230,8 @@ function buildLimitedIntentSignalLine(input) {
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов."
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату."
};
const byShape = {
AGGREGATE_LOOKUP: "Сигнал запроса: агрегатный вопрос по периоду/срезу.",
@@ -1278,7 +1344,7 @@ function composeLimitedReply(input) {
if (offers.length > 0) {
lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`);
}
return lines.join("\n");
return lines.join("\n\n");
}
function buildLimitedExecutionResult(input) {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
@@ -1289,6 +1355,12 @@ function buildLimitedExecutionResult(input) {
responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched
});
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(input.reasons, requestedResultMode, undefined, resultSemantics.result_mode);
const reasons = input.intent.intent === "payables_confirmed_as_of_date" &&
!reasonsWithConfirmedFallback.includes("exact_payables_mode_limited_response")
? [...reasonsWithConfirmedFallback, "exact_payables_mode_limited_response"]
: reasonsWithConfirmedFallback;
return {
handled: true,
reply_text: composeLimitedReply({
@@ -1341,7 +1413,7 @@ function buildLimitedExecutionResult(input) {
response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations,
reasons: input.reasons
reasons
}
};
}
@@ -1371,13 +1443,29 @@ class AddressQueryService {
baseReasons.push("as_of_date_from_analysis_context");
}
}
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
const payablesConfirmedExecution = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") &&
requestedResultMode === "confirmed_balance"
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters;
if (payablesConfirmedExecution?.asOfDerived &&
!(typeof filters.extracted_filters.as_of_date === "string" && filters.extracted_filters.as_of_date.trim().length > 0)) {
if (!filters.warnings.includes("as_of_date_derived_for_confirmed_payables")) {
filters.warnings.push("as_of_date_derived_for_confirmed_payables");
}
if (!baseReasons.includes("as_of_date_derived_for_confirmed_payables")) {
baseReasons.push("as_of_date_derived_for_confirmed_payables");
}
}
const composeOptionsFromFilters = (filterSet) => ({
userMessage,
periodFrom: typeof filterSet.period_from === "string" ? filterSet.period_from : undefined,
periodTo: typeof filterSet.period_to === "string" ? filterSet.period_to : undefined,
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined
asOfDate: typeof filterSet.as_of_date === "string" ? filterSet.as_of_date : undefined,
requestedResultMode
});
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, filters.extracted_filters);
const futureGuardReferenceDate = resolveFutureGuardReferenceDate(analysisDate, executionFilters);
let anchor = (0, resolveStage_1.resolvePrimaryAnchor)(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario = intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) &&
@@ -1387,15 +1475,25 @@ class AddressQueryService {
(intent.reasons.includes("payables_debt_lifecycle_signal_detected") ||
intent.reasons.includes("supplier_tail_risk_signal_detected") ||
intent.reasons.includes("payables_signal_detected"));
const recipeIntent = debtLifecycleReceivablesScenario || debtLifecyclePayablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, filters.extracted_filters);
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
const preferConfirmedBalanceForPayablesLifecycle = debtLifecyclePayablesScenario && requestedResultMode === "confirmed_balance";
const recipeIntent = debtLifecycleReceivablesScenario
? "open_items_by_counterparty_or_contract"
: debtLifecyclePayablesScenario && !preferConfirmedBalanceForPayablesLifecycle
? "open_items_by_counterparty_or_contract"
: intent.intent;
const recipeSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(recipeIntent, executionFilters);
if (debtLifecycleReceivablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_receivables_debt_lifecycle");
}
if (debtLifecyclePayablesScenario && recipeIntent !== intent.intent) {
baseReasons.push("recipe_override_to_open_items_for_payables_debt_lifecycle");
}
if (preferConfirmedBalanceForPayablesLifecycle && !baseReasons.includes("confirmed_balance_attempt_for_payables_debt_lifecycle")) {
baseReasons.push("confirmed_balance_attempt_for_payables_debt_lifecycle");
}
if (intent.intent === "payables_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_payables_intent")) {
baseReasons.push("confirmed_balance_exact_payables_intent");
}
if (requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) {
@@ -1513,17 +1611,21 @@ class AddressQueryService {
}
}
}
let plan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, filters.extracted_filters);
let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id;
let plan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(recipeSelection.selected_recipe, executionFilters), intent.intent);
let mcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: plan.query,
limit: plan.limit
});
const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date";
if (mcp.error &&
recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" &&
isMissingSubcontoFieldError(mcp.error)) {
const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", filters.extracted_filters);
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error) &&
allowOpenItemsFallbackForMissingSubconto) {
const fallbackSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = (0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, filters.extracted_filters);
const fallbackPlan = enforceStrictAccountScopeForIntent((0, addressRecipeCatalog_1.buildAddressRecipePlan)(fallbackSelection.selected_recipe, executionFilters), intent.intent);
const fallbackMcp = await (0, addressMcpClient_1.executeAddressMcpQuery)({
query: fallbackPlan.query,
limit: fallbackPlan.limit
@@ -1531,9 +1633,16 @@ class AddressQueryService {
if (!fallbackMcp.error) {
plan = fallbackPlan;
mcp = fallbackMcp;
if (intent.intent === "list_payables_counterparties") {
effectiveRecipeId = fallbackSelection.selected_recipe.recipe_id;
}
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_to_open_items")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_to_open_items");
}
if (intent.intent === "list_payables_counterparties" &&
!baseReasons.includes("fallback_recipe_switched_to_open_items")) {
baseReasons.push("fallback_recipe_switched_to_open_items");
}
}
else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) {
@@ -1547,6 +1656,14 @@ class AddressQueryService {
}
}
}
if (mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error) &&
!allowOpenItemsFallbackForMissingSubconto &&
!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback");
}
if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
return buildLimitedExecutionResult({
@@ -1555,7 +1672,7 @@ class AddressQueryService {
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
anchor,
mcpCallStatus: deriveMcpStageStatus({
@@ -1590,10 +1707,10 @@ class AddressQueryService {
const normalizedRows = accountScopeFallbackApplied ? normalizedRawRows : scopedRows;
anchor = (0, resolveStage_1.refineAnchorFromRows)(anchor, normalizedRows);
const filtersForMatching = anchor.anchor_type === "counterparty" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, counterparty: anchor.anchor_value_resolved }
? { ...executionFilters, counterparty: anchor.anchor_value_resolved }
: anchor.anchor_type === "contract" && anchor.anchor_value_resolved
? { ...filters.extracted_filters, contract: anchor.anchor_value_resolved }
: filters.extracted_filters;
? { ...executionFilters, contract: anchor.anchor_value_resolved }
: executionFilters;
const accountScopeAudit = buildAccountScopeAudit({
intent: intent.intent,
filters: filtersForMatching,
@@ -1635,7 +1752,7 @@ class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters));
const recoveryReason = recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
: "contract_docs_recovered_via_anchor_rows";
@@ -1656,7 +1773,7 @@ class AddressQueryService {
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus("matched_non_empty"),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
@@ -1684,15 +1801,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...deriveAddressResultSemantics({
...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: recoveredRows.length
}),
}), factual.semantics),
limitations: [...filters.warnings, recoveryReason],
reasons: [...baseReasons, recoveryReason]
reasons: withConfirmedBalanceFallbackReason([...baseReasons, recoveryReason], requestedResultMode, factual.semantics)
}
};
}
@@ -1702,12 +1819,12 @@ class AddressQueryService {
(stageStatus === "materialized_but_not_anchor_matched" ||
stageStatus === "materialized_but_filtered_out_by_recipe" ||
stageStatus === "raw_rows_received_but_not_materialized")) {
const currentLimit = typeof filters.extracted_filters.limit === "number" && Number.isFinite(filters.extracted_filters.limit)
? Math.max(1, Math.trunc(filters.extracted_filters.limit))
const currentLimit = typeof executionFilters.limit === "number" && Number.isFinite(executionFilters.limit)
? Math.max(1, Math.trunc(executionFilters.limit))
: plan.limit;
if (currentLimit < ADDRESS_ANCHOR_RECOVERY_LIMIT) {
const expandedLimitFilters = {
...filters.extracted_filters,
...executionFilters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
};
const expandedSelection = (0, addressRecipeCatalog_1.selectAddressRecipe)(intent.intent, expandedLimitFilters);
@@ -1807,15 +1924,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
...deriveAddressResultSemantics({
...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: expandedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: expandedFactual.responseType,
rowsMatched: expandedFilteredRows.length
}),
}), expandedFactual.semantics),
limitations: expandedLimitations,
reasons: expandedReasons
reasons: withConfirmedBalanceFallbackReason(expandedReasons, requestedResultMode, expandedFactual.semantics)
}
};
}
@@ -1925,15 +2042,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
...deriveAddressResultSemantics({
...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: broadenedSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: broadenedFactual.responseType,
rowsMatched: broadenedFilteredRows.length
}),
}), broadenedFactual.semantics),
limitations: broadenedLimitations,
reasons: broadenedReasons
reasons: withConfirmedBalanceFallbackReason(broadenedReasons, requestedResultMode, broadenedFactual.semantics)
}
};
}
@@ -2051,15 +2168,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
...deriveAddressResultSemantics({
...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: historicalSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: historicalFactual.responseType,
rowsMatched: historicalFilteredRows.length
}),
}), historicalFactual.semantics),
limitations: historicalLimitations,
reasons: historicalReasons
reasons: withConfirmedBalanceFallbackReason(historicalReasons, requestedResultMode, historicalFactual.semantics)
}
};
}
@@ -2073,7 +2190,7 @@ class AddressQueryService {
(stageStatus === "materialized_but_not_anchor_matched" || stageStatus === "materialized_but_filtered_out_by_recipe")) {
const documentBankFallbackRows = applyIntentSpecificFilter(intent.intent, normalizedRows);
if (documentBankFallbackRows.length > 0) {
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeOptionsFromFilters(filters.extracted_filters));
const fallbackFactual = (0, composeStage_1.composeFactualReply)(intent.intent, documentBankFallbackRows, composeOptionsFromFilters(executionFilters));
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion = intent.intent === "list_documents_by_counterparty"
? "\nЕсли нужно, могу дополнительно сузить период или показать только платежи."
@@ -2094,7 +2211,7 @@ class AddressQueryService {
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: "matched_non_empty",
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
@@ -2122,15 +2239,15 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: fallbackFactual.responseType,
...deriveAddressResultSemantics({
...mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length
}),
}), fallbackFactual.semantics),
limitations: fallbackLimitations,
reasons: fallbackReasons
reasons: withConfirmedBalanceFallbackReason(fallbackReasons, requestedResultMode, fallbackFactual.semantics)
}
};
}
@@ -2219,7 +2336,7 @@ class AddressQueryService {
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
@@ -2242,7 +2359,44 @@ class AddressQueryService {
reasons: baseReasons
});
}
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
const factual = (0, composeStage_1.composeFactualReply)(intent.intent, filteredRows, composeOptionsFromFilters(executionFilters));
const factualResultSemantics = mergeAddressResultSemantics(deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}), factual.semantics);
if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: filteredRows.length,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
reasonText: "exact payables mode: confirmed balance was not proven for the requested as-of slice",
nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance",
limitations: ["exact_payables_mode_unconfirmed_output_blocked"],
reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"]
});
}
return {
handled: true,
reply_text: factual.text,
@@ -2257,7 +2411,7 @@ class AddressQueryService {
detected_intent_confidence: intent.confidence,
extracted_filters: filters.extracted_filters,
missing_required_filters: [],
selected_recipe: recipeSelection.selected_recipe.recipe_id,
selected_recipe: effectiveRecipeId,
mcp_call_status_legacy: toLegacyMcpStatus(stageStatus),
account_scope_mode: plan.account_scope_mode,
account_scope_fallback_applied: accountScopeFallbackApplied,
@@ -2285,15 +2439,9 @@ class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
filters: filters.extracted_filters,
responseType: factual.responseType,
rowsMatched: filteredRows.length
}),
...factualResultSemantics,
limitations: filters.warnings,
reasons: baseReasons
reasons: withConfirmedBalanceFallbackReason(baseReasons, requestedResultMode, factual.semantics, factualResultSemantics.result_mode)
}
};
}
@@ -9,7 +9,13 @@ const MOVEMENTS_QUERY_TEMPLATE = `
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма
Движения.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
@@ -519,6 +525,16 @@ const BASE_RECIPES = [
account_scope: ["60", "76"],
account_scope_mode: "strict"
},
{
recipe_id: "address_payables_confirmed_as_of_date_v1",
intent: "payables_confirmed_as_of_date",
purpose: "Build confirmed payables snapshot as-of date from movements on accounts 60/76",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 200,
account_scope: ["60", "76"],
account_scope_mode: "strict"
},
{
recipe_id: "address_movements_receivables_v1",
intent: "list_receivables_counterparties",
@@ -508,11 +508,11 @@ function classifyPayablesLiabilityCategory(row, counterparty) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|loan|overdraft)/iu.test(text)) {
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос)/iu.test(text)) {
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
@@ -525,6 +525,42 @@ function classifyPayablesLiabilityCategory(row, counterparty) {
reasons: Array.from(reasons)
};
}
const PAYABLES_CATEGORY_KEYS = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
function resolvePayablesLiabilityCategory(scores) {
let winner = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of PAYABLES_CATEGORY_KEYS) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
}
function hasPayablesSectionPrefix(account) {
const section = extractAccountSectionCode(account);
return section === "60" || section === "76";
}
function resolvePayablesAsOfDate(options) {
const explicit = normalizeIsoDateOnly(options.asOfDate);
if (explicit) {
return explicit;
}
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodTo) {
return periodTo;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
if (periodFrom) {
return periodFrom;
}
const now = new Date();
return toIsoDate(now.getUTCFullYear(), now.getUTCMonth() + 1, now.getUTCDate());
}
function buildPayablesCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
@@ -574,26 +610,10 @@ function buildPayablesCounterpartyRiskAggregate(rows) {
current.reasons.add(reason);
}
}
const scoreKeys = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
const toCategory = (scores) => {
let winner = "other";
let best = Number.NEGATIVE_INFINITY;
for (const key of scoreKeys) {
const score = scores[key];
if (score > best) {
best = score;
winner = key;
}
}
if (best <= 0) {
return "other";
}
return winner;
};
return Array.from(byCounterparty.values())
.map((item) => ({
...item.base,
category: toCategory(item.categoryScores),
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
@@ -606,6 +626,88 @@ function buildPayablesCounterpartyRiskAggregate(rows) {
return left.name.localeCompare(right.name);
});
}
function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) {
const byCounterparty = new Map();
const asOfTimestamp = toUtcDayTimestamp(asOfDate);
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const rowTimestamp = toUtcDayTimestamp(row.period);
if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) {
continue;
}
const amount = row.amount;
if (typeof amount !== "number" || !Number.isFinite(amount)) {
continue;
}
const absAmount = Math.abs(amount);
let delta = 0;
if (hasPayablesSectionPrefix(row.account_kt)) {
delta += absAmount;
}
if (hasPayablesSectionPrefix(row.account_dt)) {
delta -= absAmount;
}
if (Math.abs(delta) <= 0.0000001) {
continue;
}
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
outstandingAmount: delta,
operations: 1,
firstPeriod: row.period,
lastPeriod: row.period,
categoryScores: {
supplier_or_contractor: classified.scores.supplier_or_contractor,
bank_or_credit: classified.scores.bank_or_credit,
tax_or_state: classified.scores.tax_or_state,
other: classified.scores.other
},
reasons: new Set(classified.reasons)
});
continue;
}
current.outstandingAmount += delta;
current.operations += 1;
if ((row.period ?? "") < (current.firstPeriod ?? "")) {
current.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.lastPeriod ?? "")) {
current.lastPeriod = row.period;
}
current.categoryScores.supplier_or_contractor += classified.scores.supplier_or_contractor;
current.categoryScores.bank_or_credit += classified.scores.bank_or_credit;
current.categoryScores.tax_or_state += classified.scores.tax_or_state;
current.categoryScores.other += classified.scores.other;
for (const reason of classified.reasons) {
current.reasons.add(reason);
}
}
return Array.from(byCounterparty.entries())
.map(([name, item]) => ({
name,
outstandingAmount: item.outstandingAmount,
operations: item.operations,
firstPeriod: item.firstPeriod,
lastPeriod: item.lastPeriod,
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.filter((item) => item.outstandingAmount > 0.005)
.sort((left, right) => {
if (right.outstandingAmount !== left.outstandingAmount) {
return right.outstandingAmount - left.outstandingAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildCounterpartyRiskAggregate(rows) {
const byCounterparty = new Map();
for (const row of rows) {
@@ -1663,61 +1765,206 @@ function composeFactualReply(intent, rows, options = {}) {
text: lines.join("\n")
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const scopeLine = (() => {
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
if (asOfDate) {
return `Дата среза: ${formatDateRu(asOfDate)}.`;
}
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
if (periodFrom || periodTo) {
return `Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`;
}
return null;
})();
if (intent === "payables_confirmed_as_of_date") {
const payablesAsOfDate = resolvePayablesAsOfDate(options);
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const scopeLine = asOfDate
? `- Дата среза: ${formatDateRu(asOfDate)}.`
: periodFrom || periodTo
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const carryoverLine = asOfDate || periodFrom || periodTo
? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза."
: null;
const categoryCounts = confirmedBalances.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const lines = [
"Коротко: собран shortlist кандидатов на ручную проверку по потенциально незакрытым обязательствам (контур 60/76).",
"",
"Что это значит:",
"- Режим результата: эвристический скоринг по движениям.",
"- Это не финальный подтвержденный остаток к оплате.",
...(scopeLine ? ["", scopeLine] : []),
"",
`Строк в выборке: ${rows.length}.`,
`Контрагентов-кандидатов: ${counterparties.length}.`
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате (exact route).",
"- Эвристический shortlist в этом режиме не используется."
];
if (counterparties.length > 0) {
const categoryCounts = counterparties.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
lines.push("");
lines.push("Категории обязательств:");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Приоритет ручной проверки (по сумме/частоте сигналов):");
lines.push(...counterparties
.slice(0, 8)
.map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | статус: требует ручной проверки${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`));
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4));
lines.push("");
lines.push("Блок 2. Что учтено");
lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`);
if (scopeLine) {
lines.push(scopeLine);
}
lines.push("- Контур: обязательства по счетам 60/76.");
if (carryoverLine) {
lines.push(carryoverLine);
}
lines.push("");
lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${rows.length}.`);
lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`);
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Блок 5. Подтвержденные позиции к оплате");
if (confirmedBalances.length > 0) {
lines.push(...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`));
}
else {
lines.push("");
lines.push("Явных кандидатов на незакрытые обязательства по текущему срезу не найдено.");
lines.push("");
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 6));
lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено.");
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
balance_confirmed: true
}
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const payablesAsOfDate = resolvePayablesAsOfDate(options);
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const scopeLine = asOfDate
? `- Дата среза: ${formatDateRu(asOfDate)}.`
: periodFrom || periodTo
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const carryoverLine = asOfDate || periodFrom || periodTo
? "- В список могут попадать обязательства, возникшие раньше выбранного периода, если они потенциально оставались открытыми на дату среза."
: null;
const formatHeuristicItem = (item, index) => `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (lines, title, items, limit) => {
if (items.length === 0) {
return;
}
lines.push("");
lines.push(title);
lines.push(...items.slice(0, limit).map(formatHeuristicItem));
};
const buildHeuristicLines = (forcedFallbackFromConfirmed) => {
const lines = [
"Блок 1. Статус результата",
forcedFallbackFromConfirmed
? "- Режим результата: эвристический скоринг в рамках fallback, потому что подтвержденный срез обязательств к оплате недоступен."
: "- Режим результата: эвристический скоринг (shortlist кандидатов по признакам незакрытых обязательств в контуре 60/76).",
"- Тип результата: кандидаты для ручной проверки, а не финальный платежный реестр.",
"",
"Блок 2. Как читать результат",
"- Это shortlist кандидатов: нужна ручная проверка бухгалтером.",
"- Это не подтвержденный остаток к оплате и не готовое платежное поручение.",
...(scopeLine ? [scopeLine] : []),
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов-кандидатов: ${counterparties.length}.`
];
if (counterparties.length > 0) {
const categoryCounts = counterparties.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const suppliers = counterparties.filter((item) => item.category === "supplier_or_contractor");
const banks = counterparties.filter((item) => item.category === "bank_or_credit");
const taxOrState = counterparties.filter((item) => item.category === "tax_or_state");
const other = counterparties.filter((item) => item.category === "other");
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Блок 5. Кандидаты на проверку в первую очередь");
pushCategorySlice(lines, "5.1 Поставщики/подрядчики:", suppliers, 6);
pushCategorySlice(lines, "5.2 Банки/кредиты:", banks, 4);
pushCategorySlice(lines, "5.3 Налоги/госорганы:", taxOrState, 4);
pushCategorySlice(lines, "5.4 Прочие:", other, 4);
lines.push("");
lines.push("Блок 6. Примеры исходных строк");
lines.push(...formatTopRows(rows, 4));
}
else {
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push("- Явных кандидатов на незакрытые обязательства по доступному срезу не найдено.");
lines.push("");
lines.push("Блок 5. Примеры исходных строк");
lines.push(...formatTopRows(rows, 6));
}
return lines;
};
if (options.requestedResultMode === "confirmed_balance") {
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
if (confirmedBalances.length > 0) {
const categoryCounts = confirmedBalances.reduce((acc, item) => {
acc[item.category] += 1;
return acc;
}, { supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 });
const lines = [
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 60/76.",
"- Тип результата: подтвержденные остатки к оплате.",
"",
"Блок 2. Что учтено",
`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`,
...(periodFrom || periodTo
? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`]
: []),
"- Основание: движения обязательств и оплат в пределах доступного live-среза.",
...(carryoverLine ? [carryoverLine] : []),
"",
"Блок 3. Сводка выборки",
`- Строк в выборке: ${rows.length}.`,
`- Контрагентов с подтвержденным остатком: ${confirmedBalances.length}.`,
"",
"Блок 4. Категории обязательств",
`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`,
`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`,
`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`,
`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`,
"",
"Блок 5. Кому нужно заплатить в первую очередь (по сумме остатка):",
...confirmedBalances.slice(0, 10).map((item, index) => `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`)
];
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: "strong",
balance_confirmed: true
}
};
}
const fallbackLines = buildHeuristicLines(true);
return {
responseType: "FACTUAL_LIST",
text: fallbackLines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
const lines = buildHeuristicLines(false);
return {
responseType: "FACTUAL_LIST",
text: lines.join("\n")
text: lines.join("\n"),
semantics: {
result_mode: "heuristic_candidates",
evidence_strength: counterparties.length > 0 ? "medium" : "weak",
balance_confirmed: false
}
};
}
if (intent === "list_receivables_counterparties") {
@@ -350,7 +350,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
}
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date") {
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract = !currentContract ||
@@ -370,6 +372,13 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if (allTimeRequested) {
if (toNonEmptyString(merged.period_from) || toNonEmptyString(merged.period_to)) {
@@ -424,6 +433,7 @@ function resolveMissingRequiredFilters(intent, filters) {
const requiredByIntent = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
@@ -91,7 +91,9 @@ function inferAggregationProfile(intent, shape) {
intent === "vat_payable_forecast") {
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date") {
return "balance_snapshot";
}
if (intent === "open_items_by_counterparty_or_contract" ||
@@ -3695,6 +3695,7 @@ function hasOpenContractsAddressSignal(text) {
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts",
"open_items_by_counterparty_or_contract",
"payables_confirmed_as_of_date",
"list_documents_by_contract",
"bank_operations_by_contract",
"list_documents_by_counterparty",
@@ -837,6 +837,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
return ["account", "as_of_date"];
}
if (intent === "payables_confirmed_as_of_date") {
return ["as_of_date"];
}
if (
intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
@@ -851,7 +854,11 @@ function requiredFiltersByIntent(intent: AddressIntent): Array<keyof AddressFilt
}
function usesAsOfPrimaryWindow(intent: AddressIntent): boolean {
return intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts";
return (
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date"
);
}
export function extractAddressFilters(userMessage: string, intent: AddressIntent): AddressFilterExtraction {
@@ -1035,7 +1042,12 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent
// - explicit as_of has priority;
// - else use period_to boundary when provided;
// - else default to today.
if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance") && !filters.as_of_date) {
if (
(intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date") &&
!filters.as_of_date
) {
if (filters.period_to) {
filters.as_of_date = filters.period_to;
warnings.push("as_of_date_derived_from_period_to");
@@ -1432,11 +1432,12 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
const payablesDebtLifecycleSignal = hasPayablesDebtLifecycleSignal(text);
if (payablesDebtLifecycleSignal) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return {
intent: "list_payables_counterparties",
intent: payablesDebtLifecycleSignal ? "payables_confirmed_as_of_date" : "list_payables_counterparties",
confidence: "high",
reasons
};
@@ -419,6 +419,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent: AddressIntent, filte
intent === "bank_operations_by_counterparty" ||
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_receivables_counterparties"
);
}
@@ -745,6 +746,7 @@ function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
return (
intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "payables_confirmed_as_of_date" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract"
);
@@ -760,7 +762,11 @@ function isHeuristicCandidatesIntent(intent: AddressIntent): boolean {
}
function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
return (
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date"
);
}
function resolveAsOfDateBasis(filters: AddressFilterSet): AddressAsOfDateBasis | null {
@@ -845,11 +851,12 @@ function deriveAddressResultSemantics(input: {
};
}
if (isConfirmedBalanceIntent(input.intent)) {
const balanceConfirmed = input.responseType !== "LIMITED_WITH_REASON";
return {
requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true,
balance_confirmed: balanceConfirmed,
as_of_date_basis: asOfDateBasis ?? "period_end"
};
}
@@ -1463,6 +1470,8 @@ function buildLimitedOffers(input: {
if (input.intent === "list_receivables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76");
} else if (input.intent === "payables_confirmed_as_of_date") {
offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76");
} else if (input.intent === "list_payables_counterparties") {
offers.push("показать контрагентов с максимальными хвостами кредиторки по 60/76");
} else if (input.intent === "open_items_by_counterparty_or_contract" || input.intent === "list_open_contracts") {
@@ -1512,7 +1521,8 @@ function buildLimitedIntentSignalLine(input: {
open_items_by_counterparty_or_contract: "Сигнал запроса: нужен контроль незакрытых взаиморасчетов.",
list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.",
list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.",
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов."
list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.",
payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату."
};
const byShape: Partial<Record<AddressQueryShapeDetection["shape"], string>> = {
@@ -1655,7 +1665,7 @@ function composeLimitedReply(input: {
lines.push(`Что могу сделать сейчас: ${offers.join("; ")}.`);
}
return lines.join("\n");
return lines.join("\n\n");
}
function buildLimitedExecutionResult(input: {
@@ -1701,12 +1711,17 @@ function buildLimitedExecutionResult(input: {
rowsMatched: input.rowsMatched
});
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasons = withConfirmedBalanceFallbackReason(
const reasonsWithConfirmedFallback = withConfirmedBalanceFallbackReason(
input.reasons,
requestedResultMode,
undefined,
resultSemantics.result_mode
);
const reasons =
input.intent.intent === "payables_confirmed_as_of_date" &&
!reasonsWithConfirmedFallback.includes("exact_payables_mode_limited_response")
? [...reasonsWithConfirmedFallback, "exact_payables_mode_limited_response"]
: reasonsWithConfirmedFallback;
return {
handled: true,
reply_text: composeLimitedReply({
@@ -1795,7 +1810,8 @@ export class AddressQueryService {
}
const requestedResultMode = resolveRequestedResultMode(intent.intent, filters.extracted_filters);
const payablesConfirmedExecution =
intent.intent === "list_payables_counterparties" && requestedResultMode === "confirmed_balance"
(intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") &&
requestedResultMode === "confirmed_balance"
? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate)
: null;
const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters;
@@ -1846,6 +1862,9 @@ export class AddressQueryService {
if (preferConfirmedBalanceForPayablesLifecycle && !baseReasons.includes("confirmed_balance_attempt_for_payables_debt_lifecycle")) {
baseReasons.push("confirmed_balance_attempt_for_payables_debt_lifecycle");
}
if (intent.intent === "payables_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_payables_intent")) {
baseReasons.push("confirmed_balance_exact_payables_intent");
}
if (
requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
@@ -1982,11 +2001,13 @@ export class AddressQueryService {
query: plan.query,
limit: plan.limit
});
const allowOpenItemsFallbackForMissingSubconto = intent.intent !== "payables_confirmed_as_of_date";
if (
mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error)
isMissingSubcontoFieldError(mcp.error) &&
allowOpenItemsFallbackForMissingSubconto
) {
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
@@ -2019,11 +2040,21 @@ export class AddressQueryService {
}
}
} else {
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable");
}
if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_unavailable")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_unavailable");
}
}
}
if (
mcp.error &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error) &&
!allowOpenItemsFallbackForMissingSubconto &&
!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")
) {
baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback");
}
if (mcp.error) {
const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters);
@@ -2846,6 +2877,36 @@ export class AddressQueryService {
}),
factual.semantics
);
if (intent.intent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) {
return buildLimitedExecutionResult({
mode,
shape,
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
anchor,
matchFailureStage,
matchFailureReason,
mcpCallStatus: stageStatus,
rowsFetched: mcp.fetched_rows,
rawRowsReceived: mcp.raw_rows.length,
rowsAfterAccountScope: normalizedRows.length,
rowsAfterRecipeFilter: filterByAnchors.length,
rowsMaterialized: normalizedRows.length,
rowsMatched: filteredRows.length,
rawRowKeysSample: rowDiagnostics.rawRowKeysSample,
materializationDropReason: rowDiagnostics.materializationDropReason,
category: "recipe_visibility_gap",
reasonText: "exact payables mode: confirmed balance was not proven for the requested as-of slice",
nextStep: "specify as_of_date/counterparty or enable detailed settlement registers for exact confirmed balance",
limitations: ["exact_payables_mode_unconfirmed_output_blocked"],
reasons: [...baseReasons, "exact_payables_mode_unconfirmed_output_blocked"]
});
}
return {
handled: true,
reply_text: factual.text,
@@ -540,6 +540,16 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [
account_scope: ["60", "76"],
account_scope_mode: "strict"
},
{
recipe_id: "address_payables_confirmed_as_of_date_v1",
intent: "payables_confirmed_as_of_date",
purpose: "Build confirmed payables snapshot as-of date from movements on accounts 60/76",
required_filters: ["as_of_date"],
optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"],
default_limit: 200,
account_scope: ["60", "76"],
account_scope_mode: "strict"
},
{
recipe_id: "address_movements_receivables_v1",
intent: "list_receivables_counterparties",
@@ -858,7 +858,7 @@ function buildPayablesConfirmedBalanceAggregate(
continue;
}
const amount = row.amount;
if (!Number.isFinite(amount)) {
if (typeof amount !== "number" || !Number.isFinite(amount)) {
continue;
}
const absAmount = Math.abs(amount);
@@ -2252,6 +2252,82 @@ export function composeFactualReply(
};
}
if (intent === "payables_confirmed_as_of_date") {
const payablesAsOfDate = resolvePayablesAsOfDate(options);
const confirmedBalances = buildPayablesConfirmedBalanceAggregate(rows, payablesAsOfDate);
const asOfDate = normalizeIsoDateOnly(options.asOfDate);
const periodFrom = normalizeIsoDateOnly(options.periodFrom);
const periodTo = normalizeIsoDateOnly(options.periodTo);
const scopeLine = asOfDate
? `- Дата среза: ${formatDateRu(asOfDate)}.`
: periodFrom || periodTo
? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`
: null;
const carryoverLine =
asOfDate || periodFrom || periodTo
? "- В срез могут входить обязательства, возникшие до периода, если они оставались открытыми на дату среза."
: null;
const categoryCounts = confirmedBalances.reduce<Record<PayablesLiabilityCategory, number>>(
(acc, item) => {
acc[item.category] += 1;
return acc;
},
{ supplier_or_contractor: 0, bank_or_credit: 0, tax_or_state: 0, other: 0 }
);
const lines: string[] = [
"Блок 1. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате (exact route).",
"- Эвристический shortlist в этом режиме не используется."
];
lines.push("");
lines.push("Блок 2. Что учтено");
lines.push(`- Дата среза: ${formatDateRu(payablesAsOfDate)}.`);
if (scopeLine) {
lines.push(scopeLine);
}
lines.push("- Контур: обязательства по счетам 60/76.");
if (carryoverLine) {
lines.push(carryoverLine);
}
lines.push("");
lines.push("Блок 3. Сводка");
lines.push(`- Строк в выборке: ${rows.length}.`);
lines.push(`- Контрагентов с подтвержденным остатком к оплате: ${confirmedBalances.length}.`);
lines.push("");
lines.push("Блок 4. Категории обязательств");
lines.push(`- ${liabilityCategoryLabel("supplier_or_contractor")}: ${categoryCounts.supplier_or_contractor}`);
lines.push(`- ${liabilityCategoryLabel("bank_or_credit")}: ${categoryCounts.bank_or_credit}`);
lines.push(`- ${liabilityCategoryLabel("tax_or_state")}: ${categoryCounts.tax_or_state}`);
lines.push(`- ${liabilityCategoryLabel("other")}: ${categoryCounts.other}`);
lines.push("");
lines.push("Блок 5. Подтвержденные позиции к оплате");
if (confirmedBalances.length > 0) {
lines.push(
...confirmedBalances.slice(0, 10).map(
(item, index) =>
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoney(item.outstandingAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`
)
);
} else {
lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено.");
}
return {
responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY",
text: lines.join("\n"),
semantics: {
result_mode: "confirmed_balance",
evidence_strength: confirmedBalances.length > 0 ? "strong" : "medium",
balance_confirmed: true
}
};
}
if (intent === "list_payables_counterparties") {
const counterparties = buildPayablesCounterpartyRiskAggregate(rows);
const payablesAsOfDate = resolvePayablesAsOfDate(options);
@@ -439,7 +439,11 @@ function mergeFollowupFilters(
}
}
if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts") {
if (
intent === "open_items_by_counterparty_or_contract" ||
intent === "list_open_contracts" ||
intent === "payables_confirmed_as_of_date"
) {
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
const currentContract = toNonEmptyString(merged.contract);
const shouldInheritContract =
@@ -462,6 +466,13 @@ function mergeFollowupFilters(
merged.counterparty = inheritedCounterparty;
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
}
if (sameDateRequested) {
const inheritedAsOfDate = previousAsOfDate ?? previousPeriodTo ?? previousPeriodFrom;
if (inheritedAsOfDate && merged.as_of_date !== inheritedAsOfDate) {
merged.as_of_date = inheritedAsOfDate;
reasons.push("as_of_date_from_followup_context");
}
}
}
if (allTimeRequested) {
@@ -525,6 +536,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi
const requiredByIntent: Record<string, Array<keyof AddressFilterSet>> = {
account_balance_snapshot: ["account", "as_of_date"],
documents_forming_balance: ["account", "as_of_date"],
payables_confirmed_as_of_date: ["as_of_date"],
list_documents_by_counterparty: ["counterparty"],
bank_operations_by_counterparty: ["counterparty"],
list_contracts_by_counterparty: ["counterparty"],
@@ -189,7 +189,11 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape
return "management_profile";
}
if (intent === "account_balance_snapshot" || intent === "documents_forming_balance") {
if (
intent === "account_balance_snapshot" ||
intent === "documents_forming_balance" ||
intent === "payables_confirmed_as_of_date"
) {
return "balance_snapshot";
}
@@ -3652,6 +3652,7 @@ function hasOpenContractsAddressSignal(text) {
const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([
"list_open_contracts",
"open_items_by_counterparty_or_contract",
"payables_confirmed_as_of_date",
"list_documents_by_contract",
"bank_operations_by_contract",
"list_documents_by_counterparty",
@@ -13,6 +13,7 @@ export type AddressIntent =
| "list_contracts_by_counterparty"
| "list_open_contracts"
| "list_payables_counterparties"
| "payables_confirmed_as_of_date"
| "list_receivables_counterparties"
| "account_balance_snapshot"
| "open_items_by_counterparty_or_contract"
@@ -1832,7 +1832,7 @@ describe("address intent resolver expansion (M2.3a)", () => {
it("marks 'кому мы должны заплатить' as payables debt lifecycle intent", () => {
const result = resolveAddressIntent("каму мы должны заплатить за май 2020");
expect(result.intent).toBe("list_payables_counterparties");
expect(result.intent).toBe("payables_confirmed_as_of_date");
expect(result.reasons).toContain("payables_debt_lifecycle_signal_detected");
});
@@ -2494,30 +2494,31 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500
expect(result?.debug.limited_reason_category).not.toBe("unsupported");
});
it("routes 'каму мы должны заплатить за май 2020' into confirmed payables flow with explicit fallback contract", async () => {
it("routes 'каму мы должны заплатить за май 2020' into exact confirmed payables flow without heuristic fallback", async () => {
const service = new AddressQueryService();
const result = await service.tryHandle("каму мы должны заплатить за май 2020");
expect(result?.handled).toBe(true);
expect(result?.debug.detected_intent).toBe("list_payables_counterparties");
expect(result?.debug.detected_intent).toBe("payables_confirmed_as_of_date");
expect(result?.debug.requested_result_mode).toBe("confirmed_balance");
expect(result?.debug.as_of_date_basis).toBe("period_range");
expect(result?.debug.result_mode).toBe("confirmed_balance");
expect(result?.debug.as_of_date_basis).toBe("explicit_as_of_date");
expect(result?.debug.selected_recipe).toBe("address_payables_confirmed_as_of_date_v1");
expect(Array.isArray(result?.debug.reasons)).toBe(true);
expect(result?.debug.reasons).not.toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
expect(["FACTUAL_LIST", "FACTUAL_SUMMARY", "LIMITED_WITH_REASON"]).toContain(result?.response_type);
const reply = String(result?.reply_text ?? "");
if (result?.debug.result_mode === "confirmed_balance") {
expect(result?.debug.selected_recipe).toBe("address_movements_payables_v1");
expect(result?.debug.balance_confirmed).toBe(true);
expect(reply).toContain("подтвержденный срез обязательств к оплате");
expect(reply).toContain("Кому нужно заплатить в первую очередь");
} else {
expect(result?.debug.result_mode).toBe("heuristic_candidates");
if (result?.response_type === "LIMITED_WITH_REASON") {
expect(result?.debug.balance_confirmed).toBe(false);
expect(result?.debug.reasons).toContain("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
expect(reply).toContain("эвристический скоринг");
expect(reply).toContain("Контрагентов-кандидатов:");
expect(result?.debug.reasons).toContain("exact_payables_mode_limited_response");
expect(reply.toLowerCase()).not.toContain("эвристич");
} else {
expect(result?.debug.balance_confirmed).toBe(true);
expect(reply).toContain("Блок 1. Статус результата");
expect(reply).toContain("\n\nБлок 2. Как читать результат");
expect(reply).toContain("\n\nБлок 3. Сводка выборки");
expect(reply).not.toContain("Кому нужно заплатить в первую очередь");
expect(reply).toContain("\n\nБлок 2. Что учтено");
expect(reply).toContain("\n\nБлок 3. Сводка");
expect(reply).toContain("\n\nБлок 4. Категории обязательств");
expect(reply).toContain("\n\nБлок 5. Подтвержденные позиции к оплате");
expect(reply).not.toContain("эвристический");
}
});