From af15e21bf616d38cea660c0402e46509943ae04d Mon Sep 17 00:00:00 2001 From: dctouch Date: Sun, 19 Apr 2026 15:31:50 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0:=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?organization=20authority=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20lat?= =?UTF-8?q?e=20company=20switch=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8C=20phase16=20multi-company=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ontinuity_stabilization_plan_2026-04-17.md | 7 + ...rness_phase16_multicompany_late_pivot.json | 246 ++++++++++++++++++ .../dist/services/addressFilterExtractor.js | 8 + .../dist/services/addressQueryService.js | 56 ++++ .../address_runtime/decomposeStage.js | 22 +- .../services/assistantContinuityPolicy.js | 18 +- ...ssistantOrganizationScopeRuntimeAdapter.js | 10 +- .../dist/services/assistantRoutePolicy.js | 40 ++- .../assistantRuntimeContractRegistry.js | 123 ++++++++- .../backend/dist/services/assistantService.js | 1 + .../src/services/addressFilterExtractor.ts | 9 + .../src/services/addressQueryService.ts | 65 +++++ .../address_runtime/decomposeStage.ts | 27 +- .../src/services/assistantContinuityPolicy.ts | 18 +- ...ssistantOrganizationScopeRuntimeAdapter.ts | 12 +- .../src/services/assistantRoutePolicy.ts | 39 +++ .../assistantRuntimeContractRegistry.ts | 135 +++++++++- .../backend/src/services/assistantService.ts | 1 + .../addressFollowupTemporalRegression.test.ts | 27 ++ .../addressInventoryWarehouseAnchor.test.ts | 6 + ...ddressReferentialOrganizationScope.test.ts | 70 +++++ .../tests/assistantContinuityPolicy.test.ts | 22 ++ ...antOrganizationScopeRuntimeAdapter.test.ts | 57 +++- .../tests/assistantRoutePolicy.test.ts | 54 ++++ .../assistantRuntimeContractRegistry.test.ts | 26 +- 25 files changed, 1063 insertions(+), 36 deletions(-) create mode 100644 docs/orchestration/address_truth_harness_phase16_multicompany_late_pivot.json create mode 100644 llm_normalizer/backend/tests/addressReferentialOrganizationScope.test.ts 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 14d04c7..993fc60 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 @@ -499,6 +499,13 @@ Still open after the accepted phase12 replay: - keeping that contract in one helper reduces another major chance that future domain expansion introduces contradictory exact-route vs deep-fallback precedence in nearby branches; - targeted route/continuity/transition suites remain green after the move, including direct regression coverage that an exact VAT route must stay in the address lane even when only a semantic deep hint is present; - a fresh live rerun of `address_truth_harness_phase12_wider_saved_session_pool` on `2026-04-19` remains accepted `20/20`, which is the proof that the flagship mixed contour still survives after extracting the route-protection seam. +- the next replay-breadth and continuity-authority pass now closes a non-flagship late-switch seam that was still dangerous before multi-domain expansion: + - `assistantOrganizationScopeRuntimeAdapter` now prefers continuity-backed selected/active organization over stale address navigation scope after a late chat-side company switch, so a fresh company fixation in living-chat can no longer be silently overwritten by the previous address snapshot on the very next exact-data turn; + - `mergeFollowupContextWithOrganizationScopeRuntime(...)` now treats active session organization as the stronger authority over stale `previous_filters.organization` / `root_filters.organization`, which closes the last hot-path drift where follow-up carryover could rehydrate the old company even after the user had already switched contours; + - this matters because the system previously looked “almost fixed” on flagship chains while still failing a real multi-company late-pivot path: `Alternative Plus -> switch to RAYM in chat -> referential inventory/receivables follow-up -> switch back`; + - targeted `assistantOrganizationScopeRuntimeAdapter`, `assistantContinuityPolicy`, `assistantRoutePolicy`, and referential-organization regressions are green after the owner-precedence fix, and backend build stays green; + - the phase16 live replay `address_truth_harness_phase16_multicompany_late_pivot` is now accepted on `2026-04-19`, which is the first explicit proof that a non-flagship multi-company late switch keeps truthful company authority across both inventory and receivables exact routes in the same saved session; + - the same phase16 pass also hardened replay-gate honesty: its receivables step now accepts semantically equivalent honest empty-match phrasing (`31.03.2020` or `31 марта 2020`, `долг` / `долж`) instead of overfitting to one single first-line wording, so this pack is now a trustworthy breadth gate rather than a fragile phrasing oracle. ## Next Execution Slice (2026-04-18) diff --git a/docs/orchestration/address_truth_harness_phase16_multicompany_late_pivot.json b/docs/orchestration/address_truth_harness_phase16_multicompany_late_pivot.json new file mode 100644 index 0000000..f2273cf --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase16_multicompany_late_pivot.json @@ -0,0 +1,246 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase16_multicompany_late_pivot", + "domain": "address_phase16_multicompany_late_pivot", + "title": "Phase 16 multi-company late-pivot replay for breadth beyond the flagship path", + "description": "Alternative AGENT replay focused on a non-flagship saved-session contour: proactive scope offer, explicit company selection, late company switch, human capability-meta interrupt, return to exact business questions, same-date carryover inside the second company, and a final switch back to the first company. The scenario validates that organization authority survives real session pivots instead of being proven only on one flagship trajectory.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_smalltalk_scope_offer", + "title": "Smalltalk entry proactively offers organization scope", + "question": "приветик, как дела?", + "required_answer_patterns_all": [ + "(?i)привет|норм|дела", + "(?i)организац|компан|альтернатива плюс|лайсвуд|райм" + ], + "forbidden_answer_patterns": [ + "(?i)mcp", + "(?i)tool_gate_reason", + "(?i)living_reason", + "(?i)snapshot_items" + ], + "criticality": "critical", + "semantic_tags": [ + "smalltalk_entry", + "scope_offer" + ] + }, + { + "step_id": "step_02_choose_alternative_plus", + "title": "Explicitly select Alternative Plus as the first active organization", + "question": "Альтернатива Плюс", + "required_answer_patterns_all": [ + "(?i)зафиксир|рабочую организац|работаем по", + "(?i)альтернатива плюс" + ], + "criticality": "critical", + "semantic_tags": [ + "organization_authority", + "company_selected" + ] + }, + { + "step_id": "step_03_inventory_today_alt", + "title": "Inventory root uses the first selected organization", + "question": "что у нас сейчас на складе по остаткам?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "{{runtime.today_iso}}", + "organization": "ООО Альтернатива Плюс" + }, + "required_direct_answer_patterns_any": [ + "(?i)остат|на складе", + "{{runtime.today_dot_regex}}" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "company_authority" + ] + }, + { + "step_id": "step_04_switch_to_raym", + "title": "Late company switch changes the active organization instead of asking again from scratch", + "question": "теперь давай по РАЙМ", + "required_answer_patterns_all": [ + "(?i)зафиксир|переключ|работаем по|активн", + "(?i)райм" + ], + "forbidden_answer_patterns": [ + "(?i)mcp", + "(?i)read_only", + "(?i)не могу определить" + ], + "criticality": "critical", + "semantic_tags": [ + "late_company_switch", + "organization_authority" + ] + }, + { + "step_id": "step_05_inventory_today_raym", + "title": "Inventory root after the late switch uses RAYM instead of the old company", + "question": "а по этой компании что сейчас на складе?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "{{runtime.today_iso}}", + "organization": "РАЙМ" + }, + "required_direct_answer_patterns_any": [ + "(?i)остат|на складе", + "{{runtime.today_dot_regex}}" + ], + "forbidden_direct_answer_patterns": [ + "(?i)уточните организацию", + "(?i)по какой компании" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "late_company_switch" + ] + }, + { + "step_id": "step_06_capability_meta_after_switch", + "title": "Capability-meta interrupt stays human and does not tear down the second company context", + "question": "а что ты вообще умеешь?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "required_answer_patterns_any": [ + "(?i)могу|умею", + "(?i)ндс|документ|контрагент|склад|остат|долг" + ], + "forbidden_answer_patterns": [ + "(?i)mcp", + "(?i)read_only", + "(?i)snapshot", + "(?i)assistant_state", + "(?i)tool_gate_reason" + ], + "criticality": "important", + "semantic_tags": [ + "capability_meta", + "meta_interrupt" + ] + }, + { + "step_id": "step_07_receivables_march_2020_raym", + "title": "Return to business contour after the meta interrupt keeps RAYM as the active company", + "question": "а по этой компании кто нам должен на март 2020?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "receivables_confirmed_as_of_date" + ], + "required_filters": { + "as_of_date": "2020-03-31", + "period_from": "2020-03-01", + "period_to": "2020-03-31", + "organization": "РАЙМ" + }, + "required_direct_answer_patterns_any": [ + "(?i)долг|долж", + "(?:31\\.03\\.2020|31\\s+марта\\s+2020)" + ], + "forbidden_direct_answer_patterns": [ + "(?i)уточните организацию", + "(?i)по какой компании" + ], + "criticality": "critical", + "semantic_tags": [ + "receivables_root", + "meta_return_to_business" + ] + }, + { + "step_id": "step_08_same_date_inventory_raym", + "title": "Same-date inventory follow-up keeps the second company and the restored date", + "question": "а по этой же дате остатки на складе?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "2020-03-31", + "period_from": "2020-03-01", + "period_to": "2020-03-31", + "organization": "РАЙМ" + }, + "required_direct_answer_patterns_any": [ + "(?i)остат|на складе", + "31\\.03\\.2020" + ], + "forbidden_direct_answer_patterns": [ + "(?i)уточните организацию", + "(?i)по какой компании" + ], + "criticality": "critical", + "semantic_tags": [ + "same_date_pivot", + "inventory_root", + "second_company_continuity" + ] + }, + { + "step_id": "step_09_switch_back_to_alt", + "title": "Switch back to Alternative Plus late in the same session", + "question": "ок, верни обратно Альтернативу Плюс", + "required_answer_patterns_all": [ + "(?i)зафиксир|переключ|работаем по|активн", + "(?i)альтернатива плюс" + ], + "criticality": "important", + "semantic_tags": [ + "late_company_switch_back", + "organization_authority" + ] + }, + { + "step_id": "step_10_activity_age_after_switch_back", + "title": "Activity-age analytics still work after two company pivots in one session", + "question": "а по Альтернативе Плюс сколько лет активности в базе 1С?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_intents": [ + "counterparty_activity_lifecycle" + ], + "required_direct_answer_patterns_any": [ + "(?i)активност", + "(?i)первая подтвержденная|последняя подтвержденная|лет" + ], + "forbidden_direct_answer_patterns": [ + "(?i)не найден контрагент", + "(?i)уточните организацию", + "(?i)по какой компании" + ], + "criticality": "critical", + "semantic_tags": [ + "activity_age", + "switch_back_integrity" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 3dc4d40..ccc7d06 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -1200,6 +1200,13 @@ function isImplicitSelfScopeWarehouseAnchor(candidate) { function hasSelectedObjectScopeSignal(text) { return /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(String(text ?? "")); } +function isReferentialSameDateWarehousePhrase(candidate) { + const normalized = cleanupAnchorValue(candidate) + .toLowerCase() + .replace(/ё/g, "е") + .trim(); + return /^(?:по)\s+(?:этой|той)(?:\s+же)?\s+дат(?:е|у|ой)$/iu.test(normalized); +} function extractInventoryWarehouseAnchor(text) { const patterns = [ /(?:на|по)\s+склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu, @@ -1216,6 +1223,7 @@ function extractInventoryWarehouseAnchor(text) { candidate.includes("->") || candidate.includes("=>") || isImplicitSelfScopeWarehouseAnchor(candidate) || + isReferentialSameDateWarehousePhrase(candidate) || isLowQualityWarehouseAnchorValue(candidate) || normalizedCandidate.startsWith("по состоянию") || isTemporalWarehousePhrase(candidate) || diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 28428a9..e3c6348 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1485,6 +1485,32 @@ function stripOrganizationLegalForm(value) { function sameOrganizationEntityReference(left, right) { return (0, assistantOrganizationMatcher_1.organizationsLikelySameEntity)(left, right); } +function isReferentialOrganizationScopeValue(value) { + const normalized = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(value ?? ""); + if (!normalized) { + return false; + } + return /^(?:эта|этой|эту|этой же|эта же|данная|данной|данную)\s+(?:компания|организация|фирма|контора)$/iu.test(normalized) || + /^(?:по|у|для)\s+(?:этой|этой же|данной)\s+(?:компании|организации|фирме|конторе)$/iu.test(normalized) || + /^(?:по|у|для)\s+ней$/iu.test(normalized); +} +function hasReferentialOrganizationScopeSignal(userMessage) { + const normalized = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeSearchText)(userMessage ?? ""); + if (!normalized) { + return false; + } + return /(?:^| )(?:по|у|для)\s+(?:этой|этой же|данной)\s+(?:компании|организации|фирме|конторе)(?: |$)/iu.test(normalized) || + /(?:^| )(?:по|у|для)\s+ней(?: |$)/iu.test(normalized) || + /(?:^| )(?:эта|этой|эту|эта же|данная|данной)\s+(?:компания|организация|фирма|контора)(?: |$)/iu.test(normalized); +} +function isQuestionFragmentPartyAnchor(value) { + const normalized = normalizeSearchText(String(value ?? "")); + if (!normalized) { + return false; + } + return /(?:^| )(?:что|кто|какие|какой|сколько|сейчас)(?: |$)/iu.test(normalized) || + /(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/iu.test(normalized); +} function applyPreExecutionOrganizationScopeGrounding(input) { const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(input.activeOrganization ?? null); const candidateOrganizations = (0, assistantOrganizationMatcher_1.mergeKnownOrganizations)([ @@ -1492,6 +1518,7 @@ function applyPreExecutionOrganizationScopeGrounding(input) { activeOrganization ]); const resolvedOrganizationFromMessage = (0, assistantOrganizationMatcher_1.resolveOrganizationSelectionFromMessage)(input.userMessage, candidateOrganizations); + const referentialOrganizationScopeDetected = hasReferentialOrganizationScopeSignal(input.userMessage); if (!input.filters.organization && input.semanticFrame?.scope_kind === "implicit_self_scope" && activeOrganization) { @@ -1517,6 +1544,35 @@ function applyPreExecutionOrganizationScopeGrounding(input) { input.semanticFrame.anchor_value = resolvedOrganizationFromMessage; } } + if (activeOrganization && + ((typeof input.filters.organization === "string" && + isReferentialOrganizationScopeValue(input.filters.organization)) || + referentialOrganizationScopeDetected) && + !sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization)) { + input.filters.organization = activeOrganization; + if (!input.warnings.includes("organization_grounded_from_referential_scope")) { + input.warnings.push("organization_grounded_from_referential_scope"); + } + if (!input.baseReasons.includes("organization_grounded_from_referential_scope")) { + input.baseReasons.push("organization_grounded_from_referential_scope"); + } + if (input.semanticFrame?.anchor_kind === "organization") { + input.semanticFrame.anchor_value = activeOrganization; + } + } + if (activeOrganization && + sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) && + typeof input.filters.counterparty === "string" && + (isLikelyLowQualityPartyAnchor(input.filters.counterparty) || + isQuestionFragmentPartyAnchor(input.filters.counterparty))) { + delete input.filters.counterparty; + if (!input.warnings.includes("counterparty_cleared_from_referential_organization_scope")) { + input.warnings.push("counterparty_cleared_from_referential_organization_scope"); + } + if (!input.baseReasons.includes("counterparty_cleared_from_referential_organization_scope")) { + input.baseReasons.push("counterparty_cleared_from_referential_organization_scope"); + } + } if (!input.filters.organization && !activeOrganization && !resolvedOrganizationFromMessage && candidateOrganizations.length === 1) { input.filters.organization = candidateOrganizations[0]; if (!input.warnings.includes("organization_auto_selected_from_single_scope_candidate")) { diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index d88c85b..02f36a0 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -34,6 +34,9 @@ function hasAllTimeHint(text) { function hasSameDateHint(text) { return /(?:на\s+ту\s+же\s+дат[ауеы]|на\s+эту\s+же\s+дат[ауеы]|на\s+эту\s+дат[ауеы]|эту\s+дат[ауеы]|та\s+же\s+дата|дат[ауеы],?\s+котор(?:ую|ая)\s+(?:до\s+этого|раньше|ранее)\s+(?:рассматривали|смотрели)|дат[ауеы],?\s+которая\s+был[ао]?\s+ранее\s+рассмотрен[ао]?|same\s+date|as\s+of\s+same\s+date|the\s+same\s+date|date\s+we\s+looked\s+at\s+before|previously\s+considered\s+date)/iu.test(String(text ?? "")); } +function hasSameDatePrepositionHint(text) { + return /(?:по\s+(?:этой|той)\s+же\s+дат(?:е|у|ой))/iu.test(String(text ?? "")); +} function hasSamePeriodHint(text) { return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|на\s+этот\s+период|за\s+тот\s+период|на\s+тот\s+период|этот\s+период|тот\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test(String(text ?? "")); } @@ -628,7 +631,7 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext); const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext); const allTimeRequested = hasAllTimeHint(userMessage); - const sameDateRequested = hasSameDateHint(userMessage); + const sameDateRequested = hasSameDateHint(userMessage) || hasSameDatePrepositionHint(userMessage); const samePeriodRequested = hasSamePeriodHint(userMessage); const explicitQuotedItem = extractSelectedObjectItemFromFollowupText(userMessage); if (!toNonEmptyString(merged.organization) && previousOrganization) { @@ -789,6 +792,23 @@ function mergeFollowupFilters(current, intent, userMessage, followupContext) { merged.as_of_date = inheritedAsOfDate; reasons.push("as_of_date_from_followup_context"); } + if ((intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + previousPeriodFrom && + merged.period_from !== previousPeriodFrom) { + merged.period_from = previousPeriodFrom; + reasons.push("period_from_from_followup_context"); + } + if ((intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + previousPeriodTo && + merged.period_to !== previousPeriodTo) { + merged.period_to = previousPeriodTo; + reasons.push("period_to_from_followup_context"); + } + const currentWarehouse = toNonEmptyString(merged.warehouse); + if (currentWarehouse && hasSameDatePrepositionHint(currentWarehouse)) { + delete merged.warehouse; + reasons.push("warehouse_cleared_from_same_date_followup_noise"); + } } if (samePeriodRequested && (intent === "vat_payable_confirmed_as_of_date" || diff --git a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js index aef9588..32d8cbe 100644 --- a/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantContinuityPolicy.js @@ -645,15 +645,15 @@ function resolveAssistantOrganizationAuthority(input) { assistantSignals.lastAssistantActiveOrganization ?? continuityActiveOrganization ?? (knownOrganizations.length === 1 ? knownOrganizations[0] : null); - const organizationClarificationCandidates = Array.isArray(input.lastOrganizationClarificationDebug?.organization_candidates) - ? mergeKnownOrganizations([ - ...input.lastOrganizationClarificationDebug.organization_candidates, - ...knownOrganizations, - selectedOrganization, - activeOrganization, - continuityActiveOrganization - ]) - : []; + const organizationClarificationCandidates = mergeKnownOrganizations([ + ...(Array.isArray(input.lastOrganizationClarificationDebug?.organization_candidates) + ? input.lastOrganizationClarificationDebug.organization_candidates + : []), + ...knownOrganizations, + selectedOrganization, + activeOrganization, + continuityActiveOrganization + ]); const organizationClarificationSelectionFromScope = selectedOrganization ?? activeOrganization; return { continuitySnapshot, diff --git a/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js b/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js index 6cf0f71..8c099c1 100644 --- a/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js +++ b/llm_normalizer/backend/dist/services/assistantOrganizationScopeRuntimeAdapter.js @@ -35,6 +35,7 @@ function resolveSessionOrganizationScopeContextRuntime(input) { const continuityKnownOrganizations = Array.isArray(continuityAuthority.knownOrganizations) ? continuityAuthority.knownOrganizations : []; + const continuitySelectedOrganization = input.normalizeOrganizationScopeValue(continuityAuthority.selectedOrganization); const continuityActiveOrganization = input.normalizeOrganizationScopeValue(continuityAuthority.activeOrganization); const knownOrganizations = Array.from(new Map([ ...continuityKnownOrganizations, @@ -44,8 +45,9 @@ function resolveSessionOrganizationScopeContextRuntime(input) { const selectedOrganization = input.resolveOrganizationSelectionFromMessage(input.userMessage, knownOrganizations); const navigationActiveOrganization = resolveActiveOrganizationFromNavigationState(input.addressNavigationState, input.normalizeOrganizationScopeValue); const activeOrganization = selectedOrganization ?? - navigationActiveOrganization ?? + continuitySelectedOrganization ?? continuityActiveOrganization ?? + navigationActiveOrganization ?? (knownOrganizations.length === 1 ? knownOrganizations[0] : null); return { knownOrganizations, @@ -64,7 +66,8 @@ function mergeFollowupContextWithOrganizationScopeRuntime(input) { const previousFilters = previousFiltersRaw && typeof previousFiltersRaw === "object" ? { ...previousFiltersRaw } : {}; - if (!input.toNonEmptyString(previousFilters.organization)) { + const previousOrganization = input.toNonEmptyString(previousFilters.organization); + if (!previousOrganization || previousOrganization !== normalizedOrganization) { previousFilters.organization = normalizedOrganization; } base.previous_filters = previousFilters; @@ -72,7 +75,8 @@ function mergeFollowupContextWithOrganizationScopeRuntime(input) { const rootFilters = rootFiltersRaw && typeof rootFiltersRaw === "object" ? { ...rootFiltersRaw } : {}; - if (!input.toNonEmptyString(rootFilters.organization)) { + const rootOrganization = input.toNonEmptyString(rootFilters.organization); + if (!rootOrganization || rootOrganization !== normalizedOrganization) { rootFilters.organization = normalizedOrganization; } if (Object.keys(rootFilters).length > 0) { diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 1ae3508..0cb4cb3 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -143,7 +143,7 @@ function resolveAddressLaneProtectionArbitration(input) { }; } function createAssistantRoutePolicy(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: resolveAddressToolGateDecisionOverride, hasAddressLlmPreDecomposeCandidate, 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: resolveAddressToolGateDecisionOverride, hasAddressLlmPreDecomposeCandidate, hasSameDateAccountFollowupSignalForPredecompose, hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, shouldEmitOrganizationSelectionReply, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, resolveProviderExecutionState } = deps; function resolveBaseAddressToolGateDecision(addressInputMessage, followupContext, llmPreDecomposeMeta = null, rawUserMessage = null) { const repairedInputMessage = repairAddressMojibake(String(addressInputMessage ?? "")); const rawMessageForGate = String(rawUserMessage ?? addressInputMessage ?? ""); @@ -447,6 +447,13 @@ function createAssistantRoutePolicy(deps) { !dataScopeMetaQuery && !capabilityMetaQuery && !dataRetrievalSignal); + const organizationScopeSwitchDetected = Boolean(organizationClarificationSelection && + !dataScopeMetaQuery && + !capabilityMetaQuery && + (shouldEmitOrganizationSelectionReply(rawUserMessage, organizationClarificationSelection) || + shouldEmitOrganizationSelectionReply(repairedRawUserMessage, organizationClarificationSelection) || + shouldEmitOrganizationSelectionReply(effectiveAddressUserMessage, organizationClarificationSelection) || + shouldEmitOrganizationSelectionReply(repairedEffectiveAddressUserMessage, organizationClarificationSelection))); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const baseToolGate = typeof resolveAddressToolGateDecisionOverride === "function" ? resolveAddressToolGateDecisionOverride(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage) @@ -653,6 +660,37 @@ function createAssistantRoutePolicy(deps) { } }; } + if (organizationScopeSwitchDetected) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "organization_scope_switch_detected", + livingMode: "chat", + livingReason: "organization_scope_switch_detected", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext || continuitySnapshot.hasGroundedAddressContext), + organization_scope_switch_detected: true, + organization_scope_selection: organizationClarificationSelection, + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "organization_scope_switch_detected", + living_mode: "chat", + living_reason: "organization_scope_switch_detected" + } + } + }; + } const supportedExactInvestigativeAddressBypass = Boolean(llmContractMode === "deep_analysis" && semanticApplyCanonicalRecommended && strictDeepInvestigationBypassAllowed && diff --git a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js index 75f459f..59dda4b 100644 --- a/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js +++ b/llm_normalizer/backend/dist/services/assistantRuntimeContractRegistry.js @@ -1,6 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.INVENTORY_CAPABILITY_CONTRACTS = exports.ASSISTANT_TRANSITION_CONTRACTS = void 0; +exports.ROOT_EXACT_CAPABILITY_CONTRACTS = exports.INVENTORY_CAPABILITY_CONTRACTS = exports.ASSISTANT_TRANSITION_CONTRACTS = void 0; exports.listAssistantTransitionContracts = listAssistantTransitionContracts; exports.getAssistantTransitionContract = getAssistantTransitionContract; exports.listInventoryCapabilityContracts = listInventoryCapabilityContracts; @@ -139,6 +139,60 @@ const INVENTORY_SELECTED_OBJECT_TESTS = [ "new_explicit_selected_object_overrides_old_focus", "full_anchor_not_degraded_by_canonical_rewrite" ]; +const SHARED_ROOT_EXACT_TESTS = [ + "root_context_survives_domain_pivot_without_object_leak", + "limited_mode_remains_truthful" +]; +function rootExactCapability(input) { + return { + schema_version: assistantRuntimeContracts_1.ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION, + capability_id: input.capability_id, + domain_id: input.domainId, + runtime_lane: "address_exact", + intent_ids: input.intent_ids, + entry_modes: ["root_entry", "root_followup", "clarification_resume"], + supported_transition_classes: input.transitions, + frame_compatibility: { + root_frame: "optional", + selected_object_frame: "optional", + meta_frame: "forbidden" + }, + required_anchors: input.requiredAnchors, + optional_anchors: input.optionalAnchors ?? ["organization", "date_scope", "account", "counterparty", "contract"], + anchor_source_priority: ["explicit_user_anchor", "root_frame", "semantic_hint"], + anchor_admissibility_rules: [ + "confirmed_root_scope_beats_semantic_hint", + "no_low_quality_counterparty_rewrite", + "no_conversational_noise_as_entity" + ], + organization_scope_behavior: "reuse_or_clarify", + date_scope_behavior: "reuse", + temporal_ceiling_policy: "must_not_expand_without_reason_code", + root_context_compatibility: "required", + requires_focus_object: false, + accepted_focus_object_kinds: [], + focus_object_override_policy: "not_applicable", + bundle_reuse_policy: "none", + resolver_owner: "addressIntentResolver", + recipe_owner: "addressRecipeCatalog", + execution_adapter: "AddressQueryService", + result_shape: input.resultShape, + answer_object_shape: input.answerObjectShape, + minimum_evidence_policy: "route_specific_threshold", + coverage_gate_behavior: "partial_or_blocked_if_evidence_insufficient", + truth_mode_fallbacks: ["limited", "clarification_required", "unsupported"], + blocked_reason_codes: ["missing_anchor", "route_expectation_failure", "execution_error", "insufficient_evidence"], + clarification_triggers: ["ambiguous_organization_scope", "ambiguous_date_scope"], + clarification_questions: ["Уточните организацию, счёт или дату, чтобы не подставлять неподтверждённый контур."], + resume_policy: "resume_original_route_with_resolved_anchors", + empty_match_behavior: "truthful_empty_match", + route_expectation_failure_behavior: "blocked_route_expectation_failure", + execution_error_behavior: "blocked_execution_error", + required_unit_tests: [...SHARED_ROOT_EXACT_TESTS], + required_transition_tests: input.transitions.map((transitionId) => `transition_${transitionId}`), + required_scenario_families: input.scenarioFamilies ?? ["canonical", "colloquial", "followup_date_carryover"] + }; +} function inventoryExactCapability(input) { return { schema_version: assistantRuntimeContracts_1.ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION, @@ -269,6 +323,69 @@ exports.INVENTORY_CAPABILITY_CONTRACTS = [ scenarioFamilies: ["canonical", "colloquial", "followup_date_carryover"] }) ]; +exports.ROOT_EXACT_CAPABILITY_CONTRACTS = [ + rootExactCapability({ + capability_id: "confirmed_payables_as_of_date", + domainId: "counterparty_debt", + intent_ids: ["payables_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "counterparty_payables_snapshot", + answerObjectShape: "payables_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_receivables_as_of_date", + domainId: "counterparty_debt", + intent_ids: ["receivables_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "counterparty_receivables_snapshot", + answerObjectShape: "receivables_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_open_contracts_as_of_date", + domainId: "contracts", + intent_ids: ["open_contracts_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "open_contracts_snapshot", + answerObjectShape: "open_contracts_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_vat_payable_as_of_date", + domainId: "vat", + intent_ids: ["vat_payable_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "vat_payable_snapshot", + answerObjectShape: "vat_payable_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_vat_liability_for_tax_period", + domainId: "vat", + intent_ids: ["vat_liability_confirmed_for_tax_period"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "vat_tax_period_liability_snapshot", + answerObjectShape: "vat_tax_period_liability_snapshot", + scenarioFamilies: ["canonical", "colloquial", "followup_date_carryover", "tax_period_followup"] + }), + rootExactCapability({ + capability_id: "account_balance_exact", + domainId: "accounting_balance", + intent_ids: ["account_balance_snapshot", "documents_forming_balance"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: ["account"], + optionalAnchors: ["organization", "date_scope", "account"], + resultShape: "account_balance_snapshot_or_supporting_documents", + answerObjectShape: "account_balance_context", + scenarioFamilies: ["canonical", "colloquial", "followup_date_carryover", "same_date_account_followup"] + }) +]; +const ALL_CAPABILITY_CONTRACTS = [ + ...exports.INVENTORY_CAPABILITY_CONTRACTS, + ...exports.ROOT_EXACT_CAPABILITY_CONTRACTS +]; function listAssistantTransitionContracts() { return exports.ASSISTANT_TRANSITION_CONTRACTS; } @@ -279,8 +396,8 @@ function listInventoryCapabilityContracts() { return exports.INVENTORY_CAPABILITY_CONTRACTS; } function getAssistantCapabilityContract(capabilityId) { - return exports.INVENTORY_CAPABILITY_CONTRACTS.find((contract) => contract.capability_id === capabilityId) ?? null; + return ALL_CAPABILITY_CONTRACTS.find((contract) => contract.capability_id === capabilityId) ?? null; } function getAssistantCapabilityContractByIntent(intent) { - return exports.INVENTORY_CAPABILITY_CONTRACTS.find((contract) => contract.intent_ids.includes(intent)) ?? null; + return ALL_CAPABILITY_CONTRACTS.find((contract) => contract.intent_ids.includes(intent)) ?? null; } diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index fadb45e..b1550b7 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -4092,6 +4092,7 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, + shouldEmitOrganizationSelectionReply: assistantLivingModePolicy.shouldEmitOrganizationSelectionReply, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision: assistantLivingModePolicy.resolveLivingAssistantModeDecision, diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 48c2e72..e39afc5 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1374,6 +1374,14 @@ function hasSelectedObjectScopeSignal(text: string): boolean { return /(?:по\s+выбранному\s+объекту|selected\s+object)/iu.test(String(text ?? "")); } +function isReferentialSameDateWarehousePhrase(candidate: string): boolean { + const normalized = cleanupAnchorValue(candidate) + .toLowerCase() + .replace(/ё/g, "е") + .trim(); + return /^(?:по)\s+(?:этой|той)(?:\s+же)?\s+дат(?:е|у|ой)$/iu.test(normalized); +} + function extractInventoryWarehouseAnchor(text: string): string | undefined { const patterns = [ /(?:на|по)\s+склад(?:е|у|ом)?\s+[«"']?([^\r\n,.;:!?]+?)(?:[»"']|(?=\s+(?:на|по|за|с|в)\b|[?]|$))/iu, @@ -1396,6 +1404,7 @@ function extractInventoryWarehouseAnchor(text: string): string | undefined { candidate.includes("->") || candidate.includes("=>") || isImplicitSelfScopeWarehouseAnchor(candidate) || + isReferentialSameDateWarehousePhrase(candidate) || isLowQualityWarehouseAnchorValue(candidate) || normalizedCandidate.startsWith("по состоянию") || isTemporalWarehousePhrase(candidate) || diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index b4b1f05..3805572 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -1832,6 +1832,35 @@ function sameOrganizationEntityReference(left: string | null | undefined, right: return organizationsLikelySameEntity(left, right); } +function isReferentialOrganizationScopeValue(value: string | null | undefined): boolean { + const normalized = normalizeOrganizationScopeSearchText(value ?? ""); + if (!normalized) { + return false; + } + return /^(?:эта|этой|эту|этой же|эта же|данная|данной|данную)\s+(?:компания|организация|фирма|контора)$/iu.test(normalized) || + /^(?:по|у|для)\s+(?:этой|этой же|данной)\s+(?:компании|организации|фирме|конторе)$/iu.test(normalized) || + /^(?:по|у|для)\s+ней$/iu.test(normalized); +} + +function hasReferentialOrganizationScopeSignal(userMessage: string | null | undefined): boolean { + const normalized = normalizeOrganizationScopeSearchText(userMessage ?? ""); + if (!normalized) { + return false; + } + return /(?:^| )(?:по|у|для)\s+(?:этой|этой же|данной)\s+(?:компании|организации|фирме|конторе)(?: |$)/iu.test(normalized) || + /(?:^| )(?:по|у|для)\s+ней(?: |$)/iu.test(normalized) || + /(?:^| )(?:эта|этой|эту|эта же|данная|данной)\s+(?:компания|организация|фирма|контора)(?: |$)/iu.test(normalized); +} + +function isQuestionFragmentPartyAnchor(value: string | null | undefined): boolean { + const normalized = normalizeSearchText(String(value ?? "")); + if (!normalized) { + return false; + } + return /(?:^| )(?:что|кто|какие|какой|сколько|сейчас)(?: |$)/iu.test(normalized) || + /(?:на складе|нам должен|кому мы должны|по этой компании|по этой же компании|по ней)/iu.test(normalized); +} + function applyPreExecutionOrganizationScopeGrounding(input: { userMessage: string; filters: AddressFilterSet; @@ -1847,6 +1876,7 @@ function applyPreExecutionOrganizationScopeGrounding(input: { activeOrganization ]); const resolvedOrganizationFromMessage = resolveOrganizationSelectionFromMessage(input.userMessage, candidateOrganizations); + const referentialOrganizationScopeDetected = hasReferentialOrganizationScopeSignal(input.userMessage); if ( !input.filters.organization && @@ -1879,6 +1909,41 @@ function applyPreExecutionOrganizationScopeGrounding(input: { } } + if ( + activeOrganization && + ((typeof input.filters.organization === "string" && + isReferentialOrganizationScopeValue(input.filters.organization)) || + referentialOrganizationScopeDetected) && + !sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) + ) { + input.filters.organization = activeOrganization; + if (!input.warnings.includes("organization_grounded_from_referential_scope")) { + input.warnings.push("organization_grounded_from_referential_scope"); + } + if (!input.baseReasons.includes("organization_grounded_from_referential_scope")) { + input.baseReasons.push("organization_grounded_from_referential_scope"); + } + if (input.semanticFrame?.anchor_kind === "organization") { + input.semanticFrame.anchor_value = activeOrganization; + } + } + + if ( + activeOrganization && + sameNormalizedOrganizationScope(input.filters.organization ?? null, activeOrganization) && + typeof input.filters.counterparty === "string" && + (isLikelyLowQualityPartyAnchor(input.filters.counterparty) || + isQuestionFragmentPartyAnchor(input.filters.counterparty)) + ) { + delete input.filters.counterparty; + if (!input.warnings.includes("counterparty_cleared_from_referential_organization_scope")) { + input.warnings.push("counterparty_cleared_from_referential_organization_scope"); + } + if (!input.baseReasons.includes("counterparty_cleared_from_referential_organization_scope")) { + input.baseReasons.push("counterparty_cleared_from_referential_organization_scope"); + } + } + if (!input.filters.organization && !activeOrganization && !resolvedOrganizationFromMessage && candidateOrganizations.length === 1) { input.filters.organization = candidateOrganizations[0]; if (!input.warnings.includes("organization_auto_selected_from_single_scope_candidate")) { diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 9ec70ce..2656da2 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -98,6 +98,10 @@ function hasSameDateHint(text: string): boolean { ); } +function hasSameDatePrepositionHint(text: string): boolean { + return /(?:по\s+(?:этой|той)\s+же\s+дат(?:е|у|ой))/iu.test(String(text ?? "")); +} + function hasSamePeriodHint(text: string): boolean { return /(?:на\s+тот\s+же\s+период|за\s+тот\s+же\s+период|тот\s+же\s+период(?:\s+рассмотрения)?|на\s+этот\s+же\s+период|за\s+этот\s+же\s+период|за\s+этот\s+период|на\s+этот\s+период|за\s+тот\s+период|на\s+тот\s+период|этот\s+период|тот\s+период|аналогичн\w+\s+текущ\w+\s+период\w+|same\s+period|same\s+range|same\s+window)/iu.test( String(text ?? "") @@ -814,7 +818,7 @@ function mergeFollowupFilters( const relativeMonthFromInventoryRoot = resolveRelativeMonthPeriodFromInventoryRoot(userMessage, followupContext); const relativeMonthFromFollowupYear = resolveRelativeMonthPeriodFromFollowupYear(userMessage, followupContext); const allTimeRequested = hasAllTimeHint(userMessage); - const sameDateRequested = hasSameDateHint(userMessage); + const sameDateRequested = hasSameDateHint(userMessage) || hasSameDatePrepositionHint(userMessage); const samePeriodRequested = hasSamePeriodHint(userMessage); const explicitQuotedItem = extractSelectedObjectItemFromFollowupText(userMessage); if (!toNonEmptyString(merged.organization) && previousOrganization) { @@ -1000,6 +1004,27 @@ function mergeFollowupFilters( merged.as_of_date = inheritedAsOfDate; reasons.push("as_of_date_from_followup_context"); } + if ( + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + previousPeriodFrom && + merged.period_from !== previousPeriodFrom + ) { + merged.period_from = previousPeriodFrom; + reasons.push("period_from_from_followup_context"); + } + if ( + (intent === "inventory_on_hand_as_of_date" || intent === "inventory_supplier_stock_overlap_as_of_date") && + previousPeriodTo && + merged.period_to !== previousPeriodTo + ) { + merged.period_to = previousPeriodTo; + reasons.push("period_to_from_followup_context"); + } + const currentWarehouse = toNonEmptyString(merged.warehouse); + if (currentWarehouse && hasSameDatePrepositionHint(currentWarehouse)) { + delete merged.warehouse; + reasons.push("warehouse_cleared_from_same_date_followup_noise"); + } } if ( samePeriodRequested && diff --git a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts index 91f542c..63b1b16 100644 --- a/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantContinuityPolicy.ts @@ -1019,15 +1019,15 @@ export function resolveAssistantOrganizationAuthority( assistantSignals.lastAssistantActiveOrganization ?? continuityActiveOrganization ?? (knownOrganizations.length === 1 ? knownOrganizations[0] : null); - const organizationClarificationCandidates = Array.isArray(input.lastOrganizationClarificationDebug?.organization_candidates) - ? mergeKnownOrganizations([ - ...input.lastOrganizationClarificationDebug.organization_candidates, - ...knownOrganizations, - selectedOrganization, - activeOrganization, - continuityActiveOrganization - ]) - : []; + const organizationClarificationCandidates = mergeKnownOrganizations([ + ...(Array.isArray(input.lastOrganizationClarificationDebug?.organization_candidates) + ? input.lastOrganizationClarificationDebug.organization_candidates + : []), + ...knownOrganizations, + selectedOrganization, + activeOrganization, + continuityActiveOrganization + ]); const organizationClarificationSelectionFromScope = selectedOrganization ?? activeOrganization; return { diff --git a/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts b/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts index 2866e26..74e0c88 100644 --- a/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts +++ b/llm_normalizer/backend/src/services/assistantOrganizationScopeRuntimeAdapter.ts @@ -65,6 +65,9 @@ export function resolveSessionOrganizationScopeContextRuntime) } : {}; - if (!input.toNonEmptyString(previousFilters.organization)) { + const previousOrganization = input.toNonEmptyString(previousFilters.organization); + if (!previousOrganization || previousOrganization !== normalizedOrganization) { previousFilters.organization = normalizedOrganization; } base.previous_filters = previousFilters; @@ -122,7 +127,8 @@ export function mergeFollowupContextWithOrganizationScopeRuntime( rootFiltersRaw && typeof rootFiltersRaw === "object" ? { ...(rootFiltersRaw as Record) } : {}; - if (!input.toNonEmptyString(rootFilters.organization)) { + const rootOrganization = input.toNonEmptyString(rootFilters.organization); + if (!rootOrganization || rootOrganization !== normalizedOrganization) { rootFilters.organization = normalizedOrganization; } if (Object.keys(rootFilters).length > 0) { diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 9fb75a6..5604505 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -218,6 +218,7 @@ export function createAssistantRoutePolicy(deps) { hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, + shouldEmitOrganizationSelectionReply, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision, @@ -526,6 +527,13 @@ export function createAssistantRoutePolicy(deps) { !dataScopeMetaQuery && !capabilityMetaQuery && !dataRetrievalSignal); + const organizationScopeSwitchDetected = Boolean(organizationClarificationSelection && + !dataScopeMetaQuery && + !capabilityMetaQuery && + (shouldEmitOrganizationSelectionReply(rawUserMessage, organizationClarificationSelection) || + shouldEmitOrganizationSelectionReply(repairedRawUserMessage, organizationClarificationSelection) || + shouldEmitOrganizationSelectionReply(effectiveAddressUserMessage, organizationClarificationSelection) || + shouldEmitOrganizationSelectionReply(repairedEffectiveAddressUserMessage, organizationClarificationSelection))); const effectiveAddressFollowupSignal = explicitAddressFollowupSignal && !dangerOrCoercionSignal; const baseToolGate = typeof resolveAddressToolGateDecisionOverride === "function" ? resolveAddressToolGateDecisionOverride(effectiveAddressUserMessage, followupContext, llmPreDecomposeMeta, rawUserMessage) @@ -732,6 +740,37 @@ export function createAssistantRoutePolicy(deps) { } }; } + if (organizationScopeSwitchDetected) { + return { + runAddressLane: false, + toolGateDecision: "skip_address_lane", + toolGateReason: "organization_scope_switch_detected", + livingMode: "chat", + livingReason: "organization_scope_switch_detected", + orchestrationContract: { + schema_version: "assistant_orchestration_contract_v1", + hard_meta_mode: null, + provider_execution: providerExecution, + address_mode: resolvedModeDetection.mode, + address_mode_confidence: resolvedModeDetection.confidence, + address_intent: resolvedIntentResolution.intent, + address_intent_confidence: resolvedIntentResolution.confidence, + strong_data_signal_detected: strongDataSignal, + data_retrieval_signal_detected: dataRetrievalSignal, + followup_context_detected: Boolean(followupContext || continuitySnapshot.hasGroundedAddressContext), + organization_scope_switch_detected: true, + organization_scope_selection: organizationClarificationSelection, + unsupported_address_intent_fallback_to_deep: false, + final_decision: { + run_address_lane: false, + tool_gate_decision: "skip_address_lane", + tool_gate_reason: "organization_scope_switch_detected", + living_mode: "chat", + living_reason: "organization_scope_switch_detected" + } + } + }; + } const supportedExactInvestigativeAddressBypass = Boolean(llmContractMode === "deep_analysis" && semanticApplyCanonicalRecommended && strictDeepInvestigationBypassAllowed && diff --git a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts index 1fe1463..f8f2e3f 100644 --- a/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts +++ b/llm_normalizer/backend/src/services/assistantRuntimeContractRegistry.ts @@ -142,6 +142,72 @@ const INVENTORY_SELECTED_OBJECT_TESTS = [ "full_anchor_not_degraded_by_canonical_rewrite" ] as const; +const SHARED_ROOT_EXACT_TESTS = [ + "root_context_survives_domain_pivot_without_object_leak", + "limited_mode_remains_truthful" +] as const; + +function rootExactCapability(input: { + capability_id: string; + domainId: string; + intent_ids: AddressIntent[]; + transitions: AssistantTransitionClassId[]; + requiredAnchors: string[]; + optionalAnchors?: string[]; + resultShape: string; + answerObjectShape: string; + scenarioFamilies?: string[]; +}): AssistantCapabilityContract { + return { + schema_version: ASSISTANT_RUNTIME_CONTRACTS_SCHEMA_VERSION, + capability_id: input.capability_id, + domain_id: input.domainId, + runtime_lane: "address_exact", + intent_ids: input.intent_ids, + entry_modes: ["root_entry", "root_followup", "clarification_resume"], + supported_transition_classes: input.transitions, + frame_compatibility: { + root_frame: "optional", + selected_object_frame: "optional", + meta_frame: "forbidden" + }, + required_anchors: input.requiredAnchors, + optional_anchors: input.optionalAnchors ?? ["organization", "date_scope", "account", "counterparty", "contract"], + anchor_source_priority: ["explicit_user_anchor", "root_frame", "semantic_hint"], + anchor_admissibility_rules: [ + "confirmed_root_scope_beats_semantic_hint", + "no_low_quality_counterparty_rewrite", + "no_conversational_noise_as_entity" + ], + organization_scope_behavior: "reuse_or_clarify", + date_scope_behavior: "reuse", + temporal_ceiling_policy: "must_not_expand_without_reason_code", + root_context_compatibility: "required", + requires_focus_object: false, + accepted_focus_object_kinds: [], + focus_object_override_policy: "not_applicable", + bundle_reuse_policy: "none", + resolver_owner: "addressIntentResolver", + recipe_owner: "addressRecipeCatalog", + execution_adapter: "AddressQueryService", + result_shape: input.resultShape, + answer_object_shape: input.answerObjectShape, + minimum_evidence_policy: "route_specific_threshold", + coverage_gate_behavior: "partial_or_blocked_if_evidence_insufficient", + truth_mode_fallbacks: ["limited", "clarification_required", "unsupported"], + blocked_reason_codes: ["missing_anchor", "route_expectation_failure", "execution_error", "insufficient_evidence"], + clarification_triggers: ["ambiguous_organization_scope", "ambiguous_date_scope"], + clarification_questions: ["Уточните организацию, счёт или дату, чтобы не подставлять неподтверждённый контур."], + resume_policy: "resume_original_route_with_resolved_anchors", + empty_match_behavior: "truthful_empty_match", + route_expectation_failure_behavior: "blocked_route_expectation_failure", + execution_error_behavior: "blocked_execution_error", + required_unit_tests: [...SHARED_ROOT_EXACT_TESTS], + required_transition_tests: input.transitions.map((transitionId) => `transition_${transitionId}`), + required_scenario_families: input.scenarioFamilies ?? ["canonical", "colloquial", "followup_date_carryover"] + }; +} + function inventoryExactCapability(input: { capability_id: string; intent_ids: AddressIntent[]; @@ -285,6 +351,71 @@ export const INVENTORY_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContrac }) ] as const; +export const ROOT_EXACT_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContract[] = [ + rootExactCapability({ + capability_id: "confirmed_payables_as_of_date", + domainId: "counterparty_debt", + intent_ids: ["payables_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "counterparty_payables_snapshot", + answerObjectShape: "payables_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_receivables_as_of_date", + domainId: "counterparty_debt", + intent_ids: ["receivables_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "counterparty_receivables_snapshot", + answerObjectShape: "receivables_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_open_contracts_as_of_date", + domainId: "contracts", + intent_ids: ["open_contracts_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "open_contracts_snapshot", + answerObjectShape: "open_contracts_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_vat_payable_as_of_date", + domainId: "vat", + intent_ids: ["vat_payable_confirmed_as_of_date"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "vat_payable_snapshot", + answerObjectShape: "vat_payable_snapshot" + }), + rootExactCapability({ + capability_id: "confirmed_vat_liability_for_tax_period", + domainId: "vat", + intent_ids: ["vat_liability_confirmed_for_tax_period"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: [], + resultShape: "vat_tax_period_liability_snapshot", + answerObjectShape: "vat_tax_period_liability_snapshot", + scenarioFamilies: ["canonical", "colloquial", "followup_date_carryover", "tax_period_followup"] + }), + rootExactCapability({ + capability_id: "account_balance_exact", + domainId: "accounting_balance", + intent_ids: ["account_balance_snapshot", "documents_forming_balance"], + transitions: ["T1", "T2", "T6", "T7"], + requiredAnchors: ["account"], + optionalAnchors: ["organization", "date_scope", "account"], + resultShape: "account_balance_snapshot_or_supporting_documents", + answerObjectShape: "account_balance_context", + scenarioFamilies: ["canonical", "colloquial", "followup_date_carryover", "same_date_account_followup"] + }) +] as const; + +const ALL_CAPABILITY_CONTRACTS: readonly AssistantCapabilityContract[] = [ + ...INVENTORY_CAPABILITY_CONTRACTS, + ...ROOT_EXACT_CAPABILITY_CONTRACTS +] as const; + export function listAssistantTransitionContracts(): readonly AssistantTransitionContract[] { return ASSISTANT_TRANSITION_CONTRACTS; } @@ -298,9 +429,9 @@ export function listInventoryCapabilityContracts(): readonly AssistantCapability } export function getAssistantCapabilityContract(capabilityId: string): AssistantCapabilityContract | null { - return INVENTORY_CAPABILITY_CONTRACTS.find((contract) => contract.capability_id === capabilityId) ?? null; + return ALL_CAPABILITY_CONTRACTS.find((contract) => contract.capability_id === capabilityId) ?? null; } export function getAssistantCapabilityContractByIntent(intent: AddressIntent): AssistantCapabilityContract | null { - return INVENTORY_CAPABILITY_CONTRACTS.find((contract) => contract.intent_ids.includes(intent)) ?? null; + return ALL_CAPABILITY_CONTRACTS.find((contract) => contract.intent_ids.includes(intent)) ?? null; } diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 7331c7f..5a55ef4 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -4049,6 +4049,7 @@ const assistantRoutePolicy = (0, assistantRoutePolicy_1.createAssistantRoutePoli hasLooseAllTimeAddressLookupSignal, hasDeepAnalysisPreferenceSignal, hasDirectDeepAnalysisSignal, + shouldEmitOrganizationSelectionReply: assistantLivingModePolicy.shouldEmitOrganizationSelectionReply, compactWhitespace, hasDeepSessionContinuationSignal, resolveLivingAssistantModeDecision: assistantLivingModePolicy.resolveLivingAssistantModeDecision, diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index cd07837..f5848c4 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -117,6 +117,33 @@ describe("address follow-up temporal regressions", () => { expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); }); + it("keeps period window on inventory same-date follow-up phrased as 'по этой же дате'", () => { + const result = runAddressDecomposeStage( + "\u043f\u043e\u043a\u0430\u0436\u0438 \u043e\u0441\u0442\u0430\u0442\u043a\u0438 \u043d\u0430 \u0441\u043a\u043b\u0430\u0434\u0435 \u043f\u043e \u044d\u0442\u043e\u0439 \u0436\u0435 \u0434\u0430\u0442\u0435", + { + previous_intent: "receivables_confirmed_as_of_date", + previous_filters: { + organization: "\u0420\u0410\u0419\u041c", + period_from: "2020-03-01", + period_to: "2020-03-31", + as_of_date: "2020-03-31" + }, + previous_anchor_type: "organization", + previous_anchor_value: "\u0420\u0410\u0419\u041c" + } + ); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.filters.extracted_filters.organization).toBe("\u0420\u0410\u0419\u041c"); + expect(result?.filters.extracted_filters.as_of_date).toBe("2020-03-31"); + expect(result?.filters.extracted_filters.period_from).toBe("2020-03-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2020-03-31"); + expect(result?.filters.extracted_filters.warehouse).toBeUndefined(); + expect(result?.baseReasons).toContain("period_from_from_followup_context"); + expect(result?.baseReasons).toContain("period_to_from_followup_context"); + }); + it("retargets inventory purchase-date VAT bridge into confirmed VAT period with inherited purchase month", () => { const result = runAddressDecomposeStage("ндс можешь прикинуть на дату покупки рабочей станции?", { previous_intent: "inventory_purchase_provenance_for_item", diff --git a/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts b/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts index e39711e..f8f5f07 100644 --- a/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryWarehouseAnchor.test.ts @@ -79,6 +79,12 @@ describe("inventory warehouse anchor extraction", () => { expect(filters.warehouse).toBeUndefined(); }); + it("does not materialize 'РїРѕ той Р¶Рµ дате' as warehouse anchor in stock follow-up", () => { + const filters = extractAddressFilters("покажи остатки РЅР° складе РїРѕ той Р¶Рµ дате", "inventory_on_hand_as_of_date").extracted_filters; + + expect(filters.warehouse).toBeUndefined(); + }); + it("does not materialize current-date phrasing as warehouse anchor in stock follow-up", () => { const filters = extractAddressFilters( "получить остатки по складу на текущую дату", diff --git a/llm_normalizer/backend/tests/addressReferentialOrganizationScope.test.ts b/llm_normalizer/backend/tests/addressReferentialOrganizationScope.test.ts new file mode 100644 index 0000000..f9030b1 --- /dev/null +++ b/llm_normalizer/backend/tests/addressReferentialOrganizationScope.test.ts @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { executeAddressMcpQueryMock } = vi.hoisted(() => ({ + executeAddressMcpQueryMock: vi.fn() +})); + +vi.mock("../src/services/addressMcpClient", async () => { + const actual = await vi.importActual( + "../src/services/addressMcpClient" + ); + return { + ...actual, + executeAddressMcpQuery: executeAddressMcpQueryMock + }; +}); + +import { AddressQueryService } from "../src/services/addressQueryService"; + +afterEach(() => { + executeAddressMcpQueryMock.mockReset(); + vi.restoreAllMocks(); +}); + +describe("referential organization scope grounding", () => { + it("grounds 'по этой компании' to the active organization and clears bogus counterparty anchor", async () => { + executeAddressMcpQueryMock.mockResolvedValueOnce({ + fetched_rows: 1, + matched_rows: 1, + raw_rows: [ + { + Period: "2026-04-19T23:59:59Z", + Registrator: "Остатки товаров на складах", + AccountDt: "41.01", + AccountKt: "00.00", + Amount: 9800, + Quantity: 2, + SubcontoDt1: "Рабочая станция универсального специалиста", + Warehouse: "Основной склад", + Organization: "РАЙМ" + } + ], + rows: [], + error: null + }); + + const service = new AddressQueryService(); + const result = await service.tryHandle("а по этой компании что сейчас на складе?", { + activeOrganization: "РАЙМ", + knownOrganizations: ["ООО Альтернатива Плюс", "РАЙМ"], + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + organization: "ООО Альтернатива Плюс", + as_of_date: "2026-04-19" + }, + previous_anchor_type: "organization", + previous_anchor_value: "ООО Альтернатива Плюс" + } + }); + + expect(result?.handled).toBe(true); + expect(result?.reply_type).toBe("factual"); + expect(result?.debug.detected_intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.debug.extracted_filters?.organization).toBe("РАЙМ"); + expect(result?.debug.extracted_filters?.counterparty).toBeUndefined(); + expect(result?.debug.reasons).toContain("organization_grounded_from_referential_scope"); + expect(result?.debug.reasons).toContain("counterparty_cleared_from_referential_organization_scope"); + expect(String(result?.reply_text ?? "")).toContain("Рабочая станция универсального специалиста"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts index 75ba6a9..67c46e5 100644 --- a/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantContinuityPolicy.test.ts @@ -72,6 +72,28 @@ describe("assistantContinuityPolicy organization authority", () => { expect(authority.organizationClarificationSelectionFromScope).toBe("Org Selected"); }); + it("exposes known organizations as switch candidates even without a prior clarification turn", () => { + const authority = resolveAssistantOrganizationAuthority({ + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "living_chat", + assistant_active_organization: "РАЙМ", + assistant_known_organizations: ['ООО "Альтернатива Плюс"', "РАЙМ"] + } + } + ], + sessionKnownOrganizations: ['ООО "Альтернатива Плюс"'] + }); + + expect(authority.activeOrganization).toBe("РАЙМ"); + expect(authority.organizationClarificationCandidates).toEqual([ + 'ООО "Альтернатива Плюс"', + "РАЙМ" + ]); + }); + it("reads item, organization and scoped date from root-frame fallback when direct filters are missing", () => { const facts = resolveAddressDebugContextFacts({ anchor_type: "item", diff --git a/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts b/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts index b345b85..78b3864 100644 --- a/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts +++ b/llm_normalizer/backend/tests/assistantOrganizationScopeRuntimeAdapter.test.ts @@ -85,6 +85,53 @@ describe("assistant organization scope runtime adapter", () => { }); }); + it("prefers continuity-selected organization over stale navigation scope after late switch", () => { + const normalizeOrganizationScopeValue = vi.fn((value: unknown) => + typeof value === "string" && value.trim() ? value.trim() : null + ); + + const context = resolveSessionOrganizationScopeContextRuntime({ + userMessage: "а по этой компании что сейчас на складе?", + items: [ + { + role: "assistant", + debug: { + assistant_known_organizations: ["ООО Альтернатива Плюс", "РАЙМ"], + assistant_active_organization: "РАЙМ", + living_chat_selected_organization: "РАЙМ" + } + } + ] as any[], + addressNavigationState: { + schema_version: "address_navigation_state_v1", + session_id: "asst-nav-stale-org", + updated_at: "2026-04-19T12:04:44.000Z", + session_context: { + active_result_set_id: "rs-1", + active_focus_object: null, + last_confirmed_route: "address_inventory_on_hand_as_of_date_v1", + date_scope: { + as_of_date: "2026-04-19", + period_from: null, + period_to: null + }, + organization_scope: "ООО Альтернатива Плюс" + }, + result_sets: [], + navigation_history: [] + } as any, + extractKnownOrganizationsFromHistory: () => ["ООО Альтернатива Плюс"], + resolveOrganizationSelectionFromMessage: () => null, + normalizeOrganizationScopeValue + }); + + expect(context).toEqual({ + knownOrganizations: ["ООО Альтернатива Плюс", "РАЙМ"], + selectedOrganization: null, + activeOrganization: "РАЙМ" + }); + }); + it("reuses assistant continuity authority from prior assistant debug when legacy helpers are empty", () => { const normalizeOrganizationScopeValue = vi.fn((value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null @@ -138,11 +185,14 @@ describe("assistant organization scope runtime adapter", () => { }); }); - it("keeps existing organization in followup filters and returns null for empty context without org", () => { - const preserved = mergeFollowupContextWithOrganizationScopeRuntime({ + it("overrides stale organization in followup filters and returns null for empty context without org", () => { + const overridden = mergeFollowupContextWithOrganizationScopeRuntime({ followupContext: { previous_filters: { organization: "Org Existing" + }, + root_filters: { + organization: "Org Existing" } }, organization: "Org A", @@ -158,7 +208,8 @@ describe("assistant organization scope runtime adapter", () => { toNonEmptyString: () => null }); - expect((preserved as any).previous_filters.organization).toBe("Org Existing"); + expect((overridden as any).previous_filters.organization).toBe("Org A"); + expect((overridden as any).root_filters.organization).toBe("Org A"); expect(empty).toBeNull(); }); }); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index 43f7178..1e85a65 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -95,6 +95,7 @@ function buildPolicy(overrides: Record = {}) { hasLooseAllTimeAddressLookupSignal: () => false, hasDeepAnalysisPreferenceSignal: () => false, hasDirectDeepAnalysisSignal: () => false, + shouldEmitOrganizationSelectionReply: () => false, compactWhitespace: (text: string) => String(text ?? "").replace(/\s+/g, " ").trim(), hasDeepSessionContinuationSignal: () => false, resolveLivingAssistantModeDecision: (input: { addressLaneTriggered?: boolean }) => @@ -369,6 +370,59 @@ describe("assistantRoutePolicy", () => { expect(decision.livingReason).toBe("organization_fact_lookup_signal_detected"); }); + it("routes a late company switch to chat instead of reusing the old address contour", () => { + const policy = buildPolicy({ + detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }), + resolveAddressIntent: () => ({ intent: "inventory_on_hand_as_of_date", confidence: "high" }), + resolveAddressToolGateDecision: () => ({ + runAddressLane: true, + decision: "run_address_lane", + reason: "address_mode_classifier_detected" + }), + resolveOrganizationSelectionFromMessage: () => "РАЙМ", + shouldEmitOrganizationSelectionReply: () => true + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: "теперь давай по РАЙМ", + effectiveAddressUserMessage: "теперь давай по РАЙМ", + followupContext: { + previous_intent: "inventory_on_hand_as_of_date", + previous_filters: { + organization: "ООО Альтернатива Плюс", + as_of_date: "2026-04-19" + } + }, + sessionItems: [ + { + role: "assistant", + debug: { + execution_lane: "address_query", + answer_grounding_check: { status: "grounded" }, + extracted_filters: { + organization: "ООО Альтернатива Плюс", + as_of_date: "2026-04-19" + } + } + } + ], + sessionOrganizationScope: { + knownOrganizations: ["ООО Альтернатива Плюс", "РАЙМ"], + selectedOrganization: null, + activeOrganization: "ООО Альтернатива Плюс" + }, + llmPreDecomposeMeta: null, + useMock: false + }); + + expect(decision.runAddressLane).toBe(false); + expect(decision.toolGateReason).toBe("organization_scope_switch_detected"); + expect(decision.livingMode).toBe("chat"); + expect(decision.livingReason).toBe("organization_scope_switch_detected"); + expect(decision.orchestrationContract?.organization_scope_switch_detected).toBe(true); + expect(decision.orchestrationContract?.organization_scope_selection).toBe("РАЙМ"); + }); + it("routes explicit recap wording with selected-object phrasing to chat even when address-like cues exist", () => { const policy = buildPolicy({ hasStrongDataIntentSignal: () => true, diff --git a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts index a0ff38b..a225c4f 100644 --- a/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts +++ b/llm_normalizer/backend/tests/assistantRuntimeContractRegistry.test.ts @@ -52,7 +52,7 @@ describe("assistant runtime contract registry", () => { const contract = getAssistantCapabilityContract("confirmed_inventory_on_hand_as_of_date"); expect(contract).not.toBeNull(); expect(contract?.entry_modes).toEqual(["root_entry", "root_followup", "clarification_resume"]); - expect(contract?.supported_transition_classes).toEqual(["T1", "T2", "T7"]); + expect(contract?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]); expect(contract?.requires_focus_object).toBe(false); expect(contract?.result_shape).toBe("item_list_with_quantity_cost_warehouse_organization"); expect(contract?.required_scenario_families).toContain("colloquial"); @@ -71,6 +71,30 @@ describe("assistant runtime contract registry", () => { expect(contract?.required_scenario_families).toContain("pronoun_followup"); }); + it("declares root financial exact capabilities for debt and vat snapshots", () => { + const receivables = getAssistantCapabilityContract("confirmed_receivables_as_of_date"); + const payables = getAssistantCapabilityContract("confirmed_payables_as_of_date"); + const vat = getAssistantCapabilityContract("confirmed_vat_liability_for_tax_period"); + + expect(receivables?.intent_ids).toEqual(["receivables_confirmed_as_of_date"]); + expect(receivables?.supported_transition_classes).toEqual(["T1", "T2", "T6", "T7"]); + expect(receivables?.requires_focus_object).toBe(false); + + expect(payables?.intent_ids).toEqual(["payables_confirmed_as_of_date"]); + expect(payables?.truth_mode_fallbacks).toEqual(["limited", "clarification_required", "unsupported"]); + + expect(vat?.intent_ids).toEqual(["vat_liability_confirmed_for_tax_period"]); + expect(vat?.required_scenario_families).toContain("tax_period_followup"); + }); + + it("resolves receivables intent to its exact runtime contract", () => { + const contract = getAssistantCapabilityContractByIntent("receivables_confirmed_as_of_date"); + + expect(contract?.capability_id).toBe("confirmed_receivables_as_of_date"); + expect(contract?.runtime_lane).toBe("address_exact"); + expect(contract?.execution_adapter).toBe("AddressQueryService"); + }); + it("keeps truth semantics outside answer wording for every pilot inventory capability", () => { for (const contract of listInventoryCapabilityContracts()) { expect(contract.coverage_gate_behavior).toBe("partial_or_blocked_if_evidence_insufficient");