ДОМЕНЫ - ВОПРОСЫ - Этап 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
@@ -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",