ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Спека exact-маршрута payables на дату: confirmed_balance без эвристического финала

This commit is contained in:
2026-04-12 13:46:14 +03:00
parent ca2feab893
commit 1b2ee93176
19 changed files with 2453 additions and 252 deletions
@@ -313,6 +313,10 @@ const CUSTOMER_REVENUE_AND_PAYMENTS_HINTS = [
"самые доходные заказчики",
"топ клиентов по сумме поступлений",
"топ заказчиков по сумме поступлений",
"кто больше всего принес денег",
"кто больше всего принёс денег",
"кто принес больше всего денег",
"кто принёс больше всего денег",
"кто нам больше всего занес",
"кто нам больше всего занёс",
"кто нам принес больше всего",
@@ -782,6 +786,10 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
asksWhoPays;
const asksCounterpartySource = /(?:с\s+каких|от\s+каких|от\s+кого|from\s+which|from\s+who)/iu.test(text);
const asksIncomingFlow = /(?:приход|поступлен|входящ|зачислен|inflow|incoming)/iu.test(text);
const asksWhoBringsMostMoney =
/(?:кто\s+(?:нам\s+)?(?:больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш(?:ий|ая|ее|ие))\s+(?:прин[её]с|зан[её]с).*(?:деньг|денег))/iu.test(
text
);
const asksDealBudgetRanking =
/(?:сделк|deal|бюджет)/iu.test(text) &&
/(?:топ|top|сам(?:ый|ая|ое|ые)|крупн|мален|жирн|мелк|больше\s+всего|чаще\s+всего|наибольш|максимальн|минимальн)/iu.test(
@@ -816,6 +824,9 @@ function hasCustomerRevenueAndPaymentsSignal(text: string): boolean {
if (!hasFuzzySupplierLexeme && asksWhoPays && (asksRankOrTop || hasCounterpartyLexeme)) {
return true;
}
if (!hasFuzzySupplierLexeme && asksWhoBringsMostMoney) {
return true;
}
if (!hasFuzzySupplierLexeme && (asksRevenueTotal || asksOverallTurnover)) {
return true;
}
@@ -956,6 +967,22 @@ function hasSupplierTailRiskSignal(text: string): boolean {
return hasSupplier && hasTail && (hasRisk || hasPeriodCue);
}
function hasPayablesDebtLifecycleSignal(text: string): boolean {
const hasOweSignal =
/(?:кому\s+мы\s+должны|мы\s+должны|кому\s+должны|должн(?:ы|а|о)\s+(?:заплат|оплат|перечис)|к\s+оплате|на\s+оплату|who\s+we\s+owe|owe\s+to|payables?|кредитор(?:ск)?)/iu.test(
text
);
if (!hasOweSignal) {
return false;
}
const hasPastPaymentSignal = /(?:заплатил(?:и)?|платил(?:и)?|кому\s+ушло|выплатил(?:и)?|списан|outflow|payout)/iu.test(text);
const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text);
if (hasPastPaymentSignal && hasTopRankingSignal) {
return false;
}
return true;
}
function hasReceivablesLatencyRiskSignal(text: string): boolean {
const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text);
const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text);
@@ -1404,10 +1431,14 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
}
if (hasAny(text, PAYABLES_STRONG)) {
const reasons = ["payables_signal_detected"];
if (hasPayablesDebtLifecycleSignal(text)) {
reasons.push("payables_debt_lifecycle_signal_detected");
}
return {
intent: "list_payables_counterparties",
confidence: "high",
reasons: ["payables_signal_detected"]
reasons
};
}
@@ -1447,7 +1478,7 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti
return {
intent: "list_payables_counterparties",
confidence: "medium",
reasons: ["supplier_tail_risk_signal_detected"]
reasons: ["supplier_tail_risk_signal_detected", "payables_debt_lifecycle_signal_detected"]
};
}
@@ -1,8 +1,10 @@
import {
import {
FEATURE_ASSISTANT_ADDRESS_QUERY_V1,
FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1
} from "../config";
import type {
AddressAsOfDateBasis,
AddressEvidenceStrength,
AddressExecutionResult,
AddressFilterSet,
AddressIntent,
@@ -10,14 +12,19 @@ import type {
AddressMatchFailureStage,
AddressMcpCallStatus,
AddressQueryShapeDetection,
AddressResultMode,
AddressResponseType,
AddressRuntimeReadiness
} from "../types/addressQuery";
import { buildAddressRecipePlan, selectAddressRecipe } from "./addressRecipeCatalog";
import {
buildAddressRecipePlan,
selectAddressRecipe,
type AddressRecipeExecutionPlan
} from "./addressRecipeCatalog";
import { executeAddressMcpQuery } from "./addressMcpClient";
import { runAddressDecomposeStage, type AddressFollowupContext } from "./address_runtime/decomposeStage";
import { resolvePrimaryAnchor, refineAnchorFromRows, type AnchorResolutionDebug } from "./address_runtime/resolveStage";
import { composeFactualReply, inferReplyType } from "./address_runtime/composeStage";
import { composeFactualReply, inferReplyType, type ComposeReplySemantics } from "./address_runtime/composeStage";
interface NormalizedAddressRow {
period: string | null;
@@ -36,6 +43,7 @@ interface AddressTryHandleOptions {
const ACCOUNT_SCOPE_FIELDS_CHECKED = ["account_dt", "account_kt", "registrator", "analytics"] as const;
const ACCOUNT_SCOPE_MATCH_STRATEGY = "account_code_regex_plus_alias_map_v1" as const;
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([
@@ -742,6 +750,197 @@ function isCounterpartyRiskIntent(intent: AddressIntent): boolean {
);
}
function isHeuristicCandidatesIntent(intent: AddressIntent): boolean {
return (
intent === "list_receivables_counterparties" ||
intent === "list_payables_counterparties" ||
intent === "list_open_contracts" ||
intent === "open_items_by_counterparty_or_contract"
);
}
function isConfirmedBalanceIntent(intent: AddressIntent): boolean {
return intent === "account_balance_snapshot" || intent === "documents_forming_balance";
}
function resolveAsOfDateBasis(filters: AddressFilterSet): AddressAsOfDateBasis | null {
const asOfDate = normalizeAnalysisDateHint(filters.as_of_date);
if (asOfDate) {
return "explicit_as_of_date";
}
const periodFrom = normalizeAnalysisDateHint(filters.period_from);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
if (periodFrom && periodTo) {
return "period_range";
}
if (!periodFrom && periodTo) {
return "period_end";
}
if (periodFrom) {
return "period_range";
}
return null;
}
function deriveAddressEvidenceStrength(input: {
intent: AddressIntent;
selectedRecipe: string | null;
responseType: AddressResponseType;
rowsMatched: number;
}): AddressEvidenceStrength | undefined {
if (isHeuristicCandidatesIntent(input.intent)) {
if (input.rowsMatched <= 0 || input.responseType === "LIMITED_WITH_REASON") {
return "weak";
}
if (input.selectedRecipe === "address_open_items_by_party_or_contract_v1") {
return "medium";
}
return "weak";
}
if (isConfirmedBalanceIntent(input.intent)) {
if (input.rowsMatched > 0) {
return "strong";
}
return input.responseType === "LIMITED_WITH_REASON" ? "weak" : "medium";
}
return undefined;
}
function resolveRequestedResultMode(intent: AddressIntent, filters: AddressFilterSet): AddressResultMode | undefined {
if (isConfirmedBalanceIntent(intent)) {
return "confirmed_balance";
}
if (isHeuristicCandidatesIntent(intent)) {
const asOfDateBasis = resolveAsOfDateBasis(filters);
if (asOfDateBasis === "explicit_as_of_date" || asOfDateBasis === "period_end" || asOfDateBasis === "period_range") {
return "confirmed_balance";
}
return "heuristic_candidates";
}
return undefined;
}
function deriveAddressResultSemantics(input: {
intent: AddressIntent;
selectedRecipe: string | null;
filters: AddressFilterSet;
responseType: AddressResponseType;
rowsMatched: number;
}): {
requested_result_mode?: AddressResultMode;
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
as_of_date_basis?: AddressAsOfDateBasis | null;
} {
const asOfDateBasis = resolveAsOfDateBasis(input.filters);
const requestedResultMode = resolveRequestedResultMode(input.intent, input.filters);
if (isHeuristicCandidatesIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "heuristic_candidates",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: false,
as_of_date_basis: asOfDateBasis
};
}
if (isConfirmedBalanceIntent(input.intent)) {
return {
requested_result_mode: requestedResultMode,
result_mode: "confirmed_balance",
evidence_strength: deriveAddressEvidenceStrength(input),
balance_confirmed: true,
as_of_date_basis: asOfDateBasis ?? "period_end"
};
}
if (requestedResultMode) {
return {
requested_result_mode: requestedResultMode
};
}
return {};
}
type AddressResultSemantics = ReturnType<typeof deriveAddressResultSemantics>;
function mergeAddressResultSemantics(
base: AddressResultSemantics,
override: ComposeReplySemantics | undefined
): AddressResultSemantics {
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: string[],
requestedResultMode: AddressResultMode | undefined,
semantics: ComposeReplySemantics | undefined,
baseResultMode?: AddressResultMode
): string[] {
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: AddressRecipeExecutionPlan,
intent: AddressIntent
): AddressRecipeExecutionPlan {
if (intent !== "list_receivables_counterparties" || plan.account_scope_mode === "strict") {
return plan;
}
return {
...plan,
account_scope_mode: "strict"
};
}
function resolveExecutionFiltersForPayablesConfirmedBalance(
filters: AddressFilterSet,
analysisDate: string | null
): {
executionFilters: AddressFilterSet;
asOfDerived: string | null;
} {
const explicitAsOf = normalizeAnalysisDateHint(filters.as_of_date);
const periodTo = normalizeAnalysisDateHint(filters.period_to);
const derivedAsOf = explicitAsOf ?? periodTo ?? analysisDate ?? null;
const executionFilters: AddressFilterSet = {
...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: string | null, filters: AddressFilterSet): string | null {
if (analysisDate) {
return analysisDate;
@@ -1494,6 +1693,20 @@ function buildLimitedExecutionResult(input: {
category: AddressLimitedReasonCategory;
}): AddressExecutionResult {
const accountScopeAudit = input.accountScopeAudit ?? buildDefaultAccountScopeAudit(input.filters);
const resultSemantics = deriveAddressResultSemantics({
intent: input.intent.intent,
selectedRecipe: input.selectedRecipe,
filters: input.filters,
responseType: "LIMITED_WITH_REASON",
rowsMatched: input.rowsMatched
});
const requestedResultMode = resolveRequestedResultMode(input.intent.intent, input.filters);
const reasons = withConfirmedBalanceFallbackReason(
input.reasons,
requestedResultMode,
undefined,
resultSemantics.result_mode
);
return {
handled: true,
reply_text: composeLimitedReply({
@@ -1544,8 +1757,9 @@ function buildLimitedExecutionResult(input: {
runtime_readiness: runtimeReadinessForLimitedCategory(input.category),
limited_reason_category: input.category,
response_type: "LIMITED_WITH_REASON",
...resultSemantics,
limitations: input.limitations,
reasons: input.reasons
reasons
}
};
}
@@ -1579,23 +1793,66 @@ export 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" && 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: AddressFilterSet) => ({
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 = resolvePrimaryAnchor(intent.intent, filters.extracted_filters);
const debtLifecycleReceivablesScenario =
intent.intent === "list_receivables_counterparties" &&
Array.isArray(intent.reasons) &&
intent.reasons.includes("receivables_debt_lifecycle_signal_detected");
const recipeIntent = debtLifecycleReceivablesScenario ? "open_items_by_counterparty_or_contract" : intent.intent;
const recipeSelection = selectAddressRecipe(recipeIntent, filters.extracted_filters);
const debtLifecyclePayablesScenario =
intent.intent === "list_payables_counterparties" &&
Array.isArray(intent.reasons) &&
(intent.reasons.includes("payables_debt_lifecycle_signal_detected") ||
intent.reasons.includes("supplier_tail_risk_signal_detected") ||
intent.reasons.includes("payables_signal_detected"));
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 = 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 (
requestedResultMode === "confirmed_balance" &&
recipeIntent === "open_items_by_counterparty_or_contract" &&
!baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")
) {
baseReasons.push("confirmed_balance_unavailable_fallback_to_heuristic_candidates");
}
if (intent.intent === "unknown") {
return buildLimitedExecutionResult({
@@ -1716,19 +1973,27 @@ export class AddressQueryService {
}
}
let plan = buildAddressRecipePlan(recipeSelection.selected_recipe, filters.extracted_filters);
let effectiveRecipeId = recipeSelection.selected_recipe.recipe_id;
let plan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(recipeSelection.selected_recipe, executionFilters),
intent.intent
);
let mcp = await executeAddressMcpQuery({
query: plan.query,
limit: plan.limit
});
if (
mcp.error &&
recipeSelection.selected_recipe.recipe_id === "address_movements_receivables_v1" &&
(plan.recipe.recipe_id === "address_movements_receivables_v1" ||
plan.recipe.recipe_id === "address_movements_payables_v1") &&
isMissingSubcontoFieldError(mcp.error)
) {
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", filters.extracted_filters);
const fallbackSelection = selectAddressRecipe("open_items_by_counterparty_or_contract", executionFilters);
if (fallbackSelection.selected_recipe && fallbackSelection.missing_required_filters.length === 0) {
const fallbackPlan = buildAddressRecipePlan(fallbackSelection.selected_recipe, filters.extracted_filters);
const fallbackPlan = enforceStrictAccountScopeForIntent(
buildAddressRecipePlan(fallbackSelection.selected_recipe, executionFilters),
intent.intent
);
const fallbackMcp = await executeAddressMcpQuery({
query: fallbackPlan.query,
limit: fallbackPlan.limit
@@ -1736,9 +2001,18 @@ export 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")) {
baseReasons.push("mcp_missing_subconto_field_auto_fallback_failed");
@@ -1759,7 +2033,7 @@ export class AddressQueryService {
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
anchor,
mcpCallStatus: deriveMcpStageStatus({
@@ -1797,10 +2071,10 @@ export class AddressQueryService {
anchor = refineAnchorFromRows(anchor, normalizedRows);
const filtersForMatching: AddressFilterSet =
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,
@@ -1849,7 +2123,7 @@ export class AddressQueryService {
const recoveredBankRows = applyIntentSpecificFilter("bank_operations_by_contract", filterByAnchors);
const recoveredRows = recoveredBankRows.length > 0 ? recoveredBankRows : filterByAnchors;
if (recoveredRows.length > 0) {
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(filters.extracted_filters));
const factual = composeFactualReply(intent.intent, recoveredRows, composeOptionsFromFilters(executionFilters));
const recoveryReason =
recoveredBankRows.length > 0
? "contract_docs_recovered_via_bank_fallback"
@@ -1872,7 +2146,7 @@ export 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,
@@ -1900,8 +2174,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
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
)
}
};
}
@@ -1915,12 +2203,12 @@ export class AddressQueryService {
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))
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: AddressFilterSet = {
...filters.extracted_filters,
...executionFilters,
limit: ADDRESS_ANCHOR_RECOVERY_LIMIT
};
const expandedSelection = selectAddressRecipe(intent.intent, expandedLimitFilters);
@@ -2034,8 +2322,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: expandedFactual.responseType,
...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
)
}
};
}
@@ -2160,8 +2462,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: broadenedFactual.responseType,
...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
)
}
};
}
@@ -2298,8 +2614,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: historicalFactual.responseType,
...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
)
}
};
}
@@ -2319,7 +2649,7 @@ export class AddressQueryService {
const fallbackFactual = composeFactualReply(
intent.intent,
documentBankFallbackRows,
composeOptionsFromFilters(filters.extracted_filters)
composeOptionsFromFilters(executionFilters)
);
const fallbackPrefix = "По вашему запросу показываю найденные документы и операции в доступном срезе базы.";
const fallbackSuggestion =
@@ -2342,7 +2672,7 @@ export 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,
@@ -2370,8 +2700,22 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: fallbackFactual.responseType,
...mergeAddressResultSemantics(
deriveAddressResultSemantics({
intent: intent.intent,
selectedRecipe: effectiveRecipeId,
filters: filters.extracted_filters,
responseType: fallbackFactual.responseType,
rowsMatched: documentBankFallbackRows.length
}),
fallbackFactual.semantics
),
limitations: fallbackLimitations,
reasons: fallbackReasons
reasons: withConfirmedBalanceFallbackReason(
fallbackReasons,
requestedResultMode,
fallbackFactual.semantics
)
}
};
}
@@ -2467,7 +2811,7 @@ export class AddressQueryService {
intent,
filters: filters.extracted_filters,
missingRequiredFilters: [],
selectedRecipe: recipeSelection.selected_recipe.recipe_id,
selectedRecipe: effectiveRecipeId,
accountScopeMode: plan.account_scope_mode,
accountScopeFallbackApplied,
accountScopeAudit,
@@ -2491,7 +2835,17 @@ export class AddressQueryService {
});
}
const factual = composeFactualReply(intent.intent, filteredRows, composeOptionsFromFilters(filters.extracted_filters));
const factual = 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
);
return {
handled: true,
reply_text: factual.text,
@@ -2506,7 +2860,7 @@ export 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,
@@ -2534,8 +2888,14 @@ export class AddressQueryService {
runtime_readiness: "LIVE_QUERYABLE_WITH_LIMITS",
limited_reason_category: null,
response_type: factual.responseType,
...factualResultSemantics,
limitations: filters.warnings,
reasons: baseReasons
reasons: withConfirmedBalanceFallbackReason(
baseReasons,
requestedResultMode,
factual.semantics,
factualResultSemantics.result_mode
)
}
};
}
@@ -12,7 +12,13 @@ const MOVEMENTS_QUERY_TEMPLATE = `
ПРЕДСТАВЛЕНИЕ(Движения.Регистратор) КАК Регистратор,
ПРЕДСТАВЛЕНИЕ(Движения.СчетДт) КАК СчетДт,
ПРЕДСТАВЛЕНИЕ(Движения.СчетКт) КАК СчетКт,
Движения.Сумма КАК Сумма
Движения.Сумма КАК Сумма,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт1) КАК СубконтоДт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт2) КАК СубконтоДт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоДт3) КАК СубконтоДт3,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт1) КАК СубконтоКт1,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт2) КАК СубконтоКт2,
ПРЕДСТАВЛЕНИЕ(Движения.СубконтоКт3) КАК СубконтоКт3
ИЗ
РегистрБухгалтерии.Хозрасчетный КАК Движения
__WHERE_CLAUSE__
@@ -1,4 +1,9 @@
import type { AddressIntent, AddressResponseType } from "../../types/addressQuery";
import type {
AddressEvidenceStrength,
AddressIntent,
AddressResponseType,
AddressResultMode
} from "../../types/addressQuery";
export interface ComposeStageRow {
period: string | null;
@@ -14,6 +19,13 @@ interface ComposeFactualReplyOptions {
periodFrom?: string;
periodTo?: string;
asOfDate?: string;
requestedResultMode?: AddressResultMode;
}
export interface ComposeReplySemantics {
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
}
type PeriodProfileFocus =
@@ -618,6 +630,307 @@ interface CounterpartyRiskAggregate {
lastPeriod: string | null;
}
type PayablesLiabilityCategory = "supplier_or_contractor" | "bank_or_credit" | "tax_or_state" | "other";
interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate {
category: PayablesLiabilityCategory;
categoryReasons: string[];
}
interface PayablesConfirmedBalanceAggregate {
name: string;
outstandingAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
category: PayablesLiabilityCategory;
categoryReasons: string[];
}
function liabilityCategoryLabel(category: PayablesLiabilityCategory): string {
if (category === "supplier_or_contractor") {
return "поставщики/подрядчики";
}
if (category === "bank_or_credit") {
return "банки/кредиты";
}
if (category === "tax_or_state") {
return "налоги/госорганы";
}
return "прочие";
}
function classifyPayablesLiabilityCategory(row: ComposeStageRow, counterparty: string): {
scores: Record<PayablesLiabilityCategory, number>;
reasons: string[];
} {
const scores: Record<PayablesLiabilityCategory, number> = {
supplier_or_contractor: 0,
bank_or_credit: 0,
tax_or_state: 0,
other: 0
};
const reasons = new Set<string>();
const text = `${counterparty} ${row.registrator} ${row.analytics.join(" ")}`.toLowerCase();
const accountPrefixes = [extractAccountSectionCode(row.account_dt), extractAccountSectionCode(row.account_kt)].filter(
(item): item is string => Boolean(item)
);
if (accountPrefixes.includes("60")) {
scores.supplier_or_contractor += 3;
reasons.add("участие счета 60");
}
if (accountPrefixes.includes("66") || accountPrefixes.includes("67")) {
scores.bank_or_credit += 4;
reasons.add("участие счета 66/67");
}
if (accountPrefixes.includes("68") || accountPrefixes.includes("69")) {
scores.tax_or_state += 4;
reasons.add("участие счета 68/69");
}
if (accountPrefixes.includes("76")) {
scores.supplier_or_contractor += 1;
reasons.add("участие счета 76");
}
if (/(?:банк|сбер|втб|альфа|газпромбанк|кредит|депозит|loan|overdraft|deposit)/iu.test(text)) {
scores.bank_or_credit += 3;
reasons.add("банк/кредит в аналитике");
}
if (/(?:уфк|ифнс|фнс|налог|пфр|фсс|сфр|казнач|бюджет|гос|департамент|министер|муницип|город москвы|федерал)/iu.test(text)) {
scores.tax_or_state += 3;
reasons.add("налог/госорган в аналитике");
}
if (/(?:\bип\b|ооо|ао|зао|пао|подряд|поставщик|supplier|vendor|contractor)/iu.test(text)) {
scores.supplier_or_contractor += 2;
reasons.add("коммерческий контрагент в аналитике");
}
return {
scores,
reasons: Array.from(reasons)
};
}
const PAYABLES_CATEGORY_KEYS: PayablesLiabilityCategory[] = ["supplier_or_contractor", "bank_or_credit", "tax_or_state", "other"];
function resolvePayablesLiabilityCategory(
scores: Record<PayablesLiabilityCategory, number>
): PayablesLiabilityCategory {
let winner: PayablesLiabilityCategory = "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: string | null): boolean {
const section = extractAccountSectionCode(account);
return section === "60" || section === "76";
}
function resolvePayablesAsOfDate(options: ComposeFactualReplyOptions): string {
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: ComposeStageRow[]): PayablesCounterpartyRiskAggregate[] {
const byCounterparty = new Map<
string,
{
base: CounterpartyRiskAggregate;
categoryScores: Record<PayablesLiabilityCategory, number>;
reasons: Set<string>;
}
>();
for (const row of rows) {
const name = extractCounterpartyName(row);
if (!name) {
continue;
}
const amountRaw = row.amount ?? 0;
if (!Number.isFinite(amountRaw)) {
continue;
}
const amount = Math.abs(amountRaw);
const classified = classifyPayablesLiabilityCategory(row, name);
const current = byCounterparty.get(name);
if (!current) {
byCounterparty.set(name, {
base: {
name,
totalAmount: amount,
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.base.totalAmount += amount;
current.base.operations += 1;
if ((row.period ?? "") < (current.base.firstPeriod ?? "")) {
current.base.firstPeriod = row.period;
}
if ((row.period ?? "") > (current.base.lastPeriod ?? "")) {
current.base.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.values())
.map((item) => ({
...item.base,
category: resolvePayablesLiabilityCategory(item.categoryScores),
categoryReasons: Array.from(item.reasons).slice(0, 2)
}))
.sort((left, right) => {
if (right.totalAmount !== left.totalAmount) {
return right.totalAmount - left.totalAmount;
}
if (right.operations !== left.operations) {
return right.operations - left.operations;
}
return left.name.localeCompare(right.name);
});
}
function buildPayablesConfirmedBalanceAggregate(
rows: ComposeStageRow[],
asOfDate: string
): PayablesConfirmedBalanceAggregate[] {
const byCounterparty = new Map<
string,
{
outstandingAmount: number;
operations: number;
firstPeriod: string | null;
lastPeriod: string | null;
categoryScores: Record<PayablesLiabilityCategory, number>;
reasons: Set<string>;
}
>();
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 (!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: ComposeStageRow[]): CounterpartyRiskAggregate[] {
const byCounterparty = new Map<string, CounterpartyRiskAggregate>();
@@ -885,7 +1198,7 @@ export function composeFactualReply(
intent: AddressIntent,
rows: ComposeStageRow[],
options: ComposeFactualReplyOptions = {}
): { responseType: AddressResponseType; text: string } {
): { responseType: AddressResponseType; text: string; semantics?: ComposeReplySemantics } {
if (intent === "document_type_and_account_section_profile") {
const rowsByMarker = new Map<string, ComposeStageRow[]>();
for (const row of rows) {
@@ -1940,34 +2253,172 @@ export function composeFactualReply(
}
if (intent === "list_payables_counterparties") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const lines = [
"Проверил поставщиков с признаками незакрытых хвостов по взаиморасчетам (контур 60/76).",
`Строк в выборке: ${rows.length}.`,
`Контрагентов с сигналом: ${counterparties.length}.`
];
if (counterparties.length > 0) {
lines.push("Приоритет ручной проверки (по сумме/частоте хвостов):");
lines.push(
...counterparties
.slice(0, 8)
.map(
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: PayablesCounterpartyRiskAggregate, index: number): string =>
`${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`;
const pushCategorySlice = (
lines: string[],
title: string,
items: PayablesCounterpartyRiskAggregate[],
limit: number
): void => {
if (items.length === 0) {
return;
}
lines.push("");
lines.push(title);
lines.push(...items.slice(0, limit).map(formatHeuristicItem));
};
const buildHeuristicLines = (forcedFallbackFromConfirmed: boolean): string[] => {
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<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 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<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. Статус результата",
"- Режим результата: подтвержденный срез обязательств к оплате по состоянию на дату среза в контуре 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} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}`
`${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток к оплате: ${formatMoney(item.outstandingAmount)} | операций в срезе: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}`
)
);
lines.push("Примеры исходных строк:");
lines.push(...formatTopRows(rows, 4));
} else {
lines.push("Явных признаков системной задолженности по доступному срезу не найдено.");
lines.push(...formatTopRows(rows, 6));
];
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") {
const counterparties = buildCounterpartyRiskAggregate(rows);
const debtAgingFocus = hasReceivablesDebtAgingFocus(options.userMessage);
@@ -1414,6 +1414,11 @@ function buildAddressDebugPayload(addressDebug, llmPreDecomposeMeta = null) {
runtime_readiness: addressDebug.runtime_readiness,
limited_reason_category: addressDebug.limited_reason_category,
response_type: addressDebug.response_type,
requested_result_mode: addressDebug.requested_result_mode ?? undefined,
result_mode: addressDebug.result_mode ?? undefined,
evidence_strength: addressDebug.evidence_strength ?? undefined,
balance_confirmed: typeof addressDebug.balance_confirmed === "boolean" ? addressDebug.balance_confirmed : undefined,
as_of_date_basis: addressDebug.as_of_date_basis ?? undefined,
execution_lane: "address_query",
llm_decomposition_applied: Boolean(llmMeta?.applied),
llm_decomposition_attempted: Boolean(llmMeta?.attempted),
@@ -1542,6 +1547,19 @@ const ADDRESS_PREDECOMPOSE_NOISE_TOKENS = new Set([
"kakoi",
"vse",
"all",
"\u043f\u0443\u043d\u043a\u0442",
"\u043f\u0443\u043d\u043a\u0442\u0430",
"\u043f\u0443\u043d\u043a\u0442\u0443",
"\u043f\u0443\u043d\u043a\u0442\u043e\u043c",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044f",
"\u043f\u043e\u0437\u0438\u0446\u0438\u0438",
"\u043f\u043e\u0437\u0438\u0446\u0438\u044e",
"\u0441\u0442\u0440\u043e\u043a\u0430",
"\u0441\u0442\u0440\u043e\u043a\u0438",
"\u0441\u0442\u0440\u043e\u043a\u0443",
"item",
"row",
"line",
"blya",
"blyat",
"епт",
@@ -1566,51 +1584,51 @@ const ADDRESS_FALLBACK_STRIP_TOKENS = new Set([
"please"
]);
const ADDRESS_MONTH_ALIAS_MAP = {
янв: "01",
январ: "01",
"\u044f\u043d\u0432": "01",
"\u044f\u043d\u0432\u0430\u0440": "01",
january: "01",
jan: "01",
фев: "02",
феврал: "02",
"\u0444\u0435\u0432": "02",
"\u0444\u0435\u0432\u0440\u0430\u043b": "02",
february: "02",
feb: "02",
мар: "03",
март: "03",
"\u043c\u0430\u0440": "03",
"\u043c\u0430\u0440\u0442": "03",
march: "03",
apr: "04",
апр: "04",
апрел: "04",
"\u0430\u043f\u0440": "04",
"\u0430\u043f\u0440\u0435\u043b": "04",
april: "04",
май: "05",
ма: "05",
"\u043c\u0430\u0439": "05",
"\u043c\u0430": "05",
may: "05",
июн: "06",
июнь: "06",
"\u0438\u044e\u043d": "06",
"\u0438\u044e\u043d\u044c": "06",
june: "06",
jun: "06",
июл: "07",
июль: "07",
"\u0438\u044e\u043b": "07",
"\u0438\u044e\u043b\u044c": "07",
july: "07",
jul: "07",
авг: "08",
август: "08",
"\u0430\u0432\u0433": "08",
"\u0430\u0432\u0433\u0443\u0441\u0442": "08",
august: "08",
aug: "08",
сен: "09",
сент: "09",
сентябр: "09",
"\u0441\u0435\u043d": "09",
"\u0441\u0435\u043d\u0442": "09",
"\u0441\u0435\u043d\u0442\u044f\u0431\u0440": "09",
september: "09",
sep: "09",
окт: "10",
октябр: "10",
"\u043e\u043a\u0442": "10",
"\u043e\u043a\u0442\u044f\u0431\u0440": "10",
october: "10",
oct: "10",
ноя: "11",
ноябр: "11",
"\u043d\u043e\u044f": "11",
"\u043d\u043e\u044f\u0431\u0440": "11",
november: "11",
nov: "11",
дек: "12",
декабр: "12",
"\u0434\u0435\u043a": "12",
"\u0434\u0435\u043a\u0430\u0431\u0440": "12",
december: "12",
dec: "12"
};
@@ -1839,6 +1857,10 @@ function resolveAddressDeterministicFallback(userMessage, sanitizedUserMessage)
const bankSignal = ADDRESS_BANK_SIGNAL_PATTERN.test(source);
const contractSignal = ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
const balanceSignal = ADDRESS_BALANCE_SIGNAL_PATTERN.test(source);
const hasIndexPointerSignal = /(?:\u043f\u0443\u043d\u043a\u0442|\u043f\u043e\u0437\u0438\u0446|\u0441\u0442\u0440\u043e\u043a|item|row|line)/iu.test(sourceRaw);
if (hasIndexPointerSignal && extractDisplayedEntityIndexMention(sourceRaw) !== null) {
return null;
}
if (balanceSignal && account) {
let periodClause = "";
let rule = "balance_account_rewrite";
@@ -2157,6 +2179,14 @@ const FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS = new Set([
"company",
"group"
]);
const FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT = {
counterparty_activity_lifecycle: "counterparty",
customer_revenue_and_payments: "counterparty",
supplier_payouts_profile: "counterparty",
counterparty_population_and_roles: "counterparty",
contract_usage_and_value: "contract",
list_contracts_by_counterparty: "contract"
};
function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
@@ -2167,7 +2197,25 @@ function normalizeCounterpartyForFollowupMatch(value) {
function normalizeCounterpartyTokenForFollowupMatch(value) {
return normalizeCounterpartyForFollowupMatch(value).replace(/[._-]+/g, "");
}
function extractDisplayedCounterpartyCandidates(replyText) {
function normalizeCounterpartyStemForFollowupMatch(value) {
const compact = normalizeCounterpartyTokenForFollowupMatch(value);
if (!compact || !/[а-яё]/iu.test(compact)) {
return compact;
}
const stem = compact.replace(/(?:иями|ями|ами|ией|ей|ий|ов|ев|ом|ем|ах|ях|ую|юю|ая|яя|ое|ее|ые|ие|ого|его|ому|ему|ыми|ими|ым|им|ам|ям|у|ю|а|я|е|и|ы|о)$/iu, "");
return stem.length >= 3 ? stem : compact;
}
function inferDisplayedEntityTypeFromIntent(intent) {
const normalized = compactWhitespace(String(intent ?? "").toLowerCase());
if (!normalized) {
return "unknown";
}
return FOLLOWUP_DISPLAY_ENTITY_TYPE_BY_INTENT[normalized] ?? "unknown";
}
function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknown") {
if (entityType === "unknown") {
return [];
}
const lines = String(replyText ?? "").split(/\r?\n/);
const candidates = [];
for (const line of lines) {
@@ -2175,10 +2223,15 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!compactLine) {
continue;
}
if (!/^\d+\.\s+/.test(compactLine)) {
const numberedMatch = compactLine.match(/^(\d+)\.\s+(.+)$/);
if (!numberedMatch) {
continue;
}
const afterNumber = compactLine.replace(/^\d+\.\s+/, "");
const index = Number.parseInt(String(numberedMatch[1] ?? ""), 10);
if (!Number.isFinite(index) || index <= 0) {
continue;
}
const afterNumber = String(numberedMatch[2] ?? "");
const parts = afterNumber.split("|").map((item) => compactWhitespace(item));
let counterpartyCandidate = parts[0] ?? "";
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
@@ -2188,9 +2241,20 @@ function extractDisplayedCounterpartyCandidates(replyText) {
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
candidates.push(cleanedCandidate);
candidates.push({
index,
value: cleanedCandidate,
entityType
});
}
return Array.from(new Set(candidates));
const dedup = new Map();
for (const candidate of candidates) {
const key = `${candidate.entityType}:${candidate.index}:${normalizeCounterpartyForFollowupMatch(candidate.value)}`;
if (!dedup.has(key)) {
dedup.set(key, candidate);
}
}
return Array.from(dedup.values());
}
function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
const aliases = new Set();
@@ -2203,13 +2267,14 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
.split(/\s+/)
.map((token) => token.trim())
.filter(Boolean);
const withoutLegalTokens = normalizedTokens
const tokensForAlias = Array.from(new Set(normalizedTokens.flatMap((token) => [token, ...token.split(/-+/).map((part) => part.trim()).filter(Boolean)])));
const withoutLegalTokens = tokensForAlias
.filter((token) => !FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS.has(token))
.join(" ");
if (withoutLegalTokens) {
aliases.add(withoutLegalTokens);
}
for (const token of normalizedTokens) {
for (const token of tokensForAlias) {
const compactToken = normalizeCounterpartyTokenForFollowupMatch(token);
if (compactToken.length < 3) {
continue;
@@ -2221,6 +2286,10 @@ function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
continue;
}
aliases.add(compactToken);
const stemToken = normalizeCounterpartyStemForFollowupMatch(compactToken);
if (stemToken.length >= 4) {
aliases.add(stemToken);
}
}
return Array.from(aliases)
.map((alias) => compactWhitespace(alias))
@@ -2234,31 +2303,95 @@ function hasCounterpartyAliasMention(normalizedMessage, alias) {
}
const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+");
const boundaryPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}(?:$|[^a-zа-я0-9])`, "iu");
return boundaryPattern.test(normalizedMessage);
if (boundaryPattern.test(normalizedMessage)) {
return true;
}
if (trimmedAlias.length < 4 || !/[а-яё]/iu.test(trimmedAlias)) {
return false;
}
const fuzzyPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}[а-яё]{0,3}(?:$|[^a-zа-я0-9])`, "iu");
return fuzzyPattern.test(normalizedMessage);
}
function resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) {
function extractDisplayedEntityIndexMention(userMessage) {
const normalized = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase());
if (!normalized) {
return null;
}
const tokenStart = "(?:^|[^\\p{L}\\p{N}_])";
const tokenEnd = "(?=$|[^\\p{L}\\p{N}_])";
const pointerPattern = "(?:\\u043f\\u0443\\u043d\\u043a\\u0442(?:\\u0430|\\u0443|\\u043e\\u043c)?|\\u043f\\u043e\\u0437\\u0438\\u0446\\u0438(?:\\u044f|\\u0438|\\u044e|\\u0435\\u0439)|\\u0441\\u0442\\u0440\\u043e\\u043a(?:\\u0430|\\u0438|\\u0435|\\u0443)|item|row|line)";
const pointerSignalPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}`, "iu");
const directPattern = new RegExp(`${tokenStart}${pointerPattern}${tokenEnd}\\D{0,8}(\\d{1,3})(?!\\d)`, "iu");
const directMatch = normalized.match(directPattern);
if (directMatch) {
const value = Number.parseInt(String(directMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
const reversePattern = new RegExp(`${tokenStart}(\\d{1,3})(?:-?(?:\\u0439|\\u044f|\\u0435|\\u0433\\u043e|\\u043c\\u0443))?\\s+${pointerPattern}${tokenEnd}`, "iu");
const reverseMatch = normalized.match(reversePattern);
if (reverseMatch) {
const value = Number.parseInt(String(reverseMatch[1] ?? ""), 10);
return Number.isFinite(value) && value > 0 ? value : null;
}
if (pointerSignalPattern.test(normalized)) {
const numericMatches = Array.from(normalized.matchAll(/(?:^|[^\p{N}])(\d{1,3})(?!\d)/gu))
.map((match) => Number.parseInt(String(match[1] ?? ""), 10))
.filter((value) => Number.isFinite(value) && value > 0);
if (numericMatches.length === 1) {
return numericMatches[0];
}
}
return null;
}
function resolveDisplayedAddressEntityMention(userMessage, displayedEntities) {
const normalizedMessage = normalizeCounterpartyForFollowupMatch(userMessage);
if (!normalizedMessage) {
return null;
}
if (!Array.isArray(displayedCounterparties) || displayedCounterparties.length === 0) {
if (!Array.isArray(displayedEntities) || displayedEntities.length === 0) {
return null;
}
const indexMention = extractDisplayedEntityIndexMention(userMessage);
if (indexMention !== null) {
const indexedCandidate = displayedEntities.find((candidate) => Number(candidate.index) === indexMention);
if (indexedCandidate) {
return {
value: indexedCandidate.value,
entityType: indexedCandidate.entityType,
matchKind: "index",
index: indexedCandidate.index
};
}
}
let bestMatch = null;
for (const candidate of displayedCounterparties) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate);
for (const candidate of displayedEntities) {
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate.value);
for (const alias of aliases) {
if (!hasCounterpartyAliasMention(normalizedMessage, alias)) {
continue;
}
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate) === alias ? 1 : 0);
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate.value) === alias ? 1 : 0);
if (!bestMatch || score > bestMatch.score) {
bestMatch = { value: candidate, score };
bestMatch = {
value: candidate.value,
entityType: candidate.entityType,
index: candidate.index,
matchKind: "alias",
score
};
}
break;
}
}
return bestMatch?.value ?? null;
if (!bestMatch) {
return null;
}
return {
value: bestMatch.value,
entityType: bestMatch.entityType,
matchKind: bestMatch.matchKind,
index: bestMatch.index
};
}
function findRecentAddressFilterValue(items, key) {
for (let index = items.length - 1; index >= 0; index -= 1) {
@@ -2435,12 +2568,17 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
const hasAlternateFollowupSignal = toNonEmptyString(alternateMessage)
? hasAddressFollowupContextSignal(alternateMessage)
: false;
const hasPrimaryIndexReferenceSignal = extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = toNonEmptyString(alternateMessage)
? extractDisplayedEntityIndexMention(String(alternateMessage ?? "")) !== null
: false;
const hasIndexReferenceSignal = hasPrimaryIndexReferenceSignal || hasAlternateIndexReferenceSignal;
const hasStandaloneAddressTopic = hasStandaloneAddressTopicSignal(userMessage) ||
(toNonEmptyString(alternateMessage) ? hasStandaloneAddressTopicSignal(alternateMessage) : false);
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal) {
if (hasStandaloneAddressTopic && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal) {
if (!hasPrimaryFollowupSignal && !hasAlternateFollowupSignal && !hasImplicitContinuationSignal && !hasIndexReferenceSignal) {
return null;
}
if (!previousAddressDebug) {
@@ -2487,16 +2625,24 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
previousFilters.organization = historicalOrganization;
}
}
const displayedCounterparties = extractDisplayedCounterpartyCandidates(toNonEmptyString(previousAddressItem?.text) ?? "");
const counterpartyFromFollowupText = resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) ??
const displayedEntityType = inferDisplayedEntityTypeFromIntent(sourceIntent);
const displayedEntities = extractDisplayedAddressEntityCandidates(toNonEmptyString(previousAddressItem?.text) ?? "", displayedEntityType);
const resolvedEntityFromFollowup = resolveDisplayedAddressEntityMention(userMessage, displayedEntities) ??
(toNonEmptyString(alternateMessage)
? resolveDisplayedCounterpartyMention(String(alternateMessage ?? ""), displayedCounterparties)
? resolveDisplayedAddressEntityMention(String(alternateMessage ?? ""), displayedEntities)
: null);
if (counterpartyFromFollowupText) {
previousFilters.counterparty = counterpartyFromFollowupText;
previousAnchorType = "counterparty";
previousAnchor = counterpartyFromFollowupText;
resolvedCounterpartyFromDisplay = true;
if (resolvedEntityFromFollowup) {
if (resolvedEntityFromFollowup.entityType === "counterparty") {
previousFilters.counterparty = resolvedEntityFromFollowup.value;
previousAnchorType = "counterparty";
previousAnchor = resolvedEntityFromFollowup.value;
resolvedCounterpartyFromDisplay = true;
}
else if (resolvedEntityFromFollowup.entityType === "contract") {
previousFilters.contract = resolvedEntityFromFollowup.value;
previousAnchorType = "contract";
previousAnchor = resolvedEntityFromFollowup.value;
}
if (followupSelectionMode !== "switch_to_suggested_intent") {
followupSelectionMode = "carry_referenced_entity";
}
@@ -3330,7 +3476,9 @@ function resolveAddressToolGateDecision(addressInputMessage, followupContext, ll
llmContractIntent === "unknown" &&
!followupContext &&
!hasClassifierSignal &&
!strongDataSignalFromRawMessage) {
!hasIntentSignal &&
!strongDataSignalFromRawMessage &&
!strongDataSignalFromEffectiveMessage) {
return {
runAddressLane: false,
decision: "skip_address_lane",
@@ -4466,7 +4614,7 @@ function isPlausibleOrganizationName(value) {
if (/(?:справочникссылка|документссылка|плансчетовссылка|standardodata|recordtype|cmp:)/i.test(candidate)) {
return false;
}
return /[A-Za-zА-Яа-яЁё]/u.test(candidate);
return /[A-Za-z\u0400-\u04FF]/u.test(candidate);
}
function appendOrganizationFactsFromValue(value, hints, bucket, depth = 0) {
if (depth > 4 || value === null || value === undefined) {
@@ -24,6 +24,9 @@ export type AddressIntent =
| "unknown";
export type AddressResponseType = "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
export type AddressResultMode = "heuristic_candidates" | "confirmed_balance";
export type AddressEvidenceStrength = "weak" | "medium" | "strong";
export type AddressAsOfDateBasis = "period_end" | "explicit_as_of_date" | "period_range";
export type AddressQueryShape =
| "AGGREGATE_LOOKUP"
@@ -189,6 +192,11 @@ export interface AddressExecutionDebug {
runtime_readiness: AddressRuntimeReadiness;
limited_reason_category: AddressLimitedReasonCategory | null;
response_type: AddressResponseType;
requested_result_mode?: AddressResultMode;
result_mode?: AddressResultMode;
evidence_strength?: AddressEvidenceStrength;
balance_confirmed?: boolean;
as_of_date_basis?: AddressAsOfDateBasis | null;
limitations: string[];
reasons: string[];
}
@@ -427,6 +427,11 @@ export interface AssistantDebugPayload {
runtime_readiness?: "LIVE_QUERYABLE" | "LIVE_QUERYABLE_WITH_LIMITS" | "REQUIRES_SPECIALIZED_RECIPE" | "DEEP_ONLY" | "UNKNOWN";
limited_reason_category?: "empty_match" | "missing_anchor" | "recipe_visibility_gap" | "execution_error" | "unsupported" | null;
response_type?: "FACTUAL_LIST" | "FACTUAL_SUMMARY" | "LIMITED_WITH_REASON";
requested_result_mode?: "heuristic_candidates" | "confirmed_balance";
result_mode?: "heuristic_candidates" | "confirmed_balance";
evidence_strength?: "weak" | "medium" | "strong";
balance_confirmed?: boolean;
as_of_date_basis?: "period_end" | "explicit_as_of_date" | "period_range" | null;
execution_lane?: "address_query" | "deep_analysis";
llm_decomposition_applied?: boolean;
llm_decomposition_attempted?: boolean;