diff --git a/docs/TECH/1CLLMARCH-FACT.md b/docs/TECH/1CLLMARCH-FACT.md index 41d0d02..c16211e 100644 --- a/docs/TECH/1CLLMARCH-FACT.md +++ b/docs/TECH/1CLLMARCH-FACT.md @@ -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 diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 05ceec4..4af7477 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -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); diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index cd6d3b4..eb8b9de 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -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, diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 546635c..827c1e0 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -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); diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 07c300e..2a504e7 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -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, diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 8d27d19..6470239 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -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", diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index 28f2743..9352dbd 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -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";