diff --git a/docs/TECH/address_route_baseline_v1.json b/docs/TECH/address_route_baseline_v1.json index 4974f98..ad48014 100644 --- a/docs/TECH/address_route_baseline_v1.json +++ b/docs/TECH/address_route_baseline_v1.json @@ -8,6 +8,12 @@ "capability_layer": "compute", "capability_route_mode": "exact" }, + { + "intent": "receivables_confirmed_as_of_date", + "capability_id": "confirmed_receivables_as_of_date", + "capability_layer": "compute", + "capability_route_mode": "exact" + }, { "intent": "list_payables_counterparties", "capability_id": "payables_candidates_list", diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index 31a54ec..6766405 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -8,6 +8,12 @@ "expected_requested_result_modes": ["confirmed_balance"], "expected_result_modes": ["confirmed_balance"] }, + { + "intent": "receivables_confirmed_as_of_date", + "expected_selected_recipes": ["address_receivables_confirmed_as_of_date_v1"], + "expected_requested_result_modes": ["confirmed_balance"], + "expected_result_modes": ["confirmed_balance"] + }, { "intent": "list_payables_counterparties", "expected_selected_recipes": ["address_movements_payables_v1", "address_open_items_by_party_or_contract_v1"], diff --git a/llm_normalizer/backend/dist/config.js b/llm_normalizer/backend/dist/config.js index 9d6ecb4..9dc1cbd 100644 --- a/llm_normalizer/backend/dist/config.js +++ b/llm_normalizer/backend/dist/config.js @@ -3,8 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.PRESETS_DIR = exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1 = exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = exports.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = exports.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0; -exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = void 0; +exports.TRACES_DIR = exports.DATA_DIR = exports.VAT_PAYABLE_19_PREFIXES = exports.VAT_PAYABLE_68_PREFIXES = exports.ASSISTANT_MCP_LIVE_LIMIT = exports.ASSISTANT_MCP_TIMEOUT_MS = exports.ASSISTANT_MCP_CHANNEL = exports.ASSISTANT_MCP_PROXY_URL = exports.FEATURE_ASSISTANT_LIVING_CHAT_ROUTER_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_HARD_GUARD_V1 = exports.FEATURE_ASSISTANT_ROUTE_EXPECTATION_AUDIT_V1 = exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = exports.FEATURE_ASSISTANT_CAPABILITY_ROUTE_GUARD_V1 = exports.FEATURE_ASSISTANT_ADDRESS_NAVIGATION_STATE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LIVE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_LLM_PREDECOMPOSE_V1 = exports.FEATURE_ASSISTANT_ADDRESS_QUERY_V1 = exports.FEATURE_ASSISTANT_MCP_RUNTIME_V1 = exports.FEATURE_ASSISTANT_GRAPH_RUNTIME_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_ANSWER_V1 = exports.FEATURE_ASSISTANT_LIFECYCLE_RUNTIME_V1 = exports.FEATURE_ASSISTANT_STAGE2_EVAL_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNIT_CONTINUITY_V1 = exports.FEATURE_ASSISTANT_PROBLEM_CENTRIC_ANSWER_V1 = exports.FEATURE_ASSISTANT_PROBLEM_UNITS_V1 = exports.FEATURE_ASSISTANT_ACCOUNTANT_EVAL_V1 = exports.FEATURE_ASSISTANT_ANSWER_POLICY_V11 = exports.FEATURE_ASSISTANT_ANTI_GENERIC_RANKING_GUARD_V1 = exports.FEATURE_ASSISTANT_MIN_EVIDENCE_GATE_V1 = exports.FEATURE_ASSISTANT_BROAD_GUARD_V1 = exports.FEATURE_ASSISTANT_EVIDENCE_ENRICHMENT_V1 = exports.FEATURE_ASSISTANT_STATE_FOLLOWUP_BINDING_V1 = exports.FEATURE_ASSISTANT_CONTRACTS_V11 = exports.FEATURE_ASSISTANT_INVESTIGATION_STATE_V1 = exports.DEFAULT_PROMPT_VERSION = exports.DEFAULT_MAX_OUTPUT_TOKENS = exports.DEFAULT_TEMPERATURE = exports.DEFAULT_MODEL = exports.DEFAULT_OPENAI_BASE_URL = exports.TIMEZONE = exports.PORT = exports.MODULE_ROOT = exports.BACKEND_ROOT = void 0; +exports.MANUAL_CASE_DECISION_SCHEMA_FILE = exports.ASSISTANT_CAPABILITIES_REGISTRY_FILE = exports.ASSISTANT_CANON_FILE = exports.ARCH_EXPORT_2020_DIR = exports.SCHEMAS_DIR = exports.EVAL_DATASETS_DIR = exports.REPORTS_DIR = exports.PROMPTS_DIR = exports.AUTORUN_GENERATOR_HISTORY_FILE = exports.AUTORUN_GENERATOR_DIR = exports.AUTORUN_ANNOTATIONS_FILE = exports.AUTORUN_ANNOTATIONS_DIR = exports.ASSISTANT_ANNOTATIONS_FILE = exports.ASSISTANT_ANNOTATIONS_DIR = exports.ASSISTANT_SESSIONS_DIR = exports.EVAL_CASES_DIR = exports.PRESETS_DIR = void 0; const path_1 = __importDefault(require("path")); exports.BACKEND_ROOT = path_1.default.resolve(__dirname, ".."); exports.MODULE_ROOT = path_1.default.resolve(exports.BACKEND_ROOT, ".."); @@ -66,6 +66,7 @@ exports.FEATURE_ASSISTANT_ROUTE_ADDRESS_GENERIC_V1 = toBooleanFlag(process.env.F exports.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1, true); exports.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, true); exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1, true); +exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1, true); exports.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, true); exports.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1, true); exports.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 = toBooleanFlag(process.env.FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1, false); diff --git a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js index 4493244..b5ed105 100644 --- a/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js +++ b/llm_normalizer/backend/dist/services/addressCapabilityPolicy.js @@ -4,7 +4,12 @@ exports.resolveAddressCapabilityRouteDecision = resolveAddressCapabilityRouteDec exports.isCapabilityRouteBlocked = isCapabilityRouteBlocked; exports.resolveShadowRouteIntent = resolveShadowRouteIntent; const config_1 = require("../config"); -const COMPUTE_EXACT_INTENTS = new Set(["account_balance_snapshot", "documents_forming_balance", "payables_confirmed_as_of_date"]); +const COMPUTE_EXACT_INTENTS = new Set([ + "account_balance_snapshot", + "documents_forming_balance", + "payables_confirmed_as_of_date", + "receivables_confirmed_as_of_date" +]); const NAVIGATION_INTENTS = new Set([ "list_documents_by_counterparty", "bank_operations_by_counterparty", @@ -31,6 +36,9 @@ function defaultCapabilityId(intent) { if (intent === "payables_confirmed_as_of_date") { return "confirmed_payables_as_of_date"; } + if (intent === "receivables_confirmed_as_of_date") { + return "confirmed_receivables_as_of_date"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -58,6 +66,14 @@ function resolveCapabilityEnabled(intent) { reason: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 ? "payables_confirmed_route_enabled" : "payables_confirmed_route_disabled_by_flag" }; } + if (intent === "receivables_confirmed_as_of_date") { + return { + enabled: config_1.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1, + reason: config_1.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 + ? "receivables_confirmed_route_enabled" + : "receivables_confirmed_route_disabled_by_flag" + }; + } if (intent === "list_payables_counterparties") { return { enabled: config_1.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index b34cbec..13d0c96 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -748,6 +748,9 @@ function requiredFiltersByIntent(intent) { if (intent === "payables_confirmed_as_of_date") { return ["as_of_date"]; } + if (intent === "receivables_confirmed_as_of_date") { + return ["as_of_date"]; + } if (intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || intent === "list_contracts_by_counterparty") { @@ -761,7 +764,8 @@ function requiredFiltersByIntent(intent) { function usesAsOfPrimaryWindow(intent) { return (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || - intent === "payables_confirmed_as_of_date"); + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date"); } function extractAddressFilters(userMessage, intent) { const rawText = String(userMessage ?? "").trim(); @@ -923,7 +927,8 @@ function extractAddressFilters(userMessage, intent) { // - else default to today. if ((intent === "account_balance_snapshot" || intent === "documents_forming_balance" || - intent === "payables_confirmed_as_of_date") && + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date") && !filters.as_of_date) { if (filters.period_to) { filters.as_of_date = filters.period_to; diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 73033e6..0a1e45a 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -3,7 +3,11 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.resolveAddressIntent = resolveAddressIntent; const RECEIVABLES_STRONG = [ "кто должен нам", + "кто нам должен", + "кто нам должэн", "нам должны", + "нам должен", + "нам должэны", "who owes us", "receivable", "receivables", @@ -14,7 +18,11 @@ const RECEIVABLES_STRONG = [ ]; const PAYABLES_STRONG = [ "кому должны мы", + "кому должэны мы", + "кому мы должны", + "кому мы должэны", "мы должны", + "мы должэны", "who we owe", "payable", "payables", @@ -865,6 +873,18 @@ function hasPayablesDebtLifecycleSignal(text) { } return true; } +function hasReceivablesDebtLifecycleSignal(text) { + const hasOweUsSignal = /(?:кто\s+нам\s+долж(?:ен|ны)?|кто\s+долж(?:ен|ны)?\s+нам|нам\s+долж(?:ен|ны)|должник(?:и|ов|а)?|дебитор(?:ы|ов|ск)?|задолж|долг(?:и|ов|а|у)?|к\s+получению|на\s+поступление|к\s+взысканию|who\s+owes\s+us|receivables?)/iu.test(text); + if (!hasOweUsSignal) { + return false; + } + const hasPastInflowSignal = /(?:прин[её]с|зан[её]с|поступил|приход|inflow|paid\s+us|already\s+paid)/iu.test(text); + const hasTopRankingSignal = /(?:топ|top|больше\s+всего|сам(?:ый|ая|ое|ые)|наибольш|максимальн)/iu.test(text); + if (hasPastInflowSignal && hasTopRankingSignal) { + return false; + } + return true; +} function hasReceivablesLatencyRiskSignal(text) { const hasBuyer = /(?:покупател|клиент|заказчик|customer|buyer)/iu.test(text); const hasCounterparty = /(?:контрагент|counterparty|partner)/iu.test(text); @@ -1239,10 +1259,15 @@ function resolveAddressIntent(userMessage) { }; } if (hasAny(text, RECEIVABLES_STRONG)) { + const receivablesDebtLifecycleSignal = hasReceivablesDebtLifecycleSignal(text); + const reasons = ["receivables_signal_detected"]; + if (receivablesDebtLifecycleSignal) { + reasons.push("receivables_debt_lifecycle_signal_detected"); + } return { - intent: "list_receivables_counterparties", + intent: receivablesDebtLifecycleSignal ? "receivables_confirmed_as_of_date" : "list_receivables_counterparties", confidence: "high", - reasons: ["receivables_signal_detected"] + reasons }; } if (hasAny(text, PAYABLES_STRONG)) { diff --git a/llm_normalizer/backend/dist/services/addressNavigationState.js b/llm_normalizer/backend/dist/services/addressNavigationState.js index bcaa7bc..ed7f3b9 100644 --- a/llm_normalizer/backend/dist/services/addressNavigationState.js +++ b/llm_normalizer/backend/dist/services/addressNavigationState.js @@ -14,6 +14,7 @@ const DISPLAY_ENTITY_TYPE_BY_INTENT = { customer_revenue_and_payments: "counterparty", supplier_payouts_profile: "counterparty", list_payables_counterparties: "counterparty", + receivables_confirmed_as_of_date: "counterparty", list_receivables_counterparties: "counterparty", list_contracts_by_counterparty: "contract", list_documents_by_counterparty: "document_ref", @@ -28,6 +29,7 @@ const RESULT_SET_TYPE_BY_INTENT = { supplier_payouts_profile: "counterparty_list", list_payables_counterparties: "counterparty_list", payables_confirmed_as_of_date: "balance_snapshot", + receivables_confirmed_as_of_date: "balance_snapshot", list_receivables_counterparties: "counterparty_list", list_contracts_by_counterparty: "contract_list", list_documents_by_counterparty: "document_list", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 564ae64..e97f1b3 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -349,6 +349,7 @@ function shouldAttemptCounterpartyCatalogResolution(intent, filters) { intent === "open_items_by_counterparty_or_contract" || intent === "list_payables_counterparties" || intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date" || intent === "list_receivables_counterparties"); } async function resolveCounterpartyViaCatalog(anchorRaw) { @@ -633,6 +634,7 @@ function isCounterpartyRiskIntent(intent) { return (intent === "list_receivables_counterparties" || intent === "list_payables_counterparties" || intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date" || intent === "list_open_contracts" || intent === "open_items_by_counterparty_or_contract"); } @@ -645,7 +647,8 @@ function isHeuristicCandidatesIntent(intent) { function isConfirmedBalanceIntent(intent) { return (intent === "account_balance_snapshot" || intent === "documents_forming_balance" || - intent === "payables_confirmed_as_of_date"); + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date"); } function resolveAsOfDateBasis(filters) { const asOfDate = normalizeAnalysisDateHint(filters.as_of_date); @@ -1296,6 +1299,9 @@ function buildLimitedOffers(input) { if (input.intent === "list_receivables_counterparties") { offers.push("показать контрагентов с максимальными хвостами дебиторки по 62/76"); } + else if (input.intent === "receivables_confirmed_as_of_date") { + offers.push("показать подтвержденный реестр открытой дебиторской задолженности на дату среза по 62/76"); + } else if (input.intent === "payables_confirmed_as_of_date") { offers.push("показать подтвержденный реестр открытых обязательств на дату среза по 60/76"); } @@ -1341,6 +1347,7 @@ function buildLimitedIntentSignalLine(input) { list_open_contracts: "Сигнал запроса: нужен список незакрытых договоров на дату.", list_receivables_counterparties: "Сигнал запроса: нужен ранжированный список должников.", list_payables_counterparties: "Сигнал запроса: нужен ранжированный список кредиторов.", + receivables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез дебиторской задолженности на дату.", payables_confirmed_as_of_date: "Сигнал запроса: нужен подтвержденный срез обязательств к оплате на дату." }; const byShape = { @@ -1467,9 +1474,16 @@ function buildLimitedExecutionResult(input) { }); 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"] + const reasons = (input.intent.intent === "payables_confirmed_as_of_date" || input.intent.intent === "receivables_confirmed_as_of_date") && + !reasonsWithConfirmedFallback.includes(input.intent.intent === "payables_confirmed_as_of_date" + ? "exact_payables_mode_limited_response" + : "exact_receivables_mode_limited_response") + ? [ + ...reasonsWithConfirmedFallback, + input.intent.intent === "payables_confirmed_as_of_date" + ? "exact_payables_mode_limited_response" + : "exact_receivables_mode_limited_response" + ] : reasonsWithConfirmedFallback; const routeExpectationAudit = input.routeExpectationAudit ?? buildRouteExpectationAudit({ @@ -1574,11 +1588,16 @@ class AddressQueryService { } } 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" + const confirmedBalancePayablesIntent = (intent.intent === "list_payables_counterparties" || intent.intent === "payables_confirmed_as_of_date") && + requestedResultMode === "confirmed_balance"; + const confirmedBalanceReceivablesIntent = intent.intent === "receivables_confirmed_as_of_date" && requestedResultMode === "confirmed_balance"; + const payablesConfirmedExecution = confirmedBalancePayablesIntent ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) : null; - const executionFilters = payablesConfirmedExecution?.executionFilters ?? filters.extracted_filters; + const receivablesConfirmedExecution = confirmedBalanceReceivablesIntent + ? resolveExecutionFiltersForPayablesConfirmedBalance(filters.extracted_filters, analysisDate) + : null; + const executionFilters = payablesConfirmedExecution?.executionFilters ?? receivablesConfirmedExecution?.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")) { @@ -1588,6 +1607,15 @@ class AddressQueryService { baseReasons.push("as_of_date_derived_for_confirmed_payables"); } } + if (receivablesConfirmedExecution?.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_receivables")) { + filters.warnings.push("as_of_date_derived_for_confirmed_receivables"); + } + if (!baseReasons.includes("as_of_date_derived_for_confirmed_receivables")) { + baseReasons.push("as_of_date_derived_for_confirmed_receivables"); + } + } const capabilityDecision = (0, addressCapabilityPolicy_1.resolveAddressCapabilityRouteDecision)(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -1654,6 +1682,10 @@ class AddressQueryService { if (intent.intent === "payables_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_payables_intent")) { baseReasons.push("confirmed_balance_exact_payables_intent"); } + if (intent.intent === "receivables_confirmed_as_of_date" && + !baseReasons.includes("confirmed_balance_exact_receivables_intent")) { + baseReasons.push("confirmed_balance_exact_receivables_intent"); + } if (requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && !baseReasons.includes("confirmed_balance_unavailable_fallback_to_heuristic_candidates")) { @@ -1789,7 +1821,8 @@ class AddressQueryService { }); const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" || plan.recipe.recipe_id === "address_movements_payables_v1" || - plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1"; + plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1" || + plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1"; const missingSubcontoErrorDetected = Boolean(mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error)); if (missingSubcontoErrorDetected) { let missingSubcontoResolvedByComposite = false; @@ -1814,6 +1847,11 @@ class AddressQueryService { baseReasons.push("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto"); } } + else if (intent.intent === "receivables_confirmed_as_of_date") { + if (!baseReasons.includes("confirmed_receivables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) { + baseReasons.push("confirmed_receivables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto"); + } + } } else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed")) { baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed"); @@ -1847,6 +1885,13 @@ class AddressQueryService { baseReasons.push("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items"); } } + else if (intent.intent === "receivables_confirmed_as_of_date") { + composeIntent = "list_receivables_counterparties"; + routeExpectationIntent = "list_receivables_counterparties"; + if (!baseReasons.includes("confirmed_receivables_exact_mode_missing_subconto_fallback_to_open_items")) { + baseReasons.push("confirmed_receivables_exact_mode_missing_subconto_fallback_to_open_items"); + } + } } else { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { @@ -1864,8 +1909,8 @@ class AddressQueryService { if (mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) && - !baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback")) { - baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback"); + !baseReasons.includes("confirmed_exact_mode_missing_subconto_no_heuristic_fallback")) { + baseReasons.push("confirmed_exact_mode_missing_subconto_no_heuristic_fallback"); } if (mcp.error) { const errorScopeAudit = buildDefaultAccountScopeAudit(filters.extracted_filters); @@ -2613,7 +2658,10 @@ class AddressQueryService { routeExpectationAudit: finalRouteExpectationAudit }); } - if (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { + if (((intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || + (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date")) && + factualResultSemantics.balance_confirmed !== true) { + const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables"; return buildLimitedExecutionResult({ mode, shape, @@ -2637,10 +2685,10 @@ class AddressQueryService { 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", + reasonText: `exact ${exactModeName} 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"], + limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], + reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], capabilityAudit, shadowRouteAudit, routeExpectationAudit: finalRouteExpectationAudit diff --git a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js index 3a1f7df..847273a 100644 --- a/llm_normalizer/backend/dist/services/addressRecipeCatalog.js +++ b/llm_normalizer/backend/dist/services/addressRecipeCatalog.js @@ -44,6 +44,28 @@ const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` УПОРЯДОЧИТЬ ПО Сумма __ORDER_DIRECTION__ `; +const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокДт > 0 + И (__RECEIVABLE_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -558,6 +580,17 @@ const BASE_RECIPES = [ account_scope_mode: "strict", query_template: "payables_confirmed_as_of_balance_profile" }, + { + recipe_id: "address_receivables_confirmed_as_of_date_v1", + intent: "receivables_confirmed_as_of_date", + purpose: "Build confirmed receivables snapshot as-of date from movements on accounts 62/76", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 200, + account_scope: ["62", "76"], + account_scope_mode: "strict", + query_template: "receivables_confirmed_as_of_balance_profile" + }, { recipe_id: "address_movements_receivables_v1", intent: "list_receivables_counterparties", @@ -947,17 +980,35 @@ function buildAddressRecipePlan(recipe, filters) { .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() - : MOVEMENTS_QUERY_TEMPLATE - .replace("__LIMIT__", String(resolvedLimit)) - .replace("__WHERE_CLAUSE__", (() => { - const extraConditions = []; - const accountCondition = buildMovementAccountCondition(filters); - if (accountCondition) { - extraConditions.push(accountCondition); - } - return buildWhereClause(filters, "Движения.Период", extraConditions); - })()) - .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() + : MOVEMENTS_QUERY_TEMPLATE + .replace("__LIMIT__", String(resolvedLimit)) + .replace("__WHERE_CLAUSE__", (() => { + const extraConditions = []; + const accountCondition = buildMovementAccountCondition(filters); + if (accountCondition) { + extraConditions.push(accountCondition); + } + return buildWhereClause(filters, "Движения.Период", extraConditions); + })()) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); return { recipe, query, diff --git a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js index 7ae9a0c..7ddb947 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/composeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/composeStage.js @@ -102,10 +102,7 @@ function formatMoneyRub(value) { return `${formatNumberWithDots(value, 2)} ₽`; } function emphasizeNumericTokens(line) { - if (!line) { - return line; - } - return line.replace(/(? `**${token}**`); + return line; } function parseIsoDateToken(value) { const source = String(value ?? "").trim(); @@ -514,6 +511,18 @@ function liabilityCategoryLabel(category) { } return "прочие"; } +function receivablesCategoryLabel(category) { + if (category === "supplier_or_contractor") { + return "покупатели/заказчики"; + } + if (category === "bank_or_credit") { + return "банки/финансовые"; + } + if (category === "tax_or_state") { + return "бюджет/госорганы"; + } + return "прочие"; +} function classifyPayablesLiabilityCategory(row, counterparty) { const scores = { supplier_or_contractor: 0, @@ -582,6 +591,10 @@ function hasPayablesSectionPrefix(account) { const section = extractAccountSectionCode(account); return section === "60" || section === "76"; } +function hasReceivablesSectionPrefix(account) { + const section = extractAccountSectionCode(account); + return section === "62" || section === "76"; +} function resolvePayablesAsOfDate(options) { const explicit = normalizeIsoDateOnly(options.asOfDate); if (explicit) { @@ -762,6 +775,105 @@ function buildPayablesConfirmedBalanceAggregate(rows, asOfDate) { return left.name.localeCompare(right.name); }); } +function buildReceivablesConfirmedBalanceAggregate(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 (hasReceivablesSectionPrefix(row.account_dt)) { + delta += absAmount; + } + if (hasReceivablesSectionPrefix(row.account_kt)) { + delta -= absAmount; + } + if (Math.abs(delta) <= 0.0000001) { + continue; + } + const classified = classifyPayablesLiabilityCategory(row, name); + const contract = extractContractName(row); + const sourceRefs = extractPayablesSourceRefs(row, name, contract); + 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), + contracts: new Set(contract ? [contract] : []), + documents: new Set(row.registrator ? [row.registrator] : []), + sourceRefs: new Set(sourceRefs) + }); + 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); + } + if (contract) { + current.contracts.add(contract); + } + if (row.registrator) { + current.documents.add(row.registrator); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + 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), + contracts: Array.from(item.contracts).slice(0, 2), + documents: Array.from(item.documents).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) + })) + .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) { @@ -1932,7 +2044,7 @@ function composeFactualReply(intent, rows, options = {}) { `Итого подтвержденный долг на ${formatDateRu(payablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`, "", "Блок 1. Статус результата", - "- Режим результата: подтвержденный срез обязательств к оплате (exact route)." + "- Результат: подтвержденный срез обязательств к оплате." ]; lines.push(""); lines.push("Блок 2. Что учтено"); @@ -1957,7 +2069,13 @@ function composeFactualReply(intent, rows, options = {}) { 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)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`)); + lines.push(...confirmedBalances.slice(0, 10).flatMap((item, index) => [ + `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`, + "" + ])); + if (lines[lines.length - 1] === "") { + lines.pop(); + } } else { lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено."); @@ -1972,6 +2090,73 @@ function composeFactualReply(intent, rows, options = {}) { } }; } + if (intent === "receivables_confirmed_as_of_date") { + const receivablesAsOfDate = resolveReceivablesAsOfDate(options); + const confirmedBalances = buildReceivablesConfirmedBalanceAggregate(rows, receivablesAsOfDate); + const asOfDate = normalizeIsoDateOnly(options.asOfDate); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const totalOutstandingAmount = confirmedBalances.reduce((sum, item) => sum + item.outstandingAmount, 0); + const periodScopeLine = !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 = [ + `Итого подтвержденная дебиторская задолженность на ${formatDateRu(receivablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный срез дебиторской задолженности." + ]; + lines.push(""); + lines.push("Блок 2. Что учтено"); + lines.push(`- Дата среза: ${formatDateRu(receivablesAsOfDate)}.`); + if (periodScopeLine) { + lines.push(periodScopeLine); + } + lines.push("- Контур: дебиторская задолженность по счетам 62/76."); + if (carryoverLine) { + lines.push(carryoverLine); + } + lines.push(""); + lines.push("Блок 3. Сводка"); + lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); + lines.push(`- Контрагентов с подтвержденным остатком к получению: ${formatNumberWithDots(confirmedBalances.length)}.`); + lines.push(""); + lines.push("Блок 4. Категории дебиторской задолженности"); + lines.push(`- ${receivablesCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`); + lines.push(`- ${receivablesCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}.`); + lines.push(`- ${receivablesCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}.`); + lines.push(`- ${receivablesCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}.`); + lines.push(""); + lines.push("Блок 5. Подтвержденные позиции к получению"); + if (confirmedBalances.length > 0) { + lines.push(...confirmedBalances.slice(0, 10).flatMap((item, index) => [ + `${index + 1}. ${item.name} | категория: ${receivablesCategoryLabel(item.category)} | остаток к получению: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`, + "" + ])); + if (lines[lines.length - 1] === "") { + lines.pop(); + } + } + else { + lines.push("- Подтвержденной открытой дебиторской задолженности на дату среза не найдено."); + } + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: lines.map(emphasizeNumericTokens).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); diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index efddc46..201571e 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -352,7 +352,8 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { } if (intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || - intent === "payables_confirmed_as_of_date") { + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date") { const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const currentContract = toNonEmptyString(merged.contract); const shouldInheritContract = !currentContract || @@ -434,6 +435,7 @@ function resolveMissingRequiredFilters(intent, filters) { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], + receivables_confirmed_as_of_date: ["as_of_date"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], diff --git a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js index fe4fb4f..c80a37c 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js +++ b/llm_normalizer/backend/dist/services/address_runtime/predecomposeContract.js @@ -93,7 +93,8 @@ function inferAggregationProfile(intent, shape) { } if (intent === "account_balance_snapshot" || intent === "documents_forming_balance" || - intent === "payables_confirmed_as_of_date") { + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date") { return "balance_snapshot"; } if (intent === "open_items_by_counterparty_or_contract" || diff --git a/llm_normalizer/backend/dist/services/answerComposer.js b/llm_normalizer/backend/dist/services/answerComposer.js index 2ad1a13..36d7b94 100644 --- a/llm_normalizer/backend/dist/services/answerComposer.js +++ b/llm_normalizer/backend/dist/services/answerComposer.js @@ -568,7 +568,7 @@ function scrubRawTechnicalRefs(value) { .replace(RAW_REF_TOKEN_PATTERN, "reference") .replace(/\(\s*\[id\]\s*\)/g, "") .replace(/\[\s*id\s*\](?:\s*,\s*\[\s*id\s*\])+/g, "[id]") - .replace(/\s{2,}/g, " ") + .replace(/[ \t]{2,}/g, " ") .trim(); } function stripSyntheticPlaceholders(value) { @@ -582,23 +582,48 @@ function stripSyntheticPlaceholders(value) { } function sanitizeUserFacingReply(value) { const raw = String(value ?? ""); - const hardCutMatch = raw.match(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json)\b/i); + const hardCutMatch = raw.match(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json|technical_debug_payload_json)\b/i); const preCut = hardCutMatch ? raw.slice(0, hardCutMatch.index) : raw; const withoutDebugBlocks = preCut .replace(/###\s*debug_payload_json[\s\S]*?(?:```[\s\S]*?```|$)/gi, "") + .replace(/###\s*technical_debug_payload_json[\s\S]*?(?:```[\s\S]*?```|$)/gi, "") .replace(/###\s*technical_breakdown_json[\s\S]*?(?:```[\s\S]*?```|$)/gi, "") - .replace(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json)\b[\s\S]*$/gi, "") + .replace(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json|technical_debug_payload_json)\b[\s\S]*$/gi, "") .replace(/```json[\s\S]*?```/gi, ""); const normalized = scrubRawTechnicalRefs(withoutDebugBlocks).replace(/[ \t]+\n/g, "\n"); - const cleanedLines = normalized + const preparedLines = normalized .split(/\r?\n/g) .map((line) => stripSyntheticPlaceholders(line)) .map((line) => stripMojibakeFragments(line)) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .filter((line) => !/^(?:-\s*)?(?:action|clarify|open|limit|note):\s*$/i.test(line)) - .filter((line) => !hasUserFacingLeakage(line)) - .filter((line) => !looksLikeMojibake(line)); + .map((line) => line.trim()); + const cleanedLines = []; + let previousWasBlank = false; + for (const line of preparedLines) { + if (line.length === 0) { + if (!previousWasBlank && cleanedLines.length > 0) { + cleanedLines.push(""); + } + previousWasBlank = true; + continue; + } + if (/^(?:-\s*)?(?:action|clarify|open|limit|note):\s*$/i.test(line)) { + continue; + } + if (hasUserFacingLeakage(line)) { + continue; + } + if (looksLikeMojibake(line)) { + continue; + } + cleanedLines.push(line); + previousWasBlank = false; + } + while (cleanedLines.length > 0 && cleanedLines[0] === "") { + cleanedLines.shift(); + } + while (cleanedLines.length > 0 && cleanedLines[cleanedLines.length - 1] === "") { + cleanedLines.pop(); + } const cleaned = cleanedLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); return cleaned || "Available data requires clarification for a reliable user-facing answer."; } diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 0810c04..e9f93a5 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3714,7 +3714,10 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "supplier_payouts_profile", "list_open_contracts", "open_items_by_counterparty_or_contract", + "list_payables_counterparties", + "list_receivables_counterparties", "payables_confirmed_as_of_date", + "receivables_confirmed_as_of_date", "list_documents_by_contract", "bank_operations_by_contract", "list_documents_by_counterparty", diff --git a/llm_normalizer/backend/src/config.ts b/llm_normalizer/backend/src/config.ts index b0ccd18..933f898 100644 --- a/llm_normalizer/backend/src/config.ts +++ b/llm_normalizer/backend/src/config.ts @@ -139,6 +139,10 @@ export const FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 = toBooleanFlag( process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1, true ); +export const FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 = toBooleanFlag( + process.env.FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1, + true +); export const FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1 = toBooleanFlag( process.env.FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, true diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index 41d8017..f98584b 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -5,6 +5,7 @@ import { FEATURE_ASSISTANT_ROUTE_DRILLDOWN_V1, FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1, FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, + FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1, FEATURE_ASSISTANT_ROUTE_RECEIVABLES_HEURISTIC_V1, FEATURE_ASSISTANT_ROUTE_SHADOW_PAYABLES_EXACT_V1 } from "../config"; @@ -22,7 +23,12 @@ export interface AddressCapabilityRouteDecision { capability_route_reason: string; } -const COMPUTE_EXACT_INTENTS = new Set(["account_balance_snapshot", "documents_forming_balance", "payables_confirmed_as_of_date"]); +const COMPUTE_EXACT_INTENTS = new Set([ + "account_balance_snapshot", + "documents_forming_balance", + "payables_confirmed_as_of_date", + "receivables_confirmed_as_of_date" +]); const NAVIGATION_INTENTS = new Set([ "list_documents_by_counterparty", "bank_operations_by_counterparty", @@ -53,6 +59,9 @@ function defaultCapabilityId(intent: AddressIntent): string { if (intent === "payables_confirmed_as_of_date") { return "confirmed_payables_as_of_date"; } + if (intent === "receivables_confirmed_as_of_date") { + return "confirmed_receivables_as_of_date"; + } if (intent === "list_payables_counterparties") { return "payables_candidates_list"; } @@ -81,6 +90,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re reason: FEATURE_ASSISTANT_ROUTE_PAYABLES_CONFIRMED_V1 ? "payables_confirmed_route_enabled" : "payables_confirmed_route_disabled_by_flag" }; } + if (intent === "receivables_confirmed_as_of_date") { + return { + enabled: FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1, + reason: FEATURE_ASSISTANT_ROUTE_RECEIVABLES_CONFIRMED_V1 + ? "receivables_confirmed_route_enabled" + : "receivables_confirmed_route_disabled_by_flag" + }; + } if (intent === "list_payables_counterparties") { return { enabled: FEATURE_ASSISTANT_ROUTE_PAYABLES_HEURISTIC_V1, diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 729d7ba..5370237 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -840,6 +840,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array 0) @@ -2011,6 +2034,17 @@ export class AddressQueryService { baseReasons.push("as_of_date_derived_for_confirmed_payables"); } } + if ( + receivablesConfirmedExecution?.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_receivables")) { + filters.warnings.push("as_of_date_derived_for_confirmed_receivables"); + } + if (!baseReasons.includes("as_of_date_derived_for_confirmed_receivables")) { + baseReasons.push("as_of_date_derived_for_confirmed_receivables"); + } + } const capabilityDecision = resolveAddressCapabilityRouteDecision(intent.intent); const capabilityAudit = buildCapabilityAudit(intent.intent); const shadowRouteAudit = buildShadowRouteAudit({ @@ -2080,6 +2114,12 @@ export class AddressQueryService { if (intent.intent === "payables_confirmed_as_of_date" && !baseReasons.includes("confirmed_balance_exact_payables_intent")) { baseReasons.push("confirmed_balance_exact_payables_intent"); } + if ( + intent.intent === "receivables_confirmed_as_of_date" && + !baseReasons.includes("confirmed_balance_exact_receivables_intent") + ) { + baseReasons.push("confirmed_balance_exact_receivables_intent"); + } if ( requestedResultMode === "confirmed_balance" && recipeIntent === "open_items_by_counterparty_or_contract" && @@ -2229,7 +2269,8 @@ export class AddressQueryService { const missingSubcontoFallbackEligible = plan.recipe.recipe_id === "address_movements_receivables_v1" || plan.recipe.recipe_id === "address_movements_payables_v1" || - plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1"; + plan.recipe.recipe_id === "address_payables_confirmed_as_of_date_v1" || + plan.recipe.recipe_id === "address_receivables_confirmed_as_of_date_v1"; const missingSubcontoErrorDetected = Boolean( mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) ); @@ -2255,6 +2296,10 @@ export class AddressQueryService { if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) { baseReasons.push("confirmed_payables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto"); } + } else if (intent.intent === "receivables_confirmed_as_of_date") { + if (!baseReasons.includes("confirmed_receivables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto")) { + baseReasons.push("confirmed_receivables_exact_mode_missing_subconto_axis_fallback_to_composite_subconto"); + } } } else if (!baseReasons.includes("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed")) { baseReasons.push("mcp_missing_subconto_axis_auto_fallback_to_composite_subconto_failed"); @@ -2290,6 +2335,12 @@ export class AddressQueryService { if (!baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items")) { baseReasons.push("confirmed_payables_exact_mode_missing_subconto_fallback_to_open_items"); } + } else if (intent.intent === "receivables_confirmed_as_of_date") { + composeIntent = "list_receivables_counterparties"; + routeExpectationIntent = "list_receivables_counterparties"; + if (!baseReasons.includes("confirmed_receivables_exact_mode_missing_subconto_fallback_to_open_items")) { + baseReasons.push("confirmed_receivables_exact_mode_missing_subconto_fallback_to_open_items"); + } } } else { if (!baseReasons.includes("mcp_missing_subconto_field_auto_fallback_failed")) { @@ -2307,9 +2358,9 @@ export class AddressQueryService { mcp.error && missingSubcontoFallbackEligible && isMissingSubcontoFieldError(mcp.error) && - !baseReasons.includes("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback") + !baseReasons.includes("confirmed_exact_mode_missing_subconto_no_heuristic_fallback") ) { - baseReasons.push("confirmed_payables_exact_mode_missing_subconto_no_heuristic_fallback"); + baseReasons.push("confirmed_exact_mode_missing_subconto_no_heuristic_fallback"); } if (mcp.error) { @@ -3176,7 +3227,12 @@ export class AddressQueryService { routeExpectationAudit: finalRouteExpectationAudit }); } - if (intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date" && factualResultSemantics.balance_confirmed !== true) { + if ( + ((intent.intent === "payables_confirmed_as_of_date" && composeIntent === "payables_confirmed_as_of_date") || + (intent.intent === "receivables_confirmed_as_of_date" && composeIntent === "receivables_confirmed_as_of_date")) && + factualResultSemantics.balance_confirmed !== true + ) { + const exactModeName = intent.intent === "payables_confirmed_as_of_date" ? "payables" : "receivables"; return buildLimitedExecutionResult({ mode, shape, @@ -3200,10 +3256,10 @@ export class AddressQueryService { 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", + reasonText: `exact ${exactModeName} 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"], + limitations: [`exact_${exactModeName}_mode_unconfirmed_output_blocked`], + reasons: [...baseReasons, `exact_${exactModeName}_mode_unconfirmed_output_blocked`], capabilityAudit, shadowRouteAudit, routeExpectationAudit: finalRouteExpectationAudit diff --git a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts index 0b88400..be86810 100644 --- a/llm_normalizer/backend/src/services/addressRecipeCatalog.ts +++ b/llm_normalizer/backend/src/services/addressRecipeCatalog.ts @@ -49,6 +49,29 @@ const PAYABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` Сумма __ORDER_DIRECTION__ `; +const RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетДт, + "" КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокДт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокДт > 0 + И (__RECEIVABLE_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; + const BANK_DOCS_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ БанкСписание.Дата КАК Период, @@ -574,6 +597,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "strict", query_template: "payables_confirmed_as_of_balance_profile" }, + { + recipe_id: "address_receivables_confirmed_as_of_date_v1", + intent: "receivables_confirmed_as_of_date", + purpose: "Build confirmed receivables snapshot as-of date from movements on accounts 62/76", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 200, + account_scope: ["62", "76"], + account_scope_mode: "strict", + query_template: "receivables_confirmed_as_of_balance_profile" + }, { recipe_id: "address_movements_receivables_v1", intent: "list_receivables_counterparties", @@ -1044,6 +1078,25 @@ export function buildAddressRecipePlan( .replaceAll("__PAYABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "76"])) .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); })() + : recipe.query_template === "receivables_confirmed_as_of_balance_profile" + ? (() => { + const asOfExpr = + (typeof filters.as_of_date === "string" && filters.as_of_date.trim().length > 0 + ? toDateTimeExpr(filters.as_of_date, true) + : null) ?? + (typeof filters.period_to === "string" && filters.period_to.trim().length > 0 + ? toDateTimeExpr(filters.period_to, true) + : null) ?? + (typeof filters.period_from === "string" && filters.period_from.trim().length > 0 + ? toDateTimeExpr(filters.period_from, true) + : null) ?? + "ТЕКУЩАЯДАТА()"; + return RECEIVABLES_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__RECEIVABLE_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["62", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() : MOVEMENTS_QUERY_TEMPLATE .replace("__LIMIT__", String(resolvedLimit)) .replace("__WHERE_CLAUSE__", (() => { diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index b57029d..02f031a 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -176,10 +176,7 @@ function formatMoneyRub(value: number): string { } function emphasizeNumericTokens(line: string): string { - if (!line) { - return line; - } - return line.replace(/(? `**${token}**`); + return line; } function parseIsoDateToken(value: string | null | undefined): { year: number; month: number; day: number } | null { @@ -700,6 +697,19 @@ function liabilityCategoryLabel(category: PayablesLiabilityCategory): string { return "прочие"; } +function receivablesCategoryLabel(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; reasons: string[]; @@ -784,6 +794,11 @@ function hasPayablesSectionPrefix(account: string | null): boolean { return section === "60" || section === "76"; } +function hasReceivablesSectionPrefix(account: string | null): boolean { + const section = extractAccountSectionCode(account); + return section === "62" || section === "76"; +} + function resolvePayablesAsOfDate(options: ComposeFactualReplyOptions): string { const explicit = normalizeIsoDateOnly(options.asOfDate); if (explicit) { @@ -998,6 +1013,126 @@ function buildPayablesConfirmedBalanceAggregate( }); } +function buildReceivablesConfirmedBalanceAggregate( + rows: ComposeStageRow[], + asOfDate: string +): PayablesConfirmedBalanceAggregate[] { + const byCounterparty = new Map< + string, + { + outstandingAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + categoryScores: Record; + reasons: Set; + contracts: Set; + documents: Set; + sourceRefs: Set; + } + >(); + 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 (hasReceivablesSectionPrefix(row.account_dt)) { + delta += absAmount; + } + if (hasReceivablesSectionPrefix(row.account_kt)) { + delta -= absAmount; + } + if (Math.abs(delta) <= 0.0000001) { + continue; + } + + const classified = classifyPayablesLiabilityCategory(row, name); + const contract = extractContractName(row); + const sourceRefs = extractPayablesSourceRefs(row, name, contract); + 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), + contracts: new Set(contract ? [contract] : []), + documents: new Set(row.registrator ? [row.registrator] : []), + sourceRefs: new Set(sourceRefs) + }); + 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); + } + if (contract) { + current.contracts.add(contract); + } + if (row.registrator) { + current.documents.add(row.registrator); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + + 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), + contracts: Array.from(item.contracts).slice(0, 2), + documents: Array.from(item.documents).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3) + })) + .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(); @@ -2462,7 +2597,7 @@ export function composeFactualReply( `Итого подтвержденный долг на ${formatDateRu(payablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`, "", "Блок 1. Статус результата", - "- Режим результата: подтвержденный срез обязательств к оплате (exact route)." + "- Результат: подтвержденный срез обязательств к оплате." ]; lines.push(""); @@ -2492,11 +2627,14 @@ export function composeFactualReply( lines.push("Блок 5. Подтвержденные позиции к оплате"); if (confirmedBalances.length > 0) { lines.push( - ...confirmedBalances.slice(0, 10).map( - (item, index) => - `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}` - ) + ...confirmedBalances.slice(0, 10).flatMap((item, index) => [ + `${index + 1}. ${item.name} | категория: ${liabilityCategoryLabel(item.category)} | остаток: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`, + "" + ]) ); + if (lines[lines.length - 1] === "") { + lines.pop(); + } } else { lines.push("- Подтвержденных открытых обязательств к оплате на дату среза не найдено."); } @@ -2512,6 +2650,86 @@ export function composeFactualReply( }; } + if (intent === "receivables_confirmed_as_of_date") { + const receivablesAsOfDate = resolveReceivablesAsOfDate(options); + const confirmedBalances = buildReceivablesConfirmedBalanceAggregate(rows, receivablesAsOfDate); + const asOfDate = normalizeIsoDateOnly(options.asOfDate); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const totalOutstandingAmount = confirmedBalances.reduce((sum, item) => sum + item.outstandingAmount, 0); + const periodScopeLine = + !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: string[] = [ + `Итого подтвержденная дебиторская задолженность на ${formatDateRu(receivablesAsOfDate)}: ${formatMoneyRub(totalOutstandingAmount)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный срез дебиторской задолженности." + ]; + + lines.push(""); + lines.push("Блок 2. Что учтено"); + lines.push(`- Дата среза: ${formatDateRu(receivablesAsOfDate)}.`); + if (periodScopeLine) { + lines.push(periodScopeLine); + } + lines.push("- Контур: дебиторская задолженность по счетам 62/76."); + if (carryoverLine) { + lines.push(carryoverLine); + } + + lines.push(""); + lines.push("Блок 3. Сводка"); + lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); + lines.push(`- Контрагентов с подтвержденным остатком к получению: ${formatNumberWithDots(confirmedBalances.length)}.`); + + lines.push(""); + lines.push("Блок 4. Категории дебиторской задолженности"); + lines.push(`- ${receivablesCategoryLabel("supplier_or_contractor")}: ${formatNumberWithDots(categoryCounts.supplier_or_contractor)}.`); + lines.push(`- ${receivablesCategoryLabel("bank_or_credit")}: ${formatNumberWithDots(categoryCounts.bank_or_credit)}.`); + lines.push(`- ${receivablesCategoryLabel("tax_or_state")}: ${formatNumberWithDots(categoryCounts.tax_or_state)}.`); + lines.push(`- ${receivablesCategoryLabel("other")}: ${formatNumberWithDots(categoryCounts.other)}.`); + + lines.push(""); + lines.push("Блок 5. Подтвержденные позиции к получению"); + if (confirmedBalances.length > 0) { + lines.push( + ...confirmedBalances.slice(0, 10).flatMap((item, index) => [ + `${index + 1}. ${item.name} | категория: ${receivablesCategoryLabel(item.category)} | остаток к получению: ${formatMoneyRub(item.outstandingAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${item.categoryReasons.length > 0 ? ` | основание: ${item.categoryReasons.join(", ")}` : ""}${formatPayablesEvidenceSuffix(item)}`, + "" + ]) + ); + if (lines[lines.length - 1] === "") { + lines.pop(); + } + } else { + lines.push("- Подтвержденной открытой дебиторской задолженности на дату среза не найдено."); + } + + return { + responseType: confirmedBalances.length > 0 ? "FACTUAL_LIST" : "FACTUAL_SUMMARY", + text: lines.map(emphasizeNumericTokens).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); diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 34ae88e..cf56227 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -442,7 +442,8 @@ function mergeFollowupFilters( if ( intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || - intent === "payables_confirmed_as_of_date" + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date" ) { const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null); const currentContract = toNonEmptyString(merged.contract); @@ -537,6 +538,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], + receivables_confirmed_as_of_date: ["as_of_date"], list_documents_by_counterparty: ["counterparty"], bank_operations_by_counterparty: ["counterparty"], list_contracts_by_counterparty: ["counterparty"], diff --git a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts index 046353c..3533eda 100644 --- a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts +++ b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts @@ -192,7 +192,8 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape if ( intent === "account_balance_snapshot" || intent === "documents_forming_balance" || - intent === "payables_confirmed_as_of_date" + intent === "payables_confirmed_as_of_date" || + intent === "receivables_confirmed_as_of_date" ) { return "balance_snapshot"; } diff --git a/llm_normalizer/backend/src/services/answerComposer.ts b/llm_normalizer/backend/src/services/answerComposer.ts index fc6b01b..a4cafc0 100644 --- a/llm_normalizer/backend/src/services/answerComposer.ts +++ b/llm_normalizer/backend/src/services/answerComposer.ts @@ -665,7 +665,7 @@ function scrubRawTechnicalRefs(value: string): string { .replace(RAW_REF_TOKEN_PATTERN, "reference") .replace(/\(\s*\[id\]\s*\)/g, "") .replace(/\[\s*id\s*\](?:\s*,\s*\[\s*id\s*\])+/g, "[id]") - .replace(/\s{2,}/g, " ") + .replace(/[ \t]{2,}/g, " ") .trim(); } @@ -681,23 +681,50 @@ function stripSyntheticPlaceholders(value: string): string { function sanitizeUserFacingReply(value: string): string { const raw = String(value ?? ""); - const hardCutMatch = raw.match(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json)\b/i); + const hardCutMatch = raw.match( + /(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json|technical_debug_payload_json)\b/i + ); const preCut = hardCutMatch ? raw.slice(0, hardCutMatch.index) : raw; const withoutDebugBlocks = preCut .replace(/###\s*debug_payload_json[\s\S]*?(?:```[\s\S]*?```|$)/gi, "") + .replace(/###\s*technical_debug_payload_json[\s\S]*?(?:```[\s\S]*?```|$)/gi, "") .replace(/###\s*technical_breakdown_json[\s\S]*?(?:```[\s\S]*?```|$)/gi, "") - .replace(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json)\b[\s\S]*$/gi, "") + .replace(/(?:^|\n)\s*#{0,6}\s*(?:debug_payload_json|technical_breakdown_json|technical_debug_payload_json)\b[\s\S]*$/gi, "") .replace(/```json[\s\S]*?```/gi, ""); const normalized = scrubRawTechnicalRefs(withoutDebugBlocks).replace(/[ \t]+\n/g, "\n"); - const cleanedLines = normalized + const preparedLines = normalized .split(/\r?\n/g) .map((line) => stripSyntheticPlaceholders(line)) .map((line) => stripMojibakeFragments(line)) - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .filter((line) => !/^(?:-\s*)?(?:action|clarify|open|limit|note):\s*$/i.test(line)) - .filter((line) => !hasUserFacingLeakage(line)) - .filter((line) => !looksLikeMojibake(line)); + .map((line) => line.trim()); + const cleanedLines: string[] = []; + let previousWasBlank = false; + for (const line of preparedLines) { + if (line.length === 0) { + if (!previousWasBlank && cleanedLines.length > 0) { + cleanedLines.push(""); + } + previousWasBlank = true; + continue; + } + if (/^(?:-\s*)?(?:action|clarify|open|limit|note):\s*$/i.test(line)) { + continue; + } + if (hasUserFacingLeakage(line)) { + continue; + } + if (looksLikeMojibake(line)) { + continue; + } + cleanedLines.push(line); + previousWasBlank = false; + } + while (cleanedLines.length > 0 && cleanedLines[0] === "") { + cleanedLines.shift(); + } + while (cleanedLines.length > 0 && cleanedLines[cleanedLines.length - 1] === "") { + cleanedLines.pop(); + } const cleaned = cleanedLines.join("\n").replace(/\n{3,}/g, "\n\n").trim(); return cleaned || "Available data requires clarification for a reliable user-facing answer."; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 9dde126..c920b86 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3672,7 +3672,10 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "supplier_payouts_profile", "list_open_contracts", "open_items_by_counterparty_or_contract", + "list_payables_counterparties", + "list_receivables_counterparties", "payables_confirmed_as_of_date", + "receivables_confirmed_as_of_date", "list_documents_by_contract", "bank_operations_by_contract", "list_documents_by_counterparty", diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index c48247c..58ebfc2 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -14,6 +14,7 @@ export type AddressIntent = | "list_open_contracts" | "list_payables_counterparties" | "payables_confirmed_as_of_date" + | "receivables_confirmed_as_of_date" | "list_receivables_counterparties" | "account_balance_snapshot" | "open_items_by_counterparty_or_contract" @@ -130,7 +131,8 @@ export interface AddressRecipeDefinition { | "contract_value_profile" | "contracts_by_counterparty_profile" | "vat_payable_forecast_profile" - | "payables_confirmed_as_of_balance_profile"; + | "payables_confirmed_as_of_balance_profile" + | "receivables_confirmed_as_of_balance_profile"; required_filters: Array; optional_filters: Array; default_limit: number; diff --git a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts index 2ecc80a..94bca47 100644 --- a/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts +++ b/llm_normalizer/backend/tests/addressCapabilityPolicy.test.ts @@ -15,6 +15,15 @@ describe("address capability policy", () => { expect(isCapabilityRouteBlocked(decision)).toBe(false); }); + it("maps confirmed receivables intent to compute exact capability", () => { + const decision = resolveAddressCapabilityRouteDecision("receivables_confirmed_as_of_date"); + expect(decision.capability_id).toBe("confirmed_receivables_as_of_date"); + expect(decision.capability_layer).toBe("compute"); + expect(decision.capability_route_mode).toBe("exact"); + expect(decision.capability_route_enabled).toBe(true); + expect(isCapabilityRouteBlocked(decision)).toBe(false); + }); + it("maps document drilldown intent to navigation capability", () => { const decision = resolveAddressCapabilityRouteDecision("list_documents_by_contract"); expect(decision.capability_id).toBe("documents_drilldown"); diff --git a/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts new file mode 100644 index 0000000..18fcd34 --- /dev/null +++ b/llm_normalizer/backend/tests/addressReceivablesConfirmedRoute.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; +import { extractAddressFilters } from "../src/services/addressFilterExtractor"; +import { buildAddressRecipePlan, selectAddressRecipe } from "../src/services/addressRecipeCatalog"; +import { resolveAddressCapabilityRouteDecision } from "../src/services/addressCapabilityPolicy"; +import { evaluateAddressRouteExpectation } from "../src/services/addressRouteExpectations"; +import { AddressQueryService } from "../src/services/addressQueryService"; + +describe("receivables confirmed as-of route", () => { + it("routes 'кто нам должен' wording into exact receivables intent", () => { + const result = resolveAddressIntent("кто нам должен на июль 2020"); + expect(result.intent).toBe("receivables_confirmed_as_of_date"); + expect(result.reasons).toContain("receivables_debt_lifecycle_signal_detected"); + }); + + it("selects confirmed receivables recipe and builds balance query", () => { + const filters = extractAddressFilters("кто нам должен на июль 2020", "receivables_confirmed_as_of_date").extracted_filters; + const selected = selectAddressRecipe("receivables_confirmed_as_of_date", filters); + expect(selected.selected_recipe?.recipe_id).toBe("address_receivables_confirmed_as_of_date_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, filters); + expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки"); + expect(plan.query).toContain("СуммаРазвернутыйОстатокДт"); + expect(plan.query).toContain("Остатки.Счет"); + expect(plan.query).toContain("62"); + expect(plan.query).toContain("76"); + }); + + it("exposes compute exact capability and route expectation for exact receivables", () => { + const capability = resolveAddressCapabilityRouteDecision("receivables_confirmed_as_of_date"); + expect(capability.capability_id).toBe("confirmed_receivables_as_of_date"); + expect(capability.capability_layer).toBe("compute"); + expect(capability.capability_route_mode).toBe("exact"); + + const expectation = evaluateAddressRouteExpectation({ + intent: "receivables_confirmed_as_of_date", + selectedRecipe: "address_receivables_confirmed_as_of_date_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(expectation.status).toBe("matched"); + }); + + it("uses exact receivables route in runtime for monthly as-of query", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("кто нам должен на июль 2020"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("receivables_confirmed_as_of_date"); + expect(result?.debug.selected_recipe).toBe("address_receivables_confirmed_as_of_date_v1"); + expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); + expect(result?.debug.route_expectation_status).toBe("matched"); + expect(result?.debug.limited_reason_category).not.toBe("unsupported"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts index ad3fe7e..470c33a 100644 --- a/llm_normalizer/backend/tests/addressRouteExpectations.test.ts +++ b/llm_normalizer/backend/tests/addressRouteExpectations.test.ts @@ -23,6 +23,17 @@ describe("address route expectations contract", () => { expect(audit.reason).toBe("route_expectation_matched"); }); + it("matches expected recipe and result mode for exact receivables route", () => { + const audit = evaluateAddressRouteExpectation({ + intent: "receivables_confirmed_as_of_date", + selectedRecipe: "address_receivables_confirmed_as_of_date_v1", + requestedResultMode: "confirmed_balance", + resultMode: "confirmed_balance" + }); + expect(audit.status).toBe("matched"); + expect(audit.reason).toBe("route_expectation_matched"); + }); + it("detects selected recipe mismatch", () => { const audit = evaluateAddressRouteExpectation({ intent: "payables_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/answerComposerFormatting.test.ts b/llm_normalizer/backend/tests/answerComposerFormatting.test.ts new file mode 100644 index 0000000..9a6dc2d --- /dev/null +++ b/llm_normalizer/backend/tests/answerComposerFormatting.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeAssistantReplyForUserFacing } from "../src/services/answerComposer"; + +describe("answer composer user-facing formatting", () => { + it("keeps intentional blank lines between semantic blocks", () => { + const source = [ + "Блок 1. Статус результата", + "- Дата среза: 31.07.2020.", + "", + "Блок 2. Что учтено", + "- Контур: дебиторка 62/76.", + "", + "Блок 3. Сводка", + "- Строк: 24." + ].join("\n"); + + const sanitized = sanitizeAssistantReplyForUserFacing(source); + + expect(sanitized).toContain("Блок 1. Статус результата"); + expect(sanitized).toContain("\n\nБлок 2. Что учтено\n"); + expect(sanitized).toContain("\n\nБлок 3. Сводка\n"); + }); + + it("removes debug payload and still preserves block separators", () => { + const source = [ + "Итого подтвержденная дебиторка: 1.000,00 ₽.", + "", + "Блок 1. Статус результата", + "- Exact route.", + "", + "Блок 2. Что учтено", + "- Дата среза: 31.07.2020.", + "", + "### technical_debug_payload_json", + "```json", + "{\"trace_id\":\"t-1\"}", + "```" + ].join("\n"); + + const sanitized = sanitizeAssistantReplyForUserFacing(source); + + expect(sanitized).toContain("Блок 1. Статус результата"); + expect(sanitized).toContain("\n\nБлок 2. Что учтено\n"); + expect(sanitized).not.toContain("technical_debug_payload_json"); + expect(sanitized).not.toContain("trace_id"); + }); +}); +