Post-F: закрепить семантическую целостность MCP-цепочек

This commit is contained in:
2026-04-24 18:35:12 +03:00
parent 830e05fe97
commit 796a9a93f2
24 changed files with 2408 additions and 104 deletions
@@ -52,6 +52,14 @@ function isInternalMechanicsLine(value) {
function userFacingUnknowns(values) {
return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value));
}
function rankedValueFlowUnknownLines(pilot) {
if (!pilot.derived_ranked_value_flow) {
return userFacingUnknowns(pilot.evidence.unknown_facts);
}
const ranking = pilot.derived_ranked_value_flow;
const period = ranking.period_scope ? `периода ${ranking.period_scope}` : "проверенного окна";
return [`Полный рейтинг контрагентов вне ${period} этим поиском не подтвержден.`];
}
function userFacingLimitations(values) {
return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value));
}
@@ -718,13 +726,15 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
if (monthlyConfirmedLines.length > 0) {
pushReason(reasonCodes, "answer_contains_monthly_breakdown");
}
const confirmedLines = derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [...pilot.evidence.confirmed_facts, derivedMetadataLine]
: pilot.evidence.confirmed_facts;
const confirmedLines = pilot.derived_ranked_value_flow && derivedValueLine
? [derivedValueLine]
: derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
: derivedMetadataLine
? [...pilot.evidence.confirmed_facts, derivedMetadataLine]
: pilot.evidence.confirmed_facts;
return {
schema_version: exports.ASSISTANT_MCP_DISCOVERY_ANSWER_DRAFT_SCHEMA_VERSION,
policy_owner: "assistantMcpDiscoveryAnswerAdapter",
@@ -732,7 +742,7 @@ function buildAssistantMcpDiscoveryAnswerDraft(pilot) {
headline: headlineFor(mode, pilot),
confirmed_lines: uniqueStrings(confirmedLines),
inference_lines: uniqueStrings(inferenceLines),
unknown_lines: userFacingUnknowns(pilot.evidence.unknown_facts),
unknown_lines: rankedValueFlowUnknownLines(pilot),
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
next_step_line: nextStepFor(mode, pilot),
internal_mechanics_allowed: false,
@@ -59,9 +59,42 @@ function isReferentialEntityPlaceholder(value) {
"этом"
]).has((0, addressTextRepair_1.normalizeRussianComparableText)(value));
}
function isReferentialOrganizationPlaceholder(value) {
if (!value) {
return false;
}
return new Set([
"эта организация",
"этой организации",
"этой организацией",
"эту организацию",
"эта компания",
"этой компании",
"этой компанией",
"эту компанию",
"наша организация",
"нашей организации",
"нашей компанией"
]).has((0, addressTextRepair_1.normalizeRussianComparableText)(value));
}
function isValueFlowPredicateEntityCandidate(value) {
if (!value) {
return false;
}
const text = compactLower(value);
const looksLikeRankingPredicate = /(?:прин[её]с|принес|выручк|доходн|больше\s+всего|наибольш)/iu.test(text) &&
/(?:организац|компан|ден[её]г|выручк|поступлен|плат[её]ж)/iu.test(text);
if (!looksLikeRankingPredicate) {
return false;
}
return !/(?<!\p{L})(?:ооо|ип|ао|пао|зао|llc|inc|corp)(?!\p{L})/iu.test(text);
}
function isInvalidEntityCandidate(value) {
return Boolean(value && (isReferentialEntityPlaceholder(value) || isValueFlowPredicateEntityCandidate(value)));
}
function normalizeFollowupCounterpartyCandidate(value) {
const text = candidateValue(value);
if (!text || isReferentialEntityPlaceholder(text)) {
if (!text || isInvalidEntityCandidate(text)) {
return null;
}
return text;
@@ -71,7 +104,7 @@ function pushScopedEntityCandidate(target, value, groundedFollowupEntity) {
if (!text) {
return;
}
if (groundedFollowupEntity && isReferentialEntityPlaceholder(text)) {
if ((groundedFollowupEntity && isReferentialEntityPlaceholder(text)) || isValueFlowPredicateEntityCandidate(text)) {
return;
}
pushUnique(target, text);
@@ -88,7 +121,7 @@ function pushNormalizedEntityResolutionCandidate(target, value) {
return;
}
const normalized = canonicalizeEntityResolutionCandidate(text);
if (normalized && !target.includes(normalized)) {
if (normalized && !isInvalidEntityCandidate(normalized) && !target.includes(normalized)) {
target.push(normalized);
}
}
@@ -119,18 +152,25 @@ function collectEntityCandidates(value) {
const result = [];
if (Array.isArray(value)) {
for (const item of value) {
pushUnique(result, candidateValue(item));
const candidate = candidateValue(item);
if (!isInvalidEntityCandidate(candidate)) {
pushUnique(result, candidate);
}
}
return result;
}
pushUnique(result, candidateValue(value));
const candidate = candidateValue(value);
if (!isInvalidEntityCandidate(candidate)) {
pushUnique(result, candidate);
}
return result;
}
function collectPredecomposeEntities(predecompose) {
const entities = toRecordObject(predecompose?.entities);
const organization = toNonEmptyString(entities?.organization);
return {
counterparty: toNonEmptyString(entities?.counterparty),
organization: toNonEmptyString(entities?.organization)
organization: isReferentialOrganizationPlaceholder(organization) ? null : organization
};
}
function collectDateScope(predecompose) {
@@ -541,23 +581,25 @@ function hasMetadataDownstreamContinuationSignal(text) {
}
function hasEntityResolutionSignal(text) {
const hasSearchVerb = /(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|поиск|search|find|look\s*up)/iu.test(text);
const hasEntityNoun = /(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)/iu.test(text);
const hasEntityNoun = /(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)/iu.test(text);
return hasSearchVerb && hasEntityNoun;
}
function normalizeEntityResolutionCandidate(value) {
return value
.replace(/^(?:в\s*1с\s+|в\s+1c\s+|по\s+имени\s+)/iu, "")
.replace(/[?!.]+$/gu, "")
.replace(/^(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?)\s+/iu, "")
.replace(/^(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?)\s+/iu, "")
.replace(/^(?:counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+/iu, "")
.replace(/^группу\s+/iu, "Группа ")
.replace(/^[«"'\s]+|[»"'\s]+$/gu, "")
.replace(/\s+/g, " ")
.trim();
}
function rawEntityResolutionCandidate(text) {
const patterns = [
/(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\s+(?:в\s*1с\s+|в\s+1c\s+)?(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+)$/iu,
/(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+?)\s+(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\b/iu
/(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\s+(?:в\s*1с\s+|в\s+1c\s+)?(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+)$/iu,
/(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\s+(?:в\s*1с\s+|в\s+1c\s+)(.+)$/iu,
/(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+?)\s+(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\b/iu
];
for (const pattern of patterns) {
const match = text.match(pattern);
@@ -797,6 +839,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? resolveEntityResolutionAmbiguityChoice(rawEntitySourceText, followupSeed.entityResolutionAmbiguityCandidates)
: null;
const entityResolutionSignal = rawEntityResolutionSignal || Boolean(rawEntityCandidate) || Boolean(entityResolutionClarificationCandidate);
const rawEntitySearchOverridesStaleScope = Boolean(rawEntityCandidate && entityResolutionSignal);
const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family);
const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family);
const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis);
@@ -815,10 +858,14 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
hasMovementEvidenceFollowupSignal(rawText) ||
hasPronounMovementEvidenceFollowupSignal(rawText);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(rawAssistantTurnMeaningOrganizationScope)
? null
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope;
const currentTurnFreshOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization;
const currentTurnOrganizationScope = currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const explicitOrganizationScopeSignal = Boolean(rawOrganizationMentionSignal && currentTurnOrganizationScope);
const organizationClarificationFollowupApplicable = Boolean(followupSeed.domain === "counterparty_value" &&
!followupSeed.counterparty &&
@@ -1130,30 +1177,40 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
lifecycleSignal ||
metadataGroundedDocumentLaneApplicable ||
metadataGroundedMovementLaneApplicable));
const metadataLaneScopeHint = explicitCurrentCounterpartyOverridesFollowupEntity
const metadataLaneScopeHint = rawEntitySearchOverridesStaleScope
? null
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ??
followupSeed.metadataSelectedEntitySet ??
null;
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
followupSeed.discoveryEntity ??
followupSeed.metadataSelectedEntitySet ??
null;
const metadataScopedLaneWithoutSubject = Boolean((metadataGroundedMovementLaneApplicable || metadataGroundedDocumentLaneApplicable) &&
!followupSeed.counterparty &&
metadataLaneCarryoverAvailable);
const groundedFollowupEntity = metadataScopedLaneWithoutSubject
? null
: explicitCurrentCounterpartyOverridesFollowupEntity
: rawEntitySearchOverridesStaleScope
? null
: followupSeed.counterparty ?? followupSeed.discoveryEntity;
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: followupSeed.counterparty ?? followupSeed.discoveryEntity;
const entityCandidates = entityResolutionSignal ? [] : [];
if (entityResolutionSignal) {
pushNormalizedEntityResolutionCandidate(entityCandidates, entityResolutionClarificationCandidate);
pushNormalizedEntityResolutionCandidate(entityCandidates, rawEntityCandidate);
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, candidate);
if (!rawEntitySearchOverridesStaleScope || sameScopedName(candidate, rawEntityCandidate)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, candidate);
}
}
if (!rawEntitySearchOverridesStaleScope || sameScopedName(normalizedPredecomposeCounterparty, rawEntityCandidate)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, normalizedPredecomposeCounterparty);
}
if (!rawEntitySearchOverridesStaleScope) {
pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty);
}
pushNormalizedEntityResolutionCandidate(entityCandidates, normalizedPredecomposeCounterparty);
pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty);
}
else {
if (groundedFollowupEntity) {
@@ -1190,9 +1247,12 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
pushUnique(entityCandidates, predecomposeEntities.organization);
pushUnique(entityCandidates, followupSeed.organization);
}
const explicitOrganizationScope = valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? currentTurnOrganizationScope ?? followupSeed.organization
: null;
const explicitOrganizationScope = rawEntitySearchOverridesStaleScope && !currentTurnFreshOrganizationScope
? null
: valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? (rawEntitySearchOverridesStaleScope ? currentTurnFreshOrganizationScope : currentTurnOrganizationScope) ??
followupSeed.organization
: null;
if (explicitCurrentCounterpartyCandidate &&
(valueFlowSignal || lifecycleSignal || metadataGroundedDocumentLaneApplicable || metadataGroundedMovementLaneApplicable)) {
for (let index = entityCandidates.length - 1; index >= 0; index -= 1) {
@@ -1226,11 +1286,16 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
(clarificationLoopStillNeedsPeriod ||
openScopeValueFlowWithoutResolvedCounterparty ||
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal))));
const normalizedPredecomposeDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope) ? null : predecomposeDateScope;
const normalizedAssistantTurnMeaningDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope)
const normalizedPredecomposeDateScope = (rawEntitySearchOverridesStaleScope && !currentTurnCarriesExplicitPeriod) ||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope))
? null
: predecomposeDateScope;
const normalizedAssistantTurnMeaningDateScope = rawEntitySearchOverridesStaleScope ||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope))
? null
: assistantTurnMeaningDateScope;
const normalizedFollowupDateScope = suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)
const normalizedFollowupDateScope = rawEntitySearchOverridesStaleScope ||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope))
? null
: followupSeed.dateScope;
const explicitDateScope = rawAllTimeScopeSignal
@@ -1277,7 +1342,9 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
? metadataActionFromRawText(rawText) ?? seededAction
: rawAction ?? seededAction,
asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis,
seeded_ranking_need: valueFlowSignal && followupSeed.rankingNeed ? followupSeed.rankingNeed : undefined,
seeded_ranking_need: valueFlowSignal && followupSeed.rankingNeed && !rawEntitySearchOverridesStaleScope
? followupSeed.rankingNeed
: undefined,
explicit_entity_candidates: entityCandidates,
metadata_ambiguity_entity_sets: metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0
? followupSeed.metadataAmbiguityEntitySets
@@ -1381,28 +1448,30 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
groundedValueFlowFollowupApplicable
});
const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0;
const sourceSignal = assistantTurnMeaning
? "assistant_turn_meaning"
: followupDiscoverySeedApplicable ||
Boolean(entityResolutionClarificationCandidate) ||
effectiveMetadataFollowupSeedApplicable ||
metadataAmbiguityLaneClarificationApplicable
? "followup_context"
: metadataGroundedMovementLaneApplicable
const sourceSignal = rawEntitySearchOverridesStaleScope
? "raw_text"
: assistantTurnMeaning
? "assistant_turn_meaning"
: followupDiscoverySeedApplicable ||
Boolean(entityResolutionClarificationCandidate) ||
effectiveMetadataFollowupSeedApplicable ||
metadataAmbiguityLaneClarificationApplicable
? "followup_context"
: metadataGroundedDocumentLaneApplicable
: metadataGroundedMovementLaneApplicable
? "followup_context"
: predecomposeContract
? "predecompose_contract"
: lifecycleSignal
? "raw_text"
: valueFlowSignal
: metadataGroundedDocumentLaneApplicable
? "followup_context"
: predecomposeContract
? "predecompose_contract"
: lifecycleSignal
? "raw_text"
: entityResolutionSignal
: valueFlowSignal
? "raw_text"
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
: entityResolutionSignal
? "raw_text"
: "none";
: rawMetadataSignal || effectiveMetadataFollowupSeedApplicable
? "raw_text"
: "none";
if (lifecycleSignal) {
pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected");
}
@@ -1521,7 +1590,7 @@ function buildAssistantMcpDiscoveryTurnInput(input) {
normalizedPredecomposeCounterparty) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");
}
if (followupSeed.counterparty) {
if (followupSeed.counterparty && !rawEntitySearchOverridesStaleScope) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_followup_context");
}
if (followupDateScopeApplied) {
+20 -8
View File
@@ -1932,7 +1932,7 @@ function textMojibakeScoreForAddress(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ?’“”•–—™љ›њќћџ]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ\uFFFD?’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
@@ -1942,7 +1942,7 @@ function looksLikeMojibakeForAddress(value) {
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ?’“”•–—™љ›њќћџ]/.test(source)) {
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ\uFFFD?’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
@@ -2173,7 +2173,7 @@ function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`“”„’?]/g, " ")
.replace(/[«»"'`“”„’\uFFFD?]/g, " ")
.replace(/[^a-zа-я0-9\s._-]+/giu, " "));
}
function normalizeCounterpartyTokenForFollowupMatch(value) {
@@ -2219,7 +2219,7 @@ function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknow
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
counterpartyCandidate = parts[1] ?? counterpartyCandidate;
}
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`?]+|["'«»“”„`?]+$/gu, ""));
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`\uFFFD?]+|["'«»“”„`\uFFFD?]+$/gu, ""));
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
@@ -3268,6 +3268,11 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) {
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) ||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(source));
}
function isCounterpartyDrilldownIntentForPredecompose(intent) {
return intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty";
}
function hasPredecomposeDiagnosticUncertaintyLead(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
@@ -3486,8 +3491,16 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceHasExplicitAccountAnchor = (0, addressIntentResolver_1.hasAccountNumberAnchor)(repairedSourceMessage || userMessage) ||
(0, addressIntentResolver_1.hasCompactAccountCodeToken)(repairedSourceMessage || userMessage);
const candidateInjectsAccountAnchor = Boolean(toNonEmptyString(candidatePredecomposeContract?.entities?.account));
if (sourceIntentResolution.intent === "inventory_on_hand_as_of_date" &&
candidateIntentResolution.intent === "inventory_on_hand_as_of_date" &&
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAccountInjectedIntoCounterpartyAnchor = isCounterpartyDrilldownIntentForPredecompose(sourceIntentResolution.intent) &&
sourceIntentResolution.intent === candidateIntentResolution.intent &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
!sourceHasExplicitAccountAnchor &&
candidateInjectsAccountAnchor;
if (((sourceIntentResolution.intent === "inventory_on_hand_as_of_date" &&
candidateIntentResolution.intent === "inventory_on_hand_as_of_date") ||
candidateAccountInjectedIntoCounterpartyAnchor) &&
!sourceHasExplicitAccountAnchor &&
candidateInjectsAccountAnchor) {
return attachAddressPredecomposeContract({
@@ -3500,10 +3513,9 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
reason: "normalized_fragment_rejected_anchor_injection",
fallbackRuleHit: null,
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
semanticHints: null
}, userMessage);
}
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
@@ -82,6 +82,15 @@ function userFacingUnknowns(values: string[]): string[] {
return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value));
}
function rankedValueFlowUnknownLines(pilot: AssistantMcpDiscoveryPilotExecutionContract): string[] {
if (!pilot.derived_ranked_value_flow) {
return userFacingUnknowns(pilot.evidence.unknown_facts);
}
const ranking = pilot.derived_ranked_value_flow;
const period = ranking.period_scope ? `периода ${ranking.period_scope}` : "проверенного окна";
return [`Полный рейтинг контрагентов вне ${period} этим поиском не подтвержден.`];
}
function userFacingLimitations(values: string[]): string[] {
return uniqueStrings(values).filter((value) => !isInternalMechanicsLine(value));
}
@@ -856,7 +865,9 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
if (monthlyConfirmedLines.length > 0) {
pushReason(reasonCodes, "answer_contains_monthly_breakdown");
}
const confirmedLines = derivedValueLine
const confirmedLines = pilot.derived_ranked_value_flow && derivedValueLine
? [derivedValueLine]
: derivedValueLine
? [...pilot.evidence.confirmed_facts, derivedValueLine, ...monthlyConfirmedLines]
: derivedEntityResolutionLine
? [...pilot.evidence.confirmed_facts, derivedEntityResolutionLine]
@@ -871,7 +882,7 @@ export function buildAssistantMcpDiscoveryAnswerDraft(
headline: headlineFor(mode, pilot),
confirmed_lines: uniqueStrings(confirmedLines),
inference_lines: uniqueStrings(inferenceLines),
unknown_lines: userFacingUnknowns(pilot.evidence.unknown_facts),
unknown_lines: rankedValueFlowUnknownLines(pilot),
limitation_lines: userFacingLimitations([...pilot.query_limitations, ...pilot.evidence.query_limitations]),
next_step_line: nextStepFor(mode, pilot),
internal_mechanics_allowed: false,
@@ -102,9 +102,46 @@ function isReferentialEntityPlaceholder(value: string): boolean {
]).has(normalizeRussianComparableText(value));
}
function isReferentialOrganizationPlaceholder(value: string | null): boolean {
if (!value) {
return false;
}
return new Set([
"эта организация",
"этой организации",
"этой организацией",
"эту организацию",
"эта компания",
"этой компании",
"этой компанией",
"эту компанию",
"наша организация",
"нашей организации",
"нашей компанией"
]).has(normalizeRussianComparableText(value));
}
function isValueFlowPredicateEntityCandidate(value: string | null): boolean {
if (!value) {
return false;
}
const text = compactLower(value);
const looksLikeRankingPredicate =
/(?:прин[её]с|принес|выручк|доходн|больше\s+всего|наибольш)/iu.test(text) &&
/(?:организац|компан|ден[её]г|выручк|поступлен|плат[её]ж)/iu.test(text);
if (!looksLikeRankingPredicate) {
return false;
}
return !/(?<!\p{L})(?:ооо|ип|ао|пао|зао|llc|inc|corp)(?!\p{L})/iu.test(text);
}
function isInvalidEntityCandidate(value: string | null): boolean {
return Boolean(value && (isReferentialEntityPlaceholder(value) || isValueFlowPredicateEntityCandidate(value)));
}
function normalizeFollowupCounterpartyCandidate(value: unknown): string | null {
const text = candidateValue(value);
if (!text || isReferentialEntityPlaceholder(text)) {
if (!text || isInvalidEntityCandidate(text)) {
return null;
}
return text;
@@ -119,7 +156,7 @@ function pushScopedEntityCandidate(
if (!text) {
return;
}
if (groundedFollowupEntity && isReferentialEntityPlaceholder(text)) {
if ((groundedFollowupEntity && isReferentialEntityPlaceholder(text)) || isValueFlowPredicateEntityCandidate(text)) {
return;
}
pushUnique(target, text);
@@ -138,7 +175,7 @@ function pushNormalizedEntityResolutionCandidate(target: string[], value: unknow
return;
}
const normalized = canonicalizeEntityResolutionCandidate(text);
if (normalized && !target.includes(normalized)) {
if (normalized && !isInvalidEntityCandidate(normalized) && !target.includes(normalized)) {
target.push(normalized);
}
}
@@ -175,11 +212,17 @@ function collectEntityCandidates(value: unknown): string[] {
const result: string[] = [];
if (Array.isArray(value)) {
for (const item of value) {
pushUnique(result, candidateValue(item));
const candidate = candidateValue(item);
if (!isInvalidEntityCandidate(candidate)) {
pushUnique(result, candidate);
}
}
return result;
}
pushUnique(result, candidateValue(value));
const candidate = candidateValue(value);
if (!isInvalidEntityCandidate(candidate)) {
pushUnique(result, candidate);
}
return result;
}
@@ -188,9 +231,10 @@ function collectPredecomposeEntities(predecompose: Record<string, unknown> | nul
organization: string | null;
} {
const entities = toRecordObject(predecompose?.entities);
const organization = toNonEmptyString(entities?.organization);
return {
counterparty: toNonEmptyString(entities?.counterparty),
organization: toNonEmptyString(entities?.organization)
organization: isReferentialOrganizationPlaceholder(organization) ? null : organization
};
}
@@ -767,7 +811,7 @@ function hasMetadataDownstreamContinuationSignal(text: string): boolean {
function hasEntityResolutionSignal(text: string): boolean {
const hasSearchVerb = /(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|поиск|search|find|look\s*up)/iu.test(text);
const hasEntityNoun =
/(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)/iu.test(
/(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)/iu.test(
text
);
return hasSearchVerb && hasEntityNoun;
@@ -777,8 +821,9 @@ function normalizeEntityResolutionCandidate(value: string): string {
return value
.replace(/^(?:в\s*1с\s+|в\s+1c\s+|по\s+имени\s+)/iu, "")
.replace(/[?!.]+$/gu, "")
.replace(/^(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?)\s+/iu, "")
.replace(/^(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?)\s+/iu, "")
.replace(/^(?:counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+/iu, "")
.replace(/^группу\s+/iu, "Группа ")
.replace(/^[«"'\s]+|[»"'\s]+$/gu, "")
.replace(/\s+/g, " ")
.trim();
@@ -786,8 +831,9 @@ function normalizeEntityResolutionCandidate(value: string): string {
function rawEntityResolutionCandidate(text: string): string | null {
const patterns = [
/(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\s+(?:в\s*1с\s+|в\s+1c\s+)?(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+)$/iu,
/(?:контрагент(?:а|ов)?|поставщик(?:а|ов)?|клиент(?:а|ов)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+?)\s+(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\b/iu
/(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\s+(?:в\s*1с\s+|в\s+1c\s+)?(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+)$/iu,
/(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\s+(?:в\s*1с\s+|в\s+1c\s+)(.+)$/iu,
/(?:контрагент(?:а|ов|у|ом|е)?|поставщик(?:а|ов|у|ом|е)?|клиент(?:а|ов|у|ом|е)?|counterpart(?:y|ies)|supplier(?:s)?|customer(?:s)?)\s+(.+?)\s+(?:найд(?:и|ите|ем|у)|поищ(?:и|ите|ем)|найти|search|find|look\s*up)\b/iu
];
for (const pattern of patterns) {
const match = text.match(pattern);
@@ -1085,6 +1131,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
: null;
const entityResolutionSignal =
rawEntityResolutionSignal || Boolean(rawEntityCandidate) || Boolean(entityResolutionClarificationCandidate);
const rawEntitySearchOverridesStaleScope = Boolean(rawEntityCandidate && entityResolutionSignal);
const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family);
const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family);
const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis);
@@ -1105,11 +1152,17 @@ export function buildAssistantMcpDiscoveryTurnInput(
hasMovementEvidenceFollowupSignal(rawText) ||
hasPronounMovementEvidenceFollowupSignal(rawText);
const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope);
const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const rawAssistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope);
const assistantTurnMeaningOrganizationScope = isReferentialOrganizationPlaceholder(
rawAssistantTurnMeaningOrganizationScope
)
? null
: rawAssistantTurnMeaningOrganizationScope;
const rawOrganizationMentionSignal = hasOrganizationScopeSignalUtf8(rawText);
const rawOrganizationScope = extractOrganizationScopeFromRawText(rawUserText ?? rawEffectiveText ?? rawSignalSourceText);
const currentTurnFreshOrganizationScope = rawOrganizationScope ?? predecomposeEntities.organization;
const currentTurnOrganizationScope =
rawOrganizationScope ?? predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope;
currentTurnFreshOrganizationScope ?? assistantTurnMeaningOrganizationScope;
const explicitOrganizationScopeSignal = Boolean(rawOrganizationMentionSignal && currentTurnOrganizationScope);
const organizationClarificationFollowupApplicable = Boolean(
followupSeed.domain === "counterparty_value" &&
@@ -1492,7 +1545,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
metadataGroundedMovementLaneApplicable)
);
const metadataLaneScopeHint =
explicitCurrentCounterpartyOverridesFollowupEntity
rawEntitySearchOverridesStaleScope
? null
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: rawMetadataScopeHint ??
followupSeed.metadataScopeHint ??
@@ -1506,6 +1561,8 @@ export function buildAssistantMcpDiscoveryTurnInput(
);
const groundedFollowupEntity = metadataScopedLaneWithoutSubject
? null
: rawEntitySearchOverridesStaleScope
? null
: explicitCurrentCounterpartyOverridesFollowupEntity
? null
: followupSeed.counterparty ?? followupSeed.discoveryEntity;
@@ -1514,10 +1571,16 @@ export function buildAssistantMcpDiscoveryTurnInput(
pushNormalizedEntityResolutionCandidate(entityCandidates, entityResolutionClarificationCandidate);
pushNormalizedEntityResolutionCandidate(entityCandidates, rawEntityCandidate);
for (const candidate of collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, candidate);
if (!rawEntitySearchOverridesStaleScope || sameScopedName(candidate, rawEntityCandidate)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, candidate);
}
}
if (!rawEntitySearchOverridesStaleScope || sameScopedName(normalizedPredecomposeCounterparty, rawEntityCandidate)) {
pushNormalizedEntityResolutionCandidate(entityCandidates, normalizedPredecomposeCounterparty);
}
if (!rawEntitySearchOverridesStaleScope) {
pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty);
}
pushNormalizedEntityResolutionCandidate(entityCandidates, normalizedPredecomposeCounterparty);
pushNormalizedEntityResolutionCandidate(entityCandidates, followupSeed.counterparty);
} else {
if (groundedFollowupEntity) {
pushUnique(entityCandidates, groundedFollowupEntity);
@@ -1562,8 +1625,11 @@ export function buildAssistantMcpDiscoveryTurnInput(
pushUnique(entityCandidates, followupSeed.organization);
}
const explicitOrganizationScope =
valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? currentTurnOrganizationScope ?? followupSeed.organization
rawEntitySearchOverridesStaleScope && !currentTurnFreshOrganizationScope
? null
: valueFlowOrganizationStaysScope || !openScopeValueFlowWithoutCounterparty
? (rawEntitySearchOverridesStaleScope ? currentTurnFreshOrganizationScope : currentTurnOrganizationScope) ??
followupSeed.organization
: null;
if (
explicitCurrentCounterpartyCandidate &&
@@ -1609,13 +1675,18 @@ export function buildAssistantMcpDiscoveryTurnInput(
(valueFlowOrganizationStaysScope && (Boolean(followupSeed.rankingNeed) || bidirectionalValueFlowSignal)))
);
const normalizedPredecomposeDateScope =
suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope) ? null : predecomposeDateScope;
(rawEntitySearchOverridesStaleScope && !currentTurnCarriesExplicitPeriod) ||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(predecomposeDateScope))
? null
: predecomposeDateScope;
const normalizedAssistantTurnMeaningDateScope =
suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope)
rawEntitySearchOverridesStaleScope ||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(assistantTurnMeaningDateScope))
? null
: assistantTurnMeaningDateScope;
const normalizedFollowupDateScope =
suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope)
rawEntitySearchOverridesStaleScope ||
(suppressImplicitCurrentDateScope && isImplicitCurrentDateScope(followupSeed.dateScope))
? null
: followupSeed.dateScope;
const explicitDateScope =
@@ -1670,7 +1741,9 @@ export function buildAssistantMcpDiscoveryTurnInput(
: rawAction ?? seededAction,
asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis,
seeded_ranking_need:
valueFlowSignal && followupSeed.rankingNeed ? followupSeed.rankingNeed : undefined,
valueFlowSignal && followupSeed.rankingNeed && !rawEntitySearchOverridesStaleScope
? followupSeed.rankingNeed
: undefined,
explicit_entity_candidates: entityCandidates,
metadata_ambiguity_entity_sets:
metadataAmbiguityLaneClarificationApplicable && followupSeed.metadataAmbiguityEntitySets.length > 0
@@ -1782,9 +1855,11 @@ export function buildAssistantMcpDiscoveryTurnInput(
groundedValueFlowFollowupApplicable
});
const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0;
const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning
? "assistant_turn_meaning"
: followupDiscoverySeedApplicable ||
const sourceSignal: AssistantMcpDiscoveryTurnInputSource = rawEntitySearchOverridesStaleScope
? "raw_text"
: assistantTurnMeaning
? "assistant_turn_meaning"
: followupDiscoverySeedApplicable ||
Boolean(entityResolutionClarificationCandidate) ||
effectiveMetadataFollowupSeedApplicable ||
metadataAmbiguityLaneClarificationApplicable
@@ -1925,7 +2000,7 @@ export function buildAssistantMcpDiscoveryTurnInput(
) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose");
}
if (followupSeed.counterparty) {
if (followupSeed.counterparty && !rawEntitySearchOverridesStaleScope) {
pushReason(reasonCodes, "mcp_discovery_counterparty_from_followup_context");
}
if (followupDateScopeApplied) {
@@ -1888,7 +1888,7 @@ function textMojibakeScoreForAddress(value) {
const source = String(value ?? "");
const cyrillic = (source.match(/[А-Яа-яЁё]/g) ?? []).length;
const latin = (source.match(/[A-Za-z]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ?’“”•–—™љ›њќћџ]/g) ?? []).length;
const hardMarkers = (source.match(/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ\uFFFD?’“”•–—™љ›њќћџ]/g) ?? []).length;
const pairMarkers = (source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length;
const doubleEncodedMarkers = (source.match(/(?:Г[Ђ-џ]|В[Ђ-џ]|Ã.|Â.)/gu) ?? []).length;
return cyrillic + latin - hardMarkers * 3 - pairMarkers * 2 - doubleEncodedMarkers * 2;
@@ -1898,7 +1898,7 @@ function looksLikeMojibakeForAddress(value) {
if (!source.trim()) {
return false;
}
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ?’“”•–—™љ›њќћџ]/.test(source)) {
if (/[Ѓѓ‚„…†‡€‰‹ЉЊЌЋЏ\uFFFD?’“”•–—™љ›њќћџ]/.test(source)) {
return true;
}
if ((source.match(/(?:Р.|С.|Ð.|Ñ.)/g) ?? []).length >= 2) {
@@ -2129,7 +2129,7 @@ function normalizeCounterpartyForFollowupMatch(value) {
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
.toLowerCase()
.replace(/ё/g, "е")
.replace(/[«»"'`“”„’?]/g, " ")
.replace(/[«»"'`“”„’\uFFFD?]/g, " ")
.replace(/[^a-zа-я0-9\s._-]+/giu, " "));
}
function normalizeCounterpartyTokenForFollowupMatch(value) {
@@ -2175,7 +2175,7 @@ function extractDisplayedAddressEntityCandidates(replyText, entityType = "unknow
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
counterpartyCandidate = parts[1] ?? counterpartyCandidate;
}
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»`?]+|["'«»`?]+$/gu, ""));
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»`\uFFFD?]+|["'«»`\uFFFD?]+$/gu, ""));
if (!cleanedCandidate || cleanedCandidate.length < 2) {
continue;
}
@@ -3224,6 +3224,11 @@ function hasSameDateAccountFollowupSignalForPredecompose(text) {
/(?:^|\s)по\s+\d{2}(?:[.,]\d{1,2})?(?=$|[\s,.;:!?])/iu.test(source) ||
/\b\d{2}(?:[.,]\d{1,2})\b/u.test(source));
}
function isCounterpartyDrilldownIntentForPredecompose(intent) {
return intent === "list_documents_by_counterparty" ||
intent === "bank_operations_by_counterparty" ||
intent === "list_contracts_by_counterparty";
}
function hasPredecomposeDiagnosticUncertaintyLead(text) {
const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase());
if (!normalized) {
@@ -3442,8 +3447,16 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
const sourceHasExplicitAccountAnchor = (0, addressIntentResolver_1.hasAccountNumberAnchor)(repairedSourceMessage || userMessage) ||
(0, addressIntentResolver_1.hasCompactAccountCodeToken)(repairedSourceMessage || userMessage);
const candidateInjectsAccountAnchor = Boolean(toNonEmptyString(candidatePredecomposeContract?.entities?.account));
if (sourceIntentResolution.intent === "inventory_on_hand_as_of_date" &&
candidateIntentResolution.intent === "inventory_on_hand_as_of_date" &&
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAccountInjectedIntoCounterpartyAnchor = isCounterpartyDrilldownIntentForPredecompose(sourceIntentResolution.intent) &&
sourceIntentResolution.intent === candidateIntentResolution.intent &&
sourceAnchorQuality.anchorType === "counterparty" &&
sourceAnchorQuality.quality >= 2 &&
!sourceHasExplicitAccountAnchor &&
candidateInjectsAccountAnchor;
if (((sourceIntentResolution.intent === "inventory_on_hand_as_of_date" &&
candidateIntentResolution.intent === "inventory_on_hand_as_of_date") ||
candidateAccountInjectedIntoCounterpartyAnchor) &&
!sourceHasExplicitAccountAnchor &&
candidateInjectsAccountAnchor) {
return attachAddressPredecomposeContract({
@@ -3456,10 +3469,9 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
reason: "normalized_fragment_rejected_anchor_injection",
fallbackRuleHit: null,
sanitizedUserMessage,
semanticHints: candidateMeta?.semanticHints ?? null
semanticHints: null
}, userMessage);
}
const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent;
const sourceSelectedObjectItemAnchorValue = toNonEmptyString((0, addressFilterExtractor_1.extractSelectedObjectQuotedValue)(userMessage)) ??
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { AssistantService } from "../src/services/assistantService";
import { AssistantSessionStore } from "../src/services/assistantSessionStore";
@@ -1444,6 +1444,102 @@ describe("assistant address llm pre-decompose candidate preference", () => {
expect(response.debug?.fallback_rule_hit).toBe("documents_counterparty_year_rewrite");
});
it("rejects account injection when LLM truncates a numeric counterparty suffix", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
tryHandle: vi.fn(async (message: string) => {
calls.push({ message });
return buildAddressLaneResult(message);
})
} as any;
const normalizerService = {
normalize: vi.fn(async () => ({
trace_id: "norm-predecompose-counterparty-suffix-account-injection",
ok: true,
normalized: {
schema_version: "normalized_query_v2_0_2",
user_message_raw: "Покажи документы по Жуковке 51.",
message_in_scope: true,
scope_confidence: "high",
contains_multiple_tasks: false,
fragments: [
{
fragment_id: "F1",
raw_fragment_text: "Покажи документы по Жуковке 51.",
normalized_fragment_text: "Показать документы, связанные с контрагентом Жуковка по счету 51",
domain_relevance: "in_scope",
business_scope: "company_specific_accounting",
entity_hints: ["Жуковка"],
account_hints: ["51"],
document_hints: ["документы"],
register_hints: [],
semantic_hints: {
scope_target_kind: "counterparty",
scope_target_text: "Жуковка",
date_scope_kind: "implicit_current",
self_scope_detected: false,
selected_object_scope_detected: false
},
time_scope: { type: "unspecified", 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: false,
asks_for_evidence: false,
mentions_period_close_context: false
},
candidate_labels: ["simple_factual"],
confidence: "high",
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-counterparty-suffix-${Date.now()}`,
user_message: "Покажи документы по Жуковке 51.",
llmProvider: "local",
useMock: false
} as any);
expect(response.ok).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].message).toBe("Покажи документы по Жуковке 51.");
expect(response.debug?.llm_decomposition_applied).toBe(false);
expect(response.debug?.llm_decomposition_reason).toBe("normalized_fragment_rejected_anchor_injection");
expect(response.debug?.llm_predecompose_contract?.entities?.account).toBeNull();
expect(response.debug?.llm_predecompose_contract?.entities?.counterparty).toBe("Жуковке 51");
});
it("rewrites payment-style counterparty phrasing to bank operations", async () => {
const calls: Array<{ message: string }> = [];
const addressQueryService = {
@@ -341,6 +341,62 @@ describe("assistant MCP discovery answer adapter", () => {
expect(draft.next_step_line).toContain("организац");
});
it("renders confirmed ranked value-flow without raw technical evidence lines", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
schema_version: "assistant_data_need_graph_v1",
policy_owner: "assistantMcpDiscoveryDataNeedGraph",
subject_candidates: [],
business_fact_family: "value_flow",
action_family: "turnover",
aggregation_need: null,
time_scope_need: "explicit_period",
comparison_need: null,
ranking_need: "top_desc",
proof_expectation: "coverage_checked_fact",
clarification_gaps: [],
decomposition_candidates: ["collect_scoped_movements", "aggregate_ranked_axis_values", "probe_coverage"],
forbidden_overclaim_flags: ["no_raw_model_claims", "no_unchecked_fact_totals"],
reason_codes: ["data_need_graph_built", "data_need_graph_ranking_top_desc"]
},
turnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_value_or_turnover"
}
});
const pilot = await executeAssistantMcpDiscoveryPilot(
planner,
buildDeps([
{
Period: "2020-01-15T00:00:00",
Amount: 12000,
Counterparty: "\u0421\u0411\u0415\u0420\u0411\u0410\u041d\u041a, \u041f\u0410\u041e",
Organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
},
{
Period: "2020-02-20T00:00:00",
Amount: 5000,
Counterparty: "\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a",
Organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
}
])
);
const draft = buildAssistantMcpDiscoveryAnswerDraft(pilot);
const userText = [...draft.confirmed_lines, ...draft.inference_lines, ...draft.unknown_lines].join("\n");
expect(draft.answer_mode).toBe("confirmed_with_bounded_inference");
expect(draft.confirmed_lines).toHaveLength(1);
expect(userText).toContain("\u0411\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u0434\u0435\u043d\u0435\u0433 \u043f\u0440\u0438\u043d\u0451\u0441 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442");
expect(userText).toContain("\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441");
expect(userText).not.toContain("1C incoming value-flow");
expect(userText).not.toContain("Full ranking outside");
expect(draft.unknown_lines[0]).toContain("\u041f\u043e\u043b\u043d\u044b\u0439 \u0440\u0435\u0439\u0442\u0438\u043d\u0433");
});
it("asks for both organization and period when an open total still misses both axes", async () => {
const planner = planAssistantMcpDiscovery({
dataNeedGraph: {
@@ -1426,6 +1426,45 @@ describe("assistant MCP discovery turn input adapter", () => {
);
});
it("lets an explicit current entity search override stale ranking follow-up scope", () => {
const staleEntity = "\u043f\u0440\u0438\u043d\u0451\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0443\u044e \u0432\u044b\u0440\u0443\u0447\u043a\u0443 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438 \u0432";
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0422\u0435\u043f\u0435\u0440\u044c \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u0442\u0435\u043c\u0430 \u043f\u043e \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u043e\u043c\u0443 \u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0443. \u041d\u0430\u0439\u0434\u0438 \u0432 1\u0421 \u0413\u0440\u0443\u043f\u043f\u0443 \u0421\u0412\u041a.",
assistantTurnMeaning: {
asked_domain_family: "entity_resolution",
asked_action_family: "search_business_entity",
explicit_entity_candidates: [staleEntity],
explicit_organization_scope: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
explicit_date_scope: "2021",
unsupported_but_understood_family: "entity_resolution",
stale_replay_forbidden: true
},
followupContext: {
previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1",
previous_discovery_ranking_need: "top_desc",
previous_filters: {
organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441",
counterparty: staleEntity,
period_from: "2021-01-01",
period_to: "2021-12-31"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.source_signal).toBe("raw_text");
expect(result.turn_meaning_ref?.explicit_entity_candidates).toEqual(["\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a"]);
expect(result.turn_meaning_ref?.metadata_scope_hint).toBeUndefined();
expect(result.turn_meaning_ref?.explicit_organization_scope).toBeUndefined();
expect(result.turn_meaning_ref?.explicit_date_scope).toBeUndefined();
expect(result.turn_meaning_ref?.seeded_ranking_need).toBeUndefined();
expect(result.data_need_graph?.subject_candidates).toEqual(["\u0413\u0440\u0443\u043f\u043f\u0430 \u0421\u0412\u041a"]);
expect(result.reason_codes).toContain("mcp_discovery_entity_scope_from_raw_entity_search");
expect(result.reason_codes).not.toContain("mcp_discovery_counterparty_from_followup_context");
});
it("marks top-value wording as a ranking data need without inventing a missing subject gap", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "кто больше всего принес денег в 2020"
@@ -1444,6 +1483,46 @@ describe("assistant MCP discovery turn input adapter", () => {
]);
});
it("keeps open organization ranking subjectless when assistant meaning invents a predicate-shaped entity", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage:
"\u0418 \u043a\u0442\u043e \u0431\u043e\u043b\u044c\u0448\u0435 \u0432\u0441\u0435\u0433\u043e \u043f\u0440\u0438\u043d\u0435\u0441 \u0434\u0435\u043d\u0435\u0433 \u044d\u0442\u043e\u0439 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438 \u0432 2020 \u0433\u043e\u0434\u0443?",
assistantTurnMeaning: {
asked_domain_family: "counterparty_value",
asked_action_family: "turnover",
explicit_entity_candidates: [
"\u043f\u0440\u0438\u043d\u0451\u0441 \u043d\u0430\u0438\u0431\u043e\u043b\u044c\u0448\u0443\u044e \u0432\u044b\u0440\u0443\u0447\u043a\u0443 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438 \u0432"
],
explicit_organization_scope: "\u044d\u0442\u0430 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f",
explicit_date_scope: "2020",
unsupported_but_understood_family: "counterparty_value_or_turnover",
stale_replay_forbidden: true
},
predecomposeContract: {
entities: {
organization: "\u044d\u0442\u0430 \u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f"
}
},
followupContext: {
previous_discovery_pilot_scope: "counterparty_value_flow_query_movements_v1",
previous_filters: {
organization: "\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
}
}
});
expect(result.adapter_status).toBe("ready");
expect(result.should_run_discovery).toBe(true);
expect(result.turn_meaning_ref?.explicit_entity_candidates).toBeUndefined();
expect(result.turn_meaning_ref?.explicit_organization_scope).toBe(
"\u041e\u041e\u041e \u0410\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u0430 \u041f\u043b\u044e\u0441"
);
expect(result.turn_meaning_ref?.explicit_date_scope).toBe("2020");
expect(result.data_need_graph?.subject_candidates).toEqual([]);
expect(result.data_need_graph?.ranking_need).toBe("top_desc");
expect(result.data_need_graph?.decomposition_candidates).toContain("aggregate_ranked_axis_values");
});
it("keeps organization as scope for open bidirectional comparison wording instead of inventing a subject candidate", () => {
const result = buildAssistantMcpDiscoveryTurnInput({
userMessage: "что больше: входящие или исходящие деньги за 2020 год по ООО Альтернатива Плюс?",