ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Этап 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);
|
- `assistantP0EvalHarness.test.ts`: `5 passed` (extended timeout budget);
|
||||||
- `npm --prefix llm_normalizer/backend run build` passed.
|
- `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
|
## Stage 5 (P3): Quality Loop Driven By GUI Markup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,18 @@ function hasAddressFollowupContextSignal(text) {
|
||||||
}
|
}
|
||||||
return tokenCount <= 6;
|
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) {
|
function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
const merged = { ...current };
|
const merged = { ...current };
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
|
|
@ -294,6 +306,24 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) {
|
||||||
reasons.push(currentCounterparty ? "counterparty_replaced_from_followup_context" : "counterparty_from_followup_context");
|
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") {
|
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
|
||||||
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||||
const currentContract = toNonEmptyString(merged.contract);
|
const currentContract = toNonEmptyString(merged.contract);
|
||||||
|
|
|
||||||
|
|
@ -2138,7 +2138,7 @@ function isAddressLaneDebugPayload(debug) {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function findLastAddressAssistantDebug(items) {
|
function findLastAddressAssistantItem(items) {
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (!item || item.role !== "assistant" || !item.debug) {
|
if (!item || item.role !== "assistant" || !item.debug) {
|
||||||
|
|
@ -2146,11 +2146,164 @@ function findLastAddressAssistantDebug(items) {
|
||||||
}
|
}
|
||||||
const debug = item.debug;
|
const debug = item.debug;
|
||||||
if (isAddressLaneDebugPayload(debug)) {
|
if (isAddressLaneDebugPayload(debug)) {
|
||||||
return debug;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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) {
|
function findRecentAddressFilterValue(items, key) {
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
|
|
@ -2315,7 +2468,8 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
|
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 followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null;
|
||||||
const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
|
const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
|
||||||
Boolean(followupOffer?.enabled) &&
|
Boolean(followupOffer?.enabled) &&
|
||||||
|
|
@ -2348,12 +2502,13 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
followupSelectionMode = "switch_to_suggested_intent";
|
followupSelectionMode = "switch_to_suggested_intent";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||||
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||||
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
||||||
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
||||||
readAddressFilterString(previousAddressDebug, "account") ??
|
readAddressFilterString(previousAddressDebug, "account") ??
|
||||||
readAddressFilterString(previousAddressDebug, "contract");
|
readAddressFilterString(previousAddressDebug, "contract");
|
||||||
|
let resolvedCounterpartyFromDisplay = false;
|
||||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||||
? { ...previousFiltersRaw }
|
? { ...previousFiltersRaw }
|
||||||
|
|
@ -2376,6 +2531,20 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
previousFilters.organization = historicalOrganization;
|
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) {
|
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -2384,7 +2553,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
previous_intent: previousIntent ?? undefined,
|
previous_intent: previousIntent ?? undefined,
|
||||||
previous_filters: previousFilters,
|
previous_filters: previousFilters,
|
||||||
previous_anchor_type: previousAnchorType ?? undefined,
|
previous_anchor_type: previousAnchorType ?? undefined,
|
||||||
previous_anchor_value: previousAnchor
|
previous_anchor_value: previousAnchor,
|
||||||
|
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined
|
||||||
},
|
},
|
||||||
previousAddressIntent: previousIntent,
|
previousAddressIntent: previousIntent,
|
||||||
previousAddressAnchor: previousAnchor,
|
previousAddressAnchor: previousAnchor,
|
||||||
|
|
@ -2398,11 +2568,14 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
const canonicalMessage = String(effectiveMessage ?? sourceMessage);
|
const canonicalMessage = String(effectiveMessage ?? sourceMessage);
|
||||||
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
||||||
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||||
const targetIntent = toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
|
||||||
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? 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 hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||||
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
||||||
const hasExplicitIntent = Boolean(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent));
|
const hasExplicitIntent = Boolean(explicitIntent);
|
||||||
const decision = !hasFollowupContext
|
const decision = !hasFollowupContext
|
||||||
? "new_topic"
|
? "new_topic"
|
||||||
: selectionMode === "switch_to_suggested_intent"
|
: selectionMode === "switch_to_suggested_intent"
|
||||||
|
|
@ -2421,6 +2594,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
if (hasExplicitIntent) {
|
if (hasExplicitIntent) {
|
||||||
reasons.push("llm_contract_intent_available");
|
reasons.push("llm_contract_intent_available");
|
||||||
}
|
}
|
||||||
|
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
||||||
|
reasons.push("operation_intent_from_current_message");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
schema_version: "address_dialog_continuation_contract_v2",
|
schema_version: "address_dialog_continuation_contract_v2",
|
||||||
source_message: sourceMessage,
|
source_message: sourceMessage,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export interface AddressFollowupContext {
|
||||||
previous_filters?: AddressFilterSet;
|
previous_filters?: AddressFilterSet;
|
||||||
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
previous_anchor_type?: "account" | "counterparty" | "contract" | "document_ref" | "unknown" | null;
|
||||||
previous_anchor_value?: string | null;
|
previous_anchor_value?: string | null;
|
||||||
|
resolved_counterparty_from_display?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressDecomposeStageResult {
|
export interface AddressDecomposeStageResult {
|
||||||
|
|
@ -321,6 +322,24 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
|
||||||
return tokenCount <= 6;
|
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(
|
function mergeFollowupFilters(
|
||||||
current: AddressFilterSet,
|
current: AddressFilterSet,
|
||||||
intent: AddressIntent,
|
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") {
|
if (intent === "list_documents_by_contract" || intent === "bank_operations_by_contract") {
|
||||||
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
const inheritedContract = previousContract ?? (followupContext.previous_anchor_type === "contract" ? previousAnchorValue : null);
|
||||||
const currentContract = toNonEmptyString(merged.contract);
|
const currentContract = toNonEmptyString(merged.contract);
|
||||||
|
|
|
||||||
|
|
@ -2094,7 +2094,7 @@ function isAddressLaneDebugPayload(debug) {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function findLastAddressAssistantDebug(items) {
|
function findLastAddressAssistantItem(items) {
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
if (!item || item.role !== "assistant" || !item.debug) {
|
if (!item || item.role !== "assistant" || !item.debug) {
|
||||||
|
|
@ -2102,11 +2102,164 @@ function findLastAddressAssistantDebug(items) {
|
||||||
}
|
}
|
||||||
const debug = item.debug;
|
const debug = item.debug;
|
||||||
if (isAddressLaneDebugPayload(debug)) {
|
if (isAddressLaneDebugPayload(debug)) {
|
||||||
return debug;
|
return item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
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) {
|
function findRecentAddressFilterValue(items, key) {
|
||||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||||
const item = items[index];
|
const item = items[index];
|
||||||
|
|
@ -2271,7 +2424,8 @@ function hasAddressFollowupContextSignal(userMessage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMessage = null, llmPreDecomposeMeta = null) {
|
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 followupOffer = previousAddressDebug ? buildAddressFollowupOffer(previousAddressDebug) : null;
|
||||||
const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
|
const hasImplicitContinuationSignal = Boolean(previousAddressDebug) &&
|
||||||
Boolean(followupOffer?.enabled) &&
|
Boolean(followupOffer?.enabled) &&
|
||||||
|
|
@ -2304,12 +2458,13 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
followupSelectionMode = "switch_to_suggested_intent";
|
followupSelectionMode = "switch_to_suggested_intent";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
let previousAnchorType = toNonEmptyString(previousAddressDebug.anchor_type);
|
||||||
const previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
let previousAnchor = toNonEmptyString(previousAddressDebug.anchor_value_resolved) ??
|
||||||
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
toNonEmptyString(previousAddressDebug.anchor_value_raw) ??
|
||||||
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
readAddressFilterString(previousAddressDebug, "counterparty") ??
|
||||||
readAddressFilterString(previousAddressDebug, "account") ??
|
readAddressFilterString(previousAddressDebug, "account") ??
|
||||||
readAddressFilterString(previousAddressDebug, "contract");
|
readAddressFilterString(previousAddressDebug, "contract");
|
||||||
|
let resolvedCounterpartyFromDisplay = false;
|
||||||
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
const previousFiltersRaw = previousAddressDebug.extracted_filters;
|
||||||
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object"
|
||||||
? { ...previousFiltersRaw }
|
? { ...previousFiltersRaw }
|
||||||
|
|
@ -2332,6 +2487,20 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
previousFilters.organization = historicalOrganization;
|
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) {
|
if (!previousIntent && !previousAnchor && Object.keys(previousFilters).length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -2340,7 +2509,8 @@ function resolveAddressFollowupCarryoverContext(userMessage, items, alternateMes
|
||||||
previous_intent: previousIntent ?? undefined,
|
previous_intent: previousIntent ?? undefined,
|
||||||
previous_filters: previousFilters,
|
previous_filters: previousFilters,
|
||||||
previous_anchor_type: previousAnchorType ?? undefined,
|
previous_anchor_type: previousAnchorType ?? undefined,
|
||||||
previous_anchor_value: previousAnchor
|
previous_anchor_value: previousAnchor,
|
||||||
|
resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined
|
||||||
},
|
},
|
||||||
previousAddressIntent: previousIntent,
|
previousAddressIntent: previousIntent,
|
||||||
previousAddressAnchor: previousAnchor,
|
previousAddressAnchor: previousAnchor,
|
||||||
|
|
@ -2354,11 +2524,14 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
const canonicalMessage = String(effectiveMessage ?? sourceMessage);
|
const canonicalMessage = String(effectiveMessage ?? sourceMessage);
|
||||||
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
const hasFollowupContext = Boolean(carryoverMeta?.followupContext);
|
||||||
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
const previousIntent = toNonEmptyString(carryoverMeta?.previousSourceIntent) ?? null;
|
||||||
const targetIntent = toNonEmptyString(carryoverMeta?.previousAddressIntent) ?? null;
|
|
||||||
const selectionMode = toNonEmptyString(carryoverMeta?.followupSelectionMode) ?? 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 hasImplicitContinuationSignal = Boolean(carryoverMeta?.hasImplicitContinuationSignal);
|
||||||
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
const rewrittenByPredecompose = compactWhitespace(sourceMessage.toLowerCase()) !== compactWhitespace(canonicalMessage.toLowerCase());
|
||||||
const hasExplicitIntent = Boolean(toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent));
|
const hasExplicitIntent = Boolean(explicitIntent);
|
||||||
const decision = !hasFollowupContext
|
const decision = !hasFollowupContext
|
||||||
? "new_topic"
|
? "new_topic"
|
||||||
: selectionMode === "switch_to_suggested_intent"
|
: selectionMode === "switch_to_suggested_intent"
|
||||||
|
|
@ -2377,6 +2550,9 @@ function buildAddressDialogContinuationContractV2(userMessage, effectiveMessage,
|
||||||
if (hasExplicitIntent) {
|
if (hasExplicitIntent) {
|
||||||
reasons.push("llm_contract_intent_available");
|
reasons.push("llm_contract_intent_available");
|
||||||
}
|
}
|
||||||
|
if (selectionMode === "carry_referenced_entity" && explicitIntent && previousIntent && explicitIntent !== previousIntent) {
|
||||||
|
reasons.push("operation_intent_from_current_message");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
schema_version: "address_dialog_continuation_contract_v2",
|
schema_version: "address_dialog_continuation_contract_v2",
|
||||||
source_message: sourceMessage,
|
source_message: sourceMessage,
|
||||||
|
|
|
||||||
|
|
@ -3203,6 +3203,29 @@ describe("address decompose stage follow-up carryover", () => {
|
||||||
).toBe(true);
|
).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", () => {
|
it("promotes open-items intent from follow-up wording with inherited contract anchor", () => {
|
||||||
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
|
const result = runAddressDecomposeStage("а теперь открытые позиции по нему", {
|
||||||
previous_intent: "bank_operations_by_contract",
|
previous_intent: "bank_operations_by_contract",
|
||||||
|
|
|
||||||
|
|
@ -605,6 +605,109 @@ describe("assistant address follow-up carryover", () => {
|
||||||
expect(normalizerService.normalize).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("does not carry address follow-up context into capability question", async () => {
|
||||||
const calls: Array<{ message: string; options?: any }> = [];
|
const calls: Array<{ message: string; options?: any }> = [];
|
||||||
const firstMessage = "покажи документы по свк за 2020";
|
const firstMessage = "покажи документы по свк за 2020";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue