From 9c86407937375609b4b6a2c0184d900645961850 Mon Sep 17 00:00:00 2001 From: dctouch Date: Mon, 18 May 2026 10:03:58 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D1=83=D1=8E=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BB=D0=BE=D1=81=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?VAT,=20debt=20mirror=20=D0=B8=20trace-=D0=BE=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../address_runtime/decomposeStage.js | 33 +++++++++++ .../address_runtime/inventoryReplyBuilders.js | 22 ++++++-- .../services/assistantContinuityPolicy.js | 1 + .../assistantMcpDiscoveryResponseCandidate.js | 8 ++- .../services/assistantTransitionPolicy.js | 22 +++++++- .../address_runtime/decomposeStage.ts | 43 ++++++++++++++ .../address_runtime/inventoryReplyBuilders.ts | 35 ++++++++++-- .../src/services/assistantContinuityPolicy.ts | 1 + .../assistantMcpDiscoveryResponseCandidate.ts | 10 +++- .../src/services/assistantTransitionPolicy.ts | 31 +++++++++- .../addressFollowupTemporalRegression.test.ts | 45 +++++++++++++++ ...essInventorySelectedObjectFollowup.test.ts | 3 + .../tests/assistantContinuityPolicy.test.ts | 36 ++++++++++++ ...stantMcpDiscoveryResponseCandidate.test.ts | 3 + ...assistantMcpDiscoveryRuntimeBridge.test.ts | 4 +- .../tests/assistantTransitionPolicy.test.ts | 56 +++++++++++++++++++ 16 files changed, 333 insertions(+), 20 deletions(-) diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 0be93c3..22cc373 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -692,6 +692,32 @@ function isBroadDebtPolarityQuestion(intent, text) { } return /(?:^|[\s,.;:!?()\-])(?:кто|кому|какие|какой|список|топ|все|всех|всего)(?=$|[\s,.;:!?()\-])/iu.test(normalized); } +function isShortDebtRoleMirrorFollowup(intent, text, followupContext) { + if (intent !== "payables_confirmed_as_of_date" && intent !== "receivables_confirmed_as_of_date") { + return false; + } + const previousIntent = followupContext?.previous_intent ?? null; + const previousIsPayables = previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties"; + const previousIsReceivables = previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties"; + const normalized = textWithRepairedVariant(String(text ?? "")) + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[^\p{L}0-9]+/giu, " ") + .trim(); + if (!normalized) { + return false; + } + const tokens = normalized.split(/\s+/u).filter(Boolean); + if (tokens.length > 4) { + return false; + } + const semanticTokens = /^(?:а|a|и|i)$/iu.test(tokens[0] ?? "") ? tokens.slice(1) : tokens; + const phrase = semanticTokens.join(" "); + const asksReceivables = phrase === "нам" || phrase === "нам кто" || phrase === "кто нам"; + const asksPayables = phrase === "кому" || phrase === "мы кому" || phrase === "кому мы"; + return ((intent === "receivables_confirmed_as_of_date" && previousIsPayables && asksReceivables) || + (intent === "payables_confirmed_as_of_date" && previousIsReceivables && asksPayables)); +} function mergeFollowupFilters(current, intent, userMessage, followupContext) { const merged = { ...current }; const reasons = []; @@ -862,7 +888,11 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { (followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null); const currentCounterparty = toNonEmptyString(merged.counterparty); const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty; + const suppressCounterpartyForShortDebtMirror = isShortDebtRoleMirrorFollowup(intent, userMessage, followupContext) && + followupContext.previous_anchor_type !== "counterparty" && + (!currentCounterparty || isLowQualityCounterpartyAnchor(currentCounterparty)); const shouldInheritCounterparty = !suppressCounterpartyForBroadDebtQuestion && + !suppressCounterpartyForShortDebtMirror && (!currentCounterparty || (Boolean(inheritedCounterparty) && isLowQualityCounterpartyAnchor(currentCounterparty) && @@ -870,6 +900,9 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) { reasons.push("counterparty_carryover_suppressed_for_broad_debt_polarity_question"); } + if (inheritedCounterparty && suppressCounterpartyForShortDebtMirror) { + reasons.push("counterparty_carryover_suppressed_for_short_debt_mirror"); + } if (inheritedCounterparty && shouldInheritCounterparty) { merged.counterparty = inheritedCounterparty; reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 35986ba..b4d80b4 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.composeInventoryReply = composeInventoryReply; const replyContracts_1 = require("./replyContracts"); const inventoryReplyPresentation_1 = require("./inventoryReplyPresentation"); +const INVENTORY_TRACE_EVIDENCE_ROW_LIMIT = 3; function cleanupInventoryRequestedParty(value) { const cleaned = String(value ?? "") .replace(/\s*(?:->|=>|→)\s*(?:товар|позици|номенклатур|покупател|buyer|customer|item|product|sku)[\s\S]*$/iu, "") @@ -236,7 +237,10 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { - (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 8)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)); + if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`); + } } return (0, replyContracts_1.buildFactualSummaryReply)(lines, (0, replyContracts_1.buildConfirmedBalanceSemantics)(purchaseRows.length > 0 ? (summary.counterparties.length === 1 ? "strong" : "medium") : "medium", purchaseRows.length > 0)); } @@ -353,9 +357,9 @@ function composeInventoryReply(intent, rows, options, deps) { const itemLabel = requestedItemHint || (summary.item ?? "товар не определен"); const excludedCounterpartyTokens = [itemLabel]; const directAnswerLine = summary.counterparties.length === 1 - ? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` + ? `По номенклатуре ${itemLabel} в документах выбытия покупатель: ${summary.counterparties[0]}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает, что продали именно выбранный остаток/лот.` : summary.counterparties.length > 1 - ? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` + ? `По номенклатуре ${itemLabel} в документах выбытия найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает связь с конкретным остатком/лотом.` : `По товару ${itemLabel} покупатель в доступных данных не выделен.`; const lines = [directAnswerLine, "", "Подтверждение:"]; lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); @@ -363,17 +367,23 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`); lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`); if (summary.counterparties.length === 1) { - lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); + lines.push(`- По доступным движениям номенклатура отгружалась покупателю: ${summary.counterparties[0]}.`); } else if (summary.counterparties.length > 1) { - lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); + lines.push(`- По доступным движениям найдено несколько покупателей номенклатуры: ${summary.counterparties.slice(0, 4).join("; ")}.`); } else if (saleRows.length > 0) { lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных."); } + if (saleRows.length > 0) { + lines.push("- Без партионного учета этот ответ подтверждает продажи по номенклатуре, а не юридически точную продажу конкретного остатка или партии."); + } lines.push("", "Документы выбытия:"); if (saleRows.length > 0) { - lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); + lines.push(...deps.formatInventoryTraceRows(saleRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT, excludedCounterpartyTokens)); + if (saleRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(saleRows.length)} найденных строк; полный след остается в подтвержденном срезе.`); + } } else { lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных."); diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index 630fbb6..8a9b80e 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -415,6 +415,7 @@ function sameCounterpartyCandidate(left, right) { function readGroundedDiscoveryCounterparty(debug, toNonEmptyString = fallbackToNonEmptyString) { const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString); const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" || + readAssistantMcpDiscoveryTurnMeaning(debug)?.stale_replay_forbidden === true || readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug); if (suppressDiscoveryEntityCarryover) { return null; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js index 07cd323..7e6fd8c 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponseCandidate.js @@ -943,7 +943,8 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { } if (rankingNeed) { const incomingLeader = strongestIncomingYear(overview); - const netLeader = strongestNetYear(overview); + const canRankYearlyNet = !limitLine; + const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null; const leaderYear = toNonEmptyString(incomingLeader?.year_bucket); const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru); const leaderRows = Number(incomingLeader?.incoming_rows_with_amount); @@ -964,7 +965,10 @@ function buildCompactBusinessOverviewReply(entryPoint, draft) { if (requestedFinancialBoundaryLine) { lines.push(requestedFinancialBoundaryLine); } - const yearRows = businessOverviewYearRowsLine(overview); + if (!canRankYearlyNet && Array.isArray(overview.yearly_breakdown) && overview.yearly_breakdown.length > 0) { + lines.push("Годовое операционное нетто в широком срезе не ранжирую: по одному из направлений достигнут лимит строк, поэтому безопаснее дозапросить конкретный год или квартал."); + } + const yearRows = canRankYearlyNet ? businessOverviewYearRowsLine(overview) : null; if (yearRows) { lines.push(yearRows); } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 21858f9..af17620 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -7,6 +7,12 @@ function createAssistantTransitionPolicy(deps) { function normalizeFollowupText(value) { return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); } + function hasSamePeriodReferenceCue(...values) { + return values + .map((value) => normalizeFollowupText(value)) + .some((normalized) => normalized && + /(?:\b(?:same|this|that)\s+period\b|(?:за|на)\s+(?:этот|тот|такой\s+же|тот\s+же)\s+период|(?:Р·Р°|РЅР°)\s+(?:этот|тот|такой\s+Р¶Рµ|тот\s+Р¶Рµ)\s+период)/iu.test(normalized)); + } function hasBankOperationsPivotCue(text) { const normalized = normalizeFollowupText(text); if (!normalized) { @@ -955,7 +961,21 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementAlternate || hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate)); - const explicitIntentForCarryover = debtRoleSwapIntent ? debtRoleSwapIntent : explicitIntent; + const previousHasPeriodWindow = Boolean(deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to)); + const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const shouldRetargetVatSamePeriod = previousHasPeriodWindow && + hasSamePeriodReferenceCue(userMessage, alternateMessage) && + (explicitIntent === "vat_payable_confirmed_as_of_date" || + explicitIntent === "vat_payable_forecast" || + explicitIntent === "vat_liability_confirmed_for_tax_period" || + predecomposeIntent === "vat_payable_confirmed_as_of_date" || + predecomposeIntent === "vat_liability_confirmed_for_tax_period" || + deps.resolveAddressIntentFamily(explicitIntent) === "vat"); + const explicitIntentForCarryover = shouldRetargetVatSamePeriod + ? "vat_liability_confirmed_for_tax_period" + : debtRoleSwapIntent + ? debtRoleSwapIntent + : explicitIntent; const carryoverTargetIntent = (0, assistantContinuityPolicy_1.resolveFollowupTargetIntent)(inventoryPurchaseDateVatBridge, selectedObjectRetargetIntent, explicitIntentForCarryover, sourceIntent, followupSelectionMode, deps.toNonEmptyString(inventoryRootFrame?.intent), displayedEntityTargetIntent, previousIntent, explicitInventorySameDatePivot); return { followupContext: { diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index a80f66b..f763ef2 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -894,6 +894,41 @@ function isBroadDebtPolarityQuestion(intent: AddressIntent, text: string): boole ); } +function isShortDebtRoleMirrorFollowup( + intent: AddressIntent, + text: string, + followupContext: AddressFollowupContext | null +): boolean { + if (intent !== "payables_confirmed_as_of_date" && intent !== "receivables_confirmed_as_of_date") { + return false; + } + const previousIntent = followupContext?.previous_intent ?? null; + const previousIsPayables = + previousIntent === "payables_confirmed_as_of_date" || previousIntent === "list_payables_counterparties"; + const previousIsReceivables = + previousIntent === "receivables_confirmed_as_of_date" || previousIntent === "list_receivables_counterparties"; + const normalized = textWithRepairedVariant(String(text ?? "")) + .toLowerCase() + .replace(/ё/g, "е") + .replace(/[^\p{L}0-9]+/giu, " ") + .trim(); + if (!normalized) { + return false; + } + const tokens = normalized.split(/\s+/u).filter(Boolean); + if (tokens.length > 4) { + return false; + } + const semanticTokens = /^(?:а|a|и|i)$/iu.test(tokens[0] ?? "") ? tokens.slice(1) : tokens; + const phrase = semanticTokens.join(" "); + const asksReceivables = phrase === "нам" || phrase === "нам кто" || phrase === "кто нам"; + const asksPayables = phrase === "кому" || phrase === "мы кому" || phrase === "кому мы"; + return ( + (intent === "receivables_confirmed_as_of_date" && previousIsPayables && asksReceivables) || + (intent === "payables_confirmed_as_of_date" && previousIsReceivables && asksPayables) + ); +} + function mergeFollowupFilters( current: AddressFilterSet, intent: AddressIntent, @@ -1104,8 +1139,13 @@ function mergeFollowupFilters( (followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null); const currentCounterparty = toNonEmptyString(merged.counterparty); const suppressCounterpartyForBroadDebtQuestion = isBroadDebtPolarityQuestion(intent, userMessage) && !currentCounterparty; + const suppressCounterpartyForShortDebtMirror = + isShortDebtRoleMirrorFollowup(intent, userMessage, followupContext) && + followupContext.previous_anchor_type !== "counterparty" && + (!currentCounterparty || isLowQualityCounterpartyAnchor(currentCounterparty)); const shouldInheritCounterparty = !suppressCounterpartyForBroadDebtQuestion && + !suppressCounterpartyForShortDebtMirror && (!currentCounterparty || (Boolean(inheritedCounterparty) && isLowQualityCounterpartyAnchor(currentCounterparty) && @@ -1113,6 +1153,9 @@ function mergeFollowupFilters( if (inheritedCounterparty && suppressCounterpartyForBroadDebtQuestion) { reasons.push("counterparty_carryover_suppressed_for_broad_debt_polarity_question"); } + if (inheritedCounterparty && suppressCounterpartyForShortDebtMirror) { + reasons.push("counterparty_carryover_suppressed_for_short_debt_mirror"); + } if (inheritedCounterparty && shouldInheritCounterparty) { merged.counterparty = inheritedCounterparty; reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context"); diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 85830d8..92aa057 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -43,6 +43,8 @@ interface InventoryTraceSummary { totalAmount: number; } +const INVENTORY_TRACE_EVIDENCE_ROW_LIMIT = 3; + interface InventoryAgingByItemAggregate { item: string; warehouse: string | null; @@ -341,7 +343,16 @@ export function composeInventoryReply( lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { - appendInventorySection(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, 8)); + appendInventorySection( + lines, + "Опорные документы:", + deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) + ); + if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push( + `- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.` + ); + } } return buildFactualSummaryReply( lines, @@ -486,9 +497,9 @@ export function composeInventoryReply( const excludedCounterpartyTokens = [itemLabel]; const directAnswerLine = summary.counterparties.length === 1 - ? `По товару ${itemLabel} покупатель определен: ${summary.counterparties[0]}.` + ? `По номенклатуре ${itemLabel} в документах выбытия покупатель: ${summary.counterparties[0]}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает, что продали именно выбранный остаток/лот.` : summary.counterparties.length > 1 - ? `По товару ${itemLabel} найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.` + ? `По номенклатуре ${itemLabel} в документах выбытия найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}. Это подтверждает продажный след по номенклатуре, но без партионного учета не доказывает связь с конкретным остатком/лотом.` : `По товару ${itemLabel} покупатель в доступных данных не выделен.`; const lines: string[] = [directAnswerLine, "", "Подтверждение:"]; lines.push(`- Первая найденная дата выбытия: ${deps.inventoryTraceDateLabel(summary.firstPeriod)}.`); @@ -496,15 +507,27 @@ export function composeInventoryReply( lines.push(`- Документов выбытия: ${deps.formatNumberWithDots(summary.documents.length)}.`); lines.push(`- Операций выбытия: ${deps.formatNumberWithDots(saleRows.length)}.`); if (summary.counterparties.length === 1) { - lines.push(`- По доступным движениям товар отгружался покупателю: ${summary.counterparties[0]}.`); + lines.push(`- По доступным движениям номенклатура отгружалась покупателю: ${summary.counterparties[0]}.`); } else if (summary.counterparties.length > 1) { - lines.push(`- По доступным движениям найдено несколько покупателей: ${summary.counterparties.slice(0, 4).join("; ")}.`); + lines.push(`- По доступным движениям найдено несколько покупателей номенклатуры: ${summary.counterparties.slice(0, 4).join("; ")}.`); } else if (saleRows.length > 0) { lines.push("- Документы выбытия найдены, но покупатель не выделен отдельным полем в доступных данных."); } + if (saleRows.length > 0) { + lines.push( + "- Без партионного учета этот ответ подтверждает продажи по номенклатуре, а не юридически точную продажу конкретного остатка или партии." + ); + } lines.push("", "Документы выбытия:"); if (saleRows.length > 0) { - lines.push(...deps.formatInventoryTraceRows(saleRows, 12, excludedCounterpartyTokens)); + lines.push( + ...deps.formatInventoryTraceRows(saleRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT, excludedCounterpartyTokens) + ); + if (saleRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { + lines.push( + `- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(saleRows.length)} найденных строк; полный след остается в подтвержденном срезе.` + ); + } } else { lines.push("- По выбранному товару не найдено проводок выбытия в доступных данных."); } diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 4f6dfb8..a9a2ad0 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -634,6 +634,7 @@ function readGroundedDiscoveryCounterparty( const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString); const suppressDiscoveryEntityCarryover = discoveryPilotScope === "metadata_inspection_v1" || + readAssistantMcpDiscoveryTurnMeaning(debug)?.stale_replay_forbidden === true || readAssistantMcpDiscoveryLoopSubjectResolutionOptional(debug); if (suppressDiscoveryEntityCarryover) { return null; diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts index aabed08..bdbd69c 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponseCandidate.ts @@ -1129,7 +1129,8 @@ function buildCompactBusinessOverviewReply( if (rankingNeed) { const incomingLeader = strongestIncomingYear(overview); - const netLeader = strongestNetYear(overview); + const canRankYearlyNet = !limitLine; + const netLeader = canRankYearlyNet ? strongestNetYear(overview) : null; const leaderYear = toNonEmptyString(incomingLeader?.year_bucket); const leaderAmount = moneyText(incomingLeader?.incoming_total_amount_human_ru); const leaderRows = Number(incomingLeader?.incoming_rows_with_amount); @@ -1152,7 +1153,12 @@ function buildCompactBusinessOverviewReply( if (requestedFinancialBoundaryLine) { lines.push(requestedFinancialBoundaryLine); } - const yearRows = businessOverviewYearRowsLine(overview); + if (!canRankYearlyNet && Array.isArray(overview.yearly_breakdown) && overview.yearly_breakdown.length > 0) { + lines.push( + "Годовое операционное нетто в широком срезе не ранжирую: по одному из направлений достигнут лимит строк, поэтому безопаснее дозапросить конкретный год или квартал." + ); + } + const yearRows = canRankYearlyNet ? businessOverviewYearRowsLine(overview) : null; if (yearRows) { lines.push(yearRows); } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 58ade0b..d6241ff 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -47,6 +47,18 @@ export function createAssistantTransitionPolicy(deps) { return deps.compactWhitespace(deps.repairAddressMojibake(String(value ?? "")).toLowerCase()).replace(/ё/g, "е"); } + function hasSamePeriodReferenceCue(...values) { + return values + .map((value) => normalizeFollowupText(value)) + .some( + (normalized) => + normalized && + /(?:\b(?:same|this|that)\s+period\b|(?:за|на)\s+(?:этот|тот|такой\s+же|тот\s+же)\s+период|(?:Р·Р°|РЅР°)\s+(?:этот|тот|такой\s+Р¶Рµ|тот\s+Р¶Рµ)\s+период)/iu.test( + normalized + ) + ); + } + function hasBankOperationsPivotCue(text) { const normalized = normalizeFollowupText(text); if (!normalized) { @@ -1332,7 +1344,24 @@ export function createAssistantTransitionPolicy(deps) { hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate) ); - const explicitIntentForCarryover = debtRoleSwapIntent ? debtRoleSwapIntent : explicitIntent; + const previousHasPeriodWindow = Boolean( + deps.toNonEmptyString(previousFilters.period_from) || deps.toNonEmptyString(previousFilters.period_to) + ); + const predecomposeIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); + const shouldRetargetVatSamePeriod = + previousHasPeriodWindow && + hasSamePeriodReferenceCue(userMessage, alternateMessage) && + (explicitIntent === "vat_payable_confirmed_as_of_date" || + explicitIntent === "vat_payable_forecast" || + explicitIntent === "vat_liability_confirmed_for_tax_period" || + predecomposeIntent === "vat_payable_confirmed_as_of_date" || + predecomposeIntent === "vat_liability_confirmed_for_tax_period" || + deps.resolveAddressIntentFamily(explicitIntent) === "vat"); + const explicitIntentForCarryover = shouldRetargetVatSamePeriod + ? "vat_liability_confirmed_for_tax_period" + : debtRoleSwapIntent + ? debtRoleSwapIntent + : explicitIntent; const carryoverTargetIntent = resolveFollowupTargetIntent( inventoryPurchaseDateVatBridge, selectedObjectRetargetIntent, diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index ef0ad85..538d7b0 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -119,6 +119,51 @@ describe("address follow-up temporal regressions", () => { expect(result?.filters.extracted_filters.counterparty).toBeUndefined(); }); + it("suppresses stale counterparty filters from organization-scoped short debt mirror follow-up", () => { + const result = runAddressDecomposeStage("а нам?", { + previous_intent: "payables_confirmed_as_of_date", + target_intent: "receivables_confirmed_as_of_date", + previous_filters: { + as_of_date: "2026-05-17", + organization: "ООО Альтернатива Плюс", + counterparty: "нас самый доходный клиент" + }, + previous_anchor_type: "organization", + previous_anchor_value: "ООО Альтернатива Плюс" + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2026-05-17"); + expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс"); + expect(result?.filters.extracted_filters.counterparty).toBeUndefined(); + expect(result?.baseReasons).toContain("counterparty_carryover_suppressed_for_short_debt_mirror"); + expect(result?.baseReasons).not.toContain("counterparty_from_followup_context"); + }); + + it("keeps resolved counterparty focus for short debt mirror follow-up", () => { + const result = runAddressDecomposeStage("а нам?", { + previous_intent: "payables_confirmed_as_of_date", + target_intent: "receivables_confirmed_as_of_date", + previous_filters: { + as_of_date: "2026-05-17", + organization: "ООО Альтернатива Плюс", + counterparty: "Группа СВК" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа СВК", + resolved_counterparty_from_display: true + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("receivables_confirmed_as_of_date"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2026-05-17"); + expect(result?.filters.extracted_filters.organization).toBe("ООО Альтернатива Плюс"); + expect(result?.filters.extracted_filters.counterparty).toBe("Группа СВК"); + expect(result?.baseReasons).toContain("counterparty_from_followup_context"); + expect(result?.baseReasons).not.toContain("counterparty_carryover_suppressed_for_short_debt_mirror"); + }); + it("keeps same-date inventory pivot anchored to the previous VAT date", () => { const result = runAddressDecomposeStage("какие остатки по складу на эту же дату", { previous_intent: "vat_payable_confirmed_as_of_date", diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index 491e65c..ac424dd 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -703,6 +703,7 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-05-31"); expect(result?.debug.reasons).toContain("inventory_selected_object_sale_trace_signal_detected"); expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("ООО \\Ромашка\\"); + expect(String(result?.reply_text ?? "").split("\n")[0]).toContain("без партионного учета не доказывает"); expect(String(result?.reply_text ?? "")).toContain("Документы выбытия"); }); @@ -750,6 +751,8 @@ describe("inventory selected-object follow-up", () => { expect(result?.debug.extracted_filters?.item).toBe("Кромка с клеем 33 альмандин 137 м"); expect(result?.debug.extracted_filters?.as_of_date).toBe("2020-03-31"); expect(String(result?.reply_text ?? "")).toContain("ООО \\Покупатель\\"); + expect(String(result?.reply_text ?? "")).not.toContain("покупатель определен"); + expect(String(result?.reply_text ?? "")).toContain("продажный след по номенклатуре"); }); it("detaches snapshot date from execution query during sale-trace history recovery", async () => { diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index de1d3ff..eeef00f 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -479,6 +479,42 @@ describe("assistantContinuityPolicy organization authority", () => { }); }); + it("does not carry MCP discovery counterparty from a stale replay-forbidden turn", () => { + const filters = resolveAddressDebugCarryoverFilters({ + detected_intent: "payables_confirmed_as_of_date", + extracted_filters: { + organization: "Org Alt", + as_of_date: "2026-05-17" + }, + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["old ranking counterparty"], + metadata_scope_hint: "old ranking counterparty", + stale_replay_forbidden: true + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_value_flow_query_movements_v1" + } + } + } + }); + + expect(filters).toEqual({ + organization: "Org Alt", + as_of_date: "2026-05-17" + }); + }); + it("hydrates inventory root-frame state from navigation scope and preserves derived current frame kind", () => { const state = hydrateInventoryRootFrameState( { diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts index 63e9243..6894ca2 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponseCandidate.test.ts @@ -445,9 +445,12 @@ describe("assistant MCP discovery response candidate", () => { expect(candidate.reply_text).toContain("нельзя автоматически читать как обычного клиента или поставщика"); expect(candidate.reply_text).toContain("не полный бухгалтерский рейтинг доходности"); expect(candidate.reply_text).toContain("не как чистую бухгалтерскую прибыль"); + expect(candidate.reply_text).toContain("Годовое операционное нетто в широком срезе не ранжирую"); expect(candidate.reply_text).toContain("проверка достигла лимита строк"); expect(candidate.reply_text).toContain("выбрать конкретный год или квартал для дозапроса"); expect(candidate.reply_text).toContain("без выдачи непроверенного итога"); + expect(candidate.reply_text).not.toContain("По расчетному операционному нетто лучший год"); + expect(candidate.reply_text).not.toContain("По годам:"); expect(candidate.reply_text).not.toContain("лимит выборки MCP"); expect(candidate.reply_text).not.toContain("MCP-срез"); expect(candidate.reply_text).not.toContain("Что подтверждено:"); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts index f131bf3..000c628 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryRuntimeBridge.test.ts @@ -657,8 +657,8 @@ describe("assistant MCP discovery runtime bridge", () => { expect(overview?.accounting_financial_result?.net_margin_to_revenue_pct).toBe(-59.41); expect(missingFamilies).not.toContain("accounting_profit_margin"); expect(userFacing).toContain("90/91/99"); - expect(userFacing).toContain("90.01"); expect(userFacing).toContain("7 136 815,85"); + expect(userFacing).not.toContain("90.01"); expect(userFacing).not.toContain("operating-flow/trading-margin proxy"); expect(result.reason_codes).toContain("pilot_derived_business_overview_accounting_financial_result_from_confirmed_rows"); expect(result.reason_codes).toContain("answer_contains_business_overview_accounting_financial_result"); @@ -850,7 +850,7 @@ describe("assistant MCP discovery runtime bridge", () => { evidence_status: "financial_institution_leads_outgoing_cash" }); expect(missingFamilies).not.toContain("vendor_risk_procurement_quality"); - expect(userFacing).toContain("procurement-concentration route"); + expect(userFacing).not.toContain("procurement-concentration route"); expect(userFacing).toContain("банк/финансовая организация"); expect(userFacing).toContain("Поставщик А"); expect(userFacing).toContain("Надежность поставщиков"); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index e60404a..9e5d917 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1861,6 +1861,62 @@ describe("assistantTransitionPolicy", () => { period_to: "2017-05-31" }); }); + + it("retargets same-period VAT payable follow-up away from current-date payable snapshots", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Receivables as of 2017-05-31 were collected for May.", + debug: { + detected_intent: "receivables_confirmed_as_of_date", + extracted_filters: { + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2017-05-31", + period_from: "2017-05-01", + period_to: "2017-05-31" + }, + anchor_type: "organization", + anchor_value_resolved: 'ООО "Альтернатива Плюс"' + } + }), + hasAddressFollowupContextSignal: () => true, + hasReferentialPointer: () => true, + resolveAddressIntent: () => ({ intent: "unknown" }), + resolveAddressIntentFamily: (intent: unknown) => { + if (String(intent ?? "").startsWith("receivables_")) return "receivables"; + if (String(intent ?? "").startsWith("vat_")) return "vat"; + return null; + }, + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "vat", + asked_action_family: "confirmed_snapshot", + explicit_intent_candidate: "vat_payable_confirmed_as_of_date", + explicit_entity_candidates: [], + intent_override_strength: "explicit_current_turn_intent", + stale_replay_forbidden: false + }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "а какой ндс мы должны примерно заплатить за этот период?", + [], + "Какой НДС мы должны заплатить за текущий период?", + { + predecomposeContract: { + intent: "vat_payable_confirmed_as_of_date" + } + }, + null + ); + + expect(carryover?.followupContext?.previous_intent).toBe("receivables_confirmed_as_of_date"); + expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + organization: 'ООО "Альтернатива Плюс"', + period_from: "2017-05-01", + period_to: "2017-05-31" + }); + }); it("carries metadata-scoped subjectless loop state through follow-up context", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({