ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Этап 4.8: референциальная continuity для follow-up по контрагентам и pivot операции
This commit is contained in:
parent
a49212608f
commit
ca2feab893
|
|
@ -2746,7 +2746,25 @@ Implemented in current pass (Stage 4.7 Stage4 compliance rollout into P0 quality
|
|||
- `assistantP0EvalHarness.test.ts`: `5 passed` (extended timeout budget);
|
||||
- `npm --prefix llm_normalizer/backend run build` passed.
|
||||
|
||||
Status: In progress (Stage 4.1-4.7 completed; continue with focused wave/manual-comment quality backlog)
|
||||
Implemented in current pass (Stage 4.8 referential continuity for entity drill-down follow-ups, 2026-04-12):
|
||||
1. Added displayed-entity carryover resolver in address follow-up context:
|
||||
- extracts counterparties from the last factual numbered list in assistant reply text;
|
||||
- resolves short follow-up mentions (e.g. surname-only alias) against displayed entities;
|
||||
- writes resolved entity into follow-up context as `counterparty` anchor and marks `resolved_counterparty_from_display`.
|
||||
2. Updated continuation diagnostics for operation pivots on the same entity:
|
||||
- `dialog_continuation_contract_v2.target_intent` now prefers explicit current-message intent (except suggested-intent switch mode);
|
||||
- emits `operation_intent_from_current_message` reason for `carry_referenced_entity` pivots.
|
||||
3. Extended decompose-stage follow-up filter carryover for value intents:
|
||||
- `customer_revenue_and_payments`, `supplier_payouts_profile`, `contract_usage_and_value`;
|
||||
- applies inherited counterparty when entity was explicitly resolved from displayed list (or previous value-profile chain), while blocking broad ranking-wording carryover.
|
||||
4. Regression updates:
|
||||
- `assistantAddressFollowupContext.test.ts` (new Kalinin continuity scenario);
|
||||
- `addressQueryRuntimeM23.test.ts` (value-intent decompose carryover assertion).
|
||||
5. Validation snapshot:
|
||||
- `npm.cmd --prefix llm_normalizer/backend run test -- --run tests/assistantAddressFollowupContext.test.ts tests/addressQueryRuntimeM23.test.ts --testTimeout=180000`: `295 passed`;
|
||||
- `npm.cmd --prefix llm_normalizer/backend run build` passed.
|
||||
|
||||
Status: In progress (Stage 4.1-4.8 completed; continue with focused wave/manual-comment quality backlog)
|
||||
|
||||
## Stage 5 (P3): Quality Loop Driven By GUI Markup
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface AddressFollowupContext {
|
|||
previous_filters?: AddressFilterSet;
|
||||
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||
previous_anchor_value?: string | null;
|
||||
resolved_counterparty_from_display?: boolean;
|
||||
}
|
||||
|
||||
export interface AddressDecomposeStageResult {
|
||||
|
|
@ -321,6 +322,24 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
|
|||
return tokenCount <= 6;
|
||||
}
|
||||
|
||||
function isValueCounterpartyIntent(intent: AddressIntent): boolean {
|
||||
return (
|
||||
intent === "customer_revenue_and_payments" ||
|
||||
intent === "supplier_payouts_profile" ||
|
||||
intent === "contract_usage_and_value"
|
||||
);
|
||||
}
|
||||
|
||||
function hasBroadCounterpartyRankingCue(text: string): boolean {
|
||||
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: AddressFilterSet,
|
||||
intent: AddressIntent,
|
||||
|
|
@ -369,6 +388,28 @@ function mergeFollowupFilters(
|
|||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -2094,7 +2094,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) {
|
||||
|
|
@ -2102,11 +2102,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];
|
||||
|
|
@ -2271,7 +2424,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) &&
|
||||
|
|
@ -2304,12 +2458,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 }
|
||||
|
|
@ -2332,6 +2487,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;
|
||||
}
|
||||
|
|
@ -2340,7 +2509,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,
|
||||
|
|
@ -2354,11 +2524,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"
|
||||
|
|
@ -2377,6 +2550,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,
|
||||
|
|
|
|||
|
|
@ -3203,6 +3203,29 @@ describe("address decompose stage follow-up carryover", () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps entity carryover for customer value follow-up when counterparty is resolved from displayed list", () => {
|
||||
const result = runAddressDecomposeStage("сколько денег за 2020 принес калинин?", {
|
||||
previous_intent: "counterparty_activity_lifecycle",
|
||||
previous_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
previous_anchor_type: "counterparty",
|
||||
previous_anchor_value: "ИП Калинин Н.М.",
|
||||
resolved_counterparty_from_display: true
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.mode.mode).toBe("address_query");
|
||||
expect(result?.intent.intent).toBe("customer_revenue_and_payments");
|
||||
expect(result?.filters.extracted_filters.counterparty).toBe("ИП Калинин Н.М.");
|
||||
expect(result?.filters.extracted_filters.period_from).toBe("2020-01-01");
|
||||
expect(result?.filters.extracted_filters.period_to).toBe("2020-12-31");
|
||||
expect(
|
||||
result?.baseReasons?.includes("counterparty_replaced_from_followup_context") ||
|
||||
result?.baseReasons?.includes("counterparty_from_followup_context")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
|
||||
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
|
||||
previous_intent: "bank_operations_by_contract",
|
||||
|
|
|
|||
|
|
@ -605,6 +605,109 @@ describe("assistant address follow-up carryover", () => {
|
|||
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves counterparty mention from previous displayed list and carries it into value follow-up", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "с кем мы работали в 2020 годы- покажи клиентов";
|
||||
const followupMessage = "сколько денег за 2020 принес калинин?";
|
||||
const lifecycleReply = [
|
||||
"Активные заказчики в 2020 году: 3.",
|
||||
"1. Группа | операций: 13 | последняя активность: 2020-12-30T12:00:00Z | лет в базе: 1",
|
||||
"2. ИП Калинин Н.М. | операций: 2 | последняя активность: 2020-03-02T12:00:03Z | лет в базе: 1",
|
||||
"3. Смарт | операций: 1 | последняя активность: 2020-02-07T12:00:03Z | лет в базе: 1"
|
||||
].join("\n");
|
||||
|
||||
const addressQueryService = {
|
||||
tryHandle: vi.fn(async (message: string, options?: any) => {
|
||||
calls.push({ message, options });
|
||||
if (message === followupMessage) {
|
||||
if (options?.followupContext?.previous_filters?.counterparty !== "ИП Калинин Н.М.") {
|
||||
return null;
|
||||
}
|
||||
return buildAddressLaneResult({
|
||||
reply_text: "ИП Калинин Н.М. | сумма: 216600 | операций: 2",
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "customer_revenue_and_payments",
|
||||
selected_recipe: "address_customer_revenue_and_payments_v1",
|
||||
extracted_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31",
|
||||
counterparty: "ИП Калинин Н.М."
|
||||
},
|
||||
anchor_type: "counterparty",
|
||||
anchor_value_raw: "калинин",
|
||||
anchor_value_resolved: "ИП Калинин Н.М.",
|
||||
reasons: ["address_action_detected", "customer_revenue_and_payments_signal_detected", "address_followup_context_applied"]
|
||||
}
|
||||
});
|
||||
}
|
||||
return buildAddressLaneResult({
|
||||
reply_text: lifecycleReply,
|
||||
debug: {
|
||||
...buildAddressLaneResult().debug,
|
||||
detected_intent: "counterparty_activity_lifecycle",
|
||||
selected_recipe: "address_counterparty_activity_lifecycle_v1",
|
||||
extracted_filters: {
|
||||
period_from: "2020-01-01",
|
||||
period_to: "2020-12-31"
|
||||
},
|
||||
anchor_type: "unknown",
|
||||
anchor_value_raw: null,
|
||||
anchor_value_resolved: null,
|
||||
reasons: ["address_action_detected", "counterparty_activity_lifecycle_signal_detected"]
|
||||
}
|
||||
});
|
||||
})
|
||||
} as any;
|
||||
|
||||
const normalizerService = {
|
||||
normalize: vi.fn(async () => ({
|
||||
assistant_reply: "normalizer_fallback_should_not_be_used",
|
||||
reply_type: "partial_coverage",
|
||||
debug: {}
|
||||
}))
|
||||
} as any;
|
||||
|
||||
const sessions = new AssistantSessionStore();
|
||||
const service = new AssistantService(
|
||||
normalizerService,
|
||||
sessions as any,
|
||||
{} as any,
|
||||
{ persistSession: vi.fn() } as any,
|
||||
addressQueryService
|
||||
);
|
||||
|
||||
const sessionId = `asst-address-followup-kalinin-${Date.now()}`;
|
||||
const first = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: firstMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.reply_type).toBe("factual");
|
||||
|
||||
const second = await service.handleMessage({
|
||||
session_id: sessionId,
|
||||
user_message: followupMessage,
|
||||
useMock: true
|
||||
} as any);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.reply_type).toBe("factual");
|
||||
expect(second.debug?.detected_intent).toBe("customer_revenue_and_payments");
|
||||
expect(second.debug?.extracted_filters?.counterparty).toBe("ИП Калинин Н.М.");
|
||||
|
||||
const contextualCall = calls.find(
|
||||
(entry) => entry.message === followupMessage && entry.options?.followupContext?.previous_filters?.counterparty === "ИП Калинин Н.М."
|
||||
);
|
||||
expect(contextualCall).toBeTruthy();
|
||||
expect(contextualCall?.options?.followupContext?.previous_anchor_type).toBe("counterparty");
|
||||
expect(contextualCall?.options?.followupContext?.previous_anchor_value).toBe("ИП Калинин Н.М.");
|
||||
expect(contextualCall?.options?.followupContext?.resolved_counterparty_from_display).toBe(true);
|
||||
expect(second.debug?.dialog_continuation_contract_v2?.target_intent).toBe("customer_revenue_and_payments");
|
||||
expect(second.debug?.dialog_continuation_contract_v2?.decision).toBe("continue_previous");
|
||||
expect(normalizerService.normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not carry address follow-up context into capability question", async () => {
|
||||
const calls: Array<{ message: string; options?: any }> = [];
|
||||
const firstMessage = "покажи документы по свк за 2020";
|
||||
|
|
|
|||
Loading…
Reference in New Issue