ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Этап 4.8: референциальная continuity для follow-up по контрагентам и pivot операции
This commit is contained in:
@@ -258,6 +258,18 @@ function hasAddressFollowupContextSignal(text) {
|
||||
}
|
||||
return tokenCount <= 6;
|
||||
}
|
||||
function isValueCounterpartyIntent(intent) {
|
||||
return (intent === "customer_revenue_and_payments" ||
|
||||
intent === "supplier_payouts_profile" ||
|
||||
intent === "contract_usage_and_value");
|
||||
}
|
||||
function hasBroadCounterpartyRankingCue(text) {
|
||||
const normalized = String(text ?? "").toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return /(?:\bкто\b|\bкакие\b|\bкакой\b|\bтоп\b|\bсписок\b|\bвсе\b|\bвсех\b|\bвсего\b|\bclients?\b|\bcounterpart(?:y|ies)\b|контрагент|клиент|заказчик)/iu.test(normalized);
|
||||
}
|
||||
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
const merged = { ...current };
|
||||
const reasons = [];
|
||||
@@ -294,6 +306,24 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (isValueCounterpartyIntent(intent)) {
|
||||
const inheritedCounterparty = previousCounterparty ??
|
||||
(followupContext.previous_anchor_type === "counterparty" ? previousAnchorValue : null);
|
||||
const currentCounterparty = toNonEmptyString(merged.counterparty);
|
||||
const previousIntentIsValueCounterparty = isValueCounterpartyIntent(followupContext.previous_intent ?? "unknown");
|
||||
const resolvedCounterpartyFromDisplay = followupContext.resolved_counterparty_from_display === true;
|
||||
const allowCarryover = !hasBroadCounterpartyRankingCue(userMessage) &&
|
||||
(resolvedCounterpartyFromDisplay || previousIntentIsValueCounterparty);
|
||||
const shouldInheritCounterparty = allowCarryover &&
|
||||
(!currentCounterparty ||
|
||||
(Boolean(inheritedCounterparty) &&
|
||||
isLowQualityCounterpartyAnchor(currentCounterparty) &&
|
||||
!isLowQualityCounterpartyAnchor(inheritedCounterparty)));
|
||||
if (inheritedCounterparty && shouldInheritCounterparty) {
|
||||
merged.counterparty = inheritedCounterparty;
|
||||
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
|
||||
}
|
||||
}
|
||||
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
|
||||
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||
const currentContract = toNonEmptyString(merged.contract);
|
||||
|
||||
+184
-8
@@ -2138,7 +2138,7 @@ function isAddressLaneDebugPayload(debug) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function findLastAddressAssistantDebug(items) {
|
||||
function findLastAddressAssistantItem(items) {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (!item || item.role !== "assistant" || !item.debug) {
|
||||
@@ -2146,11 +2146,164 @@ function findLastAddressAssistantDebug(items) {
|
||||
}
|
||||
const debug = item.debug;
|
||||
if (isAddressLaneDebugPayload(debug)) {
|
||||
return debug;
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function findLastAddressAssistantDebug(items) {
|
||||
return findLastAddressAssistantItem(items)?.debug ?? null;
|
||||
}
|
||||
const FOLLOWUP_DISPLAY_COUNTERPARTY_STOPWORDS = new Set([
|
||||
"группа",
|
||||
"компания",
|
||||
"организация",
|
||||
"контрагент",
|
||||
"контрагента",
|
||||
"контрагенту",
|
||||
"клиент",
|
||||
"клиента",
|
||||
"клиенту",
|
||||
"заказчик",
|
||||
"заказчика",
|
||||
"заказчику",
|
||||
"поставщик",
|
||||
"поставщика",
|
||||
"поставщику",
|
||||
"ип",
|
||||
"ооо",
|
||||
"ао",
|
||||
"зао",
|
||||
"пао",
|
||||
"оао",
|
||||
"llc",
|
||||
"ltd",
|
||||
"inc",
|
||||
"corp",
|
||||
"company",
|
||||
"group",
|
||||
"vendor",
|
||||
"supplier",
|
||||
"customer",
|
||||
"client"
|
||||
]);
|
||||
const FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS = new Set([
|
||||
"ип",
|
||||
"ооо",
|
||||
"ао",
|
||||
"зао",
|
||||
"пао",
|
||||
"оао",
|
||||
"llc",
|
||||
"ltd",
|
||||
"inc",
|
||||
"corp",
|
||||
"company",
|
||||
"group"
|
||||
]);
|
||||
function normalizeCounterpartyForFollowupMatch(value) {
|
||||
return compactWhitespace(repairAddressMojibake(String(value ?? ""))
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, "е")
|
||||
.replace(/[«»"'`“”„’‘]/g, " ")
|
||||
.replace(/[^a-zа-я0-9\s._-]+/giu, " "));
|
||||
}
|
||||
function normalizeCounterpartyTokenForFollowupMatch(value) {
|
||||
return normalizeCounterpartyForFollowupMatch(value).replace(/[._-]+/g, "");
|
||||
}
|
||||
function extractDisplayedCounterpartyCandidates(replyText) {
|
||||
const lines = String(replyText ?? "").split(/\r?\n/);
|
||||
const candidates = [];
|
||||
for (const line of lines) {
|
||||
const compactLine = compactWhitespace(line);
|
||||
if (!compactLine) {
|
||||
continue;
|
||||
}
|
||||
if (!/^\d+\.\s+/.test(compactLine)) {
|
||||
continue;
|
||||
}
|
||||
const afterNumber = compactLine.replace(/^\d+\.\s+/, "");
|
||||
const parts = afterNumber.split("|").map((item) => compactWhitespace(item));
|
||||
let counterpartyCandidate = parts[0] ?? "";
|
||||
if (parts.length >= 2 && /^\d{4}-\d{2}-\d{2}/.test(parts[0] ?? "")) {
|
||||
counterpartyCandidate = parts[1] ?? counterpartyCandidate;
|
||||
}
|
||||
const cleanedCandidate = compactWhitespace(counterpartyCandidate.replace(/^["'«»“”„`’‘]+|["'«»“”„`’‘]+$/gu, ""));
|
||||
if (!cleanedCandidate || cleanedCandidate.length < 2) {
|
||||
continue;
|
||||
}
|
||||
candidates.push(cleanedCandidate);
|
||||
}
|
||||
return Array.from(new Set(candidates));
|
||||
}
|
||||
function buildCounterpartyAliasesForFollowupMatch(counterpartyName) {
|
||||
const aliases = new Set();
|
||||
const normalized = normalizeCounterpartyForFollowupMatch(counterpartyName);
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
aliases.add(normalized);
|
||||
const normalizedTokens = normalized
|
||||
.split(/\s+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
const withoutLegalTokens = normalizedTokens
|
||||
.filter((token) => !FOLLOWUP_DISPLAY_COUNTERPARTY_LEGAL_TOKENS.has(token))
|
||||
.join(" ");
|
||||
if (withoutLegalTokens) {
|
||||
aliases.add(withoutLegalTokens);
|
||||
}
|
||||
for (const token of normalizedTokens) {
|
||||
const compactToken = normalizeCounterpartyTokenForFollowupMatch(token);
|
||||
if (compactToken.length < 3) {
|
||||
continue;
|
||||
}
|
||||
if (FOLLOWUP_DISPLAY_COUNTERPARTY_STOPWORDS.has(compactToken)) {
|
||||
continue;
|
||||
}
|
||||
if (/^(?:19|20)\d{2}$/.test(compactToken)) {
|
||||
continue;
|
||||
}
|
||||
aliases.add(compactToken);
|
||||
}
|
||||
return Array.from(aliases)
|
||||
.map((alias) => compactWhitespace(alias))
|
||||
.filter((alias) => alias.length > 0)
|
||||
.sort((left, right) => right.length - left.length);
|
||||
}
|
||||
function hasCounterpartyAliasMention(normalizedMessage, alias) {
|
||||
const trimmedAlias = compactWhitespace(String(alias ?? "").toLowerCase());
|
||||
if (!trimmedAlias) {
|
||||
return false;
|
||||
}
|
||||
const aliasPattern = escapeRegex(trimmedAlias).replace(/\s+/g, "\\s+");
|
||||
const boundaryPattern = new RegExp(`(?:^|[^a-zа-я0-9])${aliasPattern}(?:$|[^a-zа-я0-9])`, "iu");
|
||||
return boundaryPattern.test(normalizedMessage);
|
||||
}
|
||||
function resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) {
|
||||
const normalizedMessage = normalizeCounterpartyForFollowupMatch(userMessage);
|
||||
if (!normalizedMessage) {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(displayedCounterparties) || displayedCounterparties.length === 0) {
|
||||
return null;
|
||||
}
|
||||
let bestMatch = null;
|
||||
for (const candidate of displayedCounterparties) {
|
||||
const aliases = buildCounterpartyAliasesForFollowupMatch(candidate);
|
||||
for (const alias of aliases) {
|
||||
if (!hasCounterpartyAliasMention(normalizedMessage, alias)) {
|
||||
continue;
|
||||
}
|
||||
const score = alias.length * 10 + (normalizeCounterpartyForFollowupMatch(candidate) === alias ? 1 : 0);
|
||||
if (!bestMatch || score > bestMatch.score) {
|
||||
bestMatch = { value: candidate, score };
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return bestMatch?.value ?? null;
|
||||
}
|
||||
function findRecentAddressFilterValue(items, key) {
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
@@ -2315,7 +2468,8 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||
return false;
|
||||
}
|
||||
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
|
||||
const previousAddressDebug = findLastAddressAssistantDebug(items);
|
||||
const previousAddressItem = findLastAddressAssistantItem(items);
|
||||
const previousAddressDebug = previousAddressItem?.debug ?? null;
|
||||
const followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null;
|
||||
const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
|
||||
Boolean(followupOffer?.enabled) &&
|
||||
@@ -2348,12 +2502,13 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
followupSelectionMode = "switch_to_suggested_intent";
|
||||
}
|
||||
}
|
||||
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||
let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||
let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
||||
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
||||
readAddressFilterString(previousAddressDebug, "account") ??
|
||||
readAddressFilterString(previousAddressDebug, "contract");
|
||||
let resolvedCounterpartyFromDisplay = false;
|
||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||
? { ...previousFiltersRaw }
|
||||
@@ -2376,6 +2531,20 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
previousFilters.organization = historicalOrganization;
|
||||
}
|
||||
}
|
||||
const displayedCounterparties = extractDisplayedCounterpartyCandidates(toNonEmptyString(previousAddressItem?.text) ?? "");
|
||||
const counterpartyFromFollowupText = resolveDisplayedCounterpartyMention(userMessage, displayedCounterparties) ??
|
||||
(toNonEmptyString(alternateMessage)
|
||||
? resolveDisplayedCounterpartyMention(String(alternateMessage ?? ""), displayedCounterparties)
|
||||
: null);
|
||||
if (counterpartyFromFollowupText) {
|
||||
previousFilters.counterparty = counterpartyFromFollowupText;
|
||||
previousAnchorType = "counterparty";
|
||||
previousAnchor = counterpartyFromFollowupText;
|
||||
resolvedCounterpartyFromDisplay = true;
|
||||
if (followupSelectionMode !== "switch_to_suggested_intent") {
|
||||
followupSelectionMode = "carry_referenced_entity";
|
||||
}
|
||||
}
|
||||
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -2384,7 +2553,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||
previous_intent: previousIntent ?? undefined,
|
||||
previous_filters: previousFilters,
|
||||
previous_anchor_type: previousAnchorType ?? undefined,
|
||||
previous_anchor_value: previousAnchor
|
||||
previous_anchor_value: previousAnchor,
|
||||
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined
|
||||
},
|
||||
previousAddressIntent: previousIntent,
|
||||
previousAddressAnchor: previousAnchor,
|
||||
@@ -2398,11 +2568,14 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||
const canonicalMessage = String(effectiveMessage ?? sourceMessage);
|
||||
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
||||
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||
const targetIntent = toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? null;
|
||||
const explicitIntent = toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent);
|
||||
const targetIntent = selectionMode === "switch_to_suggested_intent"
|
||||
? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null
|
||||
: explicitIntent ?? toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
||||
const hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
||||
const hasExplicitIntent = Boolean(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent));
|
||||
const hasExplicitIntent = Boolean(explicitIntent);
|
||||
const decision = !hasFollowupContext
|
||||
? "new_topic"
|
||||
: selectionMode === "switch_to_suggested_intent"
|
||||
@@ -2421,6 +2594,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||
if (hasExplicitIntent) {
|
||||
reasons.push("llm_contract_intent_available");
|
||||
}
|
||||
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
||||
reasons.push("operation_intent_from_current_message");
|
||||
}
|
||||
return {
|
||||
schema_version: "address_dialog_continuation_contract_v2",
|
||||
source_message: sourceMessage,
|
||||
|
||||
Reference in New Issue
Block a user