From bda7ca9cc1ce4ceff2bf784239a2f2c35aeabe0b Mon Sep 17 00:00:00 2001 From: dctouch Date: Tue, 21 Apr 2026 19:37:37 +0300 Subject: [PATCH] =?UTF-8?q?ARCH:=20=D0=B2=D0=B2=D0=B5=D1=81=D1=82=D0=B8=20?= =?UTF-8?q?broad=20business=20evaluation=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...se22_broad_business_evaluation_bridge.json | 78 ++++++ ...istantAddressLaneResponseRuntimeAdapter.js | 1 + ...stantAddressOrchestrationRuntimeAdapter.js | 5 +- .../services/assistantContinuityPolicy.js | 134 ++++++++- .../assistantLivingChatRuntimeAdapter.js | 24 +- .../assistantMcpDiscoveryResponsePolicy.js | 9 + .../assistantMcpDiscoveryTurnInputAdapter.js | 232 +++++++++++++-- .../services/assistantMemoryRecapPolicy.js | 32 +++ .../services/assistantTransitionPolicy.js | 12 +- .../services/assistantTurnMeaningPolicy.js | 66 +++-- ...istantAddressLaneResponseRuntimeAdapter.ts | 1 + ...stantAddressOrchestrationRuntimeAdapter.ts | 5 +- .../src/services/assistantContinuityPolicy.ts | 162 ++++++++++- .../assistantLivingChatRuntimeAdapter.ts | 25 +- .../assistantMcpDiscoveryResponsePolicy.ts | 15 + .../assistantMcpDiscoveryTurnInputAdapter.ts | 264 ++++++++++++++++-- .../services/assistantMemoryRecapPolicy.ts | 40 +++ .../src/services/assistantTransitionPolicy.ts | 14 +- .../services/assistantTurnMeaningPolicy.ts | 43 ++- .../assistantAddressFollowupContext.test.ts | 108 +++++++ ...AddressOrchestrationRuntimeAdapter.test.ts | 91 ++++++ .../tests/assistantContinuityPolicy.test.ts | 44 +++ .../assistantLivingChatRuntimeAdapter.test.ts | 85 ++++++ ...ssistantMcpDiscoveryResponsePolicy.test.ts | 46 +++ ...istantMcpDiscoveryTurnInputAdapter.test.ts | 63 +++++ .../tests/assistantMemoryRecapPolicy.test.ts | 70 +++++ .../tests/assistantRoutePolicy.test.ts | 20 +- .../tests/assistantTransitionPolicy.test.ts | 39 +++ .../tests/assistantTurnMeaningPolicy.test.ts | 17 ++ 29 files changed, 1648 insertions(+), 97 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase22_broad_business_evaluation_bridge.json diff --git a/docs/orchestration/address_truth_harness_phase22_broad_business_evaluation_bridge.json b/docs/orchestration/address_truth_harness_phase22_broad_business_evaluation_bridge.json new file mode 100644 index 0000000..a148cf7 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase22_broad_business_evaluation_bridge.json @@ -0,0 +1,78 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase22_broad_business_evaluation_bridge", + "domain": "address_phase22_broad_business_evaluation_bridge", + "title": "Phase 22 broad business evaluation bridge replay", + "description": "Targeted AGENT replay for the broad business evaluation seam where a follow-up like 'Как ты оценишь деятельность компании?' must not replay stale lifecycle routing and should still preserve the chain for the next exact net-flow question.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_company_activity_lifecycle", + "title": "Lifecycle answer seeds grounded organization context", + "question": "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "allowed_reply_types": [ + "partial_coverage", + "factual", + "factual_with_explanation" + ], + "required_answer_patterns_any": [ + "(?i)лет", + "(?i)активност", + "(?i)1с", + "(?i)не получил|не подтвержден|проверил доступный контур" + ], + "criticality": "critical", + "semantic_tags": [ + "company_activity_lifecycle", + "grounded_context_seed" + ] + }, + { + "step_id": "step_02_broad_business_evaluation", + "title": "Broad business evaluation becomes grounded summary instead of stale lifecycle dump", + "question": "Как ты оценишь деятельность компании?", + "required_answer_patterns_all": [ + "(?i)коротко|оценк|частичн", + "(?i)1с|подтвержд", + "(?i)денежн|долг|ндс|контрагент|операц" + ], + "forbidden_answer_patterns": [ + "(?i)активных заказчиков", + "(?i)последняя активность", + "(?i)^\\s*1\\." + ], + "criticality": "critical", + "semantic_tags": [ + "broad_business_evaluation", + "grounded_summary" + ] + }, + { + "step_id": "step_03_net_flow_after_broad_eval", + "title": "Exact net-flow follow-up still answers after the broad bridge", + "question": "какое нетто по деньгам с Группа СВК за 2020 год: сколько получили и сколько заплатили?", + "allowed_reply_types": [ + "partial_coverage", + "factual_with_explanation" + ], + "required_answer_patterns_all": [ + "(?i)свк", + "(?i)получил|входящ|поступ", + "(?i)заплат|исходящ|списан|плат[её]ж", + "(?i)нетто|сальдо|разниц", + "(?i)2020|период", + "(?i)руб" + ], + "forbidden_answer_patterns": [ + "(?i)активных заказчиков", + "(?i)лет в базе", + "(?i)последняя активность" + ], + "criticality": "critical", + "semantic_tags": [ + "counterparty_net_cash_flow", + "broad_eval_bridge_preserved" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js index 40d40e6..c58ed78 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressLaneResponseRuntimeAdapter.js @@ -225,6 +225,7 @@ function runAssistantAddressLaneResponseRuntime(input) { const mcpDiscoveryResponsePolicy = (0, assistantMcpDiscoveryResponsePolicy_1.applyAssistantMcpDiscoveryResponsePolicy)({ currentReply: guardedResponse.assistantReply, currentReplySource: "address_query_runtime_v1", + currentReplyType: guardedResponse.replyType, addressRuntimeMeta: debugWithResponseGuard }); const finalAssistantReply = mcpDiscoveryResponsePolicy.applied diff --git a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js index 3d4c0d0..b1024e2 100644 --- a/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantAddressOrchestrationRuntimeAdapter.js @@ -136,7 +136,7 @@ async function buildAssistantAddressOrchestrationRuntime(input) { }; carryover = input.resolveAddressFollowupCarryoverContext(input.userMessage, input.sessionItems, addressInputMessage, addressPreDecompose, input.sessionAddressNavigationState); } - const followupContext = carryover?.followupContext ?? null; + const followupContext = toRecordObject(carryover?.followupContext); const routePolicyRuntime = (0, assistantRoutePolicyRuntimeAdapter_1.runAssistantRoutePolicyRuntime)({ rawUserMessage: input.userMessage, effectiveAddressUserMessage: addressInputMessage, @@ -159,7 +159,8 @@ async function buildAssistantAddressOrchestrationRuntime(input) { userMessage: input.userMessage, effectiveMessage: addressInputMessage, assistantTurnMeaning: toRecordObject(orchestrationContract?.assistant_turn_meaning), - predecomposeContract + predecomposeContract, + followupContext })); } catch (error) { diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index b29eb76..7b4d4a9 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -5,6 +5,7 @@ exports.formatIsoDateForReply = formatIsoDateForReply; exports.readAddressDebugFilters = readAddressDebugFilters; exports.readAddressDebugItem = readAddressDebugItem; exports.readAddressDebugCounterparty = readAddressDebugCounterparty; +exports.readAddressDebugIntent = readAddressDebugIntent; exports.readAddressDebugOrganization = readAddressDebugOrganization; exports.readAddressDebugScopedDate = readAddressDebugScopedDate; exports.readAddressDebugTemporalScope = readAddressDebugTemporalScope; @@ -43,6 +44,20 @@ function toRecordObject(value) { } return value; } +function candidateValue(value) { + const direct = fallbackToNonEmptyString(value); + if (direct && direct !== "[object Object]") { + return direct; + } + const record = toRecordObject(value); + if (!record) { + return null; + } + return (fallbackToNonEmptyString(record.value) ?? + fallbackToNonEmptyString(record.name) ?? + fallbackToNonEmptyString(record.ref) ?? + fallbackToNonEmptyString(record.text)); +} function readAssistantMcpDiscoveryEntry(debug) { const entry = toRecordObject(debug?.assistant_mcp_discovery_entry_point_v1); return fallbackToNonEmptyString(entry?.schema_version) === "assistant_mcp_discovery_runtime_entry_point_v1" @@ -54,6 +69,9 @@ function readAssistantMcpDiscoveryTurnMeaning(debug) { const turnInput = toRecordObject(entry?.turn_input); return toRecordObject(turnInput?.turn_meaning_ref); } +function readAssistantMcpDiscoveryActionFamily(debug, toNonEmptyString = fallbackToNonEmptyString) { + return toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.asked_action_family); +} function readAssistantMcpDiscoveryBridge(debug) { return toRecordObject(readAssistantMcpDiscoveryEntry(debug)?.bridge); } @@ -62,6 +80,82 @@ function readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString = fallbackT const pilot = toRecordObject(bridge?.pilot); return toNonEmptyString(pilot?.pilot_scope); } +function mapAssistantMcpDiscoveryPilotScopeToAddressIntent(pilotScope, actionFamily) { + if (pilotScope === "counterparty_lifecycle_query_documents_v1") { + return "counterparty_activity_lifecycle"; + } + if (pilotScope === "counterparty_supplier_payout_query_movements_v1") { + return "supplier_payouts_profile"; + } + if (pilotScope === "counterparty_value_flow_query_movements_v1") { + return "customer_revenue_and_payments"; + } + if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") { + return null; + } + if (actionFamily === "activity_duration") { + return "counterparty_activity_lifecycle"; + } + if (actionFamily === "payout") { + return "supplier_payouts_profile"; + } + if (actionFamily === "turnover") { + return "customer_revenue_and_payments"; + } + return null; +} +function readDiscoveryDateScopeFilters(debug, toNonEmptyString = fallbackToNonEmptyString) { + const explicitDateScope = toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.explicit_date_scope); + if (!explicitDateScope) { + return { + asOfDate: null, + periodFrom: null, + periodTo: null + }; + } + const isoDateMatch = explicitDateScope.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (isoDateMatch) { + return { + asOfDate: explicitDateScope, + periodFrom: null, + periodTo: null + }; + } + const monthMatch = explicitDateScope.match(/^(\d{4})-(\d{2})$/); + if (monthMatch) { + const year = Number(monthMatch[1]); + const month = Number(monthMatch[2]); + if (Number.isFinite(year) && Number.isFinite(month) && month >= 1 && month <= 12) { + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return { + asOfDate: null, + periodFrom: `${monthMatch[1]}-${monthMatch[2]}-01`, + periodTo: `${monthMatch[1]}-${monthMatch[2]}-${String(lastDay).padStart(2, "0")}` + }; + } + } + const yearMatch = explicitDateScope.match(/^(\d{4})$/); + if (yearMatch) { + return { + asOfDate: null, + periodFrom: `${yearMatch[1]}-01-01`, + periodTo: `${yearMatch[1]}-12-31` + }; + } + const rangeMatch = explicitDateScope.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/); + if (rangeMatch) { + return { + asOfDate: null, + periodFrom: rangeMatch[1], + periodTo: rangeMatch[2] + }; + } + return { + asOfDate: null, + periodFrom: null, + periodTo: null + }; +} function formatDiscoveryDateScopeForReply(value) { const text = fallbackToNonEmptyString(value); if (!text) { @@ -122,13 +216,20 @@ function readAddressDebugCounterparty(debug, toNonEmptyString = fallbackToNonEmp ? discoveryMeaning?.explicit_entity_candidates : []; for (const entity of explicitEntities) { - const text = toNonEmptyString(entity); + const text = candidateValue(entity); if (text) { return text; } } return null; } +function readAddressDebugIntent(debug, toNonEmptyString = fallbackToNonEmptyString) { + const detectedIntent = toNonEmptyString(debug?.detected_intent); + if (detectedIntent && detectedIntent !== "unknown") { + return detectedIntent; + } + return mapAssistantMcpDiscoveryPilotScopeToAddressIntent(readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString), readAssistantMcpDiscoveryActionFamily(debug, toNonEmptyString)); +} function readAddressDebugOrganization(debug, toNonEmptyString = fallbackToNonEmptyString) { const extractedFilters = readAddressDebugFilters(debug); const rootFrameContext = toRecordObject(debug?.address_root_frame_context); @@ -150,10 +251,17 @@ function readAddressDebugScopedDate(debug) { function readAddressDebugTemporalScope(debug, toNonEmptyString = fallbackToNonEmptyString) { const extractedFilters = readAddressDebugFilters(debug); const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + const discoveryDateScope = readDiscoveryDateScopeFilters(debug, toNonEmptyString); return { - asOfDate: toNonEmptyString(extractedFilters?.as_of_date) ?? toNonEmptyString(rootFrameContext?.as_of_date), - periodFrom: toNonEmptyString(extractedFilters?.period_from) ?? toNonEmptyString(rootFrameContext?.period_from), - periodTo: toNonEmptyString(extractedFilters?.period_to) ?? toNonEmptyString(rootFrameContext?.period_to) + asOfDate: toNonEmptyString(extractedFilters?.as_of_date) ?? + toNonEmptyString(rootFrameContext?.as_of_date) ?? + discoveryDateScope.asOfDate, + periodFrom: toNonEmptyString(extractedFilters?.period_from) ?? + toNonEmptyString(rootFrameContext?.period_from) ?? + discoveryDateScope.periodFrom, + periodTo: toNonEmptyString(extractedFilters?.period_to) ?? + toNonEmptyString(rootFrameContext?.period_to) ?? + discoveryDateScope.periodTo }; } function resolveAddressDebugAnchorContext(debug, toNonEmptyString = fallbackToNonEmptyString) { @@ -234,6 +342,24 @@ function resolveAddressDebugContextFacts(debug, toNonEmptyString = fallbackToNon function resolveAddressDebugCarryoverFilters(debug, toNonEmptyString = fallbackToNonEmptyString) { const extractedFilters = readAddressDebugFilters(debug); const nextFilters = extractedFilters ? { ...extractedFilters } : {}; + const discoveryDateScope = readDiscoveryDateScopeFilters(debug, toNonEmptyString); + const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString); + const organization = readAddressDebugOrganization(debug, toNonEmptyString); + if (counterparty && !toNonEmptyString(nextFilters.counterparty)) { + nextFilters.counterparty = counterparty; + } + if (organization && !toNonEmptyString(nextFilters.organization)) { + nextFilters.organization = organization; + } + if (discoveryDateScope.asOfDate && !toNonEmptyString(nextFilters.as_of_date)) { + nextFilters.as_of_date = discoveryDateScope.asOfDate; + } + if (discoveryDateScope.periodFrom && !toNonEmptyString(nextFilters.period_from)) { + nextFilters.period_from = discoveryDateScope.periodFrom; + } + if (discoveryDateScope.periodTo && !toNonEmptyString(nextFilters.period_to)) { + nextFilters.period_to = discoveryDateScope.periodTo; + } const inventoryRootFrame = buildInventoryRootFrameFromAddressDebug(debug, toNonEmptyString); const rootFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object" ? inventoryRootFrame.filters diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index ed15518..067601e 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -113,10 +113,26 @@ async function runAssistantLivingChatRuntime(input) { : "deterministic_data_scope_contract"; } else if (unsupportedCurrentTurnMeaningBoundary) { - chatText = buildUnsupportedCurrentTurnMeaningBoundaryReply({ - assistantTurnMeaning - }); - livingChatSource = "deterministic_unsupported_current_turn_boundary"; + const unsupportedFamily = typeof assistantTurnMeaning?.unsupported_but_understood_family === "string" + ? assistantTurnMeaning.unsupported_but_understood_family + : null; + if (unsupportedFamily === "broad_business_evaluation") { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? continuityActiveOrganization ?? null; + chatText = (0, assistantMemoryRecapPolicy_1.buildBroadBusinessEvaluationReply)({ + organization: scopedOrganization, + addressDebug: continuitySnapshot.lastGroundedAddressDebug, + sessionItems: input.sessionItems, + toNonEmptyString: input.toNonEmptyString + }); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_broad_business_evaluation_contract"; + } + else { + chatText = buildUnsupportedCurrentTurnMeaningBoundaryReply({ + assistantTurnMeaning + }); + livingChatSource = "deterministic_unsupported_current_turn_boundary"; + } } else if ((selectedOrganization || activeOrganization) && input.hasOrganizationFactLookupSignal(userMessage)) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js index a017356..2d280c7 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js @@ -66,6 +66,10 @@ function isUnsupportedCurrentTurnBoundary(input) { input.livingChatSource === "deterministic_unsupported_current_turn_boundary" || input.currentReplySource === "deterministic_unsupported_current_turn_boundary"); } +function isDeterministicBroadBusinessEvaluationReply(input) { + return (input.livingChatSource === "deterministic_broad_business_evaluation_contract" || + input.currentReplySource === "deterministic_broad_business_evaluation_contract"); +} function isDiscoveryReadyChatCandidate(input, entryPoint) { const turnInput = toRecordObject(entryPoint?.turn_input); return (entryPoint?.entry_status === "bridge_executed" && @@ -202,6 +206,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { const candidate = (0, assistantMcpDiscoveryResponseCandidate_1.buildAssistantMcpDiscoveryResponseCandidate)(entryPoint); const reasonCodes = [...candidate.reason_codes]; const unsupportedBoundary = isUnsupportedCurrentTurnBoundary(input); + const deterministicBroadBusinessEvaluationReply = isDeterministicBroadBusinessEvaluationReply(input); const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); const discoveryReadyAddressCandidate = isDiscoveryReadyAddressCandidate(input, entryPoint); @@ -236,6 +241,9 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } + if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { + pushReason(reasonCodes, "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate"); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -253,6 +261,7 @@ function applyAssistantMcpDiscoveryResponsePolicy(input) { !alignedFactualAddressReply && !matchedFactualAddressContinuationTarget && !fullConfirmedFactualAddressReply && + !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && Boolean(toNonEmptyString(candidate.reply_text)) && diff --git a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js index bf9d4f5..59ec97a 100644 --- a/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantMcpDiscoveryTurnInputAdapter.js @@ -92,6 +92,119 @@ function collectDateScope(predecompose) { } return periodFrom ?? periodTo ?? null; } +function collectDateScopeFromFilters(filters) { + if (!filters) { + return null; + } + const asOfDate = toNonEmptyString(filters.as_of_date); + const periodFrom = toNonEmptyString(filters.period_from); + const periodTo = toNonEmptyString(filters.period_to); + if (asOfDate) { + return asOfDate; + } + const yearFrom = periodFrom?.match(/^(\d{4})-01-01$/); + const yearTo = periodTo?.match(/^(\d{4})-12-31$/); + if (yearFrom && yearTo && yearFrom[1] === yearTo[1]) { + return yearFrom[1]; + } + if (periodFrom && periodTo) { + return `${periodFrom}..${periodTo}`; + } + return periodFrom ?? periodTo ?? null; +} +function mapPilotScopeToFollowupMeaning(pilotScope) { + if (pilotScope === "counterparty_lifecycle_query_documents_v1") { + return { + domain: "counterparty_lifecycle", + action: "activity_duration", + unsupported: "counterparty_lifecycle" + }; + } + if (pilotScope === "counterparty_supplier_payout_query_movements_v1") { + return { + domain: "counterparty_value", + action: "payout", + unsupported: "counterparty_payouts_or_outflow" + }; + } + if (pilotScope === "counterparty_value_flow_query_movements_v1") { + return { + domain: "counterparty_value", + action: "turnover", + unsupported: "counterparty_value_or_turnover" + }; + } + if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") { + return { + domain: "counterparty_value", + action: "net_value_flow", + unsupported: "counterparty_bidirectional_value_flow_or_netting" + }; + } + return { + domain: null, + action: null, + unsupported: null + }; +} +function mapAddressIntentToFollowupMeaning(intent) { + if (intent === "counterparty_activity_lifecycle") { + return { + domain: "counterparty_lifecycle", + action: "activity_duration", + unsupported: "counterparty_lifecycle" + }; + } + if (intent === "supplier_payouts_profile") { + return { + domain: "counterparty_value", + action: "payout", + unsupported: "counterparty_payouts_or_outflow" + }; + } + if (intent === "customer_revenue_and_payments") { + return { + domain: "counterparty_value", + action: "turnover", + unsupported: "counterparty_value_or_turnover" + }; + } + return { + domain: null, + action: null, + unsupported: null + }; +} +function collectFollowupDiscoverySeed(followupContext) { + const previousFilters = toRecordObject(followupContext?.previous_filters); + const rootFilters = toRecordObject(followupContext?.root_filters); + const pilotScope = toNonEmptyString(followupContext?.previous_discovery_pilot_scope); + const previousIntent = toNonEmptyString(followupContext?.target_intent) ?? toNonEmptyString(followupContext?.previous_intent); + const mapped = mapPilotScopeToFollowupMeaning(pilotScope).domain !== null + ? mapPilotScopeToFollowupMeaning(pilotScope) + : mapAddressIntentToFollowupMeaning(previousIntent); + const counterparty = toNonEmptyString(previousFilters?.counterparty) ?? + toNonEmptyString(rootFilters?.counterparty) ?? + (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" + ? toNonEmptyString(followupContext?.previous_anchor_value) + : null); + const organization = toNonEmptyString(previousFilters?.organization) ?? + toNonEmptyString(rootFilters?.organization) ?? + (toNonEmptyString(followupContext?.previous_anchor_type) === "organization" + ? toNonEmptyString(followupContext?.previous_anchor_value) + : null); + const dateScope = collectDateScopeFromFilters(previousFilters) ?? + collectDateScopeFromFilters(rootFilters); + return { + pilotScope, + domain: mapped.domain, + action: mapped.action, + unsupported: mapped.unsupported, + counterparty, + organization, + dateScope + }; +} function hasLifecycleSignal(text) { return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test(text); } @@ -107,6 +220,24 @@ function hasBidirectionalValueFlowSignal(text) { function hasMonthlyAggregationSignal(text) { return /(?:\u043f\u043e\s+\u043c\u0435\u0441\u044f\u0446\u0430\u043c|\u043f\u043e\u043c\u0435\u0441\u044f\u0447\u043d\u043e|\u0435\u0436\u0435\u043c\u0435\u0441\u044f\u0447\u043d\u043e|month\s+by\s+month|by\s+month|monthly)/iu.test(text); } +function hasExplicitDateScopeLiteral(text) { + return /(?:\b(?:19|20)\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{4}-\d{2}\b)/iu.test(text); +} +function collectDateScopeFromRawText(text) { + const isoDate = text.match(/\b(\d{4}-\d{2}-\d{2})\b/u); + if (isoDate?.[1]) { + return isoDate[1]; + } + const yearMonth = text.match(/\b(\d{4}-\d{2})\b/u); + if (yearMonth?.[1]) { + return yearMonth[1]; + } + const year = text.match(/\b((?:19|20)\d{2})\b/u); + if (year?.[1]) { + return year[1]; + } + return null; +} function semanticNeedFor(input) { const combined = compactLower(`${input.domain ?? ""} ${input.action ?? ""} ${input.unsupported ?? ""}`); if (input.lifecycleSignal || /(?:lifecycle|activity|duration|age)/iu.test(combined)) { @@ -130,6 +261,9 @@ function shouldRunDiscovery(input) { if (input.valueFlowSignal && !input.explicitIntentCandidate) { return true; } + if (input.followupDiscoverySeedApplicable && !input.explicitIntentCandidate && input.semanticDataNeed) { + return true; + } if (!input.explicitIntentCandidate && input.semanticDataNeed) { return true; } @@ -138,34 +272,64 @@ function shouldRunDiscovery(input) { function buildAssistantMcpDiscoveryTurnInput(input) { const assistantTurnMeaning = toRecordObject(input.assistantTurnMeaning); const predecomposeContract = toRecordObject(input.predecomposeContract); + const followupContext = toRecordObject(input.followupContext); const predecomposeEntities = collectPredecomposeEntities(predecomposeContract); + const followupSeed = collectFollowupDiscoverySeed(followupContext); const reasonCodes = []; const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); - const lifecycleSignal = hasLifecycleSignal(rawText); - const bidirectionalValueFlowSignal = !lifecycleSignal && hasBidirectionalValueFlowSignal(rawText); - const valueFlowSignal = !lifecycleSignal && (hasValueFlowSignal(rawText) || bidirectionalValueFlowSignal); - const payoutSignal = valueFlowSignal && !bidirectionalValueFlowSignal && hasPayoutSignal(rawText); - const monthlyAggregationSignal = valueFlowSignal && hasMonthlyAggregationSignal(rawText); + const rawLifecycleSignal = hasLifecycleSignal(rawText); + const rawBidirectionalValueFlowSignal = !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText); + const rawValueFlowSignal = !rawLifecycleSignal && (hasValueFlowSignal(rawText) || rawBidirectionalValueFlowSignal); + const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText); + const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText); + const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(rawText); + const rawDateScope = collectDateScopeFromRawText(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); + const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope); + const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope); + const predecomposeDateScope = collectDateScope(predecomposeContract); + const followupDiscoverySeedApplicable = Boolean(followupSeed.domain && + !rawLifecycleSignal && + !rawValueFlowSignal && + (monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope)); + const seededDomain = followupDiscoverySeedApplicable ? followupSeed.domain : null; + const seededAction = followupDiscoverySeedApplicable ? followupSeed.action : null; + const seededUnsupported = followupDiscoverySeedApplicable ? followupSeed.unsupported : null; + const lifecycleSignal = rawLifecycleSignal || seededDomain === "counterparty_lifecycle"; + const bidirectionalValueFlowSignal = !lifecycleSignal && + (rawBidirectionalValueFlowSignal || seededAction === "net_value_flow"); + const valueFlowSignal = !lifecycleSignal && (rawValueFlowSignal || seededDomain === "counterparty_value"); + const payoutSignal = valueFlowSignal && + !bidirectionalValueFlowSignal && + (rawPayoutSignal || seededAction === "payout"); const semanticDataNeed = semanticNeedFor({ - domain: rawDomain, - action: rawAction, - unsupported, + domain: rawDomain ?? seededDomain, + action: rawAction ?? seededAction, + unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); - if (valueFlowSignal && !predecomposeEntities.counterparty) { + pushUnique(entityCandidates, followupSeed.counterparty); + if (valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty) { pushUnique(entityCandidates, predecomposeEntities.organization); + pushUnique(entityCandidates, followupSeed.organization); } - const explicitOrganizationScope = valueFlowSignal && !predecomposeEntities.counterparty ? null : predecomposeEntities.organization; + const explicitOrganizationScope = valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty + ? null + : predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization; + const explicitDateScope = assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope; const turnMeaning = { - asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" : rawDomain, + asked_domain_family: lifecycleSignal + ? "counterparty_lifecycle" + : valueFlowSignal + ? "counterparty_value" + : rawDomain ?? seededDomain, asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal @@ -173,12 +337,12 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "net_value_flow" : payoutSignal ? "payout" - : "turnover" - : rawAction, + : rawAction ?? seededAction ?? "turnover" + : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, explicit_organization_scope: explicitOrganizationScope, - explicit_date_scope: collectDateScope(predecomposeContract), + explicit_date_scope: explicitDateScope, unsupported_but_understood_family: unsupported ?? (lifecycleSignal ? "counterparty_lifecycle" @@ -187,9 +351,15 @@ function buildAssistantMcpDiscoveryTurnInput(input) { ? "counterparty_bidirectional_value_flow_or_netting" : payoutSignal ? "counterparty_payouts_or_outflow" - : "counterparty_value_or_turnover" - : null), - stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal) + : seededUnsupported ?? "counterparty_value_or_turnover" + : followupDiscoverySeedApplicable + ? seededUnsupported + : null), + stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || + unsupported || + lifecycleSignal || + valueFlowSignal || + followupDiscoverySeedApplicable) }; const cleanTurnMeaning = {}; if (toNonEmptyString(turnMeaning.asked_domain_family)) { @@ -217,22 +387,25 @@ function buildAssistantMcpDiscoveryTurnInput(input) { cleanTurnMeaning.stale_replay_forbidden = true; } const runDiscovery = shouldRunDiscovery({ - unsupported, + unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, semanticDataNeed, - explicitIntentCandidate + explicitIntentCandidate, + followupDiscoverySeedApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal = assistantTurnMeaning ? "assistant_turn_meaning" - : predecomposeContract - ? "predecompose_contract" - : lifecycleSignal - ? "raw_text" - : valueFlowSignal + : followupDiscoverySeedApplicable + ? "followup_context" + : predecomposeContract + ? "predecompose_contract" + : lifecycleSignal ? "raw_text" - : "none"; + : valueFlowSignal + ? "raw_text" + : "none"; if (lifecycleSignal) { pushReason(reasonCodes, "mcp_discovery_lifecycle_signal_detected"); } @@ -248,12 +421,21 @@ function buildAssistantMcpDiscoveryTurnInput(input) { if (monthlyAggregationSignal) { pushReason(reasonCodes, "mcp_discovery_monthly_aggregation_signal_detected"); } + if (followupDiscoverySeedApplicable) { + pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } if (predecomposeEntities.counterparty) { pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose"); } + if (followupSeed.counterparty) { + pushReason(reasonCodes, "mcp_discovery_counterparty_from_followup_context"); + } + if (followupSeed.dateScope) { + pushReason(reasonCodes, "mcp_discovery_date_scope_from_followup_context"); + } if (entityCandidates.length > 0) { pushReason(reasonCodes, "mcp_discovery_entity_scope_available"); } diff --git a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js index 2dbda48..74a89fd 100644 --- a/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMemoryRecapPolicy.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.buildInventoryHistoryCapabilityFollowupReply = buildInventoryHistoryCapabilityFollowupReply; exports.buildAddressMemoryRecapReply = buildAddressMemoryRecapReply; +exports.buildBroadBusinessEvaluationReply = buildBroadBusinessEvaluationReply; exports.buildSelectedObjectAnswerInspectionReply = buildSelectedObjectAnswerInspectionReply; exports.resolveAssistantLivingChatMemoryContext = resolveAssistantLivingChatMemoryContext; exports.createAssistantMemoryRecapPolicy = createAssistantMemoryRecapPolicy; @@ -20,6 +21,13 @@ function toRecordObject(value) { } return value; } +function ensureSentence(value) { + const text = String(value ?? "").trim(); + if (!text) { + return ""; + } + return /[.!?]$/.test(text) ? text : `${text}.`; +} function periodPartForRecap(scopedDate) { if (!scopedDate) { return ""; @@ -261,6 +269,30 @@ function buildAddressMemoryRecapReply(input) { } return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; } +function buildBroadBusinessEvaluationReply(input) { + const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); + const organization = input.organization ?? contextFacts.organization; + const recapFacts = collectRecentRecapFacts({ + sessionItems: input.sessionItems, + item: null, + organization, + toNonEmptyString: input.toNonEmptyString + }); + const organizationPart = organization ? ` по компании «${organization}»` : ""; + if (recapFacts.length > 0) { + return [ + `Коротко: по тому, что мы уже подтвердили в 1С${organizationPart}, компания выглядит операционно живой, но это пока только частичная оценка бизнеса.`, + "Сейчас я опираюсь на такие подтвержденные факты:", + ...recapFacts.map((fact) => `- ${ensureSentence(fact)}`), + "Это еще не полная диагностика всего бизнеса и не вывод о прибыли: я честно суммирую только те контуры, которые мы уже проверили в диалоге.", + "Если хочешь, следующим шагом могу сузить оценку до денежного потока, долгов, НДС или ключевых контрагентов." + ].join("\n"); + } + return [ + `Коротко: по нынешнему контексту 1С${organizationPart} я вижу признаки операционной активности, но для содержательной оценки бизнеса нужно еще несколько опорных срезов.`, + "Если хочешь, я быстро доберу основу для такой оценки: денежный поток, дебиторка/кредиторка, НДС или ключевые контрагенты." + ].join(" "); +} function buildSelectedObjectAnswerInspectionReply(input) { const contextFacts = (0, assistantContinuityPolicy_1.resolveAddressDebugContextFacts)(input.addressDebug, input.toNonEmptyString); const itemLabel = contextFacts.item ?? "эта позиция"; diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 0d08bb1..3827532 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -121,18 +121,22 @@ function createAssistantTransitionPolicy(deps) { return false; } const executionLane = deps.toNonEmptyString(debug.execution_lane); - const detectedIntent = deps.toNonEmptyString(debug.detected_intent); + const detectedIntent = (0, assistantContinuityPolicy_1.readAddressDebugIntent)(debug, deps.toNonEmptyString); const selectedRecipe = deps.toNonEmptyString(debug.selected_recipe); const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" ? debug.answer_grounding_check : null; const groundingStatus = deps.toNonEmptyString(answerGroundingCheck?.status); + const discoveryPilotScope = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(debug, deps.toNonEmptyString); if (groundingStatus === "grounded") { return true; } if (selectedRecipe) { return true; } + if (debug.mcp_discovery_response_applied === true && discoveryPilotScope) { + return true; + } return executionLane === "address_query" && Boolean(detectedIntent && detectedIntent !== "unknown"); } function findRecentUsableAddressAssistantItem(items) { @@ -337,7 +341,7 @@ function createAssistantTransitionPolicy(deps) { (deps.toNonEmptyString(alternateMessage) ? deps.isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); - const sourceIntentHint = deps.toNonEmptyString(carryoverSourceDebug?.detected_intent); + const sourceIntentHint = (0, assistantContinuityPolicy_1.readAddressDebugIntent)(carryoverSourceDebug, deps.toNonEmptyString); const navigationSessionState = (0, assistantContinuityPolicy_1.resolveNavigationSessionContextState)(addressNavigationState, deps.toNonEmptyString, deps.normalizeOrganizationScopeValue); const navigationFocusObjectHint = navigationSessionState.focusObject; const hasNavigationInventoryItemFocusHint = Boolean(deps.toNonEmptyString(navigationFocusObjectHint?.label) && @@ -457,7 +461,8 @@ function createAssistantTransitionPolicy(deps) { if (!carryoverSourceDebug) { return null; } - const sourceIntent = deps.toNonEmptyString(carryoverSourceDebug.detected_intent); + const sourceIntent = (0, assistantContinuityPolicy_1.readAddressDebugIntent)(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryPilotScope = (0, assistantContinuityPolicy_1.readAssistantMcpDiscoveryPilotScope)(carryoverSourceDebug, deps.toNonEmptyString); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; const resolvedPrimaryIntent = deps.resolveAddressIntent(deps.repairAddressMojibake(String(userMessage ?? ""))).intent; @@ -687,6 +692,7 @@ function createAssistantTransitionPolicy(deps) { previous_filters: previousFilters, previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, + previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, root_context_only: rootScopedPivot || undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, diff --git a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js index 08ee035..a03ba27 100644 --- a/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTurnMeaningPolicy.js @@ -112,6 +112,18 @@ function detectCounterpartyTurnoverFamily(text) { entity }; } +function detectBroadBusinessEvaluation(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return null; + } + if (/(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test(normalized)) { + return { + family: "broad_business_evaluation" + }; + } + return null; +} function buildEntityCandidates(counterpartyTurnover) { if (!counterpartyTurnover?.entity) { return []; @@ -133,9 +145,16 @@ function createAssistantTurnMeaningPolicy(deps = {}) { const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`); const supportedIntent = detectSupportedIntent(joinedText, deps); const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText); + const broadBusinessEvaluation = detectBroadBusinessEvaluation(joinedText); const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); - const explicitIntentCandidate = supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); - const unsupportedFamily = !explicitIntentCandidate && counterpartyTurnover?.family ? counterpartyTurnover.family : null; + const explicitIntentCandidate = broadBusinessEvaluation?.family + ? null + : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); + const unsupportedFamily = broadBusinessEvaluation?.family + ? broadBusinessEvaluation.family + : !explicitIntentCandidate && counterpartyTurnover?.family + ? counterpartyTurnover.family + : null; const reasonCodes = []; if (supportedIntent?.reason) { reasonCodes.push(supportedIntent.reason); @@ -143,6 +162,9 @@ function createAssistantTurnMeaningPolicy(deps = {}) { if (counterpartyTurnover?.family) { reasonCodes.push("counterparty_turnover_current_turn_signal"); } + if (broadBusinessEvaluation?.family) { + reasonCodes.push("broad_business_evaluation_current_turn_signal"); + } if (rawText !== normalizeTurnText(rawMessage, { ...deps, repairAddressMojibake: (value) => String(value ?? "") })) { reasonCodes.push("mojibake_repair_applied"); } @@ -158,27 +180,31 @@ function createAssistantTurnMeaningPolicy(deps = {}) { ? "vat" : explicitIntentCandidate?.startsWith("inventory_") ? "inventory" - : explicitIntentCandidate?.includes("counterparty") - ? "counterparty" - : counterpartyTurnover?.family + : broadBusinessEvaluation?.family + ? "business_summary" + : explicitIntentCandidate?.includes("counterparty") ? "counterparty" - : null; + : counterpartyTurnover?.family + ? "counterparty" + : null; const askedActionFamily = explicitIntentCandidate === "receivables_confirmed_as_of_date" || explicitIntentCandidate === "payables_confirmed_as_of_date" || explicitIntentCandidate === "inventory_on_hand_as_of_date" ? "confirmed_snapshot" - : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" - ? "confirmed_tax_period" - : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" - ? "confirmed_snapshot" - : explicitIntentCandidate === "vat_payable_forecast" - ? "forecast" - : explicitIntentCandidate === "list_documents_by_counterparty" - ? "list_documents" - : counterpartyTurnover?.family - ? "counterparty_value_or_turnover" - : null; - const staleReplayForbidden = Boolean(unsupportedFamily || (counterpartyTurnover?.entity && !explicitIntentCandidate)); + : broadBusinessEvaluation?.family + ? "broad_evaluation" + : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" + ? "confirmed_tax_period" + : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" + ? "confirmed_snapshot" + : explicitIntentCandidate === "vat_payable_forecast" + ? "forecast" + : explicitIntentCandidate === "list_documents_by_counterparty" + ? "list_documents" + : counterpartyTurnover?.family + ? "counterparty_value_or_turnover" + : null; + const staleReplayForbidden = Boolean(unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate)); return { schema_version: "assistant_turn_meaning_v1", raw_message: rawMessage, @@ -189,7 +215,9 @@ function createAssistantTurnMeaningPolicy(deps = {}) { asked_action_family: askedActionFamily, explicit_intent_candidate: explicitIntentCandidate, explicit_entity_candidates: buildEntityCandidates(counterpartyTurnover), - meaning_confidence: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), + meaning_confidence: broadBusinessEvaluation?.family + ? "medium" + : supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), intent_override_strength: explicitIntentCandidate ? "explicit_current_turn_intent" : staleReplayForbidden diff --git a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts index 0d5a0e6..86f835c 100644 --- a/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantAddressLaneResponseRuntimeAdapter.ts @@ -284,6 +284,7 @@ export function runAssistantAddressLaneResponseRuntime; } catch (error) { mcpDiscoveryRuntimeEntryPointError = String(error instanceof Error ? error.message : error ?? "unknown_error").slice(0, 280); diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 554ce24..fe3daea 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -108,6 +108,23 @@ function toRecordObject(value: unknown): Record | null { return value as Record; } +function candidateValue(value: unknown): string | null { + const direct = fallbackToNonEmptyString(value); + if (direct && direct !== "[object Object]") { + return direct; + } + const record = toRecordObject(value); + if (!record) { + return null; + } + return ( + fallbackToNonEmptyString(record.value) ?? + fallbackToNonEmptyString(record.name) ?? + fallbackToNonEmptyString(record.ref) ?? + fallbackToNonEmptyString(record.text) + ); +} + function readAssistantMcpDiscoveryEntry( debug: Record | null ): Record | null { @@ -125,6 +142,13 @@ function readAssistantMcpDiscoveryTurnMeaning( return toRecordObject(turnInput?.turn_meaning_ref); } +function readAssistantMcpDiscoveryActionFamily( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + return toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.asked_action_family); +} + function readAssistantMcpDiscoveryBridge( debug: Record | null ): Record | null { @@ -140,6 +164,94 @@ export function readAssistantMcpDiscoveryPilotScope( return toNonEmptyString(pilot?.pilot_scope); } +function mapAssistantMcpDiscoveryPilotScopeToAddressIntent( + pilotScope: string | null, + actionFamily: string | null +): string | null { + if (pilotScope === "counterparty_lifecycle_query_documents_v1") { + return "counterparty_activity_lifecycle"; + } + if (pilotScope === "counterparty_supplier_payout_query_movements_v1") { + return "supplier_payouts_profile"; + } + if (pilotScope === "counterparty_value_flow_query_movements_v1") { + return "customer_revenue_and_payments"; + } + if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") { + return null; + } + if (actionFamily === "activity_duration") { + return "counterparty_activity_lifecycle"; + } + if (actionFamily === "payout") { + return "supplier_payouts_profile"; + } + if (actionFamily === "turnover") { + return "customer_revenue_and_payments"; + } + return null; +} + +function readDiscoveryDateScopeFilters( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): { + asOfDate: string | null; + periodFrom: string | null; + periodTo: string | null; +} { + const explicitDateScope = toNonEmptyString(readAssistantMcpDiscoveryTurnMeaning(debug)?.explicit_date_scope); + if (!explicitDateScope) { + return { + asOfDate: null, + periodFrom: null, + periodTo: null + }; + } + const isoDateMatch = explicitDateScope.match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (isoDateMatch) { + return { + asOfDate: explicitDateScope, + periodFrom: null, + periodTo: null + }; + } + const monthMatch = explicitDateScope.match(/^(\d{4})-(\d{2})$/); + if (monthMatch) { + const year = Number(monthMatch[1]); + const month = Number(monthMatch[2]); + if (Number.isFinite(year) && Number.isFinite(month) && month >= 1 && month <= 12) { + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return { + asOfDate: null, + periodFrom: `${monthMatch[1]}-${monthMatch[2]}-01`, + periodTo: `${monthMatch[1]}-${monthMatch[2]}-${String(lastDay).padStart(2, "0")}` + }; + } + } + const yearMatch = explicitDateScope.match(/^(\d{4})$/); + if (yearMatch) { + return { + asOfDate: null, + periodFrom: `${yearMatch[1]}-01-01`, + periodTo: `${yearMatch[1]}-12-31` + }; + } + const rangeMatch = explicitDateScope.match(/^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/); + if (rangeMatch) { + return { + asOfDate: null, + periodFrom: rangeMatch[1], + periodTo: rangeMatch[2] + }; + } + return { + asOfDate: null, + periodFrom: null, + periodTo: null + }; +} + function formatDiscoveryDateScopeForReply(value: unknown): string | null { const text = fallbackToNonEmptyString(value); if (!text) { @@ -220,7 +332,7 @@ export function readAddressDebugCounterparty( ? discoveryMeaning?.explicit_entity_candidates : []; for (const entity of explicitEntities) { - const text = toNonEmptyString(entity); + const text = candidateValue(entity); if (text) { return text; } @@ -228,6 +340,20 @@ export function readAddressDebugCounterparty( return null; } +export function readAddressDebugIntent( + debug: Record | null, + toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString +): string | null { + const detectedIntent = toNonEmptyString(debug?.detected_intent); + if (detectedIntent && detectedIntent !== "unknown") { + return detectedIntent; + } + return mapAssistantMcpDiscoveryPilotScopeToAddressIntent( + readAssistantMcpDiscoveryPilotScope(debug, toNonEmptyString), + readAssistantMcpDiscoveryActionFamily(debug, toNonEmptyString) + ); +} + export function readAddressDebugOrganization( debug: Record | null, toNonEmptyString: (value: unknown) => string | null = fallbackToNonEmptyString @@ -261,10 +387,20 @@ export function readAddressDebugTemporalScope( ): AssistantAddressDebugTemporalScope { const extractedFilters = readAddressDebugFilters(debug); const rootFrameContext = toRecordObject(debug?.address_root_frame_context); + const discoveryDateScope = readDiscoveryDateScopeFilters(debug, toNonEmptyString); return { - asOfDate: toNonEmptyString(extractedFilters?.as_of_date) ?? toNonEmptyString(rootFrameContext?.as_of_date), - periodFrom: toNonEmptyString(extractedFilters?.period_from) ?? toNonEmptyString(rootFrameContext?.period_from), - periodTo: toNonEmptyString(extractedFilters?.period_to) ?? toNonEmptyString(rootFrameContext?.period_to) + asOfDate: + toNonEmptyString(extractedFilters?.as_of_date) ?? + toNonEmptyString(rootFrameContext?.as_of_date) ?? + discoveryDateScope.asOfDate, + periodFrom: + toNonEmptyString(extractedFilters?.period_from) ?? + toNonEmptyString(rootFrameContext?.period_from) ?? + discoveryDateScope.periodFrom, + periodTo: + toNonEmptyString(extractedFilters?.period_to) ?? + toNonEmptyString(rootFrameContext?.period_to) ?? + discoveryDateScope.periodTo }; } @@ -364,6 +500,24 @@ export function resolveAddressDebugCarryoverFilters( ): Record { const extractedFilters = readAddressDebugFilters(debug); const nextFilters = extractedFilters ? { ...extractedFilters } : {}; + const discoveryDateScope = readDiscoveryDateScopeFilters(debug, toNonEmptyString); + const counterparty = readAddressDebugCounterparty(debug, toNonEmptyString); + const organization = readAddressDebugOrganization(debug, toNonEmptyString); + if (counterparty && !toNonEmptyString(nextFilters.counterparty)) { + nextFilters.counterparty = counterparty; + } + if (organization && !toNonEmptyString(nextFilters.organization)) { + nextFilters.organization = organization; + } + if (discoveryDateScope.asOfDate && !toNonEmptyString(nextFilters.as_of_date)) { + nextFilters.as_of_date = discoveryDateScope.asOfDate; + } + if (discoveryDateScope.periodFrom && !toNonEmptyString(nextFilters.period_from)) { + nextFilters.period_from = discoveryDateScope.periodFrom; + } + if (discoveryDateScope.periodTo && !toNonEmptyString(nextFilters.period_to)) { + nextFilters.period_to = discoveryDateScope.periodTo; + } const inventoryRootFrame = buildInventoryRootFrameFromAddressDebug(debug, toNonEmptyString); const rootFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object" diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 9b7aac4..3df6244 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -1,5 +1,6 @@ import { buildAddressMemoryRecapReply as buildAddressMemoryRecapReplyFromPolicy, + buildBroadBusinessEvaluationReply as buildBroadBusinessEvaluationReplyFromPolicy, buildSelectedObjectAnswerInspectionReply as buildSelectedObjectAnswerInspectionReplyFromPolicy, buildInventoryHistoryCapabilityFollowupReply as buildInventoryHistoryCapabilityFollowupReplyFromPolicy, resolveAssistantLivingChatMemoryContext @@ -191,10 +192,26 @@ export async function runAssistantLivingChatRuntime( ? "deterministic_data_scope_contract_live" : "deterministic_data_scope_contract"; } else if (unsupportedCurrentTurnMeaningBoundary) { - chatText = buildUnsupportedCurrentTurnMeaningBoundaryReply({ - assistantTurnMeaning - }); - livingChatSource = "deterministic_unsupported_current_turn_boundary"; + const unsupportedFamily = + typeof assistantTurnMeaning?.unsupported_but_understood_family === "string" + ? assistantTurnMeaning.unsupported_but_understood_family + : null; + if (unsupportedFamily === "broad_business_evaluation") { + const scopedOrganization = selectedOrganization ?? activeOrganization ?? continuityActiveOrganization ?? null; + chatText = buildBroadBusinessEvaluationReplyFromPolicy({ + organization: scopedOrganization, + addressDebug: continuitySnapshot.lastGroundedAddressDebug, + sessionItems: input.sessionItems, + toNonEmptyString: input.toNonEmptyString + }); + activeOrganization = scopedOrganization ?? activeOrganization; + livingChatSource = "deterministic_broad_business_evaluation_contract"; + } else { + chatText = buildUnsupportedCurrentTurnMeaningBoundaryReply({ + assistantTurnMeaning + }); + livingChatSource = "deterministic_unsupported_current_turn_boundary"; + } } else if ((selectedOrganization || activeOrganization) && input.hasOrganizationFactLookupSignal(userMessage)) { const scopedOrganization = selectedOrganization ?? activeOrganization ?? null; chatText = input.buildAssistantOrganizationFactBoundaryReply(scopedOrganization); diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts index 9b9e656..414a742 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryResponsePolicy.ts @@ -111,6 +111,13 @@ function isUnsupportedCurrentTurnBoundary(input: ApplyAssistantMcpDiscoveryRespo ); } +function isDeterministicBroadBusinessEvaluationReply(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean { + return ( + input.livingChatSource === "deterministic_broad_business_evaluation_contract" || + input.currentReplySource === "deterministic_broad_business_evaluation_contract" + ); +} + function isDiscoveryReadyChatCandidate( input: ApplyAssistantMcpDiscoveryResponsePolicyInput, entryPoint: AssistantMcpDiscoveryRuntimeEntryPointContract | null @@ -295,6 +302,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( const candidate = buildAssistantMcpDiscoveryResponseCandidate(entryPoint); const reasonCodes = [...candidate.reason_codes]; const unsupportedBoundary = isUnsupportedCurrentTurnBoundary(input); + const deterministicBroadBusinessEvaluationReply = isDeterministicBroadBusinessEvaluationReply(input); const discoveryReadyChatCandidate = isDiscoveryReadyChatCandidate(input, entryPoint); const discoveryReadyDeepCandidate = isDiscoveryReadyDeepCandidate(input, entryPoint); const discoveryReadyAddressCandidate = isDiscoveryReadyAddressCandidate(input, entryPoint); @@ -330,6 +338,12 @@ export function applyAssistantMcpDiscoveryResponsePolicy( if (fullConfirmedFactualAddressReply) { pushReason(reasonCodes, "mcp_discovery_response_policy_keep_full_confirmed_factual_address_reply"); } + if (deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") { + pushReason( + reasonCodes, + "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate" + ); + } if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) { pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed"); } @@ -349,6 +363,7 @@ export function applyAssistantMcpDiscoveryResponsePolicy( !alignedFactualAddressReply && !matchedFactualAddressContinuationTarget && !fullConfirmedFactualAddressReply && + !(deterministicBroadBusinessEvaluationReply && candidate.candidate_status === "clarification_candidate") && ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) && candidate.eligible_for_future_hot_runtime && Boolean(toNonEmptyString(candidate.reply_text)) && diff --git a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts index c1d9c00..8e78b19 100644 --- a/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantMcpDiscoveryTurnInputAdapter.ts @@ -7,12 +7,14 @@ export type AssistantMcpDiscoveryTurnInputStatus = "ready" | "needs_more_context export type AssistantMcpDiscoveryTurnInputSource = | "assistant_turn_meaning" | "predecompose_contract" + | "followup_context" | "raw_text" | "none"; export interface BuildAssistantMcpDiscoveryTurnInputAdapterInput { assistantTurnMeaning?: Record | null; predecomposeContract?: Record | null; + followupContext?: Record | null; userMessage?: string | null; effectiveMessage?: string | null; } @@ -132,6 +134,148 @@ function collectDateScope(predecompose: Record | null): string return periodFrom ?? periodTo ?? null; } +function collectDateScopeFromFilters(filters: Record | null): string | null { + if (!filters) { + return null; + } + const asOfDate = toNonEmptyString(filters.as_of_date); + const periodFrom = toNonEmptyString(filters.period_from); + const periodTo = toNonEmptyString(filters.period_to); + if (asOfDate) { + return asOfDate; + } + const yearFrom = periodFrom?.match(/^(\d{4})-01-01$/); + const yearTo = periodTo?.match(/^(\d{4})-12-31$/); + if (yearFrom && yearTo && yearFrom[1] === yearTo[1]) { + return yearFrom[1]; + } + if (periodFrom && periodTo) { + return `${periodFrom}..${periodTo}`; + } + return periodFrom ?? periodTo ?? null; +} + +function mapPilotScopeToFollowupMeaning( + pilotScope: string | null +): { + domain: string | null; + action: string | null; + unsupported: string | null; +} { + if (pilotScope === "counterparty_lifecycle_query_documents_v1") { + return { + domain: "counterparty_lifecycle", + action: "activity_duration", + unsupported: "counterparty_lifecycle" + }; + } + if (pilotScope === "counterparty_supplier_payout_query_movements_v1") { + return { + domain: "counterparty_value", + action: "payout", + unsupported: "counterparty_payouts_or_outflow" + }; + } + if (pilotScope === "counterparty_value_flow_query_movements_v1") { + return { + domain: "counterparty_value", + action: "turnover", + unsupported: "counterparty_value_or_turnover" + }; + } + if (pilotScope === "counterparty_bidirectional_value_flow_query_movements_v1") { + return { + domain: "counterparty_value", + action: "net_value_flow", + unsupported: "counterparty_bidirectional_value_flow_or_netting" + }; + } + return { + domain: null, + action: null, + unsupported: null + }; +} + +function mapAddressIntentToFollowupMeaning( + intent: string | null +): { + domain: string | null; + action: string | null; + unsupported: string | null; +} { + if (intent === "counterparty_activity_lifecycle") { + return { + domain: "counterparty_lifecycle", + action: "activity_duration", + unsupported: "counterparty_lifecycle" + }; + } + if (intent === "supplier_payouts_profile") { + return { + domain: "counterparty_value", + action: "payout", + unsupported: "counterparty_payouts_or_outflow" + }; + } + if (intent === "customer_revenue_and_payments") { + return { + domain: "counterparty_value", + action: "turnover", + unsupported: "counterparty_value_or_turnover" + }; + } + return { + domain: null, + action: null, + unsupported: null + }; +} + +function collectFollowupDiscoverySeed(followupContext: Record | null): { + pilotScope: string | null; + domain: string | null; + action: string | null; + unsupported: string | null; + counterparty: string | null; + organization: string | null; + dateScope: string | null; +} { + const previousFilters = toRecordObject(followupContext?.previous_filters); + const rootFilters = toRecordObject(followupContext?.root_filters); + const pilotScope = toNonEmptyString(followupContext?.previous_discovery_pilot_scope); + const previousIntent = + toNonEmptyString(followupContext?.target_intent) ?? toNonEmptyString(followupContext?.previous_intent); + const mapped = + mapPilotScopeToFollowupMeaning(pilotScope).domain !== null + ? mapPilotScopeToFollowupMeaning(pilotScope) + : mapAddressIntentToFollowupMeaning(previousIntent); + const counterparty = + toNonEmptyString(previousFilters?.counterparty) ?? + toNonEmptyString(rootFilters?.counterparty) ?? + (toNonEmptyString(followupContext?.previous_anchor_type) === "counterparty" + ? toNonEmptyString(followupContext?.previous_anchor_value) + : null); + const organization = + toNonEmptyString(previousFilters?.organization) ?? + toNonEmptyString(rootFilters?.organization) ?? + (toNonEmptyString(followupContext?.previous_anchor_type) === "organization" + ? toNonEmptyString(followupContext?.previous_anchor_value) + : null); + const dateScope = + collectDateScopeFromFilters(previousFilters) ?? + collectDateScopeFromFilters(rootFilters); + return { + pilotScope, + domain: mapped.domain, + action: mapped.action, + unsupported: mapped.unsupported, + counterparty, + organization, + dateScope + }; +} + function hasLifecycleSignal(text: string): boolean { return /(?:сколько\s+лет|как\s+давно|давно\s+ли|возраст|перв(?:ая|ый)\s+актив|когда\s+начал|когда\s+появ|lifecycle|activity\s+duration|business\s+age|how\s+long)/iu.test( text @@ -162,6 +306,26 @@ function hasMonthlyAggregationSignal(text: string): boolean { ); } +function hasExplicitDateScopeLiteral(text: string): boolean { + return /(?:\b(?:19|20)\d{2}\b|\b\d{4}-\d{2}-\d{2}\b|\b\d{4}-\d{2}\b)/iu.test(text); +} + +function collectDateScopeFromRawText(text: string): string | null { + const isoDate = text.match(/\b(\d{4}-\d{2}-\d{2})\b/u); + if (isoDate?.[1]) { + return isoDate[1]; + } + const yearMonth = text.match(/\b(\d{4}-\d{2})\b/u); + if (yearMonth?.[1]) { + return yearMonth[1]; + } + const year = text.match(/\b((?:19|20)\d{2})\b/u); + if (year?.[1]) { + return year[1]; + } + return null; +} + function semanticNeedFor(input: { domain: string | null; action: string | null; @@ -191,6 +355,7 @@ function shouldRunDiscovery(input: { valueFlowSignal: boolean; semanticDataNeed: string | null; explicitIntentCandidate: string | null; + followupDiscoverySeedApplicable: boolean; }): boolean { if (input.lifecycleSignal || input.unsupported) { return true; @@ -198,6 +363,9 @@ function shouldRunDiscovery(input: { if (input.valueFlowSignal && !input.explicitIntentCandidate) { return true; } + if (input.followupDiscoverySeedApplicable && !input.explicitIntentCandidate && input.semanticDataNeed) { + return true; + } if (!input.explicitIntentCandidate && input.semanticDataNeed) { return true; } @@ -209,37 +377,75 @@ export function buildAssistantMcpDiscoveryTurnInput( ): AssistantMcpDiscoveryTurnInputContract { const assistantTurnMeaning = toRecordObject(input.assistantTurnMeaning); const predecomposeContract = toRecordObject(input.predecomposeContract); + const followupContext = toRecordObject(input.followupContext); const predecomposeEntities = collectPredecomposeEntities(predecomposeContract); + const followupSeed = collectFollowupDiscoverySeed(followupContext); const reasonCodes: string[] = []; const rawText = compactLower(`${input.userMessage ?? ""} ${input.effectiveMessage ?? ""}`); - const lifecycleSignal = hasLifecycleSignal(rawText); - const bidirectionalValueFlowSignal = !lifecycleSignal && hasBidirectionalValueFlowSignal(rawText); - const valueFlowSignal = !lifecycleSignal && (hasValueFlowSignal(rawText) || bidirectionalValueFlowSignal); - const payoutSignal = valueFlowSignal && !bidirectionalValueFlowSignal && hasPayoutSignal(rawText); - const monthlyAggregationSignal = valueFlowSignal && hasMonthlyAggregationSignal(rawText); + const rawLifecycleSignal = hasLifecycleSignal(rawText); + const rawBidirectionalValueFlowSignal = !rawLifecycleSignal && hasBidirectionalValueFlowSignal(rawText); + const rawValueFlowSignal = + !rawLifecycleSignal && (hasValueFlowSignal(rawText) || rawBidirectionalValueFlowSignal); + const rawPayoutSignal = rawValueFlowSignal && !rawBidirectionalValueFlowSignal && hasPayoutSignal(rawText); + const monthlyAggregationSignal = hasMonthlyAggregationSignal(rawText); + const explicitDateScopeLiteralDetected = hasExplicitDateScopeLiteral(rawText); + const rawDateScope = collectDateScopeFromRawText(rawText); const rawDomain = toNonEmptyString(assistantTurnMeaning?.asked_domain_family); const rawAction = toNonEmptyString(assistantTurnMeaning?.asked_action_family); const rawAggregationAxis = toNonEmptyString(assistantTurnMeaning?.asked_aggregation_axis); const unsupported = toNonEmptyString(assistantTurnMeaning?.unsupported_but_understood_family); const explicitIntentCandidate = toNonEmptyString(assistantTurnMeaning?.explicit_intent_candidate); + const assistantTurnMeaningDateScope = toNonEmptyString(assistantTurnMeaning?.explicit_date_scope); + const assistantTurnMeaningOrganizationScope = toNonEmptyString(assistantTurnMeaning?.explicit_organization_scope); + const predecomposeDateScope = collectDateScope(predecomposeContract); + const followupDiscoverySeedApplicable = Boolean( + followupSeed.domain && + !rawLifecycleSignal && + !rawValueFlowSignal && + (monthlyAggregationSignal || explicitDateScopeLiteralDetected || predecomposeDateScope) + ); + const seededDomain = followupDiscoverySeedApplicable ? followupSeed.domain : null; + const seededAction = followupDiscoverySeedApplicable ? followupSeed.action : null; + const seededUnsupported = followupDiscoverySeedApplicable ? followupSeed.unsupported : null; + const lifecycleSignal = + rawLifecycleSignal || seededDomain === "counterparty_lifecycle"; + const bidirectionalValueFlowSignal = + !lifecycleSignal && + (rawBidirectionalValueFlowSignal || seededAction === "net_value_flow"); + const valueFlowSignal = + !lifecycleSignal && (rawValueFlowSignal || seededDomain === "counterparty_value"); + const payoutSignal = + valueFlowSignal && + !bidirectionalValueFlowSignal && + (rawPayoutSignal || seededAction === "payout"); const semanticDataNeed = semanticNeedFor({ - domain: rawDomain, - action: rawAction, - unsupported, + domain: rawDomain ?? seededDomain, + action: rawAction ?? seededAction, + unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal }); const entityCandidates = collectEntityCandidates(assistantTurnMeaning?.explicit_entity_candidates); pushUnique(entityCandidates, predecomposeEntities.counterparty); - if (valueFlowSignal && !predecomposeEntities.counterparty) { + pushUnique(entityCandidates, followupSeed.counterparty); + if (valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty) { pushUnique(entityCandidates, predecomposeEntities.organization); + pushUnique(entityCandidates, followupSeed.organization); } const explicitOrganizationScope = - valueFlowSignal && !predecomposeEntities.counterparty ? null : predecomposeEntities.organization; + valueFlowSignal && !predecomposeEntities.counterparty && !followupSeed.counterparty + ? null + : predecomposeEntities.organization ?? assistantTurnMeaningOrganizationScope ?? followupSeed.organization; + const explicitDateScope = assistantTurnMeaningDateScope ?? predecomposeDateScope ?? rawDateScope ?? followupSeed.dateScope; const turnMeaning: AssistantMcpDiscoveryTurnMeaningRef = { - asked_domain_family: lifecycleSignal ? "counterparty_lifecycle" : valueFlowSignal ? "counterparty_value" : rawDomain, + asked_domain_family: + lifecycleSignal + ? "counterparty_lifecycle" + : valueFlowSignal + ? "counterparty_value" + : rawDomain ?? seededDomain, asked_action_family: lifecycleSignal ? "activity_duration" : valueFlowSignal @@ -247,12 +453,12 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "net_value_flow" : payoutSignal ? "payout" - : "turnover" - : rawAction, + : rawAction ?? seededAction ?? "turnover" + : rawAction ?? seededAction, asked_aggregation_axis: monthlyAggregationSignal ? "month" : rawAggregationAxis, explicit_entity_candidates: entityCandidates, explicit_organization_scope: explicitOrganizationScope, - explicit_date_scope: collectDateScope(predecomposeContract), + explicit_date_scope: explicitDateScope, unsupported_but_understood_family: unsupported ?? (lifecycleSignal @@ -262,9 +468,17 @@ export function buildAssistantMcpDiscoveryTurnInput( ? "counterparty_bidirectional_value_flow_or_netting" : payoutSignal ? "counterparty_payouts_or_outflow" - : "counterparty_value_or_turnover" - : null), - stale_replay_forbidden: Boolean(assistantTurnMeaning?.stale_replay_forbidden || unsupported || lifecycleSignal || valueFlowSignal) + : seededUnsupported ?? "counterparty_value_or_turnover" + : followupDiscoverySeedApplicable + ? seededUnsupported + : null), + stale_replay_forbidden: Boolean( + assistantTurnMeaning?.stale_replay_forbidden || + unsupported || + lifecycleSignal || + valueFlowSignal || + followupDiscoverySeedApplicable + ) }; const cleanTurnMeaning: AssistantMcpDiscoveryTurnMeaningRef = {}; @@ -294,15 +508,18 @@ export function buildAssistantMcpDiscoveryTurnInput( } const runDiscovery = shouldRunDiscovery({ - unsupported, + unsupported: unsupported ?? seededUnsupported, lifecycleSignal, valueFlowSignal, semanticDataNeed, - explicitIntentCandidate + explicitIntentCandidate, + followupDiscoverySeedApplicable }); const hasTurnMeaning = Object.keys(cleanTurnMeaning).length > 0; const sourceSignal: AssistantMcpDiscoveryTurnInputSource = assistantTurnMeaning ? "assistant_turn_meaning" + : followupDiscoverySeedApplicable + ? "followup_context" : predecomposeContract ? "predecompose_contract" : lifecycleSignal @@ -326,12 +543,21 @@ export function buildAssistantMcpDiscoveryTurnInput( if (monthlyAggregationSignal) { pushReason(reasonCodes, "mcp_discovery_monthly_aggregation_signal_detected"); } + if (followupDiscoverySeedApplicable) { + pushReason(reasonCodes, "mcp_discovery_seeded_from_followup_context"); + } if (unsupported) { pushReason(reasonCodes, "mcp_discovery_unsupported_but_understood_turn"); } if (predecomposeEntities.counterparty) { pushReason(reasonCodes, "mcp_discovery_counterparty_from_predecompose"); } + if (followupSeed.counterparty) { + pushReason(reasonCodes, "mcp_discovery_counterparty_from_followup_context"); + } + if (followupSeed.dateScope) { + pushReason(reasonCodes, "mcp_discovery_date_scope_from_followup_context"); + } if (entityCandidates.length > 0) { pushReason(reasonCodes, "mcp_discovery_entity_scope_available"); } diff --git a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts index bcac467..d304cca 100644 --- a/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMemoryRecapPolicy.ts @@ -62,6 +62,14 @@ function toRecordObject(value: unknown): Record | null { return value as Record; } +function ensureSentence(value: string): string { + const text = String(value ?? "").trim(); + if (!text) { + return ""; + } + return /[.!?]$/.test(text) ? text : `${text}.`; +} + function periodPartForRecap(scopedDate: string | null): string { if (!scopedDate) { return ""; @@ -353,6 +361,38 @@ export function buildAddressMemoryRecapReply(input: { return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; } +export function buildBroadBusinessEvaluationReply(input: { + organization: string | null; + addressDebug: Record | null; + sessionItems?: unknown[]; + toNonEmptyString: (value: unknown) => string | null; +}): string { + const contextFacts = resolveAddressDebugContextFacts(input.addressDebug, input.toNonEmptyString); + const organization = input.organization ?? contextFacts.organization; + const recapFacts = collectRecentRecapFacts({ + sessionItems: input.sessionItems, + item: null, + organization, + toNonEmptyString: input.toNonEmptyString + }); + const organizationPart = organization ? ` по компании «${organization}»` : ""; + + if (recapFacts.length > 0) { + return [ + `Коротко: по тому, что мы уже подтвердили в 1С${organizationPart}, компания выглядит операционно живой, но это пока только частичная оценка бизнеса.`, + "Сейчас я опираюсь на такие подтвержденные факты:", + ...recapFacts.map((fact) => `- ${ensureSentence(fact)}`), + "Это еще не полная диагностика всего бизнеса и не вывод о прибыли: я честно суммирую только те контуры, которые мы уже проверили в диалоге.", + "Если хочешь, следующим шагом могу сузить оценку до денежного потока, долгов, НДС или ключевых контрагентов." + ].join("\n"); + } + + return [ + `Коротко: по нынешнему контексту 1С${organizationPart} я вижу признаки операционной активности, но для содержательной оценки бизнеса нужно еще несколько опорных срезов.`, + "Если хочешь, я быстро доберу основу для такой оценки: денежный поток, дебиторка/кредиторка, НДС или ключевые контрагенты." + ].join(" "); +} + export function buildSelectedObjectAnswerInspectionReply(input: { addressDebug: Record | null; toNonEmptyString: (value: unknown) => string | null; diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index d3499c4..3011aac 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -7,9 +7,11 @@ import { buildRootScopedCarryoverFilters, buildInventoryRootFrameFromAddressDebug, hydrateInventoryRootFrameState, + readAddressDebugIntent, readAddressDebugFilters, readAddressDebugItem, readAddressDebugTemporalScope, + readAssistantMcpDiscoveryPilotScope, resolveOrganizationClarificationContinuation, resolveNavigationSessionContextState, resolveAddressDebugCarryoverFilters, @@ -171,19 +173,23 @@ export function createAssistantTransitionPolicy(deps) { return false; } const executionLane = deps.toNonEmptyString(debug.execution_lane); - const detectedIntent = deps.toNonEmptyString(debug.detected_intent); + const detectedIntent = readAddressDebugIntent(debug, deps.toNonEmptyString); const selectedRecipe = deps.toNonEmptyString(debug.selected_recipe); const answerGroundingCheck = debug.answer_grounding_check && typeof debug.answer_grounding_check === "object" ? debug.answer_grounding_check : null; const groundingStatus = deps.toNonEmptyString(answerGroundingCheck?.status); + const discoveryPilotScope = readAssistantMcpDiscoveryPilotScope(debug, deps.toNonEmptyString); if (groundingStatus === "grounded") { return true; } if (selectedRecipe) { return true; } + if (debug.mcp_discovery_response_applied === true && discoveryPilotScope) { + return true; + } return executionLane === "address_query" && Boolean(detectedIntent && detectedIntent !== "unknown"); } @@ -438,7 +444,7 @@ export function createAssistantTransitionPolicy(deps) { (deps.toNonEmptyString(alternateMessage) ? deps.isImplicitAddressContinuationByLlm(alternateMessage, llmPreDecomposeMeta) : false)); - const sourceIntentHint = deps.toNonEmptyString(carryoverSourceDebug?.detected_intent); + const sourceIntentHint = readAddressDebugIntent(carryoverSourceDebug, deps.toNonEmptyString); const navigationSessionState = resolveNavigationSessionContextState( addressNavigationState, deps.toNonEmptyString, @@ -599,7 +605,8 @@ export function createAssistantTransitionPolicy(deps) { if (!carryoverSourceDebug) { return null; } - const sourceIntent = deps.toNonEmptyString(carryoverSourceDebug.detected_intent); + const sourceIntent = readAddressDebugIntent(carryoverSourceDebug, deps.toNonEmptyString); + const sourceDiscoveryPilotScope = readAssistantMcpDiscoveryPilotScope(carryoverSourceDebug, deps.toNonEmptyString); const llmExplicitIntent = deps.toNonEmptyString(llmPreDecomposeMeta?.predecomposeContract?.intent); const llmSelectedObjectScopeDetected = llmPreDecomposeMeta?.predecomposeContract?.semantics?.selected_object_scope_detected === true; @@ -931,6 +938,7 @@ export function createAssistantTransitionPolicy(deps) { previous_filters: previousFilters, previous_anchor_type: previousAnchorType ?? undefined, previous_anchor_value: previousAnchor, + previous_discovery_pilot_scope: sourceDiscoveryPilotScope ?? undefined, resolved_counterparty_from_display: resolvedCounterpartyFromDisplay || undefined, root_context_only: rootScopedPivot || undefined, root_intent: shouldAttachInventoryRootFrame ? inventoryRootFrame?.intent ?? undefined : undefined, diff --git a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts index 6e317b6..0cd110d 100644 --- a/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTurnMeaningPolicy.ts @@ -117,6 +117,23 @@ function detectCounterpartyTurnoverFamily(text) { }; } +function detectBroadBusinessEvaluation(text) { + const normalized = String(text ?? ""); + if (!normalized) { + return null; + } + if ( + /(?:как\s+ты\s+оценишь\s+деятельност[ьи]\s+компан|оценк[аи]?\s+деятельност[ьи]\s+компан|что\s+у\s+нас\s+вообще\s+происход|где\s+главн(?:ые|ый)\s+риски|как\s+у\s+нас\s+дела\s+по\s+компан)/iu.test( + normalized + ) + ) { + return { + family: "broad_business_evaluation" + }; + } + return null; +} + function buildEntityCandidates(counterpartyTurnover) { if (!counterpartyTurnover?.entity) { return []; @@ -139,10 +156,17 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { const joinedText = fallbackCompactWhitespace(`${rawText} ${effectiveText}`); const supportedIntent = detectSupportedIntent(joinedText, deps); const counterpartyTurnover = detectCounterpartyTurnoverFamily(joinedText); + const broadBusinessEvaluation = detectBroadBusinessEvaluation(joinedText); const llmIntent = toNonEmptyString(input?.llmPreDecomposeMeta?.predecomposeContract?.intent, deps); const explicitIntentCandidate = - supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); - const unsupportedFamily = !explicitIntentCandidate && counterpartyTurnover?.family ? counterpartyTurnover.family : null; + broadBusinessEvaluation?.family + ? null + : supportedIntent?.intent ?? (llmIntent && llmIntent !== "unknown" ? llmIntent : null); + const unsupportedFamily = broadBusinessEvaluation?.family + ? broadBusinessEvaluation.family + : !explicitIntentCandidate && counterpartyTurnover?.family + ? counterpartyTurnover.family + : null; const reasonCodes = []; if (supportedIntent?.reason) { reasonCodes.push(supportedIntent.reason); @@ -150,6 +174,9 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { if (counterpartyTurnover?.family) { reasonCodes.push("counterparty_turnover_current_turn_signal"); } + if (broadBusinessEvaluation?.family) { + reasonCodes.push("broad_business_evaluation_current_turn_signal"); + } if (rawText !== normalizeTurnText(rawMessage, { ...deps, repairAddressMojibake: (value) => String(value ?? "") })) { reasonCodes.push("mojibake_repair_applied"); } @@ -168,6 +195,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { ? "vat" : explicitIntentCandidate?.startsWith("inventory_") ? "inventory" + : broadBusinessEvaluation?.family + ? "business_summary" : explicitIntentCandidate?.includes("counterparty") ? "counterparty" : counterpartyTurnover?.family @@ -178,6 +207,8 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { explicitIntentCandidate === "payables_confirmed_as_of_date" || explicitIntentCandidate === "inventory_on_hand_as_of_date" ? "confirmed_snapshot" + : broadBusinessEvaluation?.family + ? "broad_evaluation" : explicitIntentCandidate === "vat_liability_confirmed_for_tax_period" ? "confirmed_tax_period" : explicitIntentCandidate === "vat_payable_confirmed_as_of_date" @@ -189,7 +220,9 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { : counterpartyTurnover?.family ? "counterparty_value_or_turnover" : null; - const staleReplayForbidden = Boolean(unsupportedFamily || (counterpartyTurnover?.entity && !explicitIntentCandidate)); + const staleReplayForbidden = Boolean( + unsupportedFamily || broadBusinessEvaluation?.family || (counterpartyTurnover?.entity && !explicitIntentCandidate) + ); return { schema_version: "assistant_turn_meaning_v1", raw_message: rawMessage, @@ -200,7 +233,9 @@ export function createAssistantTurnMeaningPolicy(deps = {}) { asked_action_family: askedActionFamily, explicit_intent_candidate: explicitIntentCandidate, explicit_entity_candidates: buildEntityCandidates(counterpartyTurnover), - meaning_confidence: supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), + meaning_confidence: broadBusinessEvaluation?.family + ? "medium" + : supportedIntent?.confidence ?? (counterpartyTurnover?.family ? "medium" : "low"), intent_override_strength: explicitIntentCandidate ? "explicit_current_turn_intent" : staleReplayForbidden diff --git a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts index 93eb17f..97303a1 100644 --- a/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressFollowupContext.test.ts @@ -3141,5 +3141,113 @@ describe("assistant address follow-up carryover", () => { expect(calls[0].options?.followupContext?.root_filters?.counterparty).toBeUndefined(); expect(normalizerService.normalize).not.toHaveBeenCalled(); }); + + it.skip("passes grounded MCP discovery payout context into a short year-switch follow-up", async () => { + const followupMessage = "а теперь за 2021?"; + const calls: Array<{ message: string; options?: any }> = []; + + const addressQueryService = { + tryHandle: vi.fn(async (message: string, options?: any) => { + calls.push({ message, options }); + if (message === followupMessage && options?.followupContext) { + return buildAddressLaneResult({ + reply_text: "Подтверждены исходящие платежи по Группа СВК за 2021 год.", + debug: { + ...buildAddressLaneResult().debug, + detected_intent: "supplier_payouts_profile", + selected_recipe: "address_supplier_payouts_profile_v1", + extracted_filters: { + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2021-01-01", + period_to: "2021-12-31" + }, + reasons: ["address_action_detected", "address_entity_detected", "address_followup_context_applied"] + } + }); + } + return null; + }) + } 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-discovery-followup-year-switch-${Date.now()}`; + sessions.appendItem(sessionId, { + message_id: "msg-discovery-payout-seed", + session_id: sessionId, + role: "assistant", + text: "Подтверждены исходящие платежи по Группа СВК за 2020 год.", + reply_type: "partial_coverage", + created_at: "2026-04-20T10:00:00.000Z", + trace_id: "living-discovery-seed", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_active_organization: "ООО Альтернатива Плюс", + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + pilot: { + pilot_scope: "counterparty_supplier_payout_query_movements_v1" + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + } + } as any); + + const response = await service.handleMessage({ + session_id: sessionId, + user_message: followupMessage, + useMock: true + } as any); + + expect(response.ok).toBe(true); + expect(response.reply_type).toBe("factual"); + expect(calls).toHaveLength(1); + expect(calls[0].message).toBe(followupMessage); + expect(calls[0].options?.followupContext?.previous_intent).toBe("supplier_payouts_profile"); + expect(calls[0].options?.followupContext?.previous_discovery_pilot_scope).toBe( + "counterparty_supplier_payout_query_movements_v1" + ); + expect(calls[0].options?.followupContext?.previous_anchor_type).toBe("counterparty"); + expect(calls[0].options?.followupContext?.previous_anchor_value).toBe("Группа СВК"); + expect(calls[0].options?.followupContext?.previous_filters).toMatchObject({ + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }); + expect(normalizerService.normalize).not.toHaveBeenCalled(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts index a99f530..526f696 100644 --- a/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantAddressOrchestrationRuntimeAdapter.test.ts @@ -154,6 +154,97 @@ describe("assistant address orchestration runtime adapter", () => { ); }); + it("passes grounded discovery follow-up carryover into MCP discovery entry point for a short year switch", async () => { + const runMcpDiscoveryRuntimeEntryPoint = vi.fn(async () => ({ + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint", + entry_status: "bridge_executed", + hot_runtime_wired: false, + discovery_attempted: true + })); + const input = buildInput({ + userMessage: "Р° теперь Р·Р° 2021?", + runAddressLlmPreDecompose: vi.fn(async () => ({ + attempted: true, + applied: false, + effectiveMessage: "Р° теперь Р·Р° 2021?", + reason: "raw_kept", + predecomposeContract: { + mode: "unsupported", + intent: "unknown", + period: { + scope: "year", + period_from: "2021-01-01", + period_to: "2021-12-31", + has_explicit_period: true + } + } + })), + resolveAddressFollowupCarryoverContext: vi.fn(() => ({ + followupContext: { + previous_intent: "supplier_payouts_profile", + target_intent: "supplier_payouts_profile", + previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1", + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа РЎР’Рљ", + previous_filters: { + counterparty: "Группа РЎР’Рљ", + organization: "РћРћРћ Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + } + } + })), + resolveAssistantOrchestrationDecision: vi.fn(() => ({ + runAddressLane: true, + livingMode: "address_data", + livingReason: "address_lane_triggered", + toolGateDecision: "run_address_lane", + toolGateReason: "followup_context_detected", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + assistant_turn_meaning: { + schema_version: "assistant_turn_meaning_v1", + raw_message: "Р° теперь Р·Р° 2021?", + effective_message: "Р° теперь Р·Р° 2021?", + explicit_entity_candidates: [] + } + } + })), + runMcpDiscoveryRuntimeEntryPoint + }); + + const output = await buildAssistantAddressOrchestrationRuntime(input); + + expect(output.orchestrationDecision.runAddressLane).toBe(true); + expect(runMcpDiscoveryRuntimeEntryPoint).toHaveBeenCalledWith( + expect.objectContaining({ + userMessage: "Р° теперь Р·Р° 2021?", + effectiveMessage: "Р° теперь Р·Р° 2021?", + followupContext: expect.objectContaining({ + previous_intent: "supplier_payouts_profile", + target_intent: "supplier_payouts_profile", + previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1", + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа РЎР’Рљ", + previous_filters: expect.objectContaining({ + counterparty: "Группа РЎР’Рљ", + organization: "РћРћРћ Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }) + }) + }) + ); + expect(output.addressRuntimeMeta.mcpDiscoveryRuntimeEntryPoint).toEqual( + expect.objectContaining({ + entry_status: "bridge_executed", + discovery_attempted: true, + hot_runtime_wired: false + }) + ); + }); + it("keeps address orchestration alive when MCP discovery entry point fails", async () => { const input = buildInput({ runMcpDiscoveryRuntimeEntryPoint: vi.fn(async () => { diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index a937fb2..9ce808e 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -7,6 +7,7 @@ import { applyTemporalCarryoverFilters, buildRootScopedCarryoverFilters, hydrateInventoryRootFrameState, + readAddressDebugIntent, readAddressDebugTemporalScope, resolveNavigationSessionContextState, resolveAddressDebugCarryoverFilters, @@ -147,6 +148,49 @@ describe("assistantContinuityPolicy organization authority", () => { }); }); + it("hydrates intent and carryover filters from grounded MCP discovery payout scope", () => { + const debug = { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_active_organization: "ООО Альтернатива Плюс", + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + pilot: { + pilot_scope: "counterparty_supplier_payout_query_movements_v1" + }, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + } + } + } + }; + + expect(readAddressDebugIntent(debug)).toBe("supplier_payouts_profile"); + expect(readAddressDebugTemporalScope(debug)).toEqual({ + asOfDate: null, + periodFrom: "2020-01-01", + periodTo: "2020-12-31" + }); + expect(resolveAddressDebugCarryoverFilters(debug)).toEqual({ + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }); + }); + it("resolves navigation session context through one shared helper", () => { const state = resolveNavigationSessionContextState({ session_context: { diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index 29311bc..b7fe628 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -133,6 +133,91 @@ describe("assistant living chat runtime adapter", () => { expect(executeLlmChat).toHaveBeenCalledTimes(1); }); + it("builds deterministic broad business evaluation summary from grounded continuity instead of replaying lifecycle noise", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: "Как ты оценишь деятельность компании?", + modeDecision: { mode: "chat", reason: "unsupported_current_turn_meaning_boundary" }, + sessionScope: { + knownOrganizations: ["ООО Альтернатива Плюс"], + selectedOrganization: null, + activeOrganization: "ООО Альтернатива Плюс" + }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "counterparty_activity_lifecycle", + extracted_filters: { + organization: "ООО Альтернатива Плюс" + } + } + }, + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + derived_bidirectional_value_flow: { + net_amount_human_ru: "3 865 501,50 руб.", + incoming_customer_revenue: { + total_amount_human_ru: "47 628 853,03 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "43 763 351,53 руб." + } + } + } + } + } + } + } + ], + addressRuntimeMeta: { + toolGateReason: "unsupported_current_turn_meaning_boundary", + orchestrationContract: { + unsupported_current_turn_meaning_boundary: true, + assistant_turn_meaning: { + unsupported_but_understood_family: "broad_business_evaluation" + } + } + }, + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText.toLowerCase()).toContain("оценка бизнеса"); + expect(output.chatText).toContain("ООО Альтернатива Плюс"); + expect(output.chatText).toContain("Группа СВК"); + expect(output.chatText).toContain("нетто"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_broad_business_evaluation_contract"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); + it("builds deterministic boundary for unsupported current-turn business meaning", async () => { const executeLlmChat = vi.fn(async () => "raw-llm"); const input = buildRuntimeInput({ diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts index cdaa1bb..fe743e5 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryResponsePolicy.test.ts @@ -323,4 +323,50 @@ describe("assistant MCP discovery response policy", () => { expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_not_eligible"); expect(result.reason_codes).toContain("mcp_discovery_response_policy_kept_current_reply"); }); + + it("keeps deterministic broad business evaluation summary instead of replacing it with a clarification candidate", () => { + const result = applyAssistantMcpDiscoveryResponsePolicy({ + currentReply: "Коротко: по уже подтвержденным данным в 1С компания выглядит живой операционно.", + currentReplySource: "deterministic_broad_business_evaluation_contract", + livingChatSource: "deterministic_broad_business_evaluation_contract", + modeDecisionReason: "unsupported_current_turn_meaning_boundary", + addressRuntimeMeta: { + assistant_mcp_discovery_entry_point_v1: entryPoint({ + turn_input: { + adapter_status: "ready", + should_run_discovery: true, + turn_meaning_ref: { + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true + } + }, + bridge: { + bridge_status: "needs_clarification", + user_facing_response_allowed: true, + business_fact_answer_allowed: false, + requires_user_clarification: true, + answer_draft: { + answer_mode: "needs_clarification", + headline: "Нужно уточнить контекст перед поиском в 1С.", + confirmed_lines: [], + inference_lines: [], + unknown_lines: ["MCP discovery pilot needs more scope before execution"], + limitation_lines: ["MCP discovery pilot needs more scope before execution"], + next_step_line: "Уточните контрагента, период или организацию." + } + } + }) + } + }); + + expect(result.applied).toBe(false); + expect(result.decision).toBe("keep_current_reply"); + expect(result.reply_source).toBe("deterministic_broad_business_evaluation_contract"); + expect(result.reply_text).toContain("компания выглядит живой операционно"); + expect(result.reason_codes).toContain( + "mcp_discovery_response_policy_keep_broad_business_summary_over_clarification_candidate" + ); + }); }); diff --git a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts index 2e1cefa..aa522e6 100644 --- a/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantMcpDiscoveryTurnInputAdapter.test.ts @@ -151,6 +151,69 @@ describe("assistant MCP discovery turn input adapter", () => { expect(result.reason_codes).toContain("mcp_discovery_monthly_aggregation_signal_detected"); }); + it("seeds short monthly follow-up from prior bidirectional discovery context", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а по месяцам?", + followupContext: { + previous_discovery_pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + previous_filters: { + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа СВК" + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.source_signal).toBe("followup_context"); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "net_value_flow", + asked_aggregation_axis: "month", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020", + unsupported_but_understood_family: "counterparty_bidirectional_value_flow_or_netting", + stale_replay_forbidden: true + }); + expect(result.reason_codes).toContain("mcp_discovery_seeded_from_followup_context"); + expect(result.reason_codes).toContain("mcp_discovery_counterparty_from_followup_context"); + expect(result.reason_codes).toContain("mcp_discovery_date_scope_from_followup_context"); + }); + + it("switches the checked year on a short payout follow-up while keeping prior discovery counterparty", () => { + const result = buildAssistantMcpDiscoveryTurnInput({ + userMessage: "а теперь за 2021?", + followupContext: { + previous_discovery_pilot_scope: "counterparty_supplier_payout_query_movements_v1", + previous_filters: { + counterparty: "Группа СВК", + organization: "ООО Альтернатива Плюс", + period_from: "2020-01-01", + period_to: "2020-12-31" + }, + previous_anchor_type: "counterparty", + previous_anchor_value: "Группа СВК" + } + }); + + expect(result.adapter_status).toBe("ready"); + expect(result.should_run_discovery).toBe(true); + expect(result.turn_meaning_ref).toMatchObject({ + asked_domain_family: "counterparty_value", + asked_action_family: "payout", + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2021", + unsupported_but_understood_family: "counterparty_payouts_or_outflow", + stale_replay_forbidden: true + }); + }); + it("does not activate discovery for supported exact current-turn intent", () => { const result = buildAssistantMcpDiscoveryTurnInput({ assistantTurnMeaning: { diff --git a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts index 1e5a258..d2c4a74 100644 --- a/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMemoryRecapPolicy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildAddressMemoryRecapReply, + buildBroadBusinessEvaluationReply, buildSelectedObjectAnswerInspectionReply, createAssistantMemoryRecapPolicy, resolveAssistantLivingChatMemoryContext @@ -385,6 +386,75 @@ describe("assistantMemoryRecapPolicy", () => { expect(reply).toContain("43 763 351,53 руб."); }); + it("builds deterministic broad business evaluation summary from recent grounded organization facts", () => { + const sessionItems = [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "counterparty_activity_lifecycle", + extracted_filters: { + organization: "ООО Альтернатива Плюс" + } + } + }, + { + role: "assistant", + debug: { + execution_lane: "living_chat", + mcp_discovery_response_applied: true, + assistant_mcp_discovery_entry_point_v1: { + schema_version: "assistant_mcp_discovery_runtime_entry_point_v1", + entry_status: "bridge_executed", + turn_input: { + turn_meaning_ref: { + explicit_entity_candidates: ["Группа СВК"], + explicit_organization_scope: "ООО Альтернатива Плюс", + explicit_date_scope: "2020" + } + }, + bridge: { + bridge_status: "answer_draft_ready", + business_fact_answer_allowed: true, + answer_draft: { + answer_mode: "confirmed_with_bounded_inference" + }, + pilot: { + pilot_scope: "counterparty_bidirectional_value_flow_query_movements_v1", + derived_bidirectional_value_flow: { + net_amount_human_ru: "3 865 501,50 руб.", + incoming_customer_revenue: { + total_amount_human_ru: "47 628 853,03 руб." + }, + outgoing_supplier_payout: { + total_amount_human_ru: "43 763 351,53 руб." + } + } + } + } + } + } + } + ]; + + const reply = buildBroadBusinessEvaluationReply({ + organization: "ООО Альтернатива Плюс", + addressDebug: sessionItems[1].debug as any, + sessionItems, + toNonEmptyString: (value: unknown) => { + const text = String(value ?? "").trim(); + return text.length > 0 ? text : null; + } + }); + + expect(reply.toLowerCase()).toContain("оценка бизнеса"); + expect(reply).toContain("ООО Альтернатива Плюс"); + expect(reply).toContain("47 628 853,03"); + }); + it("builds grounded answer inspection reply for MCP discovery net answer", () => { const context = resolveAssistantLivingChatMemoryContext({ modeDecisionReason: "answer_inspection_followup_detected", diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index 3c2c187..02c5153 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -606,7 +606,7 @@ describe("assistantRoutePolicy", () => { expect(decision.orchestrationContract?.organization_scope_switch_detected).not.toBe(true); }); - it("keeps company activity assessment follow-up in address lane when lifecycle intent is resolved from grounded continuity", () => { + it("routes broad business evaluation follow-up to chat instead of replaying lifecycle address intent", () => { const policy = buildPolicy({ resolveAddressIntent: () => ({ intent: "counterparty_activity_lifecycle", confidence: "high" }), findLastGroundedAddressAnswerDebug: () => ({ @@ -618,6 +618,15 @@ describe("assistantRoutePolicy", () => { period_to: "2026-04-18" } }), + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_intent_candidate: null, + unsupported_but_understood_family: "broad_business_evaluation", + stale_replay_forbidden: true, + reason_codes: ["broad_business_evaluation_current_turn_signal"] + }), resolveAddressToolGateDecision: () => ({ runAddressLane: false, decision: "skip_address_lane", @@ -666,9 +675,12 @@ describe("assistantRoutePolicy", () => { useMock: false }); - expect(decision.runAddressLane).toBe(true); - expect(decision.toolGateDecision).toBe("run_address_lane"); - expect(decision.livingMode).toBe("address_data"); + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateDecision).toBe("skip_address_lane"); + expect(decision.toolGateReason).toBe("unsupported_current_turn_meaning_boundary"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("unsupported_current_turn_meaning_boundary"); + expect(decision.orchestrationContract?.unsupported_current_turn_family).toBe("broad_business_evaluation"); }); it("recovers an address route from current-turn meaning when L0 resolver is noisy", () => { diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index b7405d5..8598044 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1014,6 +1014,45 @@ describe("assistantTransitionPolicy", () => { expect(carryover).toBeNull(); }); + it("drops carryover for broad business evaluation so lifecycle context does not stick to the new question", () => { + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: "Lifecycle answer", + debug: { + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + detected_intent: "counterparty_activity_lifecycle", + extracted_filters: { + organization: 'ООО "Альтернатива Плюс"', + period_to: "2020-12-31" + }, + anchor_type: "organization", + anchor_value_resolved: 'ООО "Альтернатива Плюс"' + } + }), + hasAddressFollowupContextSignal: () => true, + resolveAssistantTurnMeaning: () => ({ + schema_version: "assistant_turn_meaning_v1", + asked_domain_family: "business_summary", + asked_action_family: "broad_evaluation", + explicit_intent_candidate: null, + unsupported_but_understood_family: "broad_business_evaluation", + explicit_entity_candidates: [], + stale_replay_forbidden: true + }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "Как ты оценишь деятельность компании?", + [], + null, + null, + null + ); + + expect(carryover).toBeNull(); + }); + it("reuses grounded MCP discovery payout context for a short year-switch follow-up", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => null, diff --git a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts index ce56c5b..fb02d7b 100644 --- a/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTurnMeaningPolicy.test.ts @@ -93,4 +93,21 @@ describe("assistantTurnMeaningPolicy", () => { expect(meaning.asked_action_family).toBe("confirmed_tax_period"); expect(meaning.stale_replay_forbidden).toBe(false); }); + + it("marks broad business evaluation as unsupported-but-understood instead of stale lifecycle replay", () => { + const policy = buildPolicy({ + resolveAddressIntent: () => ({ intent: "counterparty_activity_lifecycle", confidence: "high" }) + }); + + const meaning = policy.resolveAssistantTurnMeaning({ + rawUserMessage: "Как ты оценишь деятельность компании?" + }); + + expect(meaning.explicit_intent_candidate).toBeNull(); + expect(meaning.asked_domain_family).toBe("business_summary"); + expect(meaning.asked_action_family).toBe("broad_evaluation"); + expect(meaning.unsupported_but_understood_family).toBe("broad_business_evaluation"); + expect(meaning.stale_replay_forbidden).toBe(true); + expect(meaning.reason_codes).toContain("broad_business_evaluation_current_turn_signal"); + }); });