ГЛОБАЛЬНЫЙ РЕФАКТОРИНГ АРХИТЕКТУРЫ - Этап 4.8: референциальная continuity для follow-up по контрагентам и pivot операции

This commit is contained in:
dctouch 2026-04-12 10:36:44 +03:00
parent a49212608f
commit ca2feab893
7 changed files with 584 additions and 17 deletions

View File

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

View File

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

View File

@ -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,

View File

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

View File

@ -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,

View File

@ -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",

View File

@ -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";