diff --git a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md index 8bfee6b..f0c2a00 100644 --- a/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md +++ b/docs/ARCH/11 - architecture_turnaround/11 - continuity_stabilization_plan_2026-04-17.md @@ -376,6 +376,21 @@ Still open after the accepted phase12 replay: - this matters because `root_context_only` VAT pivots from inventory drilldown should preserve restored organization/date filters without pretending that restored scope is itself a user-selected follow-up anchor; - targeted `assistantAddressFollowupContext` and `assistantTransitionPolicy` suites are now green after the fix, explicitly protecting the `inventory drilldown -> VAT pivot` regression where selected-item carryover must be removed while the inventory root company/date window remains intact; - live replay `address_truth_harness_phase12_wider_saved_session_pool_live_20260418_rerun10` remains accepted `20/20`, which is the critical proof that this anchor-sanitization convergence did not reopen the flagship saved-session continuity path. +- the next replay-breadth pass now proves a different late-session contour around answer inspection and self-correction: + - a new live pack `address_truth_harness_phase15_answer_inspection_followup` validates `smalltalk -> company fixation -> historical inventory -> selected-item purchase provenance -> selected-item sale trace -> answer inspection -> VAT-on-purchase-date bridge` inside one shared session; + - the first strict replay exposed a real architecture seam rather than a wording issue: + - after a grounded selected-item sale trace, the user could ask `у тебя написано кто контрагент: рабочая станция - это ошибка?`; + - the runtime was still trying to treat that as a fresh address retrieval request, which collapsed into `unknown / unsupported` instead of inspecting the already grounded previous answer; + - the fix is now explicit in the orchestration layer: + - living-mode policy exposes a dedicated answer-inspection signal for self-correction wording; + - meta follow-up policy can now recognize `answer inspection over grounded answer` as its own follow-up class instead of leaving it to the generic address lane; + - route policy now keeps that class out of the address lane and deliberately routes it back into living-chat inspection logic; + - living-chat runtime now serves a deterministic inspection reply contract for selected-item provenance / sale-trace answers, explicitly distinguishing `selected item` from `counterparty` and preserving the next business move; + - this matters architecturally because another ambient monolith behavior is now an explicit runtime contract: + - grounded answer inspection is no longer left to accidental prompt luck; + - self-correction over a previous exact answer can now coexist with selected-object continuity instead of breaking the session into unsupported chat; + - the neighboring bridge `selected-item trace -> VAT on purchase date` remains alive after the inspection turn, which proves that answer inspection no longer tears down the active business frame; + - live replay `address_truth_harness_phase15_answer_inspection_followup_live_20260418_rerun5` is accepted `9/9`, which is the critical proof that this inspection-follow-up contour now survives as a real saved-session path instead of a one-off manual rescue. ## Next Execution Slice (2026-04-18) diff --git a/docs/orchestration/address_truth_harness_phase15_answer_inspection_followup.json b/docs/orchestration/address_truth_harness_phase15_answer_inspection_followup.json new file mode 100644 index 0000000..533eef3 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase15_answer_inspection_followup.json @@ -0,0 +1,221 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase15_answer_inspection_followup", + "domain": "address_phase15_answer_inspection_followup", + "title": "Phase 15 answer-inspection replay for selected-item trace continuity", + "description": "Focused AGENT replay for a different saved-session seam: after a historical inventory slice and selected-item purchase/sale trace, the user inspects the previous answer shape and then asks for VAT on the purchase date using colloquial wording. The scenario validates that result-inspection follow-ups stay grounded instead of collapsing into unsupported address mode, and that the purchase-date VAT bridge still survives after the inspection turn.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_smalltalk_scope_offer", + "title": "Smalltalk entry offers organization proactively", + "question": "приветик - че как там дела", + "required_answer_patterns_all": [ + "(?i)привет", + "(?i)альтернатива плюс|лайсвуд|райм" + ], + "forbidden_answer_patterns": [ + "(?i)mcp", + "(?i)tool_gate_reason", + "(?i)living_reason" + ], + "criticality": "important", + "semantic_tags": [ + "smalltalk_entry", + "scope_offer" + ] + }, + { + "step_id": "step_02_choose_organization", + "title": "Explicit company selection fixes the session contour", + "question": "Альтернатива Плюс", + "required_answer_patterns_all": [ + "(?i)зафиксир|рабочую организац|работаем по", + "(?i)Альтернатива Плюс" + ], + "criticality": "critical", + "semantic_tags": [ + "organization_authority" + ] + }, + { + "step_id": "step_03_inventory_march_2016", + "title": "Current inventory root enters the warehouse contour first", + "question": "что там на складе по остаткам?", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "expected_recipe": "address_inventory_on_hand_as_of_date_v1", + "required_filters": { + "as_of_date": "2026-04-18", + "organization": "ООО Альтернатива Плюс" + }, + "required_direct_answer_patterns_any": [ + "(?i)на складе|остат", + "18\\.04\\.2026" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "current_snapshot" + ] + }, + { + "step_id": "step_04_inventory_history_capability", + "title": "Inventory history capability handshake before month-only follow-up", + "question": "а исторические остатки на другие даты умеешь?", + "allowed_reply_types": [ + "factual_with_explanation" + ], + "required_direct_answer_patterns_any": [ + "(?i)^да, могу", + "(?i)историческ", + "(?i)дат|месяц|год" + ], + "criticality": "important", + "semantic_tags": [ + "inventory_history_capability", + "context_setup" + ] + }, + { + "step_id": "step_05_inventory_march_2016", + "title": "Historical inventory anchor on March 2016", + "question": "март 2016", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "expected_recipe": "address_inventory_on_hand_as_of_date_v1", + "required_filters": { + "as_of_date": "2016-03-31", + "period_from": "2016-03-01", + "period_to": "2016-03-31", + "organization": "ООО Альтернатива Плюс" + }, + "required_direct_answer_patterns_any": [ + "31\\.03\\.2016", + "(?i)на складе" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "historical_anchor" + ] + }, + { + "step_id": "step_06_selected_item_purchase_provenance", + "title": "Selected workstation purchase provenance", + "question": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "expected_recipe": "address_inventory_purchase_provenance_for_item_v1", + "required_filters": { + "item": "Рабочая станция универсального специалиста (индивидуальное изготовление)", + "as_of_date": "{{step_05_inventory_march_2016.filters.as_of_date}}" + }, + "required_direct_answer_patterns_any": [ + "(?i)рабочая станция", + "(?i)поставщик|закуп" + ], + "criticality": "critical", + "semantic_tags": [ + "selected_object", + "purchase_provenance" + ] + }, + { + "step_id": "step_07_selected_item_sale_trace", + "title": "Buyer / sale trace follow-up on the same selected item", + "question": "а кому продали?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "inventory_sale_trace_for_item" + ], + "expected_recipe": "address_inventory_sale_trace_for_item_v1", + "required_filters": { + "item": "Рабочая станция универсального специалиста (индивидуальное изготовление)", + "as_of_date": "{{step_05_inventory_march_2016.filters.as_of_date}}" + }, + "required_direct_answer_patterns_any": [ + "(?i)покупател|выбыт|прод", + "(?i)рабочая станция" + ], + "criticality": "critical", + "semantic_tags": [ + "selected_object", + "sale_trace" + ] + }, + { + "step_id": "step_08_answer_inspection_counterparty_label", + "title": "Result-inspection follow-up stays grounded instead of unsupported", + "question": "у тебя написано кто контрагент: рабочая станция - это ошибка?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_direct_answer_patterns_any": [ + "(?i)рабочая станция", + "(?i)контрагент|номенклатур|позици|товар|поле" + ], + "forbidden_direct_answer_patterns": [ + "(?i)пока не поддерживается", + "(?i)ограничени[яй] адресного режима", + "(?i)не представляется возможным", + "(?i)система не поддерживает", + "(?i)tool_gate_reason", + "(?i)mcp" + ], + "criticality": "critical", + "semantic_tags": [ + "answer_inspection", + "selected_object_context", + "grounded_self_correction" + ] + }, + { + "step_id": "step_09_vat_on_purchase_date_after_inspection", + "title": "VAT bridge survives after the inspection turn", + "question": "ндс можешь прикинуть на дату покупки рабочей станции?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_intents": [ + "vat_liability_confirmed_for_tax_period" + ], + "required_direct_answer_patterns_any": [ + "(?i)ндс", + "(?i)дата покупки|налогов|период|2015|феврал|июл" + ], + "forbidden_direct_answer_patterns": [ + "(?i)пока не поддерживается", + "(?i)ограничени[яй] адресного режима", + "(?i)tool_gate_reason", + "(?i)mcp" + ], + "criticality": "critical", + "semantic_tags": [ + "purchase_date_vat_bridge", + "selected_object", + "post_inspection_continuity" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js index 901ef8d..0c7096e 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantLivingChatRuntimeAdapter.js @@ -75,6 +75,36 @@ function buildAddressMemoryRecapReply(input) { } return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; } +function buildSelectedObjectAnswerInspectionReply(input) { + const extractedFilters = input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? input.addressDebug.extracted_filters + : null; + const item = input.toNonEmptyString(extractedFilters?.item) ?? + (String(input.addressDebug?.anchor_type ?? "") === "item" + ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? + input.toNonEmptyString(input.addressDebug?.anchor_value_raw) + : null); + const detectedIntent = String(input.addressDebug?.detected_intent ?? ""); + const itemLabel = item ?? "эта позиция"; + if (detectedIntent === "inventory_sale_trace_for_item") { + return [ + `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`, + "В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.", + "Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации." + ].join(" "); + } + if (detectedIntent === "inventory_purchase_provenance_for_item" || + detectedIntent === "inventory_purchase_documents_for_item") { + return [ + `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция / номенклатура.`, + "В предыдущем ответе речь шла о закупке этой позиции: я перечислял поставщиков или закупочные документы по ней, а не называл саму позицию контрагентом." + ].join(" "); + } + return [ + `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а выбранный объект разбора.`, + "Я сейчас уточняю именно смысл предыдущего grounded-ответа по этой позиции, а не запускаю новый адресный поиск." + ].join(" "); +} async function runAssistantLivingChatRuntime(input) { const userMessage = String(input.userMessage ?? ""); const organizationAuthority = (0, assistantContinuityPolicy_1.resolveAssistantOrganizationAuthority)({ @@ -114,6 +144,7 @@ async function runAssistantLivingChatRuntime(input) { const contextualMemoryRecapFollowup = memoryRecapContext.contextualMemoryRecapFollowup; const lastGroundedInventoryAddressDebug = memoryRecapContext.lastGroundedInventoryAddressDebug; const lastMemoryAddressDebug = memoryRecapContext.lastMemoryAddressDebug; + const contextualAnswerInspectionFollowup = String(input.modeDecision?.reason ?? "") === "answer_inspection_followup_detected"; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); livingChatSource = "deterministic_safety_refusal"; @@ -178,6 +209,13 @@ async function runAssistantLivingChatRuntime(input) { activeOrganization = scopedOrganization ?? activeOrganization; livingChatSource = "deterministic_memory_recap_contract"; } + else if (contextualAnswerInspectionFollowup) { + chatText = buildSelectedObjectAnswerInspectionReply({ + addressDebug: continuitySnapshot.lastGroundedItemAddressDebug ?? continuitySnapshot.lastGroundedAddressDebug, + toNonEmptyString: input.toNonEmptyString + }); + livingChatSource = "deterministic_answer_inspection_contract"; + } else if (capabilityMetaQuery) { chatText = input.buildAssistantCapabilityContractReply(userMessage); livingChatSource = "deterministic_capability_contract"; diff --git a/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js b/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js index ea490cc..e795386 100644 --- a/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantLivingModePolicy.js @@ -96,6 +96,29 @@ function createAssistantLivingModePolicy(deps) { hasDataRetrievalRequestSignal(sample) || hasStrongDataIntentSignal(sample)); } + function hasAnswerInspectionFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText] + .filter((item) => item.length > 0) + .map((item) => item.replace(/ё/g, "е")); + if (samples.length === 0) { + return false; + } + const hasInspectionCue = samples.some((sample) => /(?:ошибк|неверн|не так|перепутал|у тебя написано|ты написал|это ошибка|правильно ли|корректн)/iu.test(sample)); + if (!hasInspectionCue) { + return false; + } + const hasAnswerObjectCue = samples.some((sample) => /(?:контрагент|покупател|поставщик|поле|ответ|позици|номенклат|документ)/iu.test(sample)); + if (!hasAnswerObjectCue) { + return false; + } + const hasFreshRetrievalAction = samples.some((sample) => /(?:покажи|выведи|найди|список|проверь по базе|подними документы|достань|рассчитай|посчитай|сколько|кто\s+нам|кто\s+у\s+нас)/iu.test(sample)); + if (hasFreshRetrievalAction) { + return false; + } + return true; + } function hasConversationMemoryRecallFollowupSignal(userMessage) { const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); @@ -348,6 +371,7 @@ function createAssistantLivingModePolicy(deps) { hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasMetaAnswerFollowupSignal, + hasAnswerInspectionFollowupSignal, hasConversationMemoryRecallFollowupSignal, hasHistoricalCapabilityFollowupSignal, hasOrganizationFactFollowupSignal, diff --git a/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js b/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js index d17285a..4444ca3 100644 --- a/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantMetaFollowupPolicy.js @@ -27,14 +27,16 @@ function createAssistantMetaFollowupPolicy(deps) { return { dataScopeMetaQuery: false, capabilityMetaQuery: false, - metaAnswerFollowupSignal: false + metaAnswerFollowupSignal: false, + answerInspectionFollowupSignal: false }; } return { dataScopeMetaQuery: hasSignalAcrossSamples(samples, deps.hasAssistantDataScopeMetaQuestionSignal), capabilityMetaQuery: hasSignalAcrossSamples(samples, deps.shouldHandleAsAssistantCapabilityMetaQuery) || hasImplicitHistoricalCapabilityMetaSignal(samples), - metaAnswerFollowupSignal: hasSignalAcrossSamples(samples, deps.hasMetaAnswerFollowupSignal) + metaAnswerFollowupSignal: hasSignalAcrossSamples(samples, deps.hasMetaAnswerFollowupSignal), + answerInspectionFollowupSignal: hasSignalAcrossSamples(samples, deps.hasAnswerInspectionFollowupSignal) }; } function resolveHardMetaMode(input) { @@ -60,9 +62,18 @@ function createAssistantMetaFollowupPolicy(deps) { (!input.llmContractIntent || String(input.llmContractIntent) === "unknown") && String(input.llmContractMode ?? "") !== "address_query"); } + function isAnswerInspectionFollowupOverGroundedAnswer(input) { + return Boolean(input.followupContext && + input.hasPriorAddressAnswerContext && + input.answerInspectionFollowupSignal && + !input.dataScopeMetaQuery && + !input.capabilityMetaQuery && + !input.aggregateBusinessAnalyticsSignal); + } return { resolveMetaSignalSet, resolveHardMetaMode, - isMetaFollowupOverGroundedAnswer + isMetaFollowupOverGroundedAnswer, + isAnswerInspectionFollowupOverGroundedAnswer }; } diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 9516370..8d32a9c 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -47,7 +47,7 @@ function shouldBypassStrictDeepInvestigationCueForAddressIntent(intent) { return Boolean(intent && ADDRESS_INTENTS_ALLOW_STRICT_DEEP_INVESTIGATION_BYPASS.has(intent)); } function createAssistantRoutePolicy(deps) { - const { repairAddressMojibake, findLastGroundedAddressAnswerDebug, findLastOrganizationClarificationAddressDebug, mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, resolveMetaSignalSet, resolveHardMetaMode, isMetaFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, hasOpenContractsAddressSignal, detectAddressQuestionMode, resolveAddressIntent, toNonEmptyString, hasStrictDeepInvestigationCue, hasStrongDataIntentSignal, hasAccountingSignal, hasDangerOrCoercionSignal, hasAddressFollowupContextSignal, hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, isGroundedInventoryContextDebug, resolveRouteMemorySignals, findLastAddressAssistantItem, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, resolveProviderExecutionState } = deps; + const { repairAddressMojibake, findLastGroundedAddressAnswerDebug, findLastOrganizationClarificationAddressDebug, mergeKnownOrganizations, normalizeOrganizationScopeValue, resolveOrganizationSelectionFromMessage, resolveMetaSignalSet, resolveHardMetaMode, isMetaFollowupOverGroundedAnswer, isAnswerInspectionFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal, hasAggregateBusinessAnalyticsSignal, hasStandaloneAddressTopicSignal, hasOpenContractsAddressSignal, detectAddressQuestionMode, resolveAddressIntent, toNonEmptyString, hasStrictDeepInvestigationCue, hasStrongDataIntentSignal, hasAccountingSignal, hasDangerOrCoercionSignal, hasAddressFollowupContextSignal, hasShortDebtMirrorFollowupSignal, isInventorySelectedObjectIntent, hasShortInventoryObjectFollowupSignal, isGroundedInventoryContextDebug, resolveRouteMemorySignals, findLastAddressAssistantItem, resolveAddressToolGateDecision, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, resolveProviderExecutionState } = deps; function hasInventoryRootRestatementFollowupSignal(text) { const normalized = compactWhitespace(repairAddressMojibake(String(text ?? "")).toLowerCase()).replace(/ё/g, "е"); if (!normalized) { @@ -580,6 +580,7 @@ function createAssistantRoutePolicy(deps) { }; } const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; + const answerInspectionFollowupSignal = metaSignals.answerInspectionFollowupSignal; const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -707,6 +708,23 @@ function createAssistantRoutePolicy(deps) { followupContext, hasPriorAddressAnswerContext, metaAnswerFollowupSignal, + answerInspectionFollowupSignal, + vatEvaluativeFollowupSignal, + dataScopeMetaQuery, + capabilityMetaQuery, + aggregateBusinessAnalyticsSignal, + dataRetrievalSignal, + strongDataSignal, + resolvedMode: resolvedModeDetection.mode, + resolvedIntent: resolvedIntentResolution.intent, + llmContractIntent, + llmContractMode + }); + const answerInspectionFollowupOverGroundedAnswer = isAnswerInspectionFollowupOverGroundedAnswer({ + followupContext, + hasPriorAddressAnswerContext, + metaAnswerFollowupSignal, + answerInspectionFollowupSignal, vatEvaluativeFollowupSignal, dataScopeMetaQuery, capabilityMetaQuery, @@ -762,6 +780,11 @@ function createAssistantRoutePolicy(deps) { toolGateDecision = "skip_address_lane"; toolGateReason = "meta_followup_over_grounded_answer"; } + if (answerInspectionFollowupOverGroundedAnswer) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "answer_inspection_followup_over_grounded_answer"; + } let livingDecision = resolveLivingAssistantModeDecision({ userMessage: rawUserMessage, addressLaneTriggered: runAddressLane, @@ -802,6 +825,12 @@ function createAssistantRoutePolicy(deps) { reason: "meta_followup_over_grounded_answer" }; } + if (answerInspectionFollowupOverGroundedAnswer) { + livingDecision = { + mode: "chat", + reason: "answer_inspection_followup_detected" + }; + } return { runAddressLane, toolGateDecision, @@ -834,6 +863,7 @@ function createAssistantRoutePolicy(deps) { deep_analysis_signal_fallback_to_deep: deepAnalysisSignalFallbackToDeep, aggregate_analytics_signal_fallback_to_deep: aggregateAnalyticsFallbackToDeep, deep_session_continuation_fallback_to_deep: deepSessionContinuationFallbackToDeep, + answer_inspection_followup_over_grounded_answer: answerInspectionFollowupOverGroundedAnswer, final_decision: { run_address_lane: runAddressLane, tool_gate_decision: toolGateDecision, diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index bc8d0fc..f553862 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -4223,7 +4223,8 @@ const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistan const assistantMetaFollowupPolicy = (0, assistantMetaFollowupPolicy_1.createAssistantMetaFollowupPolicy)({ hasAssistantDataScopeMetaQuestionSignal: assistantLivingModePolicy.hasAssistantDataScopeMetaQuestionSignal, shouldHandleAsAssistantCapabilityMetaQuery: assistantLivingModePolicy.shouldHandleAsAssistantCapabilityMetaQuery, - hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal + hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal, + hasAnswerInspectionFollowupSignal: assistantLivingModePolicy.hasAnswerInspectionFollowupSignal }); const assistantMemoryRecapPolicy = (0, assistantMemoryRecapPolicy_1.createAssistantMemoryRecapPolicy)({ hasHistoricalCapabilityFollowupSignal: assistantLivingModePolicy.hasHistoricalCapabilityFollowupSignal, @@ -4240,6 +4241,7 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli resolveMetaSignalSet: assistantMetaFollowupPolicy.resolveMetaSignalSet, resolveHardMetaMode: assistantMetaFollowupPolicy.resolveHardMetaMode, isMetaFollowupOverGroundedAnswer: assistantMetaFollowupPolicy.isMetaFollowupOverGroundedAnswer, + isAnswerInspectionFollowupOverGroundedAnswer: assistantMetaFollowupPolicy.isAnswerInspectionFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal: assistantLivingModePolicy.hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal: assistantLivingModePolicy.hasOrganizationFactFollowupSignal, diff --git a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts index 9aa3cd6..dc2bafd 100644 --- a/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantLivingChatRuntimeAdapter.ts @@ -162,6 +162,45 @@ function buildAddressMemoryRecapReply(input: { return "Да, помню предыдущий адресный контур. Могу кратко напомнить, что мы уже подтвердили, или сразу продолжить следующий шаг."; } +function buildSelectedObjectAnswerInspectionReply(input: { + addressDebug: Record | null; + toNonEmptyString: (value: unknown) => string | null; +}): string { + const extractedFilters = + input.addressDebug?.extracted_filters && typeof input.addressDebug.extracted_filters === "object" + ? (input.addressDebug.extracted_filters as Record) + : null; + const item = + input.toNonEmptyString(extractedFilters?.item) ?? + (String(input.addressDebug?.anchor_type ?? "") === "item" + ? input.toNonEmptyString(input.addressDebug?.anchor_value_resolved) ?? + input.toNonEmptyString(input.addressDebug?.anchor_value_raw) + : null); + const detectedIntent = String(input.addressDebug?.detected_intent ?? ""); + const itemLabel = item ?? "эта позиция"; + + if (detectedIntent === "inventory_sale_trace_for_item") { + return [ + `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция, по которой мы смотрели продажу.`, + "В предыдущем ответе я показывал документы выбытия по этой позиции. Покупатель в доступных данных отдельно не выделен, поэтому назвать контрагента-покупателя я там не мог.", + "Если хочешь, следующим шагом могу отдельно проверить, можно ли вытащить покупателя по связанным документам реализации." + ].join(" "); + } + + if (detectedIntent === "inventory_purchase_provenance_for_item" || + detectedIntent === "inventory_purchase_documents_for_item") { + return [ + `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а сама позиция / номенклатура.`, + "В предыдущем ответе речь шла о закупке этой позиции: я перечислял поставщиков или закупочные документы по ней, а не называл саму позицию контрагентом." + ].join(" "); + } + + return [ + `Да, если так прозвучало, это ошибка чтения ответа. «${itemLabel}» здесь не контрагент, а выбранный объект разбора.`, + "Я сейчас уточняю именно смысл предыдущего grounded-ответа по этой позиции, а не запускаю новый адресный поиск." + ].join(" "); +} + export async function runAssistantLivingChatRuntime( input: AssistantLivingChatRuntimeInput ): Promise { @@ -205,6 +244,8 @@ export async function runAssistantLivingChatRuntime( const contextualMemoryRecapFollowup = memoryRecapContext.contextualMemoryRecapFollowup; const lastGroundedInventoryAddressDebug = memoryRecapContext.lastGroundedInventoryAddressDebug; const lastMemoryAddressDebug = memoryRecapContext.lastMemoryAddressDebug; + const contextualAnswerInspectionFollowup = + String(input.modeDecision?.reason ?? "") === "answer_inspection_followup_detected"; if (capabilityMetaQuery && (destructiveSignal || dangerSignal)) { chatText = input.buildAssistantSafetyRefusalReply(); @@ -266,6 +307,12 @@ export async function runAssistantLivingChatRuntime( }); activeOrganization = scopedOrganization ?? activeOrganization; livingChatSource = "deterministic_memory_recap_contract"; + } else if (contextualAnswerInspectionFollowup) { + chatText = buildSelectedObjectAnswerInspectionReply({ + addressDebug: continuitySnapshot.lastGroundedItemAddressDebug ?? continuitySnapshot.lastGroundedAddressDebug, + toNonEmptyString: input.toNonEmptyString + }); + livingChatSource = "deterministic_answer_inspection_contract"; } else if (capabilityMetaQuery) { chatText = input.buildAssistantCapabilityContractReply(userMessage); livingChatSource = "deterministic_capability_contract"; diff --git a/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts b/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts index fa6d44a..898cffb 100644 --- a/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantLivingModePolicy.ts @@ -38,6 +38,7 @@ export interface AssistantLivingModePolicy { hasDataRetrievalRequestSignal: (text: unknown) => boolean; hasOrganizationFactLookupSignal: (text: unknown) => boolean; hasMetaAnswerFollowupSignal: (text: unknown) => boolean; + hasAnswerInspectionFollowupSignal: (text: unknown) => boolean; hasConversationMemoryRecallFollowupSignal: (text: unknown) => boolean; hasHistoricalCapabilityFollowupSignal: (text: unknown) => boolean; hasOrganizationFactFollowupSignal: (userMessage: unknown, items: unknown[]) => boolean; @@ -160,6 +161,30 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD hasStrongDataIntentSignal(sample)); } + function hasAnswerInspectionFollowupSignal(userMessage) { + const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); + const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); + const samples = [rawText, repairedText] + .filter((item) => item.length > 0) + .map((item) => item.replace(/ё/g, "е")); + if (samples.length === 0) { + return false; + } + const hasInspectionCue = samples.some((sample) => /(?:ошибк|неверн|не так|перепутал|у тебя написано|ты написал|это ошибка|правильно ли|корректн)/iu.test(sample)); + if (!hasInspectionCue) { + return false; + } + const hasAnswerObjectCue = samples.some((sample) => /(?:контрагент|покупател|поставщик|поле|ответ|позици|номенклат|документ)/iu.test(sample)); + if (!hasAnswerObjectCue) { + return false; + } + const hasFreshRetrievalAction = samples.some((sample) => /(?:покажи|выведи|найди|список|проверь по базе|подними документы|достань|рассчитай|посчитай|сколько|кто\s+нам|кто\s+у\s+нас)/iu.test(sample)); + if (hasFreshRetrievalAction) { + return false; + } + return true; + } + function hasConversationMemoryRecallFollowupSignal(userMessage) { const rawText = compactWhitespace(String(userMessage ?? "").toLowerCase()); const repairedText = compactWhitespace(repairAddressMojibake(String(userMessage ?? "")).toLowerCase()); @@ -421,6 +446,7 @@ export function createAssistantLivingModePolicy(deps: AssistantLivingModePolicyD hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasMetaAnswerFollowupSignal, + hasAnswerInspectionFollowupSignal, hasConversationMemoryRecallFollowupSignal, hasHistoricalCapabilityFollowupSignal, hasOrganizationFactFollowupSignal, diff --git a/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts b/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts index e2fc1eb..3819fa7 100644 --- a/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantMetaFollowupPolicy.ts @@ -11,6 +11,7 @@ export interface ResolveAssistantMetaFollowupOverGroundedAnswerInput { followupContext?: unknown; hasPriorAddressAnswerContext?: boolean; metaAnswerFollowupSignal?: boolean; + answerInspectionFollowupSignal?: boolean; vatEvaluativeFollowupSignal?: boolean; dataScopeMetaQuery?: boolean; capabilityMetaQuery?: boolean; @@ -33,12 +34,14 @@ export interface AssistantMetaSignalSet { dataScopeMetaQuery: boolean; capabilityMetaQuery: boolean; metaAnswerFollowupSignal: boolean; + answerInspectionFollowupSignal: boolean; } export interface AssistantMetaFollowupPolicyDeps { hasAssistantDataScopeMetaQuestionSignal: (text: unknown) => boolean; shouldHandleAsAssistantCapabilityMetaQuery: (text: unknown) => boolean; hasMetaAnswerFollowupSignal: (text: unknown) => boolean; + hasAnswerInspectionFollowupSignal: (text: unknown) => boolean; } function collectMessageSamples(input: ResolveAssistantMetaSignalSetInput): string[] { @@ -83,7 +86,8 @@ export function createAssistantMetaFollowupPolicy( return { dataScopeMetaQuery: false, capabilityMetaQuery: false, - metaAnswerFollowupSignal: false + metaAnswerFollowupSignal: false, + answerInspectionFollowupSignal: false }; } return { @@ -97,6 +101,10 @@ export function createAssistantMetaFollowupPolicy( metaAnswerFollowupSignal: hasSignalAcrossSamples( samples, deps.hasMetaAnswerFollowupSignal + ), + answerInspectionFollowupSignal: hasSignalAcrossSamples( + samples, + deps.hasAnswerInspectionFollowupSignal ) }; } @@ -132,9 +140,23 @@ export function createAssistantMetaFollowupPolicy( ); } + function isAnswerInspectionFollowupOverGroundedAnswer( + input: ResolveAssistantMetaFollowupOverGroundedAnswerInput + ): boolean { + return Boolean( + input.followupContext && + input.hasPriorAddressAnswerContext && + input.answerInspectionFollowupSignal && + !input.dataScopeMetaQuery && + !input.capabilityMetaQuery && + !input.aggregateBusinessAnalyticsSignal + ); + } + return { resolveMetaSignalSet, resolveHardMetaMode, - isMetaFollowupOverGroundedAnswer + isMetaFollowupOverGroundedAnswer, + isAnswerInspectionFollowupOverGroundedAnswer }; } diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 5daacef..b8288ad 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -57,6 +57,7 @@ export function createAssistantRoutePolicy(deps) { resolveMetaSignalSet, resolveHardMetaMode, isMetaFollowupOverGroundedAnswer, + isAnswerInspectionFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal, @@ -619,6 +620,7 @@ export function createAssistantRoutePolicy(deps) { }; } const metaAnswerFollowupSignal = metaSignals.metaAnswerFollowupSignal; + const answerInspectionFollowupSignal = metaSignals.answerInspectionFollowupSignal; const preserveAddressLaneSignal = Boolean((llmPreDecomposeMeta?.llmCanonicalCandidateDetected && llmPreDecomposeMeta?.applied && llmContractMode === "address_query") || @@ -746,6 +748,23 @@ export function createAssistantRoutePolicy(deps) { followupContext, hasPriorAddressAnswerContext, metaAnswerFollowupSignal, + answerInspectionFollowupSignal, + vatEvaluativeFollowupSignal, + dataScopeMetaQuery, + capabilityMetaQuery, + aggregateBusinessAnalyticsSignal, + dataRetrievalSignal, + strongDataSignal, + resolvedMode: resolvedModeDetection.mode, + resolvedIntent: resolvedIntentResolution.intent, + llmContractIntent, + llmContractMode + }); + const answerInspectionFollowupOverGroundedAnswer = isAnswerInspectionFollowupOverGroundedAnswer({ + followupContext, + hasPriorAddressAnswerContext, + metaAnswerFollowupSignal, + answerInspectionFollowupSignal, vatEvaluativeFollowupSignal, dataScopeMetaQuery, capabilityMetaQuery, @@ -801,6 +820,11 @@ export function createAssistantRoutePolicy(deps) { toolGateDecision = "skip_address_lane"; toolGateReason = "meta_followup_over_grounded_answer"; } + if (answerInspectionFollowupOverGroundedAnswer) { + runAddressLane = false; + toolGateDecision = "skip_address_lane"; + toolGateReason = "answer_inspection_followup_over_grounded_answer"; + } let livingDecision = resolveLivingAssistantModeDecision({ userMessage: rawUserMessage, addressLaneTriggered: runAddressLane, @@ -841,6 +865,12 @@ export function createAssistantRoutePolicy(deps) { reason: "meta_followup_over_grounded_answer" }; } + if (answerInspectionFollowupOverGroundedAnswer) { + livingDecision = { + mode: "chat", + reason: "answer_inspection_followup_detected" + }; + } return { runAddressLane, toolGateDecision, @@ -873,6 +903,7 @@ export function createAssistantRoutePolicy(deps) { deep_analysis_signal_fallback_to_deep: deepAnalysisSignalFallbackToDeep, aggregate_analytics_signal_fallback_to_deep: aggregateAnalyticsFallbackToDeep, deep_session_continuation_fallback_to_deep: deepSessionContinuationFallbackToDeep, + answer_inspection_followup_over_grounded_answer: answerInspectionFollowupOverGroundedAnswer, final_decision: { run_address_lane: runAddressLane, tool_gate_decision: toolGateDecision, diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index c2e4134..17dd227 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -4180,7 +4180,8 @@ const assistantLivingModePolicy = (0, assistantLivingModePolicy_1.createAssistan const assistantMetaFollowupPolicy = (0, assistantMetaFollowupPolicy_1.createAssistantMetaFollowupPolicy)({ hasAssistantDataScopeMetaQuestionSignal: assistantLivingModePolicy.hasAssistantDataScopeMetaQuestionSignal, shouldHandleAsAssistantCapabilityMetaQuery: assistantLivingModePolicy.shouldHandleAsAssistantCapabilityMetaQuery, - hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal + hasMetaAnswerFollowupSignal: assistantLivingModePolicy.hasMetaAnswerFollowupSignal, + hasAnswerInspectionFollowupSignal: assistantLivingModePolicy.hasAnswerInspectionFollowupSignal }); const assistantMemoryRecapPolicy = (0, assistantMemoryRecapPolicy_1.createAssistantMemoryRecapPolicy)({ hasHistoricalCapabilityFollowupSignal: assistantLivingModePolicy.hasHistoricalCapabilityFollowupSignal, @@ -4197,6 +4198,7 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli resolveMetaSignalSet: assistantMetaFollowupPolicy.resolveMetaSignalSet, resolveHardMetaMode: assistantMetaFollowupPolicy.resolveHardMetaMode, isMetaFollowupOverGroundedAnswer: assistantMetaFollowupPolicy.isMetaFollowupOverGroundedAnswer, + isAnswerInspectionFollowupOverGroundedAnswer: assistantMetaFollowupPolicy.isAnswerInspectionFollowupOverGroundedAnswer, hasDataRetrievalRequestSignal: assistantLivingModePolicy.hasDataRetrievalRequestSignal, hasOrganizationFactLookupSignal, hasOrganizationFactFollowupSignal: assistantLivingModePolicy.hasOrganizationFactFollowupSignal, diff --git a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts index 6e3d964..7d4d1f8 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatRuntimeAdapter.test.ts @@ -255,4 +255,38 @@ describe("assistant living chat runtime adapter", () => { expect(output.debug?.living_chat_continuity_active_organization).toBe("ООО Альтернатива Плюс"); expect(executeLlmChat).not.toHaveBeenCalled(); }); + it("builds deterministic answer inspection reply over grounded selected-object sale trace", async () => { + const executeLlmChat = vi.fn(async () => "raw-llm"); + const input = buildRuntimeInput({ + userMessage: "у тебя написано кто контрагент: рабочая станция - это ошибка?", + modeDecision: { mode: "chat", reason: "answer_inspection_followup_detected" }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { + status: "grounded" + }, + detected_intent: "inventory_sale_trace_for_item", + extracted_filters: { + item: "Рабочая станция универсального специалиста", + organization: "ООО Альтернатива Плюс", + as_of_date: "2016-03-31" + } + } + } + ], + executeLlmChat + }); + + const output = await runAssistantLivingChatRuntime(input); + + expect(output.handled).toBe(true); + expect(output.chatText).toContain("не контрагент"); + expect(output.chatText).toContain("Рабочая станция универсального специалиста"); + expect(output.chatText).toContain("Покупатель"); + expect(output.debug?.living_chat_response_source).toBe("deterministic_answer_inspection_contract"); + expect(executeLlmChat).not.toHaveBeenCalled(); + }); }); diff --git a/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts b/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts index 63d8024..4e9c7c7 100644 --- a/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingModePolicy.test.ts @@ -50,6 +50,12 @@ describe("assistantLivingModePolicy", () => { expect(policy.hasConversationMemoryRecallFollowupSignal("а что мы уже выяснили по этой позиции?")).toBe(true); }); + it("detects answer inspection wording for previous answer correction", () => { + const policy = buildPolicy(); + + expect(policy.hasAnswerInspectionFollowupSignal("у тебя написано кто контрагент: рабочая станция - это ошибка?")).toBe(true); + }); + it("routes casual small-talk to chat mode", () => { const policy = buildPolicy(); diff --git a/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts b/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts index 7fc755c..534d3ef 100644 --- a/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantMetaFollowupPolicy.test.ts @@ -3,11 +3,13 @@ import { createAssistantMetaFollowupPolicy } from "../src/services/assistantMeta const policy = createAssistantMetaFollowupPolicy({ hasAssistantDataScopeMetaQuestionSignal: (text: unknown) => - /по какой компании|какая база/i.test(String(text ?? "")), + /РїРѕ какой компании|какая база/i.test(String(text ?? "")), shouldHandleAsAssistantCapabilityMetaQuery: (text: unknown) => - /что ты можешь|что ты умеешь/i.test(String(text ?? "")), + /что ты можешь|что ты умеешь/i.test(String(text ?? "")), hasMetaAnswerFollowupSignal: (text: unknown) => - /это норм|что думаешь/i.test(String(text ?? "")) + /это РЅРѕСЂРј|что думаешь/i.test(String(text ?? "")), + hasAnswerInspectionFollowupSignal: (text: unknown) => + /это ошибка|у тебя написано кто контрагент/i.test(String(text ?? "")) }); describe("assistantMetaFollowupPolicy", () => { @@ -15,13 +17,14 @@ describe("assistantMetaFollowupPolicy", () => { const signals = policy.resolveMetaSignalSet({ rawUserMessage: "", repairedRawUserMessage: "", - effectiveAddressUserMessage: "по какой компании мы можем работать?", + effectiveAddressUserMessage: "РїРѕ какой компании РјС‹ можем работать?", repairedEffectiveAddressUserMessage: "" }); expect(signals.dataScopeMetaQuery).toBe(true); expect(signals.capabilityMetaQuery).toBe(false); expect(signals.metaAnswerFollowupSignal).toBe(false); + expect(signals.answerInspectionFollowupSignal).toBe(false); }); it("treats historical capability phrasing as capability meta follow-up", () => { @@ -72,4 +75,26 @@ describe("assistantMetaFollowupPolicy", () => { expect(detected).toBe(true); }); + + it("detects answer inspection follow-up over grounded answer", () => { + const signals = policy.resolveMetaSignalSet({ + rawUserMessage: "у тебя написано кто контрагент: рабочая станция - это ошибка?", + repairedRawUserMessage: "", + effectiveAddressUserMessage: "", + repairedEffectiveAddressUserMessage: "" + }); + + expect(signals.answerInspectionFollowupSignal).toBe(true); + + const detected = policy.isAnswerInspectionFollowupOverGroundedAnswer({ + followupContext: { previous_intent: "inventory_sale_trace_for_item", previous_anchor_type: "item" }, + hasPriorAddressAnswerContext: true, + answerInspectionFollowupSignal: true, + dataScopeMetaQuery: false, + capabilityMetaQuery: false, + aggregateBusinessAnalyticsSignal: false + }); + + expect(detected).toBe(true); + }); }); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index b2135a6..bcbd38a 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -47,7 +47,8 @@ function buildPolicy(overrides: Record = {}) { return { dataScopeMetaQuery: /РїРѕ какой компании|какая база|РїРѕ каким конторам/i.test(samples), capabilityMetaQuery: /что ты можешь|что ты умеешь/i.test(samples), - metaAnswerFollowupSignal: /это РЅРѕСЂРј|что думаешь/i.test(samples) + metaAnswerFollowupSignal: /это РЅРѕСЂРј|что думаешь/i.test(samples), + answerInspectionFollowupSignal: /это ошибка|у тебя написано кто контрагент/i.test(samples) }; }, resolveHardMetaMode: (input: { @@ -61,6 +62,7 @@ function buildPolicy(overrides: Record = {}) { ? "capability" : null, isMetaFollowupOverGroundedAnswer: () => false, + isAnswerInspectionFollowupOverGroundedAnswer: () => false, hasDataRetrievalRequestSignal: () => false, hasOrganizationFactLookupSignal: () => false, hasOrganizationFactFollowupSignal: () => false, @@ -236,6 +238,32 @@ describe("assistantRoutePolicy", () => { expect(decision.livingReason).toBe("memory_recap_followup_detected"); }); + it("routes answer inspection follow-up over grounded selected-object answer to chat", () => { + const policy = buildPolicy({ + findLastGroundedAddressAnswerDebug: () => ({ execution_lane: "address_query" }), + resolveAddressToolGateDecision: () => ({ + runAddressLane: true, + decision: "run_address_lane", + reason: "address_mode_classifier_detected" + }), + detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }), + isAnswerInspectionFollowupOverGroundedAnswer: () => true + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: "у тебя написано кто контрагент: рабочая станция - это ошибка?", + effectiveAddressUserMessage: "у тебя написано кто контрагент: рабочая станция - это ошибка?", + followupContext: { previous_intent: "inventory_sale_trace_for_item", previous_anchor_type: "item" }, + llmPreDecomposeMeta: null, + useMock: false + }); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateReason).toBe("answer_inspection_followup_over_grounded_answer"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("answer_inspection_followup_detected"); + }); + it("routes organization fact lookup away from address lane even with follow-up context", () => { const policy = buildPolicy({ hasDataRetrievalRequestSignal: () => true,