Закрепить selected-object закупки в 1С ассистенте

This commit is contained in:
dctouch 2026-06-02 11:22:30 +03:00
parent d77bcbe482
commit b07bcecc83
15 changed files with 400 additions and 10 deletions

View File

@ -77,7 +77,7 @@ function hasSelectedObjectInventoryProvenanceSignal(text) {
function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) { function hasSelectedObjectInventoryPurchaseDocumentsSignal(text) {
const value = String(text ?? ""); const value = String(text ?? "");
if (hasSelectedObjectInventoryCue(value) && 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; 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) || 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) ||

View File

@ -583,6 +583,10 @@ function shouldRestoreInventoryRootFrame(userMessage, intent, extractedFilters,
return hasTemporalPatch; return hasTemporalPatch;
} }
function hasSelectedObjectInventorySignal(text) { 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 ?? ""))) { if (/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(String(text ?? ""))) {
return true; return true;
} }
@ -612,8 +616,9 @@ function hasInventorySupplierFollowupCue(text) {
return (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(String(text ?? "")); return (0, inventoryLifecycleCueHelpers_1.hasInventorySupplierCue)(String(text ?? ""));
} }
function hasInventoryPurchaseDocumentsFollowupCue(text) { function hasInventoryPurchaseDocumentsFollowupCue(text) {
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+товару|ней|нему)|purchase\s+documents|documents\s+of\s+purchase|through\s+which\s+documents)/iu.test(value) || 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)); /(?:(?:покажи|показать|выведи|дай)?[\s\S]{0,30}док(?:и|умент[а-яё]*)[\s\S]{0,80}(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару)|операци)|(?:по\s+(?:ним|ней|нему|этой\s+позиции|этому\s+товару))[\s\S]{0,80}док(?:и|умент[а-яё]*))/iu.test(value));
} }
function hasInventoryProfitabilityFollowupCue(text) { function hasInventoryProfitabilityFollowupCue(text) {
@ -692,10 +697,13 @@ function hasInventoryMarginRankingFollowupCue(text) {
return wantsFoundRows || asksMarginBasis || account41Not01; return wantsFoundRows || asksMarginBasis || account41Not01;
} }
function hasAddressFollowupContextSignal(text) { function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim(); const normalized = textWithRepairedVariant(String(text ?? "")).trim();
if (!normalized) { if (!normalized) {
return false; 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)) { if (hasInventoryMarginRankingFollowupCue(normalized)) {
return true; return true;
} }

View File

@ -57,6 +57,9 @@ function inventoryRequestedPartyMatches(requested, actualParties) {
function inventoryPartyListOrUnknown(parties) { function inventoryPartyListOrUnknown(parties) {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; 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) { function normalizeInventoryReplyEntityToken(value) {
const normalized = String(value ?? "") const normalized = String(value ?? "")
.trim() .trim()
@ -383,7 +386,7 @@ function composeInventoryReply(intent, rows, options, deps) {
lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`); lines.push(`- Для ответа проверены закупочные документы не позже ${boundedAsOfLabel}.`);
} }
if (summary.documents.length > 0) { 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) { if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`); lines.push(`- Показаны первые ${INVENTORY_TRACE_EVIDENCE_ROW_LIMIT} из ${deps.formatNumberWithDots(purchaseRows.length)} найденных строк; полный след остается в подтвержденном срезе.`);
} }

View File

@ -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); return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
} }
function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) { 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 ?? "")); return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? ""));
} }
function isInventorySelectedObjectFollowupIntent(intent) { function isInventorySelectedObjectFollowupIntent(intent) {
@ -3583,6 +3588,30 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
}, userMessage); }, userMessage);
} }
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); 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 sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ?? const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage)); toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage));

View File

