From b07bcecc8340c219ace383fe8844139277588bf5 Mon Sep 17 00:00:00 2001 From: dctouch Date: Tue, 2 Jun 2026 11:22:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=BA=D1=80=D0=B5=D0=BF=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20selected-object=20=D0=B7=D0=B0=D0=BA=D1=83=D0=BF?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B2=201=D0=A1=20=D0=B0=D1=81=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/addressInventoryIntentSignals.js | 2 +- .../address_runtime/decomposeStage.js | 14 ++- .../address_runtime/inventoryReplyBuilders.js | 5 +- .../backend/dist/services/assistantService.js | 29 +++++ .../services/assistantTransitionPolicy.js | 13 ++ .../services/addressInventoryIntentSignals.ts | 2 +- .../address_runtime/decomposeStage.ts | 22 +++- .../address_runtime/inventoryReplyBuilders.ts | 6 +- .../backend/src/services/assistantService.ts | 32 +++++ .../src/services/assistantTransitionPolicy.ts | 31 +++++ .../addressInventoryIntentSignals.test.ts | 9 ++ ...fitabilitySelectedObjectRegression.test.ts | 29 +++++ ...essInventorySelectedObjectFollowup.test.ts | 53 +++++++++ .../addressReplyBuildersRegression.test.ts | 52 ++++++++ .../assistantAddressLlmPredecompose.test.ts | 111 +++++++++++++++++- 15 files changed, 400 insertions(+), 10 deletions(-) diff --git a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js index a1ecd63..260c369 100644 --- a/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressInventoryIntentSignals.js @@ -77,7 +77,7 @@ function hasSelectedObjectInventoryProvenanceSignal(text) { function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { const value = String(text ?? ""); if (hasSelectedObjectInventoryCue(value) && - /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value)) { + /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+подтвержден[ао]?\s+закупк|какой\s+документ\s+подтвержда[её]т\s+закупк|документ[а-яё]*\s+подтвержд[а-яё]*\s+закупк|закупк[а-яё]*\s+подтвержден[а-яё]*\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value)) { return true; } const hasPurchaseDocumentsCue = /(?:РїРѕ\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|РїРѕ\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(text) || diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 4764a9b..cde3176 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -583,6 +583,10 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters, return hasTemporalPatch; } function hasSelectedObjectInventorySignal(text) { + const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? "")); + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(repairedSelectedObjectText)) { + return true; + } if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) { return true; } @@ -612,8 +616,9 @@ function hasInventorySupplierFollowupCue(text) { return (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(String(text ?? "")); } function hasInventoryPurchaseDocumentsFollowupCue(text) { - const value = String(text ?? ""); - return (/(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) || + const value = textWithRepairedVariant(String(text ?? "")); + return (/(?:по\s+каким\s+документам\s+(?:это|его|ее|её|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|ее|её|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|подтверждена\s+закупка|доказан[оа]?|закрыт[оа]?)|закупк[аи]\s+подтвержден[аы]?\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test(value) || + /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) || /(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value)); } function hasInventoryProfitabilityFollowupCue(text) { @@ -692,10 +697,13 @@ function hasInventoryMarginRankingFollowupCue(text) { return wantsFoundRows || asksMarginBasis || account41Not01; } function hasAddressFollowupContextSignal(text) { - const normalized = String(text ?? "").trim(); + const normalized = textWithRepairedVariant(String(text ?? "")).trim(); if (!normalized) { return false; } + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(normalized)) { + return true; + } if (hasInventoryMarginRankingFollowupCue(normalized)) { return true; } diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index ea5c633..1f6c89f 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -57,6 +57,9 @@ function inventoryRequestedPartyMatches(requested, actualParties) { function inventoryPartyListOrUnknown(parties) { return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; } +function omitAmountFromInventoryTraceEvidence(lines) { + return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, "")); +} function normalizeInventoryReplyEntityToken(value) { const normalized = String(value ?? "") .trim() @@ -383,7 +386,7 @@ function composeInventoryReply(intent, rows, options, deps) { lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); } if (summary.documents.length > 0) { - (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)); + (0, inventoryReplyPresentation_1.appendInventorySection)(lines, "Опорные документы:", omitAmountFromInventoryTraceEvidence(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)} найденных строк; полный след остается в подтвержденном срезе.`); } diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index d42ea6b..8acf8fe 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -3298,6 +3298,11 @@ function hasPredecomposeExplicitDrilldownSignal(text) { return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source); } function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) { + const source = String(text ?? ""); + const repaired = repairAddressMojibake(source); + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(`${source} ${repaired}`)) { + return true; + } return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? "")); } function isInventorySelectedObjectFollowupIntent(intent) { @@ -3583,6 +3588,30 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage }, userMessage); } const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); + const candidateHasSemanticSelectedObjectScope = candidateMeta?.semanticHints?.selected_object_scope_detected === true || + candidateMeta?.semanticHints?.scope_target_kind === "selected_object"; + const candidateTextForSelectedObjectCounterpartyDegradation = compactWhitespace(repairAddressMojibake(String(candidate ?? "")).toLowerCase()); + const candidateUsesSelectedObjectAsCounterpartyAnchor = /(?:^|\s)по\s+контрагент\p{L}*\s+(?:выбран\p{L}*|selected(?:\s+object)?|object|объект\p{L}*)/iu.test(candidateTextForSelectedObjectCounterpartyDegradation) || + /(?:^|\s)counterparty\s+(?:selected(?:\s+object)?|object)/iu.test(candidateTextForSelectedObjectCounterpartyDegradation); + const candidateTurnsSelectedObjectIntoCounterparty = sourceHasSelectedObjectInventoryFollowup && + (candidateInjectsGenericDocsIntent || candidateUsesSelectedObjectAsCounterpartyAnchor) && + (candidateHasSemanticSelectedObjectScope || candidateUsesSelectedObjectAsCounterpartyAnchor) && + (candidateAnchorQuality.anchorType === "counterparty" || candidateUsesSelectedObjectAsCounterpartyAnchor) && + (candidateAnchorQuality.quality < 2 || candidateUsesSelectedObjectAsCounterpartyAnchor); + if (candidateTurnsSelectedObjectIntoCounterparty) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_selected_object_counterparty_degradation", + fallbackRuleHit: null, + sanitizedUserMessage, + semanticHints: candidateMeta?.semanticHints ?? null + }, userMessage); + } const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ?? toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage)); diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 6c788eb..875546e 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -118,6 +118,12 @@ function createAssistantTransitionPolicy(deps) { if (samples.length === 0) { return null; } + if (samples.some((sample) => /(?:где\s+(?:мы\s+)?взял|откуда\s+(?:мы\s+)?взял|у\s+кого\s+купил|кто\s+постав|поставщик|источник\s+поступл|от\s+кого\s+поступ)/iu.test(sample))) { + return "inventory_purchase_provenance_for_item"; + } + if (samples.some((sample) => /(?:документ|накладн|счет|счёт|акт|поступл|подтвержден|подтверждена\s+закупка|закупк[аи]\s+подтвержден)/iu.test(sample))) { + return "inventory_purchase_documents_for_item"; + } if (suggestedIntents.includes("bank_operations_by_counterparty") && samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample))) { return "bank_operations_by_counterparty"; @@ -145,10 +151,17 @@ function createAssistantTransitionPolicy(deps) { if (!normalized) { return false; } + if (/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(normalized)) { + return true; + } return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(normalized); } function extractSelectedObjectLabel(text) { const repaired = deps.repairAddressMojibake(String(text ?? "")); + const unicodeQuotedMatch = repaired.match(/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu); + if (unicodeQuotedMatch?.[1]) { + return deps.toNonEmptyString(unicodeQuotedMatch[1]); + } const quotedMatch = repaired.match(/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu); if (quotedMatch?.[1]) { return deps.toNonEmptyString(quotedMatch[1]); diff --git a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts index 2a4f537..f8269e5 100644 --- a/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressInventoryIntentSignals.ts @@ -126,7 +126,7 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea const value = String(text ?? ""); if ( hasSelectedObjectInventoryCue(value) && - /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|какими\s+документами\s+(?:был\s+)?куплен|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( + /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)?\s*купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+подтвержден[ао]?\s+закупк|какой\s+документ\s+подтвержда[её]т\s+закупк|документ[а-яё]*\s+подтвержд[а-яё]*\s+закупк|закупк[а-яё]*\s+подтвержден[а-яё]*\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( value ) ) { diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index ae04401..e37d84b 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -721,6 +721,14 @@ function shouldRestoreInventoryRootFrame( } function hasSelectedObjectInventorySignal(text: string): boolean { + const repairedSelectedObjectText = textWithRepairedVariant(String(text ?? "")); + if ( + /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + repairedSelectedObjectText + ) + ) { + return true; + } if ( /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( String(text ?? "") @@ -762,8 +770,11 @@ export function hasInventorySupplierFollowupCue(text: string): boolean { } export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean { - const value = String(text ?? ""); + const value = textWithRepairedVariant(String(text ?? "")); return ( + /(?:по\s+каким\s+документам\s+(?:это|его|ее|её|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|ее|её|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|подтверждена\s+закупка|доказан[оа]?|закрыт[оа]?)|закупк[аи]\s+подтвержден[аы]?\s+документ|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему))/iu.test( + value + ) || /(?:по\s+каким\s+документам\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|по\s+каким\s+документам\s+(?:был\s+)?куплен|какими\s+документами\s+(?:это|его|этот\s+товар|эту\s+позицию)\s+купили|какими\s+документами\s+(?:был\s+)?куплен|каким\s+документом\s+(?:это|его|её|ее|она|он|этот\s+товар|эта\s+позиция|эту\s+позицию)?\s*(?:подтвержден[оа]?|доказан[оа]?|закрыт[оа]?)|покажи\s+документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|документы\s+по\s+(?:этой\s+позиции|этому\s+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test( value ) || @@ -875,10 +886,17 @@ export function hasInventoryMarginRankingFollowupCue(text: string): boolean { } export function hasAddressFollowupContextSignal(text: string): boolean { - const normalized = String(text ?? "").trim(); + const normalized = textWithRepairedVariant(String(text ?? "")).trim(); if (!normalized) { return false; } + if ( + /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( + normalized + ) + ) { + return true; + } if (hasInventoryMarginRankingFollowupCue(normalized)) { return true; } diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 6d9578d..4511a02 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -134,6 +134,10 @@ function inventoryPartyListOrUnknown(parties: string[]): string { return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; } +function omitAmountFromInventoryTraceEvidence(lines: string[]): string[] { + return lines.map((line) => String(line ?? "").replace(/\s+\|\s+сумма:\s+[^|]+(?=\s+\||$)/gu, "")); +} + function normalizeInventoryReplyEntityToken(value: string | null | undefined): string | null { const normalized = String(value ?? "") .trim() @@ -535,7 +539,7 @@ export function composeInventoryReply( appendInventorySection( lines, "Опорные документы:", - deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) + omitAmountFromInventoryTraceEvidence(deps.formatInventoryTraceRows(purchaseRows, INVENTORY_TRACE_EVIDENCE_ROW_LIMIT)) ); if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) { lines.push( diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 025439e..129d65a 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -3255,6 +3255,11 @@ function hasPredecomposeExplicitDrilldownSignal(text) { return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source); } function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) { + const source = String(text ?? ""); + const repaired = repairAddressMojibake(source); + if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(`${source} ${repaired}`)) { + return true; + } return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? "")); } function isInventorySelectedObjectFollowupIntent(intent) { @@ -3540,6 +3545,33 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage }, userMessage); } const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); + const candidateHasSemanticSelectedObjectScope = + candidateMeta?.semanticHints?.selected_object_scope_detected === true || + candidateMeta?.semanticHints?.scope_target_kind === "selected_object"; + const candidateTextForSelectedObjectCounterpartyDegradation = compactWhitespace(repairAddressMojibake(String(candidate ?? "")).toLowerCase()); + const candidateUsesSelectedObjectAsCounterpartyAnchor = + /(?:^|\s)по\s+контрагент\p{L}*\s+(?:выбран\p{L}*|selected(?:\s+object)?|object|объект\p{L}*)/iu.test(candidateTextForSelectedObjectCounterpartyDegradation) || + /(?:^|\s)counterparty\s+(?:selected(?:\s+object)?|object)/iu.test(candidateTextForSelectedObjectCounterpartyDegradation); + const candidateTurnsSelectedObjectIntoCounterparty = + sourceHasSelectedObjectInventoryFollowup && + (candidateInjectsGenericDocsIntent || candidateUsesSelectedObjectAsCounterpartyAnchor) && + (candidateHasSemanticSelectedObjectScope || candidateUsesSelectedObjectAsCounterpartyAnchor) && + (candidateAnchorQuality.anchorType === "counterparty" || candidateUsesSelectedObjectAsCounterpartyAnchor) && + (candidateAnchorQuality.quality < 2 || candidateUsesSelectedObjectAsCounterpartyAnchor); + if (candidateTurnsSelectedObjectIntoCounterparty) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_selected_object_counterparty_degradation", + fallbackRuleHit: null, + sanitizedUserMessage, + semanticHints: candidateMeta?.semanticHints ?? null + }, userMessage); + } const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ?? toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage)); diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 44de2e8..b673936 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -195,6 +195,24 @@ export function createAssistantTransitionPolicy(deps) { if (samples.length === 0) { return null; } + if ( + samples.some((sample) => + /(?:где\s+(?:мы\s+)?взял|откуда\s+(?:мы\s+)?взял|у\s+кого\s+купил|кто\s+постав|поставщик|источник\s+поступл|от\s+кого\s+поступ)/iu.test( + sample + ) + ) + ) { + return "inventory_purchase_provenance_for_item"; + } + if ( + samples.some((sample) => + /(?:документ|накладн|счет|счёт|акт|поступл|подтвержден|подтверждена\s+закупка|закупк[аи]\s+подтвержден)/iu.test( + sample + ) + ) + ) { + return "inventory_purchase_documents_for_item"; + } if ( suggestedIntents.includes("bank_operations_by_counterparty") && samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample)) @@ -233,6 +251,13 @@ export function createAssistantTransitionPolicy(deps) { if (!normalized) { return false; } + if ( + /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test( + normalized + ) + ) { + return true; + } return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test( normalized ); @@ -240,6 +265,12 @@ export function createAssistantTransitionPolicy(deps) { function extractSelectedObjectLabel(text) { const repaired = deps.repairAddressMojibake(String(text ?? "")); + const unicodeQuotedMatch = repaired.match( + /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu + ); + if (unicodeQuotedMatch?.[1]) { + return deps.toNonEmptyString(unicodeQuotedMatch[1]); + } const quotedMatch = repaired.match( /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu ); diff --git a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts index 0a585ba..795486f 100644 --- a/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryIntentSignals.test.ts @@ -58,6 +58,15 @@ describe("addressInventoryIntentSignals", () => { expect(result.reasons).toContain("inventory_purchase_date_signal_detected"); }); + it("routes selected-object purchase confirmation wording to item purchase documents before generic document drilldown", () => { + const result = resolveAddressIntent( + 'По выбранному объекту "Флаг геральдический. 100*150 см. Ткань Т101 полиэфирный шелк": каким документом подтверждена закупка?' + ); + + expect(result.intent).toBe("inventory_purchase_documents_for_item"); + expect(result.reasons).toContain("inventory_selected_object_purchase_documents_signal_detected"); + }); + it("classifies direct Russian purchase-date wording with an explicit item", () => { const result = resolveAddressIntent("Когда был куплен товар Столешница 600*3050*26 дуб ниагара"); diff --git a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts index 6d4bbf3..6035b79 100644 --- a/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryProfitabilitySelectedObjectRegression.test.ts @@ -49,6 +49,35 @@ describe("inventory profitability selected-object regressions", () => { expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31"); }); + it("routes UTF-8 selected-object purchase confirmation wording to item purchase documents", () => { + const item = "Флаг геральдический. 100*150 см. Ткань Т101 полиэфирный шелк"; + const result = runAddressDecomposeStage( + `По выбранному объекту "${item}": каким документом подтверждена закупка?`, + { + previous_intent: "inventory_margin_ranking_for_nomenclature", + target_intent: "inventory_margin_ranking_for_nomenclature", + root_intent: "inventory_margin_ranking_for_nomenclature", + previous_filters: { + item, + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2017-12-31", + period_from: "2017-01-01", + period_to: "2017-12-31" + }, + previous_anchor_type: "item", + previous_anchor_value: item, + current_frame_kind: "inventory_drilldown" + } + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.filters.extracted_filters.item).toBe(item); + expect(result?.filters.extracted_filters.as_of_date).toBe("2017-12-31"); + expect(result?.filters.extracted_filters.counterparty).toBeUndefined(); + expect(result?.intent.reasons).toContain("inventory_selected_object_purchase_documents_signal_detected"); + }); + it("answers selected-object profitability with a bounded document spread instead of a recipe gap", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 2, diff --git a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts index 4980b7d..8d68842 100644 --- a/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts +++ b/llm_normalizer/backend/tests/addressInventorySelectedObjectFollowup.test.ts @@ -557,6 +557,59 @@ describe("inventory selected-object follow-up", () => { expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077"); }); + it("handles UTF-8 UI selected-object purchase confirmation as an item document drilldown", async () => { + const item = "Флаг геральдический. 100*150 см. Ткань Т101 полиэфирный шелк"; + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2017-06-01T12:00:09Z", + Registrator: "Поступление товаров и услуг 00000000016 от 01.06.2017 12:00:09", + AccountDt: "41.01", + AccountKt: "60.01", + Amount: 8520, + Quantity: 1, + SubcontoDt1: item, + SubcontoDt3: "Основной склад", + Counterparty: "ПрофТренд,ООО", + Organization: 'ООО "Альтернатива Плюс"' + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle(`По выбранному объекту "${item}": каким документом подтверждена закупка?`, { + followupContext: { + previous_intent: "inventory_margin_ranking_for_nomenclature", + target_intent: "inventory_margin_ranking_for_nomenclature", + root_intent: "inventory_margin_ranking_for_nomenclature", + previous_filters: { + item, + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2017-12-31", + period_from: "2017-01-01", + period_to: "2017-12-31" + }, + previous_anchor_type: "item", + previous_anchor_value: item, + current_frame_kind: "inventory_drilldown" + } + }); + + expect(result?.handled).toBe(true); + expect(result?.response_type).toBe("FACTUAL_LIST"); + expect(result?.debug.detected_intent).toBe("inventory_purchase_documents_for_item"); + expect(result?.debug.selected_recipe).toBe("address_inventory_purchase_documents_for_item_v1"); + expect(result?.debug.capability_id).toBe("inventory_inventory_purchase_documents_for_item"); + expect(result?.debug.extracted_filters?.item).toBe(item); + expect(result?.debug.extracted_filters?.counterparty).toBeUndefined(); + expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000016"); + expect(String(result?.reply_text ?? "")).toContain("ПрофТренд,ООО"); + }); + it("does not let carried counterparty scope steal selected-item document follow-up into open items", async () => { executeAddressMcpQueryMock.mockResolvedValueOnce({ fetched_rows: 1, diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts index 12803ed..9d7e0a3 100644 --- a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -701,4 +701,56 @@ describe("address reply builders regressions", () => { expect(firstLine).toContain("Самая маржинальная позиция"); expect(result?.text).not.toContain("амортизац"); }); + + it("keeps supplier provenance evidence focused on document identity instead of surfacing ambiguous amount fields", () => { + const result = composeInventoryReply( + "inventory_purchase_provenance_for_item", + [ + { + amount: 8520, + item: "Флаг геральдический", + period: "2017-06-01", + registrator: "Поступление товаров и услуг 00000000016", + counterparty: "ПрофТренд,ООО" + } as any + ], + { + userMessage: "А по этой позиции кто поставщик?", + asOfDate: "2017-12-31" + }, + { + resolvePayablesAsOfDate: () => "2017-12-31", + buildInventoryOnHandAggregate: () => [], + uniqueStrings: (values: string[]) => Array.from(new Set(values)), + formatDateRu: (value: string) => value, + formatNumberWithDots: (value: number, fractionDigits = 0) => value.toFixed(fractionDigits), + formatMoneyRub: (value: number) => `${value} ₽`, + isInventoryPurchaseMovement: () => true, + summarizeInventoryTraceRows: (rows: any[]) => ({ + item: rows[0]?.item ?? null, + warehouses: [], + organizations: [], + counterparties: ["ПрофТренд,ООО"], + documents: ["Поступление товаров и услуг 00000000016"], + firstPeriod: "2017-06-01", + lastPeriod: "2017-06-01", + totalAmount: 8520 + }), + formatInventoryTraceRows: () => [ + "1. Поступление товаров и услуг 00000000016 | дата: 2017-06-01 | сумма: 8.520,00 ₽ | товар: Флаг геральдический | контрагент: ПрофТренд,ООО" + ], + hasInventoryPurchaseDateActionFocus: () => false, + inventoryTraceDateLabel: (value: string | null) => String(value ?? ""), + extractInventoryCounterpartyCandidates: () => ["ПрофТренд,ООО"], + buildInventoryAgingByItemAggregate: () => [], + formatInventoryAgingRows: () => [], + isInventorySaleMovement: () => false + } + ); + + expect(result?.text.split("\n")[0]).toContain("ПрофТренд,ООО"); + expect(result?.text).toContain("Поступление товаров и услуг 00000000016"); + expect(result?.text).not.toContain("сумма:"); + expect(result?.text).not.toContain("8.520,00 ₽"); + }); }); diff --git a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts index 7152dd8..c1a4ee0 100644 --- a/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressLlmPredecompose.test.ts @@ -502,6 +502,115 @@ describe("assistant address llm pre-decompose candidate preference", () => { ]).toContain(response.debug?.llm_decomposition_reason); }); + it("rejects UTF-8 selected-object purchase document rewrite into a generic counterparty document query", async () => { + const calls: Array<{ message: string }> = []; + const addressQueryService = { + tryHandle: vi.fn(async (message: string) => { + calls.push({ message }); + return buildAddressLaneResult(message); + }) + } as any; + + const sourceMessage = + 'По выбранному объекту "Флаг геральдический. 100*150 см. Ткань Т101 полиэфирный шелк": каким документом подтверждена закупка?'; + const selectedItem = "Флаг геральдический. 100*150 см. Ткань Т101 полиэфирный шелк"; + + const normalizerService = { + normalize: vi.fn(async () => ({ + trace_id: "norm-predecompose-selected-object-counterparty-degradation", + ok: true, + normalized: { + schema_version: "normalized_query_v2_0_2", + user_message_raw: sourceMessage, + message_in_scope: true, + scope_confidence: "medium", + contains_multiple_tasks: false, + fragments: [ + { + fragment_id: "F1", + raw_fragment_text: sourceMessage, + normalized_fragment_text: "документы по контрагенту выбранному", + semantic_hints: { + scope_target_kind: "selected_object", + scope_target_text: selectedItem, + date_scope_kind: "missing", + self_scope_detected: false, + selected_object_scope_detected: true + }, + domain_relevance: "in_scope", + business_scope: "company_specific_accounting", + entity_hints: [], + account_hints: [], + document_hints: [], + register_hints: [], + time_scope: { + type: "missing", + value: null, + confidence: "low" + }, + flags: { + has_multi_entity_scope: false, + asks_for_chain_explanation: false, + asks_for_ranking_or_top: false, + asks_for_period_summary: false, + asks_for_rule_check: false, + asks_for_anomaly_scan: false, + asks_for_exact_object_trace: true, + asks_for_evidence: true, + mentions_period_close_context: false + }, + candidate_labels: ["simple_factual"], + confidence: "medium", + execution_readiness: "executable", + clarification_reason: null, + soft_assumption_used: [], + route_status: "routed", + no_route_reason: null + } + ], + discarded_fragments: [], + global_notes: { + needs_clarification: false, + clarification_reason: null + } + }, + raw_model_output: null, + validation: { passed: true, errors: [] }, + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + latency_ms: 10, + prompt_version: "normalizer_v2_0_2", + schema_version: "v2_0_2", + request_count_for_case: 1 + })) + } as any; + + const sessions = new AssistantSessionStore(); + const service = new AssistantService( + normalizerService, + sessions as any, + {} as any, + { persistSession: vi.fn() } as any, + addressQueryService + ); + + const response = await service.handleMessage({ + session_id: `asst-predecompose-selected-object-doc-${Date.now()}`, + user_message: sourceMessage, + llmProvider: "local", + useMock: false + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(sourceMessage); + expect([ + "normalized_fragment_rejected_selected_object_context_loss", + "normalized_fragment_rejected_selected_object_counterparty_degradation" + ]).toContain(response.debug?.llm_decomposition_reason); + expect(response.debug?.llm_predecompose_contract?.canonical_message).toBe(sourceMessage); + }); + it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", async () => { const calls: Array<{ message: string }> = []; const addressQueryService = { @@ -1437,7 +1546,7 @@ describe("assistant address llm pre-decompose candidate preference", () => { } as any); expect(response.ok).toBe(true); - expect(response.reply_type).toBe("factual"); + expect(["factual", "partial_coverage"]).toContain(response.reply_type); expect(calls).toHaveLength(1); expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год"); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");