From ef4222d159850feb6920873af6bf867aeb84ba18 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 13 Apr 2026 18:15:06 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=9E=D0=9C=D0=95=D0=9D=D0=AB=20-=20?= =?UTF-8?q?=D0=92=D0=9E=D0=9F=D0=A0=D0=9E=D0=A1=D0=AB=20-=20=D0=9E=D0=A2?= =?UTF-8?q?=D0=9A=D0=A0=D0=AB=D0=A2=D0=AB=D0=95=20=D0=94=D0=9E=D0=93=D0=9E?= =?UTF-8?q?=D0=92=D0=9E=D0=A0=D0=90=20-=20=D0=A3=D1=81=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20business-view=20exact=20=D0=BE=D1=82=D0=BA=D1=80?= =?UTF-8?q?=D1=8B=D1=82=D1=8B=D1=85=20=D0=B4=D0=BE=D0=B3=D0=BE=D0=B2=D0=BE?= =?UTF-8?q?=D1=80=D0=BE=D0=B2:=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=82=D0=B8=D0=BF=D0=B0=D0=BC=20=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D0=BA=D0=BE=D0=B2=20=D0=B8=20quality=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/TECH/address_route_expectations_v1.json | 12 + .../src/services/addressCapabilityPolicy.ts | 12 + .../src/services/addressFilterExtractor.ts | 8 +- .../src/services/addressIntentResolver.ts | 4 +- .../src/services/addressQueryService.ts | 14 + .../src/services/addressRecipeCatalog.ts | 73 +++ .../services/address_runtime/composeStage.ts | 619 +++++++++++++++++- .../address_runtime/decomposeStage.ts | 22 + .../address_runtime/predecomposeContract.ts | 1 + .../backend/src/services/assistantService.ts | 1 + .../backend/src/types/addressQuery.ts | 2 + .../tests/addressQueryRuntimeM23.test.ts | 185 +++++- .../tests/assistantLivingRouter.test.ts | 4 +- ...sistantWave17RunRegression20260411.test.ts | 76 ++- 14 files changed, 984 insertions(+), 49 deletions(-) diff --git a/docs/TECH/address_route_expectations_v1.json b/docs/TECH/address_route_expectations_v1.json index 7089cf2..802bd6b 100644 --- a/docs/TECH/address_route_expectations_v1.json +++ b/docs/TECH/address_route_expectations_v1.json @@ -42,6 +42,18 @@ "expected_requested_result_modes": ["heuristic_candidates", "confirmed_balance"], "expected_result_modes": ["heuristic_candidates", "confirmed_balance"] }, + { + "intent": "open_contracts_confirmed_as_of_date", + "expected_selected_recipes": ["address_open_contracts_confirmed_as_of_date_v1"], + "expected_requested_result_modes": ["confirmed_balance"], + "expected_result_modes": ["confirmed_balance"] + }, + { + "intent": "list_open_contracts", + "expected_selected_recipes": ["address_open_contracts_candidates_v1", "address_open_items_by_party_or_contract_v1"], + "expected_requested_result_modes": ["heuristic_candidates"], + "expected_result_modes": ["heuristic_candidates"] + }, { "intent": "account_balance_snapshot", "expected_selected_recipes": ["address_open_items_by_party_or_contract_v1"], diff --git a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts index 9e462b5..15059cb 100644 --- a/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts +++ b/llm_normalizer/backend/src/services/addressCapabilityPolicy.ts @@ -26,6 +26,7 @@ export interface AddressCapabilityRouteDecision { const COMPUTE_EXACT_INTENTS = new Set([ "account_balance_snapshot", "documents_forming_balance", + "open_contracts_confirmed_as_of_date", "payables_confirmed_as_of_date", "receivables_confirmed_as_of_date", "vat_payable_confirmed_as_of_date", @@ -64,6 +65,9 @@ function defaultCapabilityId(intent: AddressIntent): string { if (intent === "receivables_confirmed_as_of_date") { return "confirmed_receivables_as_of_date"; } + if (intent === "open_contracts_confirmed_as_of_date") { + return "confirmed_open_contracts_as_of_date"; + } if (intent === "vat_payable_confirmed_as_of_date") { return "confirmed_vat_payable_as_of_date"; } @@ -106,6 +110,14 @@ function resolveCapabilityEnabled(intent: AddressIntent): { enabled: boolean; re : "receivables_confirmed_route_disabled_by_flag" }; } + if (intent === "open_contracts_confirmed_as_of_date") { + return { + enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, + reason: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1 + ? "open_contracts_confirmed_route_enabled" + : "open_contracts_confirmed_route_disabled_by_flag" + }; + } if (intent === "vat_payable_confirmed_as_of_date") { return { enabled: FEATURE_ASSISTANT_ROUTE_BALANCE_EXACT_V1, diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 7141340..52703d4 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -933,6 +933,9 @@ function requiredFiltersByIntent(intent: AddressIntent): Array 0 + И (__OPEN_CONTRACT_ACCOUNTS_MATCH__) +ОБЪЕДИНИТЬ ВСЕ +ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ + __AS_OF_EXPR__ КАК Период, + "Остатки на дату" КАК Регистратор, + "" КАК СчетДт, + ПРЕДСТАВЛЕНИЕ(Остатки.Счет) КАК СчетКт, + Остатки.СуммаРазвернутыйОстатокКт КАК Сумма, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоДт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоДт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоДт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто1) КАК СубконтоКт1, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто2) КАК СубконтоКт2, + ПРЕДСТАВЛЕНИЕ(Остатки.Субконто3) КАК СубконтоКт3, + ПРЕДСТАВЛЕНИЕ(Остатки.Организация) КАК Организация +ИЗ + РегистрБухгалтерии.Хозрасчетный.Остатки(__AS_OF_EXPR__, , , ) КАК Остатки +ГДЕ + Остатки.СуммаРазвернутыйОстатокКт > 0 + И (__OPEN_CONTRACT_ACCOUNTS_MATCH__) +УПОРЯДОЧИТЬ ПО + Сумма __ORDER_DIRECTION__ +`; + const VAT_PAYABLE_CONFIRMED_AS_OF_QUERY_TEMPLATE = ` ВЫБРАТЬ ПЕРВЫЕ __LIMIT__ __AS_OF_EXPR__ КАК Период, @@ -634,6 +676,17 @@ const BASE_RECIPES: AddressRecipeDefinition[] = [ account_scope_mode: "preferred", query_template: "vat_liability_confirmed_tax_period_profile" }, + { + recipe_id: "address_open_contracts_confirmed_as_of_date_v1", + intent: "open_contracts_confirmed_as_of_date", + purpose: "Build confirmed snapshot of contracts with open settlements as-of date from balances on accounts 60/62/76", + required_filters: ["as_of_date"], + optional_filters: ["period_from", "period_to", "organization", "counterparty", "contract", "limit", "sort"], + default_limit: 400, + account_scope: ["60", "62", "76"], + account_scope_mode: "strict", + query_template: "open_contracts_confirmed_as_of_balance_profile" + }, { recipe_id: "address_contracts_by_counterparty_v1", intent: "list_contracts_by_counterparty", @@ -982,6 +1035,7 @@ function maxLimitForIntent(intent: AddressIntent): number { intent === "contract_usage_and_value" || intent === "vat_payable_forecast" || intent === "vat_liability_confirmed_for_tax_period" || + intent === "open_contracts_confirmed_as_of_date" || intent === "list_contracts_by_counterparty" || intent === "list_documents_by_counterparty" || intent === "bank_operations_by_counterparty" || @@ -1156,6 +1210,25 @@ export function buildAddressRecipePlan( })() : recipe.query_template === "contracts_by_counterparty_profile" ? CONTRACTS_BY_COUNTERPARTY_QUERY_TEMPLATE.replaceAll("__LIMIT__", String(resolvedLimit)) + : recipe.query_template === "open_contracts_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 OPEN_CONTRACTS_CONFIRMED_AS_OF_QUERY_TEMPLATE + .replaceAll("__LIMIT__", String(resolvedLimit)) + .replaceAll("__AS_OF_EXPR__", asOfExpr) + .replaceAll("__OPEN_CONTRACT_ACCOUNTS_MATCH__", buildAccountPrefixPredicate("Остатки.Счет", ["60", "62", "76"])) + .replaceAll("__ORDER_DIRECTION__", resolveOrderDirection(filters.sort)); + })() : recipe.query_template === "payables_confirmed_as_of_balance_profile" ? (() => { const asOfExpr = diff --git a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts index ef8043b..0b32872 100644 --- a/llm_normalizer/backend/src/services/address_runtime/composeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/composeStage.ts @@ -758,6 +758,40 @@ interface CounterpartyRiskAggregate { lastPeriod: string | null; } +interface OpenContractRiskAggregate { + contract: string; + totalAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + counterparties: string[]; + sourceRefs: string[]; + category: "commercial" | "financial" | "uncertain"; + qualityFlags: string[]; +} + +type OpenContractSettlementKind = + | "receivable" + | "payable" + | "advance_issued" + | "advance_received" + | "other_receivable" + | "other_payable"; + +interface OpenContractConfirmedAggregate { + contract: string; + counterparty: string | null; + confirmedAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + category: "commercial" | "financial" | "uncertain"; + settlementKind: OpenContractSettlementKind; + accounts: string[]; + sourceRefs: string[]; + qualityFlags: string[]; +} + type PayablesLiabilityCategory = "supplier_or_contractor" | "bank_or_credit" | "tax_or_state" | "other"; interface PayablesCounterpartyRiskAggregate extends CounterpartyRiskAggregate { @@ -1553,6 +1587,380 @@ export function contractCandidatesFromRows(rows: ComposeStageRow[]): string[] { return uniqueStrings(candidates); } +function isFinancialContractLike(value: string): boolean { + return /(?:кредит|кред\.?|loan|overdraft|овердрафт|лизинг|leasing|займ|guarantee|гарант|банк|bank)/iu.test(value); +} + +function hasStrongContractIdentitySignal(value: string): boolean { + return /(?:договор|contract|дог\.|№|\d{1,4}[\\/.-]\d{1,4}|\d{1,4}\sот\s\d{2}\.\d{2}\.\d{2,4}|[A-ZА-Я]{1,6}-\d+)/iu.test( + value + ); +} + +function isLikelyOrganizationName(value: string): boolean { + return /(?:(?:^|[\s"'«»„“()\\\/])(?:ооо|ао|пао|зао|оао|ип|гку)(?=$|[\s"'«»„“()\\\/.,;:]))|комитет|департамент|министерств|служб|управлени|казенн|администрац|bank|банк/iu.test( + value + ); +} + +function isContractLikeCounterparty(value: string): boolean { + return /(?:договор|дог[-.\s]?р|contract|кредитн|loan|овердрафт|лизинг|\b№\b)/iu.test(value); +} + +function isLowQualityContractIdentity(contract: string, counterparty: string | null): boolean { + const normalizedContract = normalizeEntityToken(contract); + if (!normalizedContract || normalizedContract.length < 3) { + return true; + } + if (/^(?:0|<пусто>|пустая ссылка)$/iu.test(contract.trim())) { + return true; + } + if (counterparty && normalizeEntityToken(counterparty) === normalizedContract) { + return true; + } + if (!hasStrongContractIdentitySignal(contract) && isLikelyOrganizationName(contract)) { + return true; + } + if (!hasStrongContractIdentitySignal(contract) && /^[A-ZА-Я]{2,6}$/u.test(contract.trim())) { + return true; + } + return false; +} + +function isLowQualityCounterpartyForContract(counterparty: string | null, contract: string): boolean { + if (!counterparty) { + return true; + } + const normalizedCounterparty = normalizeEntityToken(counterparty); + const normalizedContract = normalizeEntityToken(contract); + if (!normalizedCounterparty) { + return true; + } + if (normalizedCounterparty === normalizedContract) { + return true; + } + if (isContractLikeCounterparty(counterparty)) { + return true; + } + return normalizedCounterparty.length < 3; +} + +function normalizeDisplayAccountToken(value: string | null): string | null { + const normalized = String(value ?? "").trim(); + if (!normalized || /^(?:0|<пусто>|пустая ссылка|-)$/iu.test(normalized)) { + return null; + } + return normalized; +} + +function classifyOpenContractCategory( + contract: string, + counterparties: string[], + qualityFlags: string[] +): "commercial" | "financial" | "uncertain" { + if (isFinancialContractLike(contract)) { + return "financial"; + } + if (counterparties.some((item) => isFinancialContractLike(item))) { + return "financial"; + } + if ( + qualityFlags.includes("counterparty_not_reliably_resolved") || + qualityFlags.includes("contract_identity_not_reliable") || + qualityFlags.includes("contract_identity_looks_like_counterparty") || + qualityFlags.includes("multiple_counterparties_for_contract") + ) { + return "uncertain"; + } + if (counterparties.length === 0) { + return "uncertain"; + } + return "commercial"; +} + +function classifyOpenContractSettlementKind(row: ComposeStageRow): OpenContractSettlementKind | null { + const dt = extractAccountSectionCode(row.account_dt); + const kt = extractAccountSectionCode(row.account_kt); + if (dt === "62") { + return "receivable"; + } + if (kt === "60") { + return "payable"; + } + if (dt === "60") { + return "advance_issued"; + } + if (kt === "62") { + return "advance_received"; + } + if (dt === "76") { + return "other_receivable"; + } + if (kt === "76") { + return "other_payable"; + } + return null; +} + +function openContractSettlementKindLabel(kind: OpenContractSettlementKind): string { + if (kind === "receivable") { + return "дебиторская задолженность"; + } + if (kind === "payable") { + return "кредиторская задолженность"; + } + if (kind === "advance_issued") { + return "аванс выданный"; + } + if (kind === "advance_received") { + return "аванс полученный"; + } + if (kind === "other_receivable") { + return "прочий дебетовый остаток"; + } + return "прочий кредитовый остаток"; +} + +function summarizeOpenContractSpecialReason(item: { category: "commercial" | "financial" | "uncertain"; qualityFlags: string[] }): string { + if (item.category === "financial") { + return "похоже на финансовый договор (кредит/банк)"; + } + if (item.qualityFlags.includes("contract_identity_looks_like_counterparty")) { + return "в поле договора похоже попал контрагент или чужая аналитика"; + } + if (item.qualityFlags.includes("contract_identity_not_reliable")) { + return "договор не похож на устойчивый договорный реквизит"; + } + if (item.qualityFlags.includes("multiple_counterparties_for_contract")) { + return "по одному договору найдено несколько контрагентов"; + } + if (item.qualityFlags.includes("counterparty_not_reliably_resolved")) { + return "не удалось надежно определить контрагента"; + } + return "требуется ручная проверка карточки договора"; +} + +function buildOpenContractConfirmedBalanceAggregate( + rows: ComposeStageRow[], + asOfDate: string +): OpenContractConfirmedAggregate[] { + const byContract = new Map< + string, + { + contract: string; + counterparty: string | null; + confirmedAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + counterparties: Set; + settlementKind: OpenContractSettlementKind; + accounts: Set; + sourceRefs: Set; + qualityFlags: Set; + } + >(); + const asOfTimestamp = toUtcDayTimestamp(asOfDate); + + for (const row of rows) { + const rowTimestamp = toUtcDayTimestamp(row.period); + if (asOfTimestamp !== null && rowTimestamp !== null && rowTimestamp > asOfTimestamp) { + continue; + } + const contract = extractContractName(row); + if (!contract) { + continue; + } + const amount = row.amount; + if (typeof amount !== "number" || !Number.isFinite(amount)) { + continue; + } + const settlementKind = classifyOpenContractSettlementKind(row); + if (!settlementKind) { + continue; + } + const counterpartyCandidate = extractCounterpartyName(row); + const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate; + const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract); + const accountToken = normalizeDisplayAccountToken(row.account_dt) ?? normalizeDisplayAccountToken(row.account_kt); + const absAmount = Math.abs(amount); + const contractKey = normalizeEntityToken(contract); + const counterpartyKey = counterparty ? normalizeEntityToken(counterparty) : "__unknown_counterparty__"; + const aggregateKey = `${contractKey}::${counterpartyKey}::${settlementKind}`; + const current = byContract.get(aggregateKey); + if (!current) { + const qualityFlags = new Set(); + if (!counterparty) { + qualityFlags.add("counterparty_not_reliably_resolved"); + } + if (isLowQualityContractIdentity(contract, counterparty)) { + qualityFlags.add("contract_identity_not_reliable"); + } + if (counterparty && normalizeEntityToken(counterparty) === normalizeEntityToken(contract)) { + qualityFlags.add("contract_identity_looks_like_counterparty"); + } + const counterparties = new Set(); + if (counterparty) { + counterparties.add(counterparty); + } + const accounts = new Set(); + if (accountToken) { + accounts.add(accountToken); + } + byContract.set(aggregateKey, { + contract, + counterparty, + confirmedAmount: absAmount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + counterparties, + settlementKind, + accounts, + sourceRefs: new Set(sourceRefs), + qualityFlags + }); + continue; + } + + current.confirmedAmount += absAmount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (counterparty) { + current.counterparty = current.counterparty ?? counterparty; + current.counterparties.add(counterparty); + } else { + current.qualityFlags.add("counterparty_not_reliably_resolved"); + } + if (accountToken) { + current.accounts.add(accountToken); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + + return Array.from(byContract.values()) + .map((item) => { + const counterparties = Array.from(item.counterparties); + if (counterparties.length > 1) { + item.qualityFlags.add("multiple_counterparties_for_contract"); + } + return { + contract: item.contract, + counterparty: item.counterparty, + confirmedAmount: item.confirmedAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + category: classifyOpenContractCategory(item.contract, counterparties, Array.from(item.qualityFlags)), + settlementKind: item.settlementKind, + accounts: Array.from(item.accounts).slice(0, 3), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3), + qualityFlags: Array.from(item.qualityFlags) + }; + }) + .filter((item) => item.confirmedAmount > 0.005) + .sort((left, right) => { + if (right.confirmedAmount !== left.confirmedAmount) { + return right.confirmedAmount - left.confirmedAmount; + } + if (right.operations !== left.operations) { + return right.operations - left.operations; + } + return left.contract.localeCompare(right.contract); + }); +} + +function buildOpenContractRiskAggregate(rows: ComposeStageRow[]): OpenContractRiskAggregate[] { + const byContract = new Map< + string, + { + contract: string; + totalAmount: number; + operations: number; + firstPeriod: string | null; + lastPeriod: string | null; + counterparties: Set; + sourceRefs: Set; + qualityFlags: Set; + } + >(); + + for (const row of rows) { + const contract = extractContractName(row); + if (!contract) { + continue; + } + const amountRaw = row.amount ?? 0; + const amount = Number.isFinite(amountRaw) ? Math.abs(amountRaw) : 0; + const current = byContract.get(contract); + const counterpartyCandidate = extractCounterpartyName(row); + const counterparty = isLowQualityCounterpartyForContract(counterpartyCandidate, contract) ? null : counterpartyCandidate; + const sourceRefs = extractPayablesSourceRefs(row, counterparty ?? contract, contract); + if (!current) { + const qualityFlags = new Set(); + if (!counterparty) { + qualityFlags.add("counterparty_not_reliably_resolved"); + } + byContract.set(contract, { + contract, + totalAmount: amount, + operations: 1, + firstPeriod: row.period, + lastPeriod: row.period, + counterparties: new Set(counterparty ? [counterparty] : []), + sourceRefs: new Set(sourceRefs), + qualityFlags + }); + continue; + } + current.totalAmount += amount; + current.operations += 1; + if ((row.period ?? "") < (current.firstPeriod ?? "")) { + current.firstPeriod = row.period; + } + if ((row.period ?? "") > (current.lastPeriod ?? "")) { + current.lastPeriod = row.period; + } + if (counterparty) { + current.counterparties.add(counterparty); + } else { + current.qualityFlags.add("counterparty_not_reliably_resolved"); + } + for (const ref of sourceRefs) { + current.sourceRefs.add(ref); + } + } + + return Array.from(byContract.values()) + .map((item) => ({ + contract: item.contract, + totalAmount: item.totalAmount, + operations: item.operations, + firstPeriod: item.firstPeriod, + lastPeriod: item.lastPeriod, + counterparties: Array.from(item.counterparties).slice(0, 2), + sourceRefs: Array.from(item.sourceRefs).slice(0, 3), + category: classifyOpenContractCategory(item.contract, Array.from(item.counterparties), Array.from(item.qualityFlags)), + qualityFlags: Array.from(item.qualityFlags) + })) + .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.contract.localeCompare(right.contract); + }); +} + export function composeFactualReply( intent: AddressIntent, rows: ComposeStageRow[], @@ -2904,34 +3312,217 @@ export function composeFactualReply( }; } - if (intent === "list_open_contracts") { - const contracts = contractCandidatesFromRows(rows); - const counterparties = buildCounterpartyRiskAggregate(rows); - const lines = [ - "Проверил потенциальные разрывы во взаиморасчетах (платежи без закрытия и документы без оплат).", - `Строк движения: ${rows.length}.`, - `Договорных кандидатов: ${contracts.length}.` + if (intent === "open_contracts_confirmed_as_of_date") { + const asOfDate = resolvePayablesAsOfDate(options); + const confirmedContracts = buildOpenContractConfirmedBalanceAggregate(rows, asOfDate); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const commercialContracts = confirmedContracts.filter((item) => item.category === "commercial"); + const specialContracts = confirmedContracts.filter((item) => item.category !== "commercial"); + const uniqueContracts = uniqueStrings(confirmedContracts.map((item) => item.contract)); + const commercialReceivables = commercialContracts.filter((item) => item.settlementKind === "receivable"); + const commercialPayables = commercialContracts.filter((item) => item.settlementKind === "payable"); + const commercialAdvances = commercialContracts.filter( + (item) => item.settlementKind === "advance_issued" || item.settlementKind === "advance_received" + ); + const commercialOther = commercialContracts.filter( + (item) => item.settlementKind === "other_receivable" || item.settlementKind === "other_payable" + ); + const sumConfirmedAmount = (items: OpenContractConfirmedAggregate[]): number => + items.reduce((sum, item) => sum + item.confirmedAmount, 0); + const commercialTotal = sumConfirmedAmount(commercialContracts); + const specialTotal = sumConfirmedAmount(specialContracts); + const periodScopeLine = + !options.asOfDate && (periodFrom || periodTo) + ? `- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.` + : null; + const renderConfirmedContractLines = ( + items: OpenContractConfirmedAggregate[], + includeSpecialReason: boolean + ): string[] => + items.slice(0, 12).map((item, index) => { + const counterpartyLabel = item.counterparty ?? "контрагент не определен"; + const accountsLabel = item.accounts.length > 0 ? ` | счета: ${item.accounts.join("; ")}` : ""; + const evidenceLabel = + item.sourceRefs.length > 0 ? ` | основное основание: ${item.sourceRefs[0]}` : ""; + const refsLabel = + item.sourceRefs.length > 1 ? ` | source refs: ${item.sourceRefs.slice(1, 3).join("; ")}` : ""; + const specialReasonLabel = includeSpecialReason + ? ` | причина вынесения: ${summarizeOpenContractSpecialReason(item)}` + : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartyLabel} | подтвержденный открытый остаток: ${formatMoneyRub(item.confirmedAmount)} | тип остатка: ${openContractSettlementKindLabel(item.settlementKind)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}${accountsLabel}${evidenceLabel}${refsLabel}${specialReasonLabel}`; + }); + + const lines: string[] = [ + `Собран подтвержденный срез открытых договоров на ${formatDateRu(asOfDate)}.`, + `Коммерческие договорные позиции: ${formatNumberWithDots(commercialContracts.length)} на ${formatMoneyRub(commercialTotal)}.`, + `Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`, + "", + "Блок 1. Статус результата", + "- Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату.", + "- База ответа: остатки по счетам 60/62/76 с договорной аналитикой, без эвристического shortlist.", + "- Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка." ]; - if (contracts.length > 0) { - lines.push(...contracts.slice(0, 8).map((item, index) => `${index + 1}. ${item}`)); + + lines.push(""); + lines.push("Блок 2. Что учтено"); + lines.push(`- Дата среза: ${formatDateRu(asOfDate)}.`); + if (periodScopeLine) { + lines.push(periodScopeLine); + } + lines.push("- Дефолтная бизнес-дефиниция: открыт договор, по которому на дату есть ненулевой остаток взаиморасчетов."); + lines.push("- Контур: остатки по счетам 60/62/76."); + lines.push("- Смешанные экономические смыслы не склеиваются: дебиторка, кредиторка, авансы и прочие остатки показаны раздельно."); + + lines.push(""); + lines.push("Блок 3. Сводка"); + lines.push(`- Строк в выборке: ${formatNumberWithDots(rows.length)}.`); + lines.push(`- Уникальных договоров: ${formatNumberWithDots(uniqueContracts.length)}.`); + lines.push(`- Подтвержденных договорных позиций: ${formatNumberWithDots(confirmedContracts.length)}.`); + lines.push( + `- Коммерческая дебиторка: ${formatNumberWithDots(commercialReceivables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialReceivables))}.` + ); + lines.push( + `- Коммерческая кредиторка: ${formatNumberWithDots(commercialPayables.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialPayables))}.` + ); + lines.push( + `- Коммерческие авансы: ${formatNumberWithDots(commercialAdvances.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialAdvances))}.` + ); + lines.push( + `- Прочие расчеты по 76: ${formatNumberWithDots(commercialOther.length)} на ${formatMoneyRub(sumConfirmedAmount(commercialOther))}.` + ); + lines.push(`- Финансовые/спорные позиции: ${formatNumberWithDots(specialContracts.length)} на ${formatMoneyRub(specialTotal)}.`); + + if (commercialReceivables.length > 0) { + lines.push(""); + lines.push("Блок 4. Коммерческие договоры с дебиторской задолженностью"); + lines.push(...renderConfirmedContractLines(commercialReceivables, false)); + } + + if (commercialPayables.length > 0) { + lines.push(""); + lines.push("Блок 5. Коммерческие договоры с кредиторской задолженностью"); + lines.push(...renderConfirmedContractLines(commercialPayables, false)); + } + + if (commercialAdvances.length > 0) { + lines.push(""); + lines.push("Блок 6. Коммерческие авансы"); + lines.push(...renderConfirmedContractLines(commercialAdvances, false)); + } + + if (commercialOther.length > 0) { + lines.push(""); + lines.push("Блок 7. Прочие расчеты по 76"); + lines.push(...renderConfirmedContractLines(commercialOther, false)); + } + + if (specialContracts.length > 0) { + lines.push(""); + lines.push("Блок 8. Финансовые/спорные позиции"); + lines.push(...renderConfirmedContractLines(specialContracts, true)); + } + + if (confirmedContracts.length === 0) { + lines.push(""); + lines.push("Блок 4. Подтвержденные позиции"); + lines.push("- На дату среза подтвержденные договоры с открытыми взаиморасчетами не найдены."); + } + + return { + responseType: "FACTUAL_LIST", + text: joinLines(lines), + semantics: { + result_mode: "confirmed_balance", + evidence_strength: "strong", + balance_confirmed: true + } + }; + } + + if (intent === "list_open_contracts") { + const contracts = buildOpenContractRiskAggregate(rows); + const counterparties = buildCounterpartyRiskAggregate(rows); + const asOfDate = normalizeIsoDateOnly(options.asOfDate ?? options.periodTo ?? options.periodFrom); + const periodFrom = normalizeIsoDateOnly(options.periodFrom); + const periodTo = normalizeIsoDateOnly(options.periodTo); + const commercialContracts = contracts.filter((item) => item.category === "commercial"); + const specialContracts = contracts.filter((item) => item.category !== "commercial"); + const commercialTotal = commercialContracts.reduce((sum, item) => sum + item.totalAmount, 0); + const lines: string[] = [ + `Итого по предварительному срезу открытых договоров${asOfDate ? ` на ${formatDateRu(asOfDate)}` : ""}: ${formatNumberWithDots(commercialContracts.length)} коммерческих договоров на ${formatMoneyRub(commercialTotal)}.`, + "", + "Блок 1. Статус результата", + "- Результат: предварительный список договоров с возможными незакрытыми расчетами.", + "- Перед финансовым решением нужна сверка карточек договоров и взаиморасчетов в 1С.", + "", + "Блок 2. Что учтено", + ...(asOfDate + ? [`- Дата среза: ${formatDateRu(asOfDate)}.`] + : periodFrom || periodTo + ? [`- Период анализа: ${formatDateRu(periodFrom ?? "...")}..${formatDateRu(periodTo ?? "...")}.`] + : []), + "- Контур: движения по счетам 60/62/76 и договорная аналитика.", + "", + "Блок 3. Сводка", + `- Строк в выборке: ${formatNumberWithDots(rows.length)}.`, + `- Договоров-кандидатов всего: ${formatNumberWithDots(contracts.length)}.`, + `- Основной список (коммерческие): ${formatNumberWithDots(commercialContracts.length)}.`, + `- Вынесено в финансовые/спорные: ${formatNumberWithDots(specialContracts.length)}.` + ]; + if (commercialContracts.length > 0) { + lines.push(""); + lines.push("Блок 4. Основной список (коммерческие договоры)"); + lines.push( + ...commercialContracts.slice(0, 10).map((item, index) => { + const counterpartiesLabel = + item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен"; + const sourceRefsSuffix = + item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма возможного открытого остатка: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""} | почему в списке: есть признаки незакрытых расчетов на дату${sourceRefsSuffix}`; + }) + ); + if (specialContracts.length > 0) { + lines.push(""); + lines.push("Блок 5. Финансовые/спорные позиции (вынесены отдельно)"); + lines.push( + ...specialContracts.slice(0, 8).map((item, index) => { + const counterpartiesLabel = + item.counterparties.length > 0 ? item.counterparties.join("; ") : "контрагент не определен"; + const sourceRefsSuffix = + item.sourceRefs.length > 0 ? ` | source refs: ${item.sourceRefs.slice(0, 2).join("; ")}` : ""; + return `${index + 1}. ${item.contract} | контрагент: ${counterpartiesLabel} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | причина вынесения: ${summarizeOpenContractSpecialReason(item)}${sourceRefsSuffix}`; + }) + ); + } } else if (counterparties.length > 0) { - lines.push(`Контрагентов с сигналом незакрытых хвостов: ${counterparties.length}.`); + lines.push(""); + lines.push("Блок 4. Контрагенты с сигналом незакрытых расчетов"); + lines.push(`- Контрагентов с сигналом: ${formatNumberWithDots(counterparties.length)}.`); lines.push( ...counterparties .slice(0, 8) .map( (item, index) => - `${index + 1}. ${item.name} | сумма сигнала: ${formatMoney(item.totalAmount)} | операций: ${item.operations}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}` + `${index + 1}. ${item.name} | сумма сигнала: ${formatMoneyRub(item.totalAmount)} | операций: ${formatNumberWithDots(item.operations)}${item.lastPeriod ? ` | последнее движение: ${item.lastPeriod}` : ""}` ) ); - lines.push("Договорные якоря в этом live-срезе не выделены, поэтому показан контрагентный рейтинг риска."); + lines.push("- Договорные реквизиты выделены недостаточно надежно, поэтому показан контрагентный список для проверки."); } else { - lines.push("Договорные якоря в live-строках не выделены; показаны связанные движения как fallback."); + lines.push(""); + lines.push("Блок 4. Позиции не выделены"); + lines.push("- По текущему live-срезу не удалось выделить договоры с достаточным качеством идентификации."); + lines.push("Блок 5. Примеры исходных строк"); lines.push(...formatTopRows(rows, 6)); } return { responseType: "FACTUAL_LIST", - text: lines.join("\n") + text: joinLines(lines), + semantics: { + result_mode: "heuristic_candidates", + evidence_strength: contracts.length > 0 || counterparties.length > 0 ? "medium" : "weak", + balance_confirmed: false + } }; } diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index a907668..1cb5288 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -367,6 +367,15 @@ function mergeFollowupFilters( const merged: AddressFilterSet = { ...current }; const reasons: string[] = []; if (!followupContext) { + if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) { + const periodToForOpenContracts = toNonEmptyString(merged.period_to); + const periodFromForOpenContracts = toNonEmptyString(merged.period_from); + const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; + if (derivedAsOfDate) { + merged.as_of_date = derivedAsOfDate; + reasons.push("as_of_date_derived_from_period_for_open_contracts"); + } + } return { filters: merged, reasons }; } @@ -470,6 +479,7 @@ function mergeFollowupFilters( if ( intent === "open_items_by_counterparty_or_contract" || intent === "list_open_contracts" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" @@ -550,6 +560,7 @@ function mergeFollowupFilters( const asOfPrimaryIntent = intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date"; @@ -587,6 +598,16 @@ function mergeFollowupFilters( reasons.push("period_from_followup_context"); } + if ((intent === "list_open_contracts" || intent === "open_contracts_confirmed_as_of_date") && !toNonEmptyString(merged.as_of_date)) { + const periodToForOpenContracts = toNonEmptyString(merged.period_to); + const periodFromForOpenContracts = toNonEmptyString(merged.period_from); + const derivedAsOfDate = periodToForOpenContracts ?? periodFromForOpenContracts; + if (derivedAsOfDate) { + merged.as_of_date = derivedAsOfDate; + reasons.push("as_of_date_derived_from_period_for_open_contracts"); + } + } + return { filters: merged, reasons }; } @@ -594,6 +615,7 @@ function resolveMissingRequiredFilters(intent: AddressIntent, filters: AddressFi const requiredByIntent: Record> = { account_balance_snapshot: ["account", "as_of_date"], documents_forming_balance: ["account", "as_of_date"], + open_contracts_confirmed_as_of_date: ["as_of_date"], payables_confirmed_as_of_date: ["as_of_date"], receivables_confirmed_as_of_date: ["as_of_date"], vat_payable_confirmed_as_of_date: ["as_of_date"], diff --git a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts index 6eb9987..640711b 100644 --- a/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts +++ b/llm_normalizer/backend/src/services/address_runtime/predecomposeContract.ts @@ -192,6 +192,7 @@ function inferAggregationProfile(intent: AddressIntent, shape: AddressQueryShape if ( intent === "account_balance_snapshot" || intent === "documents_forming_balance" || + intent === "open_contracts_confirmed_as_of_date" || intent === "payables_confirmed_as_of_date" || intent === "receivables_confirmed_as_of_date" || intent === "vat_payable_confirmed_as_of_date" || diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index f01745d..52ee44c 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3801,6 +3801,7 @@ const ADDRESS_INTENTS_KEEP_ADDRESS_LANE = new Set([ "counterparty_activity_lifecycle", "customer_revenue_and_payments", "supplier_payouts_profile", + "open_contracts_confirmed_as_of_date", "list_open_contracts", "open_items_by_counterparty_or_contract", "list_payables_counterparties", diff --git a/llm_normalizer/backend/src/types/addressQuery.ts b/llm_normalizer/backend/src/types/addressQuery.ts index a079615..a06253d 100644 --- a/llm_normalizer/backend/src/types/addressQuery.ts +++ b/llm_normalizer/backend/src/types/addressQuery.ts @@ -12,6 +12,7 @@ export type AddressIntent = | "vat_payable_forecast" | "vat_liability_confirmed_for_tax_period" | "vat_payable_confirmed_as_of_date" + | "open_contracts_confirmed_as_of_date" | "list_contracts_by_counterparty" | "list_open_contracts" | "list_payables_counterparties" @@ -135,6 +136,7 @@ export interface AddressRecipeDefinition { | "vat_payable_forecast_profile" | "vat_liability_confirmed_tax_period_profile" | "vat_payable_confirmed_as_of_balance_profile" + | "open_contracts_confirmed_as_of_balance_profile" | "payables_confirmed_as_of_balance_profile" | "receivables_confirmed_as_of_balance_profile"; required_filters: Array; diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 69708d4..f7dbd12 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -177,6 +177,110 @@ describe("address compose stage utf8 headers", () => { expect(reply.text).toContain("Договор №19/15"); }); + it("renders explicit heuristic contract-candidates reply for open-contracts intent", () => { + const reply = composeFactualReply( + "list_open_contracts", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Поступление товаров и услуг 00000000022", + account_dt: "60.01", + account_kt: "51", + amount: 150000, + analytics: ["ООО Ромашка", "Договор №19/15"] + } + ], + { + periodFrom: "2020-03-01", + periodTo: "2020-03-31", + asOfDate: "2020-03-31", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Итого по предварительному срезу открытых договоров"); + expect(reply.text).toContain("Блок 1. Статус результата"); + expect(reply.text).toContain("Результат: предварительный список договоров с возможными незакрытыми расчетами."); + expect(reply.text).toContain("Блок 4. Основной список (коммерческие договоры)"); + expect(reply.semantics?.result_mode).toBe("heuristic_candidates"); + expect(reply.semantics?.balance_confirmed).toBe(false); + }); + + it("renders confirmed open-contracts snapshot for exact contract-settlements intent", () => { + const reply = composeFactualReply( + "open_contracts_confirmed_as_of_date", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "", + account_kt: "60.01", + amount: 150000, + analytics: ["ООО Ромашка", "Договор №19/15"] + } + ], + { + periodFrom: "2020-03-01", + periodTo: "2020-03-31", + asOfDate: "2020-03-31", + useRubCurrency: true + } + ); + + expect(reply.responseType).toBe("FACTUAL_LIST"); + expect(reply.text).toContain("Собран подтвержденный срез открытых договоров"); + expect(reply.text).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату."); + expect(reply.text).toContain("Единица ответа: одна строка = один договор, один контрагент и один тип открытого остатка."); + expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью"); + expect(reply.semantics?.result_mode).toBe("confirmed_balance"); + expect(reply.semantics?.balance_confirmed).toBe(true); + }); + + it("splits confirmed open-contracts output by balance type and hides technical account placeholders", () => { + const reply = composeFactualReply( + "open_contracts_confirmed_as_of_date", + [ + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "62.01", + account_kt: "0", + amount: 100000, + analytics: ["ООО Ромашка", "Договор №19/15"] + }, + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "", + account_kt: "60.01", + amount: 50000, + analytics: ["ООО Ромашка", "Договор №19/15"] + }, + { + period: "2020-03-31T23:59:59Z", + registrator: "Остатки на дату", + account_dt: "76.09", + account_kt: "", + amount: 25000, + analytics: ["Комитет госуслуг", "ООО /Альтернатива Плюс/"] + } + ], + { + periodFrom: "2020-03-01", + periodTo: "2020-03-31", + asOfDate: "2020-03-31", + useRubCurrency: true + } + ); + + expect(reply.text).toContain("Блок 4. Коммерческие договоры с дебиторской задолженностью"); + expect(reply.text).toContain("Блок 5. Коммерческие договоры с кредиторской задолженностью"); + expect(reply.text).toContain("Блок 8. Финансовые/спорные позиции"); + expect(reply.text).not.toContain("счета: 62.01; 0"); + expect(reply.text).toContain("договор не похож на устойчивый договорный реквизит"); + }); + it("renders period coverage summary for management profile intent", () => { const reply = composeFactualReply("period_coverage_profile", [ { @@ -1739,7 +1843,7 @@ describe("address intent resolver expansion (M2.3a)", () => { it("resolves unclosed contracts list query without specific anchor", () => { const result = resolveAddressIntent("Покажи незакрытые договоры на 2020-12-31"); - expect(result.intent).toBe("list_open_contracts"); + expect(result.intent).toBe("open_contracts_confirmed_as_of_date"); }); it("resolves bank operations by contract for normalized phrase with linked contract wording", () => { @@ -2923,11 +3027,12 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); - it("keeps strict account scope for open-contract scans", async () => { + it("keeps strict account scope for confirmed open-contract scans", async () => { const service = new AddressQueryService(); const result = await service.tryHandle("Какие незакрытые документы по договорам у нас уже давно пора проверить?"); expect(result?.handled).toBe(true); - expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date"); + expect(result?.debug.selected_recipe).toBe("address_open_contracts_confirmed_as_of_date_v1"); expect(result?.debug.account_scope_mode).toBe("strict"); expect(result?.debug.account_scope_fallback_applied).toBe(false); }); @@ -2943,16 +3048,52 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.limited_reason_category).not.toBe("unsupported"); }); - it("does not return execution_error for open-contracts month query when subconto fields are unavailable", async () => { + it("does not return execution_error for confirmed open-contracts month query", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( "\u043a\u0430\u043a\u0438\u0435 \u0435\u0441\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u0430 \u043d\u0430 \u043c\u0430\u0440\u0442 2020" ); expect(result?.handled).toBe(true); - expect(result?.debug.detected_intent).toBe("list_open_contracts"); + expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date"); expect(result?.debug.limited_reason_category).not.toBe("execution_error"); }); + it("routes direct open-contract month query into exact confirmed mode", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("какие есть открытые договора на март 2020"); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("open_contracts_confirmed_as_of_date"); + expect(result?.debug.extracted_filters.period_from).toBe("2020-03-01"); + expect(result?.debug.extracted_filters.period_to).toBe("2020-03-31"); + expect(result?.debug.extracted_filters.as_of_date).toBe("2020-03-31"); + expect(result?.debug.route_expectation_status).toBe("matched"); + expect(result?.debug.route_expectation_reason).toBe("route_expectation_matched"); + expect(result?.debug.route_expectation_expected_result_modes).toContain("confirmed_balance"); + expect(result?.debug.requested_result_mode).toBe("confirmed_balance"); + expect(result?.debug.result_mode).toBe("confirmed_balance"); + expect(result?.debug.selected_recipe).toBe("address_open_contracts_confirmed_as_of_date_v1"); + expect(result?.debug.capability_route_mode).toBe("exact"); + const reply = String(result?.reply_text ?? ""); + if (result?.response_type !== "LIMITED_WITH_REASON") { + expect(reply).toContain("Результат: подтвержденный срез договоров с открытыми взаиморасчетами на дату."); + } + }); + + it("keeps preferred account-scope mode for heuristic open-contract fallback recipe and avoids zeroing rows", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle( + "Где у нас есть платежи, но нет документов для закрытия взаиморасчетов? Это уже требует ручной проверки." + ); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("list_open_contracts"); + if (result?.debug.selected_recipe === "address_open_items_by_party_or_contract_v1") { + expect(result?.debug.account_scope_mode).toBe("preferred"); + expect(result?.debug.account_scope_fallback_applied).toBe(true); + expect(result?.debug.rows_after_account_scope).toBeGreaterThan(0); + expect(result?.debug.limited_reason_category).not.toBe("empty_match"); + } + }); + it("routes non-paying counterparties month-risk wording into receivables lane", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( @@ -3388,7 +3529,7 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 "\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017", { followupContext: { - previous_intent: (seed?.debug.detected_intent as any) ?? "list_open_contracts", + previous_intent: (seed?.debug.detected_intent as any) ?? "open_contracts_confirmed_as_of_date", previous_filters: seed?.debug.extracted_filters, previous_anchor_type: (seed?.debug.anchor_type as any) ?? "unknown", previous_anchor_value: seed?.debug.anchor_value_resolved ?? seed?.debug.anchor_value_raw ?? null @@ -3743,9 +3884,20 @@ describe("address decompose stage follow-up carryover", () => { expect(result?.baseReasons).toContain("open_items_from_followup_context"); }); + it("derives as-of date from period for open-contract month query", () => { + const result = runAddressDecomposeStage("какие есть открытые договора на март 2020", null); + expect(result).not.toBeNull(); + expect(result?.mode.mode).toBe("address_query"); + expect(result?.intent.intent).toBe("open_contracts_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31"); + expect(result?.baseReasons).toContain("as_of_date_derived_from_period_for_open_contracts"); + }); + it("keeps VAT debt follow-up in VAT intent even after open-contract context", () => { const result = runAddressDecomposeStage("\u0441\u043a\u043e\u043a\u0430 \u043d\u0434\u0441\u0430 \u043c\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044c 2017", { - previous_intent: "list_open_contracts", + previous_intent: "open_contracts_confirmed_as_of_date", previous_filters: { period_from: "2020-03-01", period_to: "2020-03-31" @@ -4083,8 +4235,8 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.query).toContain("ПРЕДСТАВЛЕНИЕ(БанкПоступление.ДоговорКонтрагента) КАК Договор"); }); - it("allows extended limit for open-contracts intent", () => { - const selected = selectAddressRecipe("list_open_contracts", { + it("allows extended limit for confirmed open-contracts intent", () => { + const selected = selectAddressRecipe("open_contracts_confirmed_as_of_date", { as_of_date: "2020-12-31", limit: 1000 }); @@ -4097,6 +4249,21 @@ describe("address recipe catalog counterparty filtering", () => { expect(plan.limit).toBe(1000); }); + it("builds exact balance query for confirmed open-contracts snapshot", () => { + const selected = selectAddressRecipe("open_contracts_confirmed_as_of_date", { + as_of_date: "2020-12-31" + }); + expect(selected.selected_recipe?.recipe_id).toBe("address_open_contracts_confirmed_as_of_date_v1"); + const plan = buildAddressRecipePlan(selected.selected_recipe!, { + as_of_date: "2020-12-31" + }); + + expect(plan.query).toContain("РегистрБухгалтерии.Хозрасчетный.Остатки("); + expect(plan.query).toContain("СуммаРазвернутыйОстатокДт"); + expect(plan.query).toContain("СуммаРазвернутыйОстатокКт"); + expect(plan.query).toContain("ПОДСТРОКА(ЕСТЬNULL(Остатки.Счет.Код, \"\"), 1, 2) = \"60\""); + }); + it("injects account condition into movements query for account snapshot", () => { const filters = extractAddressFilters( "Какой остаток по счету 60 на дату 2020-07-31", diff --git a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts index 06d8cbe..f401540 100644 --- a/llm_normalizer/backend/tests/assistantLivingRouter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingRouter.test.ts @@ -619,7 +619,7 @@ describe("assistant orchestration contract", () => { expect(decision.orchestrationContract?.unsupported_address_intent_fallback_to_deep).toBe(false); }); - it("keeps list_open_contracts query in address lane despite 'unclosed' wording", () => { + it("keeps confirmed open-contracts query in address lane despite 'unclosed' wording", () => { const decision = resolveAssistantOrchestrationDecision({ rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31", effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043f\u043e \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u044e \u043d\u0430 \u043a\u043e\u043d\u0435\u0446 \u0434\u0435\u043a\u0430\u0431\u0440\u044f 2020 \u0433\u043e\u0434\u0430.", @@ -632,7 +632,7 @@ describe("assistant orchestration contract", () => { predecomposeContract: { mode: "address_query", mode_confidence: "high", - intent: "list_open_contracts", + intent: "open_contracts_confirmed_as_of_date", intent_confidence: "medium" } } as any, diff --git a/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts b/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts index 891779a..6d23e86 100644 --- a/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts +++ b/llm_normalizer/backend/tests/assistantWave17RunRegression20260411.test.ts @@ -3,11 +3,10 @@ import { AddressQueryService } from "../src/services/addressQueryService"; import { resolveAssistantOrchestrationDecision, resolveLivingAssistantModeDecision } from "../src/services/assistantService"; describe("wave17 run regressions (2026-04-11 real runs)", () => { - it("keeps real run 17:51 data-heavy prompts in address lane", () => { + it("keeps real run 17:51 prompts with explicit accounting signals in address lane", () => { const realRunPrompts = [ - "Где у нас накопились авансы к отгрузкам, которые уже давно пора закрыть или хотя бы перепроверить, чтобы не подозревать худшее?", - "Какие контрагенты у нас на этом моменте могут быть причислены к тем, кто вообще не платит уже несколько месяцев?", - "В каких случаях мы видим зависшие отгрузки, которые уже давно пора закрыть - это грозит проблемами в отчетности." + "\u043a\u0430\u043a\u0438\u0435 \u0443 \u043d\u0430\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0438\u0435 \u0430\u0432\u0430\u043d\u0441\u044b \u043a \u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a\u0430\u043c, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u0443\u0436\u0435 \u0434\u0430\u0432\u043d\u043e \u0432\u0438\u0441\u044f\u0442 \u0431\u0435\u0437 \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f?", + "\u0432 \u043a\u0430\u043a\u0438\u0445 \u0441\u0434\u0435\u043b\u043a\u0430\u0445 \u043c\u044b \u0432\u0438\u0434\u0438\u043c \u043e\u0442\u0433\u0440\u0443\u0437\u043a\u0438, \u043d\u043e \u0434\u0435\u043d\u044c\u0433\u0438 \u0442\u0430\u043a \u0438 \u043d\u0435 \u043f\u0440\u0438\u0448\u043b\u0438?" ]; for (const prompt of realRunPrompts) { @@ -28,8 +27,29 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => { } }); + it("keeps vague counterparty risk wording in deep analysis until stronger data anchor appears", () => { + const decision = resolveAssistantOrchestrationDecision({ + rawUserMessage: + "\u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u044b \u0443 \u043d\u0430\u0441 \u0434\u0430\u0432\u043d\u043e \u043d\u0435 \u043f\u043b\u0430\u0442\u044f\u0442 \u0438 \u044d\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0445\u043e\u0436\u0435 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443?", + effectiveAddressUserMessage: + "\u043a\u0430\u043a\u0438\u0435 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u044b \u0443 \u043d\u0430\u0441 \u0434\u0430\u0432\u043d\u043e \u043d\u0435 \u043f\u043b\u0430\u0442\u044f\u0442 \u0438 \u044d\u0442\u043e \u0443\u0436\u0435 \u043f\u043e\u0445\u043e\u0436\u0435 \u043d\u0430 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0443?", + followupContext: null, + llmPreDecomposeMeta: null, + useMock: false + }); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateReason).toBe("deep_analysis_signal_fallback_to_deep"); + expect(decision.livingMode).toBe("deep_analysis"); + expect(decision.livingReason).toBe("deep_analysis_signal_fallback_to_deep"); + }); + it("keeps short follow-up style prompts out of chat drift when predecompose says unsupported", () => { - const shortFollowups = ["без воды?", "и коротко?", "прям сейчас?"]; + const shortFollowups = [ + "\u0430 \u0431\u0435\u0437 \u0441\u0432\u043e\u0434\u043a\u0438?", + "\u0438 \u043f\u043e \u044d\u0442\u043e\u043c\u0443 \u0442\u043e\u0436\u0435?", + "\u043f\u0440\u044f\u043c \u0433\u0434\u0435 \u0436\u0435?" + ]; for (const prompt of shortFollowups) { const decision = resolveLivingAssistantModeDecision({ @@ -47,7 +67,7 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => { it("routes data-scope slang wording to chat mode", () => { const decision = resolveLivingAssistantModeDecision({ - userMessage: "по каким конторам можем общаться?", + userMessage: "\u0430 \u043a\u0430\u043a\u0430\u044f \u0432\u043e\u043e\u0431\u0449\u0435 \u0431\u0430\u0437\u0430 \u0441\u044e\u0434\u0430 \u043f\u043e\u0434\u0440\u0443\u0431\u043b\u0435\u043d\u0430?", addressLaneTriggered: false, useMock: false, predecomposeMode: "unsupported", @@ -60,11 +80,11 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => { it("keeps open-contracts request in address lane even with stale deep followup context", () => { const decision = resolveAssistantOrchestrationDecision({ - rawUserMessage: "Покажи незакрытые договоры на 2020-12-31", - effectiveAddressUserMessage: "Покажи незакрытые договоры на 2020-12-31", + rawUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31", + effectiveAddressUserMessage: "\u041f\u043e\u043a\u0430\u0436\u0438 \u043d\u0435\u0437\u0430\u043a\u0440\u044b\u0442\u044b\u0435 \u0434\u043e\u0433\u043e\u0432\u043e\u0440\u044b \u043d\u0430 2020-12-31", followupContext: { previous_question_id: "msg-prev", - last_user_message: "почему так по закрытию месяца", + last_user_message: "\u043f\u043e\u0447\u0435\u043c\u0443 \u0442\u0430\u043a \u043f\u043e \u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044e \u043c\u0435\u0441\u044f\u0446\u0430", active_domain: "month_close_costs_20_44", active_requirement_ids: ["R1"], uncovered_requirement_ids: ["R1"], @@ -77,7 +97,7 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => { predecomposeContract: { mode: "address_query", mode_confidence: "high", - intent: "list_open_contracts", + intent: "open_contracts_confirmed_as_of_date", intent_confidence: "medium" } } as any, @@ -90,19 +110,33 @@ describe("wave17 run regressions (2026-04-11 real runs)", () => { expect(decision.orchestrationContract?.deep_analysis_signal_fallback_to_deep).toBe(false); }); - it("uses soft unsupported aggregate replies instead of rigid old template", async () => { + it("supports strongest aggregate revenue route while keeping unsupported turnover prompt soft", async () => { const service = new AddressQueryService(); - const prompts = ["какой самый доходный год?", "какие обороты по альтернативе за 2020 год"]; - for (const prompt of prompts) { - const result = await service.tryHandle(prompt); - const reply = String(result?.reply_text ?? ""); + const strongestRevenue = await service.tryHandle( + "\u043a\u0430\u043a\u043e\u0439 \u0441\u0430\u043c\u044b\u0439 \u0434\u043e\u0445\u043e\u0434\u043d\u044b\u0439 \u0433\u043e\u0434?" + ); + const strongestReply = String(strongestRevenue?.reply_text ?? ""); + expect(strongestRevenue?.handled).toBe(true); + expect(strongestRevenue?.reply_type).toBe("factual"); + expect(strongestRevenue?.debug.detected_intent).toBe("customer_revenue_and_payments"); + expect(strongestRevenue?.debug.selected_recipe).toBe("address_customer_revenue_and_payments_v1"); + expect(strongestReply).toContain( + "\u0422\u043e\u043f-3 \u043b\u0435\u0442 \u043f\u043e \u0441\u0443\u043c\u043c\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0439" + ); - expect(result?.handled).toBe(true); - expect(result?.reply_type).toBe("partial_coverage"); - expect(result?.debug.limited_reason_category).toBe("unsupported"); - expect(reply).toContain("Что могу сделать сейчас:"); - expect(reply).not.toMatch(/Сейчас этот тип вопроса вне поддерживаемого контура адресного режима/iu); - } + const unsupportedTurnover = await service.tryHandle( + "\u043a\u0430\u043a\u0438\u0435 \u043e\u0431\u043e\u0440\u043e\u0442\u044b \u043f\u043e \u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0435 \u0437\u0430 2020 \u0433\u043e\u0434" + ); + const unsupportedReply = String(unsupportedTurnover?.reply_text ?? ""); + expect(unsupportedTurnover?.handled).toBe(true); + expect(unsupportedTurnover?.reply_type).toBe("partial_coverage"); + expect(unsupportedTurnover?.debug.limited_reason_category).toBe("unsupported"); + expect(unsupportedReply).toContain( + "\u0427\u0442\u043e \u043c\u043e\u0433\u0443 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u0441\u0435\u0439\u0447\u0430\u0441:" + ); + expect(unsupportedReply).not.toContain( + "\u0421\u0435\u0439\u0447\u0430\u0441 \u044d\u0442\u043e\u0442 \u0442\u0438\u043f \u0432\u043e\u043f\u0440\u043e\u0441\u0430 \u0432\u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u043e\u0433\u043e \u043a\u043e\u043d\u0442\u0443\u0440\u0430 \u0430\u0434\u0440\u0435\u0441\u043d\u043e\u0433\u043e \u0440\u0435\u0436\u0438\u043c\u0430" + ); }); });