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 dafd869..8bfee6b 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 @@ -362,6 +362,20 @@ Still open after the accepted phase12 replay: - active organization bootstrap now flows through selected organization, navigation organization, and shared continuity authority in one place instead of keeping a second callback-shaped fallback branch beside the authority object; - `assistantService.resolveSessionOrganizationScopeContext(...)` no longer passes that legacy callback into the runtime adapter, which reduces one more orchestration seam where old and new organization owners could drift; - targeted organization-scope, data-scope, and route regressions remain green after the change, and wide saved-session replay `address_truth_harness_phase12_wider_saved_session_pool_live_20260418_rerun9` remains accepted `20/20`, which is the critical proof that this bootstrap convergence did not reopen the flagship continuity path. +- the next replay-breadth pass now proves a different late-session contour instead of replaying only the flagship chain: + - a new live pack `address_truth_harness_phase14_counterparty_tail_resume` validates `data-scope meta -> explicit company selection -> counterparty docs -> short-name follow-up -> inventory today -> account 60 -> inventory aging -> historical inventory -> organization activity analytics` inside one shared session; + - the first draft of this pack exposed one real architecture seam rather than another continuity collapse: `Как ты оценишь деятельность компании?` after grounded organization activity-age was still falling into `living_chat` because the route depended too much on the L0 gate and not enough on the resolved supported intent; + - `addressCounterpartyIntentSignals` now treats company-activity assessment wording as the same `counterparty_activity_lifecycle` contour instead of leaving it as unsupported meta chat; + - `assistantRoutePolicy` now recovers the address lane from a supported resolved intent even when the initial L0 gate stays negative, so the system no longer loses a real business contour just because the low-level shape classifier stayed `unsupported`; + - targeted counterparty UTF-8 and route-policy regressions now explicitly protect this seam, including the exact late-tail wording `Как ты оценишь деятельность компании?`; + - live replay `address_truth_harness_phase14_counterparty_tail_resume_live_20260418_rerun2` is accepted `10/10`, which is the critical proof that replay breadth is now broader than the original flagship chain and that late-session organization analytics no longer depend on ambient chat luck. + +- the next transition-authority pass now closes a subtler root-scoped carryover seam inside the shared follow-up path: + - `assistantService.buildAddressFollowupOffer(...)` now reads follow-up anchor metadata through the shared continuity helper instead of reconstructing it from yet another local `addressDebug` parser; + - `assistantTransitionPolicy` no longer promotes assistant-side `organization authority` into `previous_anchor_type=organization` when a root-scoped inventory pivot intentionally sanitizes the selected-item carryover and keeps only the restored root frame; + - 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. ## Next Execution Slice (2026-04-18) diff --git a/docs/orchestration/address_truth_harness_phase14_counterparty_tail_resume.json b/docs/orchestration/address_truth_harness_phase14_counterparty_tail_resume.json new file mode 100644 index 0000000..e25f24a --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase14_counterparty_tail_resume.json @@ -0,0 +1,250 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase14_counterparty_tail_resume", + "domain": "address_phase14_counterparty_tail_resume", + "title": "Phase 14 counterparty-tail replay for late-session authority breadth", + "description": "Alternative AGENT replay built from a different saved-session tail. The scenario validates explicit organization selection, exact counterparty documents, a short-name counterparty follow-up, inventory and account-60 pivots without repeated company loss, old-purchase inventory aging, historical inventory on May 2020, and organization activity analytics at the tail of the same live session.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_data_scope_meta", + "title": "Session starts with a human company-scope answer", + "question": "по какой компании мы сейчас работаем?", + "required_answer_patterns_all": [ + "(?i)компан|организац|контур", + "(?i)альтернатива плюс|лайсвуд|райм" + ], + "forbidden_answer_patterns": [ + "(?i)mcp", + "(?i)read_only", + "(?i)snapshot_items", + "(?i)tool_gate_reason", + "(?i)living_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "data_scope_meta", + "multi_company_entry" + ] + }, + { + "step_id": "step_02_choose_organization_after_clarification", + "title": "Explicit organization selection fixes the contour for the tail session", + "question": "Альтернатива Плюс", + "required_answer_patterns_all": [ + "(?i)зафиксир|работаем по|рабочую организац", + "(?i)Альтернатива Плюс" + ], + "forbidden_answer_patterns": [ + "(?i)mcp", + "(?i)read_only", + "(?i)не могу определить" + ], + "criticality": "critical", + "semantic_tags": [ + "organization_authority", + "company_selected" + ] + }, + { + "step_id": "step_03_counterparty_docs_after_choice", + "title": "Counterparty-documents root becomes exact after company choice", + "question": "по чепурнову покажи все доки", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "list_documents_by_counterparty" + ], + "expected_recipe": "address_documents_by_counterparty_v1", + "required_direct_answer_patterns_any": [ + "(?i)чепурнов", + "(?i)документ|поступление" + ], + "criticality": "critical", + "semantic_tags": [ + "counterparty_root", + "company_selected" + ] + }, + { + "step_id": "step_04_short_counterparty_followup", + "title": "Short-name counterparty follow-up keeps the correct target and name", + "question": "а по свк", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "expected_intents": [ + "list_documents_by_counterparty" + ], + "required_direct_answer_patterns_any": [ + "(?i)свк|группа свк", + "(?i)документ|поступление" + ], + "forbidden_direct_answer_patterns": [ + "(?i)уточните организац", + "(?i)контрагент: группа\\s+найдено", + "(?i)mcp" + ], + "criticality": "important", + "semantic_tags": [ + "counterparty_followup", + "display_label_integrity" + ] + }, + { + "step_id": "step_05_inventory_today_after_counterparty_tail", + "title": "Inventory pivot after counterparty docs keeps the selected organization", + "question": "а сейчас у нас есть что на складе?", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "2026-04-18", + "organization": "ООО Альтернатива Плюс" + }, + "required_direct_answer_patterns_any": [ + "(?i)на складе|остат", + "18\\.04\\.2026" + ], + "forbidden_direct_answer_patterns": [ + "(?i)уточните организац", + "(?i)по какой компании" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "cross_domain_pivot" + ] + }, + { + "step_id": "step_06_open_items_account_60_august_2022", + "title": "Account 60 tails stay exact after the counterparty-to-inventory pivot", + "question": "хвосты покажи по счету 60 на август 2022", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "expected_intents": [ + "open_items_by_counterparty_or_contract" + ], + "required_filters": { + "account": "60", + "period_from": "2022-08-01", + "period_to": "2022-08-31", + "as_of_date": "2022-08-31" + }, + "required_direct_answer_patterns_any": [ + "(?i)счету 60|счёту 60", + "(?i)хвост|открыт|не найдено" + ], + "criticality": "critical", + "semantic_tags": [ + "settlements_account_60", + "late_session_tail" + ] + }, + { + "step_id": "step_07_inventory_aging_old_purchases", + "title": "Old-purchase inventory aging remains reachable late in the same session", + "question": "Есть ли остатки товара, которые закупались очень давно", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "expected_intents": [ + "inventory_aging_by_purchase_date" + ], + "required_direct_answer_patterns_any": [ + "(?i)стар|давно|ранней первой закупк", + "(?i)остат|закуп" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_aging", + "late_session_stability" + ] + }, + { + "step_id": "step_08_inventory_may_2020_after_aging", + "title": "Historical inventory root restores a concrete month after the aging branch", + "question": "Какие конкретно номенклатуры формируют остаток по складу на май 2020", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "2020-05-31", + "period_from": "2020-05-01", + "period_to": "2020-05-31", + "organization": "ООО Альтернатива Плюс" + }, + "required_direct_answer_patterns_any": [ + "31\\.05\\.2020", + "(?i)позици|остат" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "historical_restore" + ] + }, + { + "step_id": "step_09_company_activity_age_tail", + "title": "Organization activity age still works at the tail of the mixed session", + "question": "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_intents": [ + "counterparty_activity_lifecycle" + ], + "expected_recipe": "address_counterparty_activity_lifecycle_v1", + "required_direct_answer_patterns_any": [ + "(?i)активност", + "(?i)первая подтвержденная|последняя подтвержденная|лет" + ], + "forbidden_direct_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните точное наименование организации" + ], + "criticality": "critical", + "semantic_tags": [ + "organization_activity_age", + "tail_authority_proof" + ] + }, + { + "step_id": "step_10_company_activity_assessment_tail", + "title": "Tail activity assessment stays business-first instead of collapsing to garbage", + "question": "Как ты оценишь деятельность компании?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "required_direct_answer_patterns_any": [ + "(?i)коротко|активн", + "(?i)операц|заказчик|активност|последняя активность" + ], + "forbidden_direct_answer_patterns": [ + "(?i)mcp", + "(?i)read_only", + "(?i)tool_gate_reason" + ], + "criticality": "important", + "semantic_tags": [ + "activity_assessment", + "tail_quality" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js b/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js index 63c05c4..aa2d790 100644 --- a/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js @@ -65,8 +65,10 @@ function hasUnicodeCounterpartyActivityLifecycleSignal(text) { if (!normalized) { return false; } + const hasActivityAssessmentCue = /(?:\u043a\u0430\u043a\s+[\p{L}\d_-]+\s+\u043e\u0446\u0435\u043d(?:\u0438\u0448\u044c|\u0438\u0442\u044c|\u0438\u0432\u0430\u0435\u0448\u044c)|\u043e\u0446\u0435\u043d(?:\u0438\u0442\u044c|\u043a\u0430)|\u043e\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0437(?:\u0443\u0435\u0448\u044c|\u043e\u0432\u0430\u0442\u044c)|\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\u043e\s+\u0441\u043a\u0430\u0437\u0430\u0442\u044c\s+\u043e)/iu.test(normalized) && + /(?:\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442|\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442|\u0440\u0430\u0431\u043e\u0442)/iu.test(normalized); const hasActivityAgeCue = /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0432\s+\u0431\u0430\u0437\u0435|\u0432\u043e\u0437\u0440\u0430\u0441\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u043f\u0435\u0440\u0432(?:\u0430\u044f|\u044b\u0439|\u043e\u0435)\s+(?:\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u043f\u043b\u0430\u0442\u0435\u0436|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0435|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442)|\u043f\u043e\u0441\u043b\u0435\u0434\u043d(?:\u044f\u044f|\u0438\u0439|\u0435\u0435)\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u0441\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u0433\u043e\u0434\u0430\s+\u0430\u043a\u0442\u0438\u0432)/iu.test(normalized); - if (!hasActivityAgeCue) { + if (!hasActivityAgeCue && !hasActivityAssessmentCue) { return false; } const hasOneCLexeme = /(?:\u0432\s+\u0431\u0430\u0437\u0435\s+1[\u0441c]|\u0432\s+1[\u0441c]\s+\u0431\u0430\u0437\u0435|\u0438\u0437\s+1[\u0441c])/iu.test(normalized); diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 1712c8d..9516370 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -721,6 +721,20 @@ function createAssistantRoutePolicy(deps) { let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); + const semanticAddressLaneRecovery = Boolean(!runAddressLane && + supportedAddressRouteCandidateDetected && + !deepAnalysisPreferenceDetected && + !unsupportedAddressIntentFallbackToDeep && + !deepAnalysisSignalFallbackToDeep && + !aggregateAnalyticsFallbackToDeep && + !deepSessionContinuationFallbackToDeep); + if (semanticAddressLaneRecovery) { + runAddressLane = true; + toolGateDecision = "run_address_lane"; + toolGateReason = resolvedIntentResolution.intent !== "unknown" || llmContractIntent + ? "address_intent_resolver_detected" + : "address_signal_detected"; + } if (unsupportedAddressIntentFallbackToDeep) { runAddressLane = false; toolGateDecision = "skip_address_lane"; diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 0d959fa..bc8d0fc 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -73,6 +73,7 @@ const assistantBoundaryPolicy_1 = __importStar(require("./assistantBoundaryPolic const assistantLivingModePolicy_1 = __importStar(require("./assistantLivingModePolicy")); const assistantMetaFollowupPolicy_1 = __importStar(require("./assistantMetaFollowupPolicy")); const assistantMemoryRecapPolicy_1 = __importStar(require("./assistantMemoryRecapPolicy")); +const assistantContinuityPolicy_1 = __importStar(require("./assistantContinuityPolicy")); const assistantProviderExecutionPolicy_1 = __importStar(require("./assistantProviderExecutionPolicy")); const assistantRoutePolicy_1 = __importStar(require("./assistantRoutePolicy")); const assistantTransitionPolicy_1 = __importStar(require("./assistantTransitionPolicy")); @@ -2512,18 +2513,12 @@ function buildAddressFollowupOffer(addressDebug) { if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) { return null; } - const anchorType = toNonEmptyString(addressDebug.anchor_type); - const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ?? - toNonEmptyString(addressDebug.anchor_value_raw) ?? - readAddressInventoryItemFilter(addressDebug) ?? - readAddressFilterString(addressDebug, "counterparty") ?? - readAddressFilterString(addressDebug, "contract") ?? - readAddressFilterString(addressDebug, "account"); + const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); return { enabled: true, source_intent: intent, - anchor_type: anchorType ?? "unknown", - anchor_value: anchorValue, + anchor_type: anchorContext.anchorType ?? "unknown", + anchor_value: anchorContext.anchorValue, suggested_intents: suggestedIntents }; } diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index fea0756..0caaf8e 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -344,10 +344,11 @@ function createAssistantTransitionPolicy(deps) { const organizationClarificationCandidates = Array.isArray(organizationAuthority.organizationClarificationCandidates) ? organizationAuthority.organizationClarificationCandidates : []; - const organizationClarificationSelection = deps.resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? + const explicitOrganizationClarificationSelection = deps.resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? (deps.toNonEmptyString(alternateMessage) ? deps.resolveOrganizationSelectionFromMessage(String(alternateMessage ?? ""), organizationClarificationCandidates) - : null) ?? + : null); + const organizationClarificationSelection = explicitOrganizationClarificationSelection ?? deps.normalizeOrganizationScopeValue(organizationAuthority.organizationClarificationSelectionFromScope); const hasOrganizationClarificationContinuation = Boolean(lastOrganizationClarificationDebug && organizationClarificationSelection); const followupOffer = previousAddressDebug ? deps.buildAddressFollowupOffer(previousAddressDebug) : null; @@ -798,9 +799,9 @@ function createAssistantTransitionPolicy(deps) { previousAnchor = selectedObjectLabel; } } - if (organizationClarificationSelection && !previousAnchor) { + if (explicitOrganizationClarificationSelection && !previousAnchor) { previousAnchorType = "organization"; - previousAnchor = organizationClarificationSelection; + previousAnchor = explicitOrganizationClarificationSelection; } if (inventoryRootFrame && organizationClarificationSelection && diff --git a/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts b/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts index f3fe061..e0a2c7e 100644 --- a/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts @@ -114,11 +114,19 @@ function hasUnicodeCounterpartyActivityLifecycleSignal(text: string): boolean { return false; } + const hasActivityAssessmentCue = + /(?:\u043a\u0430\u043a\s+[\p{L}\d_-]+\s+\u043e\u0446\u0435\u043d(?:\u0438\u0448\u044c|\u0438\u0442\u044c|\u0438\u0432\u0430\u0435\u0448\u044c)|\u043e\u0446\u0435\u043d(?:\u0438\u0442\u044c|\u043a\u0430)|\u043e\u0445\u0430\u0440\u0430\u043a\u0442\u0435\u0440\u0438\u0437(?:\u0443\u0435\u0448\u044c|\u043e\u0432\u0430\u0442\u044c)|\u0447\u0442\u043e\s+\u043c\u043e\u0436\u043d\u043e\s+\u0441\u043a\u0430\u0437\u0430\u0442\u044c\s+\u043e)/iu.test( + normalized + ) && + /(?:\u0434\u0435\u044f\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442|\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442|\u0440\u0430\u0431\u043e\u0442)/iu.test( + normalized + ); + const hasActivityAgeCue = /(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u0441\u043a\u043e\u043b\u044c\u043a\u043e\s+\u043b\u0435\u0442\s+\u0432\s+\u0431\u0430\u0437\u0435|\u0432\u043e\u0437\u0440\u0430\u0441\u0442\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u0438|\u043f\u0435\u0440\u0432(?:\u0430\u044f|\u044b\u0439|\u043e\u0435)\s+(?:\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u043f\u043b\u0430\u0442\u0435\u0436|\u043f\u043e\u0441\u0442\u0443\u043f\u043b\u0435\u043d\u0438\u0435|\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442)|\u043f\u043e\u0441\u043b\u0435\u0434\u043d(?:\u044f\u044f|\u0438\u0439|\u0435\u0435)\s+\u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0441\u0442\u044c|\u0441\s+\u043a\u0430\u043a\u043e\u0433\u043e\s+\u0433\u043e\u0434\u0430\s+\u0430\u043a\u0442\u0438\u0432)/iu.test( normalized ); - if (!hasActivityAgeCue) { + if (!hasActivityAgeCue && !hasActivityAssessmentCue) { return false; } diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 6154db3..5daacef 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -760,6 +760,20 @@ export function createAssistantRoutePolicy(deps) { let runAddressLane = Boolean(baseToolGate?.runAddressLane); let toolGateDecision = String(baseToolGate?.decision ?? "skip_address_lane"); let toolGateReason = String(baseToolGate?.reason ?? "no_address_signal_after_l0"); + const semanticAddressLaneRecovery = Boolean(!runAddressLane && + supportedAddressRouteCandidateDetected && + !deepAnalysisPreferenceDetected && + !unsupportedAddressIntentFallbackToDeep && + !deepAnalysisSignalFallbackToDeep && + !aggregateAnalyticsFallbackToDeep && + !deepSessionContinuationFallbackToDeep); + if (semanticAddressLaneRecovery) { + runAddressLane = true; + toolGateDecision = "run_address_lane"; + toolGateReason = resolvedIntentResolution.intent !== "unknown" || llmContractIntent + ? "address_intent_resolver_detected" + : "address_signal_detected"; + } if (unsupportedAddressIntentFallbackToDeep) { runAddressLane = false; toolGateDecision = "skip_address_lane"; diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 676032b..c2e4134 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -26,6 +26,7 @@ import * as assistantBoundaryPolicy_1 from "./assistantBoundaryPolicy"; import * as assistantLivingModePolicy_1 from "./assistantLivingModePolicy"; import * as assistantMetaFollowupPolicy_1 from "./assistantMetaFollowupPolicy"; import * as assistantMemoryRecapPolicy_1 from "./assistantMemoryRecapPolicy"; +import * as assistantContinuityPolicy_1 from "./assistantContinuityPolicy"; import * as assistantProviderExecutionPolicy_1 from "./assistantProviderExecutionPolicy"; import * as assistantRoutePolicy_1 from "./assistantRoutePolicy"; import * as assistantTransitionPolicy_1 from "./assistantTransitionPolicy"; @@ -2467,18 +2468,12 @@ function buildAddressFollowupOffer(addressDebug) { if (!Array.isArray(suggestedIntents) || suggestedIntents.length === 0) { return null; } - const anchorType = toNonEmptyString(addressDebug.anchor_type); - const anchorValue = toNonEmptyString(addressDebug.anchor_value_resolved) ?? - toNonEmptyString(addressDebug.anchor_value_raw) ?? - readAddressInventoryItemFilter(addressDebug) ?? - readAddressFilterString(addressDebug, "counterparty") ?? - readAddressFilterString(addressDebug, "contract") ?? - readAddressFilterString(addressDebug, "account"); + const anchorContext = (0, assistantContinuityPolicy_1.resolveAddressDebugAnchorContext)(addressDebug, toNonEmptyString); return { enabled: true, source_intent: intent, - anchor_type: anchorType ?? "unknown", - anchor_value: anchorValue, + anchor_type: anchorContext.anchorType ?? "unknown", + anchor_value: anchorContext.anchorValue, suggested_intents: suggestedIntents }; } diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 0f80934..2ca0cbe 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -431,11 +431,13 @@ export function createAssistantTransitionPolicy(deps) { const organizationClarificationCandidates = Array.isArray(organizationAuthority.organizationClarificationCandidates) ? organizationAuthority.organizationClarificationCandidates : []; - const organizationClarificationSelection = + const explicitOrganizationClarificationSelection = deps.resolveOrganizationSelectionFromMessage(userMessage, organizationClarificationCandidates) ?? (deps.toNonEmptyString(alternateMessage) ? deps.resolveOrganizationSelectionFromMessage(String(alternateMessage ?? ""), organizationClarificationCandidates) - : null) ?? + : null); + const organizationClarificationSelection = + explicitOrganizationClarificationSelection ?? deps.normalizeOrganizationScopeValue(organizationAuthority.organizationClarificationSelectionFromScope); const hasOrganizationClarificationContinuation = Boolean( lastOrganizationClarificationDebug && organizationClarificationSelection @@ -979,9 +981,9 @@ export function createAssistantTransitionPolicy(deps) { previousAnchor = selectedObjectLabel; } } - if (organizationClarificationSelection && !previousAnchor) { + if (explicitOrganizationClarificationSelection && !previousAnchor) { previousAnchorType = "organization"; - previousAnchor = organizationClarificationSelection; + previousAnchor = explicitOrganizationClarificationSelection; } if ( inventoryRootFrame && diff --git a/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts b/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts index 2916bda..75674fd 100644 --- a/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts +++ b/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts @@ -66,6 +66,18 @@ describe("address counterparty utf8 regression", () => { it("keeps the main resolver in the supported contour for direct company activity-age wording", () => { const result = resolveAddressIntent("а по Альтернативе Плюс сколько лет активности в базе 1С?"); + expect(result.intent).toBe("counterparty_activity_lifecycle"); + }); + it("classifies company activity assessment wording into lifecycle intent", () => { + const result = resolveCounterpartyAddressIntent("Как ты оценишь деятельность компании?", utf8Deps); + + expect(result?.intent).toBe("counterparty_activity_lifecycle"); + expect(result?.reasons).toContain("counterparty_activity_lifecycle_signal_detected"); + }); + + it("keeps the main resolver in the supported contour for company activity assessment wording", () => { + const result = resolveAddressIntent("Как ты оценишь деятельность компании?"); + expect(result.intent).toBe("counterparty_activity_lifecycle"); }); }); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index cfab998..b2135a6 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -406,4 +406,69 @@ describe("assistantRoutePolicy", () => { expect(decision.orchestrationContract?.hard_meta_mode).toBeNull(); expect(decision.orchestrationContract?.followup_context_detected).toBe(false); }); + + it("keeps company activity assessment follow-up in address lane when lifecycle intent is resolved from grounded continuity", () => { + const policy = buildPolicy({ + resolveAddressIntent: () => ({ intent: "counterparty_activity_lifecycle", confidence: "high" }), + findLastGroundedAddressAnswerDebug: () => ({ + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + detected_intent: "counterparty_activity_lifecycle", + extracted_filters: { + organization: 'ООО "Альтернатива Плюс"', + period_to: "2026-04-18" + } + }), + resolveAddressToolGateDecision: () => ({ + runAddressLane: false, + decision: "skip_address_lane", + reason: "no_address_signal_after_l0" + }) + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: "Как ты оценишь деятельность компании?", + effectiveAddressUserMessage: "Как ты оценишь деятельность компании?", + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + predecomposeContract: { + mode: "unsupported", + mode_confidence: "low", + intent: "unknown", + intent_confidence: "low" + }, + semanticExtractionContract: { + valid: true, + apply_canonical_recommended: true, + extraction: { + query_shape: "UNKNOWN", + aggregation_profile: "unknown" + }, + guard_hints: { + deep_investigation_signal_detected: false + } + } + }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + detected_intent: "counterparty_activity_lifecycle", + extracted_filters: { + organization: 'ООО "Альтернатива Плюс"', + period_to: "2026-04-18" + } + } + } + ], + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(decision.livingMode).toBe("address_data"); + }); });