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
@@ -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)) ??