@ -118,6 +118,12 @@ function createAssistantTransitionPolicy(deps) {
if (samples.length === 0) { if (samples.length === 0) {
return null; 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") && if (suggestedIntents.includes("bank_operations_by_counterparty") &&
samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample))) { samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample))) {
return "bank_operations_by_counterparty"; return "bank_operations_by_counterparty";
@ -145,10 +151,17 @@ function createAssistantTransitionPolicy(deps) {
if (!normalized) { if (!normalized) {
return false; 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); return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(normalized);
} }
function extractSelectedObjectLabel(text) { function extractSelectedObjectLabel(text) {
const repaired = deps.repairAddressMojibake(String(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); const quotedMatch = repaired.match(/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu);
if (quotedMatch?.[1]) { if (quotedMatch?.[1]) {
return deps.toNonEmptyString(quotedMatch[1]); return deps.toNonEmptyString(quotedMatch[1]);

View File

@ -126,7 +126,7 @@ function hasSelectedObjectInventoryPurchaseDocumentsSignal(text: string): boolea
const value = String(text ?? ""); const value = String(text ?? "");
if ( if (
hasSelectedObjectInventoryCue(value) && 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 value
) )
) { ) {

View File

@ -721,6 +721,14 @@ function shouldRestoreInventoryRootFrame(
} }
function hasSelectedObjectInventorySignal(text: string): boolean { 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 ( if (
/(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test( /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|по\s+ней|по\s+нему|по\s+ним|for\s+selected\s+object|selected\s+object)/iu.test(
String(text ?? "") String(text ?? "")
@ -762,8 +770,11 @@ export function hasInventorySupplierFollowupCue(text: string): boolean {
} }
export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean { export function hasInventoryPurchaseDocumentsFollowupCue(text: string): boolean {
const value = String(text ?? ""); const value = textWithRepairedVariant(String(text ?? ""));
return ( 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( /(?:по\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 value
) || ) ||
@ -875,10 +886,17 @@ export function hasInventoryMarginRankingFollowupCue(text: string): boolean {
} }
export function hasAddressFollowupContextSignal(text: string): boolean { export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim(); const normalized = textWithRepairedVariant(String(text ?? "")).trim();
if (!normalized) { if (!normalized) {
return false; 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)) { if (hasInventoryMarginRankingFollowupCue(normalized)) {
return true; return true;
} }

View File

@ -134,6 +134,10 @@ function inventoryPartyListOrUnknown(parties: string[]): string {
return parties.length > 0 ? parties.slice(0, 4).join("; ") : "не выделен отдельным полем"; 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 { function normalizeInventoryReplyEntityToken(value: string | null | undefined): string | null {
const normalized = String(value ?? "") const normalized = String(value ?? "")
.trim() .trim()
@ -535,7 +539,7 @@ export function composeInventoryReply(
appendInventorySection( appendInventorySection(
lines, 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) { if (purchaseRows.length > INVENTORY_TRACE_EVIDENCE_ROW_LIMIT) {
lines.push( lines.push(

View File

@ -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); return ADDRESS_DOCS_SIGNAL_PATTERN.test(source) || ADDRESS_BANK_SIGNAL_PATTERN.test(source) || ADDRESS_CONTRACT_SIGNAL_PATTERN.test(source);
} }
function hasSelectedObjectInventoryFollowupSignalForPredecompose(text) { 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 ?? "")); return /(?:по\s+выбранному\s+объекту|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(String(text ?? ""));
} }
function isInventorySelectedObjectFollowupIntent(intent) { function isInventorySelectedObjectFollowupIntent(intent) {
@ -3540,6 +3545,33 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
}, userMessage); }, userMessage);
} }
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); 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 sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ?? const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage)); toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(repairedSourceMessage || userMessage));

View File

@ -195,6 +195,24 @@ export function createAssistantTransitionPolicy(deps) {
if (samples.length === 0) { if (samples.length === 0) {
return null; 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 ( if (
suggestedIntents.includes("bank_operations_by_counterparty") && suggestedIntents.includes("bank_operations_by_counterparty") &&
samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample)) samples.some((sample) => hasBankOperationsPivotCue(sample) || hasReadableBankOperationsPivotCue(sample))
@ -233,6 +251,13 @@ export function createAssistantTransitionPolicy(deps) {
if (!normalized) { if (!normalized) {
return false; 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( return /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции|по\s+этой\s+позиции|по\s+этому\s+товару|selected\s+object)/iu.test(
normalized normalized
); );
@ -240,6 +265,12 @@ export function createAssistantTransitionPolicy(deps) {
function extractSelectedObjectLabel(text) { function extractSelectedObjectLabel(text) {
const repaired = deps.repairAddressMojibake(String(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( const quotedMatch = repaired.match(
/(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu /(?:по\s+выбранному\s+объекту|по\s+выбранной\s+позиции)\s*[:,-]?\s*[«"]([^"»]+?)["»]/iu
); );

View File

@ -58,6 +58,15 @@ describe("addressInventoryIntentSignals", () => {
expect(result.reasons).toContain("inventory_purchase_date_signal_detected"); 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", () => { it("classifies direct Russian purchase-date wording with an explicit item", () => {
const result = resolveAddressIntent("Когда был куплен товар Столешница 600*3050*26 дуб ниагара"); const result = resolveAddressIntent("Когда был куплен товар Столешница 600*3050*26 дуб ниагара");

View File

@ -49,6 +49,35 @@ describe("inventory profitability selected-object regressions", () => {
expect(result?.filters.extracted_filters.period_to).toBe("2020-05-31"); 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 () => { it("answers selected-object profitability with a bounded document spread instead of a recipe gap", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 2, fetched_rows: 2,

View File

@ -557,6 +557,59 @@ describe("inventory selected-object follow-up", () => {
expect(String(result?.reply_text ?? "")).toContain("Поступление товаров и услуг 00000000077"); 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 () => { it("does not let carried counterparty scope steal selected-item document follow-up into open items", async () => {
executeAddressMcpQueryMock.mockResolvedValueOnce({ executeAddressMcpQueryMock.mockResolvedValueOnce({
fetched_rows: 1, fetched_rows: 1,

View File

@ -701,4 +701,56 @@ describe("address reply builders regressions", () => {
expect(firstLine).toContain("Самая маржинальная позиция"); expect(firstLine).toContain("Самая маржинальная позиция");
expect(result?.text).not.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 ₽");
});
}); });

View File

@ -502,6 +502,115 @@ describe("assistant address llm pre-decompose candidate preference", () => {
]).toContain(response.debug?.llm_decomposition_reason); ]).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 () => { it("accepts exact selected-object sale rewrite when llm candidate stays on the same item", async () => {
const calls: Array<{ message: string }> = []; const calls: Array<{ message: string }> = [];
const addressQueryService = { const addressQueryService = {
@ -1437,7 +1546,7 @@ describe("assistant address llm pre-decompose candidate preference", () => {
} as any); } as any);
expect(response.ok).toBe(true); 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).toHaveLength(1);
expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год"); expect(calls[0].message).toBe("документы по контрагенту свк за 2020 год");
expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm"); expect(response.debug?.llm_decomposition_reason).toBe("fallback_rule_applied_after_llm");