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 e15638a..6326077 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 @@ -145,6 +145,39 @@ Still open after this pass: - mixed continuity is now strong enough for the current phase7 gate, but it still needs broader saved-session proof before domain expansion can be treated as low-risk; - the next architecture pass should move from one repaired mixed replay to a wider saved-session set and multi-domain acceptance pack; - remaining work should focus on keeping the unified continuity authority stable under new real user paths, not on wording-only polish or isolated route greens. +- company authority is still not proactive enough at root inventory entry in multi-company sessions without an already grounded active organization; +- the next stabilization slice should prefer system-level company authority handling over repeated local clarification templates when the session has enough business context. + +Completed in the current follow-up pass: + +- direct company activity-age wording like `а по Альтернативе Плюс сколько лет активности в базе 1С?` is now protected by a unicode-safe exact signal instead of depending on mojibake-sensitive legacy lifecycle phrases; +- capability meta answers now explain supported business groups through human examples instead of leaking internal operation ids like `vat_period_snapshot`, `inventory_on_hand_as_of_date`, `explain_boundary`, or `suggest_safe_next_step`; +- the next proof target after unit/build checks is the live phase5 replay, because it exercises both the restored activity-age path and the capability-meta interrupt in one shared session. + +Latest live replay evidence after that proof run: + +- the capability meta interrupt is now business-first and no longer leaks internal operation ids in the top block; +- the same replay exposed a stricter continuity defect that the top-level review initially missed: organization identity can drift in session state as a damaged live label like `ООО \\Альтернати"а Плюс\\`; +- when that happens, the runtime keeps both `organization` and a stale `counterparty` anchor, does not emit `counterparty_cleared_for_selected_organization_activity`, and falls into `counterparty_anchor_not_matched_in_materialized_rows`; +- this is a system-level organization-identity robustness gap between data-scope probing, continuity memory, and exact-route truth gating, not a wording-only prompt defect; +- the current stabilization slice therefore includes hardening organization identity matching itself and rerunning the same live pack until step-level human answers and review verdicts align. + +Latest phase8 runtime authority evidence after the manual mixed replay hardening: + +- live replay `address_truth_harness_phase8_manual_runtime_authority_mix_live_20260417_rerun1` proved that the activity-age route was restored, but also exposed a hidden false-green: `step_11_inventory_same_date_after_receivables` silently reused stale inventory-root date `2021-03-31` instead of the freshest receivables date `2020-03-31`; +- the first fix in `assistantService` was not sufficient on its own, because `decomposeStage` still rebuilt `inventory_root` follow-up context by overwriting `previous_filters` from `root_filters` wholesale; +- the architectural correction was to preserve `root` authority for organization / warehouse while preserving the freshest temporal scope (`as_of_date`, `period_from`, `period_to`) from the immediately previous grounded step; +- this was locked by direct regressions in `assistantTransitionPolicy.test.ts` and `addressInventoryRootFrameRegression.test.ts`, plus a live rerun against the same manual replay spec; +- live replay `address_truth_harness_phase8_manual_runtime_authority_mix_live_20260417_rerun4` is now accepted end-to-end with `14/14` steps green, including: + - `step_07_capability_meta` with business-first human wording; + - `step_11_inventory_same_date_after_receivables` on the correct date `31.03.2020`; + - `step_14_company_activity_age` with restored factual lifecycle answer; + - cleaned user-facing company labels in the data-scope meta reply (`ООО Альтернатива Плюс`, `ООО Лайсвуд`, `РАЙМ`) instead of damaged raw probe labels. + +Still open after the accepted phase8 replay: + +- proactive organization authority at the very beginning of a new multi-company bookkeeping session is still weaker than the target product feel; the current system now clarifies honestly and cleanly, but it does not yet always pre-offer company selection early in the conversational flow; +- some user-facing inventory/counterparty labels inside business answers still deserve final presentation cleanup, but these are now post-stabilization quality refinements rather than continuity-authority blockers. ## Ready Signal diff --git a/docs/orchestration/address_truth_harness_phase5_company_selection_and_activity_age.json b/docs/orchestration/address_truth_harness_phase5_company_selection_and_activity_age.json index 2242bcf..a2f11b2 100644 --- a/docs/orchestration/address_truth_harness_phase5_company_selection_and_activity_age.json +++ b/docs/orchestration/address_truth_harness_phase5_company_selection_and_activity_age.json @@ -79,6 +79,24 @@ { "step_id": "step_09_company_activity_age", "title": "Organization age should be answered through reachable activity evidence or honest boundedness", + "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", "question": "а по Альтернативе Плюс сколько лет активности в базе 1С?", "semantic_tags": [ "organization_activity_age", @@ -88,6 +106,23 @@ { "step_id": "step_10_capability_meta_interrupt", "title": "Capability meta interrupt does not destroy prior context", + "allowed_reply_types": [ + "factual_with_explanation", + "factual" + ], + "required_direct_answer_patterns_any": [ + "(?i)1СЃ", + "(?i)РЅРґСЃ|контрагент|остатк|склад" + ], + "forbidden_direct_answer_patterns": [ + "(?i)vat_period_snapshot", + "(?i)inventory_on_hand_as_of_date", + "(?i)explain_boundary", + "(?i)suggest_safe_next_step", + "(?i)read_only", + "(?i)mcp" + ], + "criticality": "warning", "question": "что ты умеешь?", "semantic_tags": [ "meta_capability" diff --git a/docs/orchestration/address_truth_harness_phase8_manual_runtime_authority_mix.json b/docs/orchestration/address_truth_harness_phase8_manual_runtime_authority_mix.json new file mode 100644 index 0000000..2320572 --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase8_manual_runtime_authority_mix.json @@ -0,0 +1,337 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase8_manual_runtime_authority_mix", + "domain": "address_phase8_manual_runtime_authority_mix", + "title": "Phase 8 manual runtime authority replay for company continuity, activity age, and human meta answers", + "description": "Mixed AGENT replay based on the latest manual session. The pack validates company authority, counterparty -> inventory transition behavior, selected-object continuity, organization activity age, capability meta cleanliness, same-date cross-domain pivot, and account 60 tails in one live session.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_smalltalk", + "title": "Casual opening stays human", + "question": "привет, как дела?", + "required_answer_patterns_any": [ + "(?i)привет|дела|помочь|норм" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason", + "(?i)address_mode", + "(?i)living_reason", + "(?i)snapshot_items" + ], + "criticality": "info", + "semantic_tags": [ + "meta_smalltalk" + ] + }, + { + "step_id": "step_02_data_scope_meta", + "title": "Data-scope meta stays deterministic and non-technical", + "question": "по какой компании мы сейчас работаем?", + "required_answer_patterns_any": [ + "(?i)компан|организац|контур", + "(?i)работ" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason", + "(?i)hard_meta_mode", + "(?i)living_reason", + "(?i)mcp", + "(?i)read_only" + ], + "criticality": "warning", + "semantic_tags": [ + "meta_scope" + ] + }, + { + "step_id": "step_03_counterparty_documents", + "title": "Counterparty documents use the legal name contour", + "question": "покажи все документы по чепурнову", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "list_documents_by_counterparty" + ], + "required_direct_answer_patterns_any": [ + "(?i)чепурнов", + "(?i)документ|отгруз|оплат|счет|акт" + ], + "criticality": "critical", + "semantic_tags": [ + "counterparty_documents" + ] + }, + { + "step_id": "step_04_counterparty_shipments", + "title": "Counterparty shipment fallback stays human and business-useful", + "question": "что нам отгружал чепурнов, какой товар или услугу?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "list_documents_by_counterparty" + ], + "required_direct_answer_patterns_any": [ + "(?i)чепурнов", + "(?i)товар|услуг|отгруз|документ|оплат" + ], + "forbidden_direct_answer_patterns": [ + "(?i)^сейчас не дам прямой адресный ответ", + "(?i)^в текущем адресном контуре этот запрос лучше не закрывать" + ], + "criticality": "critical", + "semantic_tags": [ + "counterparty_shipment_fallback" + ] + }, + { + "step_id": "step_05_inventory_root_after_counterparty", + "title": "Inventory root after counterparty branch remains human and non-technical", + "question": "какие остатки на складе на март 2021", + "allowed_reply_types": [ + "factual", + "clarification_required", + "partial_coverage" + ], + "required_answer_patterns_any": [ + "(?i)март 2021|31\\.03\\.2021|организац|компан" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason", + "(?i)address_mode", + "(?i)mcp", + "(?i)read_only", + "(?i)snapshot_items" + ], + "criticality": "warning", + "semantic_tags": [ + "inventory_root", + "company_authority_probe" + ] + }, + { + "step_id": "step_06_selected_item_supplier", + "title": "Selected-object supplier follow-up stays action-first", + "question": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": кто нам это поставил?", + "allowed_reply_types": [ + "factual", + "clarification_required" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "required_direct_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин", + "(?i)поставщик|поставил|куплен|союз|торговый дом|уточните организац" + ], + "forbidden_direct_answer_patterns": [ + "(?i)^сейчас не дам прямой адресный ответ" + ], + "criticality": "critical", + "semantic_tags": [ + "selected_object", + "selected_object_supplier" + ] + }, + { + "step_id": "step_07_capability_meta", + "title": "Capability meta answer is business-first and free from technical garbage", + "question": "что ты умеешь?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "required_direct_answer_patterns_any": [ + "(?i)могу|умею", + "(?i)документ|остатк|контрагент|ндс|склад|долг" + ], + "forbidden_direct_answer_patterns": [ + "(?i)vat_period_snapshot", + "(?i)inventory_on_hand_as_of_date", + "(?i)explain_boundary", + "(?i)suggest_safe_next_step", + "(?i)read_only", + "(?i)mcp", + "(?i)snapshot_items", + "(?i)assessed state", + "(?i)open item" + ], + "criticality": "critical", + "semantic_tags": [ + "meta_capability" + ] + }, + { + "step_id": "step_08_selected_item_documents", + "title": "Selected-object documents stay in the same contour after meta interrupt", + "question": "По выбранному объекту \"Столешница 600*3050*26 альмандин\": покажи документы по этой позиции", + "allowed_reply_types": [ + "factual", + "clarification_required" + ], + "expected_intents": [ + "inventory_purchase_documents_for_item" + ], + "required_direct_answer_patterns_any": [ + "(?i)столешница 600\\*3050\\*26 альмандин|по этой позиции", + "(?i)документ|уточните организац" + ], + "criticality": "critical", + "semantic_tags": [ + "selected_object", + "selected_object_documents" + ] + }, + { + "step_id": "step_09_memory_recap", + "title": "Memory recap does not invent grounded facts", + "question": "а ты помнишь, что мы по этой позиции уже выяснили?", + "required_answer_patterns_any": [ + "(?i)помню|по позиции|столешница" + ], + "forbidden_answer_patterns": [ + "(?i)^сейчас не дам прямой адресный ответ" + ], + "criticality": "warning", + "semantic_tags": [ + "meta_memory" + ] + }, + { + "step_id": "step_10_receivables_march_2020", + "title": "Receivables root establishes March 2020 carryover", + "question": "кто нам должен на март 2020", + "allowed_reply_types": [ + "factual" + ], + "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" + }, + "required_direct_answer_patterns_any": [ + "(?i)дебитор", + "31\\.03\\.2020" + ], + "criticality": "critical", + "semantic_tags": [ + "settlements_receivables" + ] + }, + { + "step_id": "step_11_inventory_same_date_after_receivables", + "title": "Inventory same-date pivot reuses March 2020 without re-clarification", + "question": "остатки по складу на эту же дату", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "required_filters": { + "as_of_date": "{{step_10_receivables_march_2020.filters.as_of_date}}", + "period_from": "{{step_10_receivables_march_2020.filters.period_from}}", + "period_to": "{{step_10_receivables_march_2020.filters.period_to}}" + }, + "required_direct_answer_patterns_all": [ + "(?i)на складе", + "31\\.03\\.2020" + ], + "forbidden_direct_answer_patterns": [ + "(?i)уточните организац", + "(?i)по какой компании" + ], + "required_filter_within_previous_step_period": { + "as_of_date": "step_10_receivables_march_2020" + }, + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "same_date_pivot" + ] + }, + { + "step_id": "step_12_historical_inventory_capability", + "title": "Historical inventory capability follow-up stays human", + "question": "а исторические остатки ты можешь дать?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation" + ], + "required_answer_patterns_any": [ + "(?i)историческ|история", + "(?i)могу|умею" + ], + "forbidden_answer_patterns": [ + "(?i)tool_gate_reason", + "(?i)hard_meta_mode", + "(?i)mcp", + "(?i)read_only" + ], + "criticality": "warning", + "semantic_tags": [ + "meta_historical_capability", + "inventory_root" + ] + }, + { + "step_id": "step_13_open_items_account_60", + "title": "Account 60 tails stay exact after the mixed session", + "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" + ] + }, + { + "step_id": "step_14_company_activity_age", + "title": "Organization activity age is answered through activity evidence or honest boundedness", + "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", + "company_selected" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js b/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js index 14ff153..63c05c4 100644 --- a/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js +++ b/llm_normalizer/backend/dist/services/addressCounterpartyIntentSignals.js @@ -60,6 +60,19 @@ function hasUnicodeLikelyCounterpartyAfterBy(text) { ]); return !stopWords.has(token); } +function hasUnicodeCounterpartyActivityLifecycleSignal(text) { + const normalized = String(text ?? "").toLowerCase(); + if (!normalized) { + return false; + } + 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) { + 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); + const hasBusinessAnchor = /(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0448\u0435\u0439\s+\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438|\u043d\u0430\u0448\u0435\u0439\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438|\u043e\u043e\u043e|\u0430\u043e|\u0437\u0430\u043e|\u0438\u043f)/iu.test(normalized); + return hasOneCLexeme || hasBusinessAnchor || hasUnicodeLikelyCounterpartyAfterBy(normalized); +} function resolveCounterpartyAddressIntent(text, deps) { if (hasUnicodeOpenItemsAccountSignal(text)) { return { @@ -107,6 +120,13 @@ function resolveCounterpartyAddressIntent(text, deps) { reasons: ["counterparty_item_flow_signal_detected"] }; } + if (hasUnicodeCounterpartyActivityLifecycleSignal(text)) { + return { + intent: "counterparty_activity_lifecycle", + confidence: "high", + reasons: ["counterparty_activity_lifecycle_signal_detected"] + }; + } if (deps.hasOpenContractsListSignal(text)) { return { intent: "open_contracts_confirmed_as_of_date", diff --git a/llm_normalizer/backend/dist/services/addressQueryService.js b/llm_normalizer/backend/dist/services/addressQueryService.js index 0bf6702..af7fdc9 100644 --- a/llm_normalizer/backend/dist/services/addressQueryService.js +++ b/llm_normalizer/backend/dist/services/addressQueryService.js @@ -1401,9 +1401,7 @@ function stripOrganizationLegalForm(value) { .trim(); } function sameOrganizationEntityReference(left, right) { - const leftNorm = stripOrganizationLegalForm(left); - const rightNorm = stripOrganizationLegalForm(right); - return Boolean(leftNorm && rightNorm && leftNorm === rightNorm); + return (0, assistantOrganizationMatcher_1.organizationsLikelySameEntity)(left, right); } function applyPreExecutionOrganizationScopeGrounding(input) { const activeOrganization = (0, assistantOrganizationMatcher_1.normalizeOrganizationScopeValue)(input.activeOrganization ?? null); diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index 9535cf5..bda0760 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -287,10 +287,36 @@ function buildInventoryRootFollowupContext(followupContext) { if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) { return followupContext; } + const rootFilters = followupContext.root_filters && typeof followupContext.root_filters === "object" + ? { ...followupContext.root_filters } + : {}; + const previousFilters = followupContext.previous_filters && typeof followupContext.previous_filters === "object" + ? followupContext.previous_filters + : {}; + const previousAsOfDate = toNonEmptyString(previousFilters.as_of_date); + const previousPeriodFrom = toNonEmptyString(previousFilters.period_from); + const previousPeriodTo = toNonEmptyString(previousFilters.period_to); + const previousOrganization = toNonEmptyString(previousFilters.organization); + const previousWarehouse = toNonEmptyString(previousFilters.warehouse); + if (previousAsOfDate) { + rootFilters.as_of_date = previousAsOfDate; + } + if (previousPeriodFrom) { + rootFilters.period_from = previousPeriodFrom; + } + if (previousPeriodTo) { + rootFilters.period_to = previousPeriodTo; + } + if (!toNonEmptyString(rootFilters.organization) && previousOrganization) { + rootFilters.organization = previousOrganization; + } + if (!toNonEmptyString(rootFilters.warehouse) && previousWarehouse) { + rootFilters.warehouse = previousWarehouse; + } return { ...followupContext, previous_intent: followupContext.root_intent, - previous_filters: { ...followupContext.root_filters }, + previous_filters: rootFilters, previous_anchor_type: followupContext.root_anchor_type ?? followupContext.previous_anchor_type, previous_anchor_value: followupContext.root_anchor_value ?? followupContext.previous_anchor_value, current_frame_kind: "inventory_root" diff --git a/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js b/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js index 8fd729c..ad48420 100644 --- a/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantBoundaryPolicy.js @@ -22,8 +22,9 @@ function createAssistantBoundaryPolicy(deps) { function buildAssistantDataScopeContractReply(scopeProbe = null) { const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations - .map((item) => String(item ?? "").trim()) + .map((item) => normalizeSelectedOrganization(item, deps.normalizeOrganizationScopeValue)) .filter((item) => item.length > 0) + .filter((item, index, array) => array.indexOf(item) === index) : []; if (organizations.length === 1) { return [ diff --git a/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js b/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js index 6ede5a7..ad3bfc3 100644 --- a/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js +++ b/llm_normalizer/backend/dist/services/assistantOrganizationMatcher.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.normalizeOrganizationScopeValue = normalizeOrganizationScopeValue; exports.normalizeOrganizationScopeSearchText = normalizeOrganizationScopeSearchText; exports.scoreOrganizationMentionInMessage = scoreOrganizationMentionInMessage; +exports.organizationsLikelySameEntity = organizationsLikelySameEntity; exports.mergeKnownOrganizations = mergeKnownOrganizations; exports.resolveOrganizationSelectionFromMessage = resolveOrganizationSelectionFromMessage; const ORGANIZATION_SCOPE_STOPWORDS = new Set([ @@ -53,7 +54,9 @@ const ORGANIZATION_SCOPE_STOPWORDS = new Set([ ]); function normalizeScopeLabel(value) { return String(value ?? "") + .replace(/\\/g, " ") .replace(/[“”«»]/g, '"') + .replace(/([\p{L}])"(?=[\p{L}])/gu, "$1в") .replace(/\s+/g, " ") .trim(); } @@ -104,6 +107,52 @@ function organizationTokenVariants(token) { } return Array.from(variants); } +function isSingleInsertionOrDeletionAway(left, right) { + const longer = left.length >= right.length ? left : right; + const shorter = left.length >= right.length ? right : left; + if (longer.length - shorter.length !== 1) { + return false; + } + let longIndex = 0; + let shortIndex = 0; + let mismatchUsed = false; + while (longIndex < longer.length && shortIndex < shorter.length) { + if (longer[longIndex] === shorter[shortIndex]) { + longIndex += 1; + shortIndex += 1; + continue; + } + if (mismatchUsed) { + return false; + } + mismatchUsed = true; + longIndex += 1; + } + return true; +} +function organizationTokensLookEquivalent(left, right) { + if (!left || !right) { + return false; + } + if (left === right) { + return true; + } + if (left.length >= 5 && right.length >= 5 && (left.startsWith(right) || right.startsWith(left))) { + return true; + } + const leftCompact = left.replace(/\s+/g, ""); + const rightCompact = right.replace(/\s+/g, ""); + if (!leftCompact || !rightCompact) { + return false; + } + if (leftCompact === rightCompact) { + return true; + } + if (leftCompact.length >= 6 && rightCompact.length >= 6 && isSingleInsertionOrDeletionAway(leftCompact, rightCompact)) { + return true; + } + return false; +} function scoreOrganizationMentionInMessage(message, organization) { const messageNorm = normalizeOrganizationScopeSearchText(message); const organizationNorm = normalizeOrganizationScopeSearchText(organization); @@ -163,20 +212,62 @@ function scoreOrganizationMentionInMessage(message, organization) { } return score; } +function organizationsLikelySameEntity(left, right) { + const leftNorm = normalizeOrganizationScopeSearchText(left); + const rightNorm = normalizeOrganizationScopeSearchText(right); + if (!leftNorm || !rightNorm) { + return false; + } + if (leftNorm === rightNorm) { + return true; + } + const leftTokens = tokenizeOrganizationScope(leftNorm); + const rightTokens = tokenizeOrganizationScope(rightNorm); + if (leftTokens.length === 0 || rightTokens.length === 0) { + return false; + } + const leftCompact = leftTokens.join(""); + const rightCompact = rightTokens.join(""); + if (leftCompact && rightCompact) { + if (leftCompact === rightCompact) { + return true; + } + if (leftCompact.length >= 8 && + rightCompact.length >= 8 && + isSingleInsertionOrDeletionAway(leftCompact, rightCompact)) { + return true; + } + } + const leftCovered = leftTokens.every((leftToken) => rightTokens.some((rightToken) => organizationTokensLookEquivalent(leftToken, rightToken))); + if (!leftCovered) { + return false; + } + const rightCovered = rightTokens.every((rightToken) => leftTokens.some((leftToken) => organizationTokensLookEquivalent(leftToken, rightToken))); + return rightCovered; +} function mergeKnownOrganizations(values, limit = 50) { - const dedup = new Map(); + const dedup = []; for (const raw of Array.isArray(values) ? values : []) { const normalized = normalizeOrganizationScopeValue(raw); if (!normalized) { continue; } const key = normalizeOrganizationScopeSearchText(normalized); - if (!key || dedup.has(key)) { + if (!key) { continue; } - dedup.set(key, normalized); + const existingIndex = dedup.findIndex((item) => organizationsLikelySameEntity(item, normalized)); + if (existingIndex >= 0) { + const existing = dedup[existingIndex]; + const existingKey = normalizeOrganizationScopeSearchText(existing); + if (key.length > existingKey.length || normalized.length > existing.length) { + dedup[existingIndex] = normalized; + } + continue; + } + dedup.push(normalized); } - return Array.from(dedup.values()).slice(0, limit); + return dedup.slice(0, limit); } function resolveOrganizationSelectionFromMessage(userMessage, knownOrganizations) { const known = mergeKnownOrganizations(Array.isArray(knownOrganizations) ? knownOrganizations : []); diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 927a174..9daa4c6 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -41,6 +41,7 @@ exports.evaluateCoverageForTests = evaluateCoverageForTests; exports.extractSubjectTokensForTests = extractSubjectTokensForTests; exports.resolveAssistantOrchestrationDecision = resolveAssistantOrchestrationDecision; exports.resolveSessionOrganizationScopeContextForTests = resolveSessionOrganizationScopeContextForTests; +exports.buildRootScopedCarryoverFiltersForTests = buildRootScopedCarryoverFiltersForTests; exports.extractOrganizationFactsFromRowsForTests = extractOrganizationFactsFromRowsForTests; exports.resolveOrganizationNamesByRefsForTests = resolveOrganizationNamesByRefsForTests; exports.resolveLivingAssistantModeDecision = resolveLivingAssistantModeDecision; @@ -2751,13 +2752,13 @@ function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMes function buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame) { const candidateFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object" ? inventoryRootFrame.filters - : previousFilters; + : {}; const nextFilters = {}; const organization = toNonEmptyString(candidateFilters?.organization) ?? toNonEmptyString(previousFilters?.organization); const warehouse = toNonEmptyString(candidateFilters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse); - const asOfDate = toNonEmptyString(candidateFilters?.as_of_date) ?? toNonEmptyString(previousFilters?.as_of_date); - const periodFrom = toNonEmptyString(candidateFilters?.period_from) ?? toNonEmptyString(previousFilters?.period_from); - const periodTo = toNonEmptyString(candidateFilters?.period_to) ?? toNonEmptyString(previousFilters?.period_to); + const asOfDate = toNonEmptyString(previousFilters?.as_of_date) ?? toNonEmptyString(candidateFilters?.as_of_date); + const periodFrom = toNonEmptyString(previousFilters?.period_from) ?? toNonEmptyString(candidateFilters?.period_from); + const periodTo = toNonEmptyString(previousFilters?.period_to) ?? toNonEmptyString(candidateFilters?.period_to); if (organization) { nextFilters.organization = organization; } @@ -4557,6 +4558,9 @@ function mergeFollowupContextWithOrganizationScope(followupContext, organization function resolveSessionOrganizationScopeContextForTests(userMessage, items, addressNavigationState = null) { return resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState); } +function buildRootScopedCarryoverFiltersForTests(previousFilters, inventoryRootFrame) { + return buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame); +} function normalizeGuidValue(value) { const source = normalizeScopeLabel(value); if (!source) { diff --git a/llm_normalizer/backend/dist/services/capabilitiesRegistry.js b/llm_normalizer/backend/dist/services/capabilitiesRegistry.js index 5db7352..1aa2773 100644 --- a/llm_normalizer/backend/dist/services/capabilitiesRegistry.js +++ b/llm_normalizer/backend/dist/services/capabilitiesRegistry.js @@ -16,14 +16,14 @@ const FALLBACK_REGISTRY = { { group_code: "vat", group_title: "НДС", - description: "Срезы и расчеты НДС на базе данных 1С.", + description: "Срезы и расчёты НДС на базе данных 1С", risk_level: "high", maturity_status: "partial", supported_operations: ["vat_period_snapshot", "vat_payable_forecast"], unsupported_operations: ["submit_tax_declaration"], required_entities: ["period", "organization"], optional_entities: ["counterparty"], - typical_queries: ["Сколько НДС к уплате за период?"], + typical_queries: ["Сколько НДС к уплате за период?", "Покажи срез НДС на дату"], related_routes: [], safe_alternatives: ["Показать движения по 68/19 за период"], one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] @@ -31,22 +31,71 @@ const FALLBACK_REGISTRY = { { group_code: "counterparties", group_title: "Контрагенты", - description: "Документы, операции, договоры и срезы по контрагентам.", + description: "Документы, операции, договоры и активность по контрагентам", risk_level: "medium", maturity_status: "production_ready", supported_operations: ["list_documents_by_counterparty", "list_contracts_by_counterparty"], unsupported_operations: ["edit_counterparty_card"], required_entities: ["counterparty_scope_or_contract"], optional_entities: ["period", "organization"], - typical_queries: ["Покажи документы по контрагенту"], + typical_queries: ["Покажи документы по контрагенту", "Какие операции были по банку с контрагентом?"], related_routes: [], - safe_alternatives: ["Уточнить ИНН/наименование контрагента"], + safe_alternatives: ["Уточнить ИНН или наименование контрагента"], one_c_hints: ["Справочник.Контрагенты"] }, + { + group_code: "settlements", + group_title: "Долги и расчёты", + description: "Сальдо, хвосты, незакрытые авансы и аналитика по расчётам", + risk_level: "high", + maturity_status: "production_ready", + supported_operations: ["receivables_confirmed_as_of_date", "open_items_by_counterparty_or_contract"], + unsupported_operations: ["close_period"], + required_entities: ["period_or_date"], + optional_entities: ["organization", "account", "counterparty"], + typical_queries: ["Кто нам должен на дату?", "Хвосты покажи по счёту 60 за период"], + related_routes: [], + safe_alternatives: ["Уточнить период, счёт или организацию"], + one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] + }, + { + group_code: "cash", + group_title: "Деньги", + description: "Остатки и движение по денежным счетам и кассе", + risk_level: "medium", + maturity_status: "production_ready", + supported_operations: ["account_balance_snapshot", "bank_operations_by_counterparty"], + unsupported_operations: ["post_bank_statement"], + required_entities: ["date_or_period"], + optional_entities: ["organization", "account", "counterparty"], + typical_queries: ["Какой остаток по счёту 51 на дату?", "Покажи движение денег за месяц"], + related_routes: [], + safe_alternatives: ["Уточнить счёт или период"], + one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] + }, + { + group_code: "inventory", + group_title: "Склад и товары", + description: "Подтверждённые остатки, происхождение и документы по товарным позициям", + risk_level: "medium", + maturity_status: "production_ready", + supported_operations: [ + "inventory_on_hand_as_of_date", + "inventory_purchase_provenance_for_item", + "inventory_purchase_documents_for_item" + ], + unsupported_operations: ["write_off_inventory"], + required_entities: ["date_or_period"], + optional_entities: ["organization", "warehouse", "item"], + typical_queries: ["Какие товары сейчас лежат на складе?", "Кто поставил эту позицию?"], + related_routes: [], + safe_alternatives: ["Уточнить организацию, дату или позицию"], + one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] + }, { group_code: "boundaries", group_title: "Ограничения", - description: "Операции, которые ассистент не выполняет.", + description: "Операции, которые ассистент не выполняет в этом рантайме", risk_level: "high", maturity_status: "production_ready", supported_operations: ["explain_boundary", "suggest_safe_next_step"], @@ -55,7 +104,7 @@ const FALLBACK_REGISTRY = { optional_entities: [], typical_queries: ["Можешь настроить 1С?"], related_routes: [], - safe_alternatives: ["Сформировать план диагностики для 1С/ИТ-админа"], + safe_alternatives: ["Сформировать безопасный план диагностики для 1С или ИТ-админа"], one_c_hints: [] } ] @@ -150,16 +199,20 @@ function loadCapabilitiesRegistry() { } function buildCapabilityContractReplyFromRegistry() { const registry = loadCapabilitiesRegistry(); - const topGroups = registry.groups.slice(0, 6); + const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6); const groupLines = topGroups.map((group, index) => { - const ops = group.supported_operations.slice(0, 3).join(", "); - return `${index + 1}. ${group.group_title}: ${group.description}${ops ? ` (например: ${ops})` : ""}.`; + const examples = group.typical_queries + .slice(0, 2) + .map((query) => query.trim()) + .filter((query) => query.length > 0) + .join("; "); + return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`; }); return [ - "Я ассистент по анализу данных 1С в режиме чтения.", - "Что умею по группам:", + "Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.", + "По основным группам:", ...groupLines, - "Если хотите, раскрою любую группу точечно и дам готовую формулировку запроса.", + "Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.", "Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере." ].join("\n"); } diff --git a/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts b/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts index 15771c5..f3fe061 100644 --- a/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts +++ b/llm_normalizer/backend/src/services/addressCounterpartyIntentSignals.ts @@ -108,6 +108,32 @@ function hasUnicodeLikelyCounterpartyAfterBy(text: string): boolean { return !stopWords.has(token); } +function hasUnicodeCounterpartyActivityLifecycleSignal(text: string): boolean { + const normalized = String(text ?? "").toLowerCase(); + if (!normalized) { + return false; + } + + 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) { + 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 + ); + const hasBusinessAnchor = + /(?:\u043a\u043e\u043c\u043f\u0430\u043d|\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446|\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442|\u043a\u043b\u0438\u0435\u043d\u0442|\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u043d\u0430\u0448\u0435\u0439\s+\u043a\u043e\u043c\u043f\u0430\u043d\u0438\u0438|\u043d\u0430\u0448\u0435\u0439\s+\u043e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u0438|\u043e\u043e\u043e|\u0430\u043e|\u0437\u0430\u043e|\u0438\u043f)/iu.test( + normalized + ); + + return hasOneCLexeme || hasBusinessAnchor || hasUnicodeLikelyCounterpartyAfterBy(normalized); +} + export function resolveCounterpartyAddressIntent( text: string, deps: CounterpartyIntentDeps @@ -170,6 +196,14 @@ export function resolveCounterpartyAddressIntent( }; } + if (hasUnicodeCounterpartyActivityLifecycleSignal(text)) { + return { + intent: "counterparty_activity_lifecycle", + confidence: "high", + reasons: ["counterparty_activity_lifecycle_signal_detected"] + }; + } + if (deps.hasOpenContractsListSignal(text)) { return { intent: "open_contracts_confirmed_as_of_date", diff --git a/llm_normalizer/backend/src/services/addressQueryService.ts b/llm_normalizer/backend/src/services/addressQueryService.ts index adefd33..11a1151 100644 --- a/llm_normalizer/backend/src/services/addressQueryService.ts +++ b/llm_normalizer/backend/src/services/addressQueryService.ts @@ -60,6 +60,7 @@ import { mergeKnownOrganizations, normalizeOrganizationScopeSearchText, normalizeOrganizationScopeValue, + organizationsLikelySameEntity, resolveOrganizationSelectionFromMessage } from "./assistantOrganizationMatcher"; import { @@ -1735,9 +1736,7 @@ function stripOrganizationLegalForm(value: string | null | undefined): string { } function sameOrganizationEntityReference(left: string | null | undefined, right: string | null | undefined): boolean { - const leftNorm = stripOrganizationLegalForm(left); - const rightNorm = stripOrganizationLegalForm(right); - return Boolean(leftNorm && rightNorm && leftNorm === rightNorm); + return organizationsLikelySameEntity(left, right); } function applyPreExecutionOrganizationScopeGrounding(input: { diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 5d81f3a..3eada3b 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -395,10 +395,38 @@ function buildInventoryRootFollowupContext( if (!followupContext || !followupContext.root_intent || !followupContext.root_filters) { return followupContext; } + const rootFilters = + followupContext.root_filters && typeof followupContext.root_filters === "object" + ? { ...followupContext.root_filters } + : {}; + const previousFilters = + followupContext.previous_filters && typeof followupContext.previous_filters === "object" + ? followupContext.previous_filters + : {}; + const previousAsOfDate = toNonEmptyString(previousFilters.as_of_date); + const previousPeriodFrom = toNonEmptyString(previousFilters.period_from); + const previousPeriodTo = toNonEmptyString(previousFilters.period_to); + const previousOrganization = toNonEmptyString(previousFilters.organization); + const previousWarehouse = toNonEmptyString(previousFilters.warehouse); + if (previousAsOfDate) { + rootFilters.as_of_date = previousAsOfDate; + } + if (previousPeriodFrom) { + rootFilters.period_from = previousPeriodFrom; + } + if (previousPeriodTo) { + rootFilters.period_to = previousPeriodTo; + } + if (!toNonEmptyString(rootFilters.organization) && previousOrganization) { + rootFilters.organization = previousOrganization; + } + if (!toNonEmptyString(rootFilters.warehouse) && previousWarehouse) { + rootFilters.warehouse = previousWarehouse; + } return { ...followupContext, previous_intent: followupContext.root_intent, - previous_filters: { ...followupContext.root_filters }, + previous_filters: rootFilters, previous_anchor_type: followupContext.root_anchor_type ?? followupContext.previous_anchor_type, previous_anchor_value: followupContext.root_anchor_value ?? followupContext.previous_anchor_value, current_frame_kind: "inventory_root" diff --git a/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts b/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts index 02b78d5..bee4eea 100644 --- a/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantBoundaryPolicy.ts @@ -56,8 +56,9 @@ export function createAssistantBoundaryPolicy(deps: AssistantBoundaryPolicyDeps) function buildAssistantDataScopeContractReply(scopeProbe: Record | null = null): string { const organizations = Array.isArray(scopeProbe?.organizations) ? scopeProbe.organizations - .map((item) => String(item ?? "").trim()) + .map((item) => normalizeSelectedOrganization(item, deps.normalizeOrganizationScopeValue)) .filter((item) => item.length > 0) + .filter((item, index, array) => array.indexOf(item) === index) : []; if (organizations.length === 1) { diff --git a/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts b/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts index 7acc968..3dfcad8 100644 --- a/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts +++ b/llm_normalizer/backend/src/services/assistantOrganizationMatcher.ts @@ -47,7 +47,9 @@ const ORGANIZATION_SCOPE_STOPWORDS = new Set([ function normalizeScopeLabel(value: unknown): string { return String(value ?? "") + .replace(/\\/g, " ") .replace(/[“”«»]/g, '"') + .replace(/([\p{L}])"(?=[\p{L}])/gu, "$1в") .replace(/\s+/g, " ") .trim(); } @@ -109,6 +111,54 @@ function organizationTokenVariants(token: string): string[] { return Array.from(variants); } +function isSingleInsertionOrDeletionAway(left: string, right: string): boolean { + const longer = left.length >= right.length ? left : right; + const shorter = left.length >= right.length ? right : left; + if (longer.length - shorter.length !== 1) { + return false; + } + let longIndex = 0; + let shortIndex = 0; + let mismatchUsed = false; + while (longIndex < longer.length && shortIndex < shorter.length) { + if (longer[longIndex] === shorter[shortIndex]) { + longIndex += 1; + shortIndex += 1; + continue; + } + if (mismatchUsed) { + return false; + } + mismatchUsed = true; + longIndex += 1; + } + return true; +} + +function organizationTokensLookEquivalent(left: string, right: string): boolean { + if (!left || !right) { + return false; + } + if (left === right) { + return true; + } + if (left.length >= 5 && right.length >= 5 && (left.startsWith(right) || right.startsWith(left))) { + return true; + } + const leftCompact = left.replace(/\s+/g, ""); + const rightCompact = right.replace(/\s+/g, ""); + if (!leftCompact || !rightCompact) { + return false; + } + if (leftCompact === rightCompact) { + return true; + } + if (leftCompact.length >= 6 && rightCompact.length >= 6 && isSingleInsertionOrDeletionAway(leftCompact, rightCompact)) { + return true; + } + return false; +} + export function scoreOrganizationMentionInMessage(message: unknown, organization: unknown): number { const messageNorm = normalizeOrganizationScopeSearchText(message); const organizationNorm = normalizeOrganizationScopeSearchText(organization); @@ -170,20 +220,73 @@ export function scoreOrganizationMentionInMessage(message: unknown, organization return score; } +export function organizationsLikelySameEntity(left: unknown, right: unknown): boolean { + const leftNorm = normalizeOrganizationScopeSearchText(left); + const rightNorm = normalizeOrganizationScopeSearchText(right); + if (!leftNorm || !rightNorm) { + return false; + } + if (leftNorm === rightNorm) { + return true; + } + + const leftTokens = tokenizeOrganizationScope(leftNorm); + const rightTokens = tokenizeOrganizationScope(rightNorm); + if (leftTokens.length === 0 || rightTokens.length === 0) { + return false; + } + + const leftCompact = leftTokens.join(""); + const rightCompact = rightTokens.join(""); + if (leftCompact && rightCompact) { + if (leftCompact === rightCompact) { + return true; + } + if ( + leftCompact.length >= 8 && + rightCompact.length >= 8 && + isSingleInsertionOrDeletionAway(leftCompact, rightCompact) + ) { + return true; + } + } + + const leftCovered = leftTokens.every((leftToken) => + rightTokens.some((rightToken) => organizationTokensLookEquivalent(leftToken, rightToken)) + ); + if (!leftCovered) { + return false; + } + + const rightCovered = rightTokens.every((rightToken) => + leftTokens.some((leftToken) => organizationTokensLookEquivalent(leftToken, rightToken)) + ); + return rightCovered; +} + export function mergeKnownOrganizations(values: unknown[], limit = 50): string[] { - const dedup = new Map(); + const dedup: string[] = []; for (const raw of Array.isArray(values) ? values : []) { const normalized = normalizeOrganizationScopeValue(raw); if (!normalized) { continue; } const key = normalizeOrganizationScopeSearchText(normalized); - if (!key || dedup.has(key)) { + if (!key) { continue; } - dedup.set(key, normalized); + const existingIndex = dedup.findIndex((item) => organizationsLikelySameEntity(item, normalized)); + if (existingIndex >= 0) { + const existing = dedup[existingIndex]; + const existingKey = normalizeOrganizationScopeSearchText(existing); + if (key.length > existingKey.length || normalized.length > existing.length) { + dedup[existingIndex] = normalized; + } + continue; + } + dedup.push(normalized); } - return Array.from(dedup.values()).slice(0, limit); + return dedup.slice(0, limit); } export function resolveOrganizationSelectionFromMessage( diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 4511847..bb357ba 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2707,13 +2707,13 @@ function hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMes function buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame) { const candidateFilters = inventoryRootFrame?.filters && typeof inventoryRootFrame.filters === "object" ? inventoryRootFrame.filters - : previousFilters; + : {}; const nextFilters = {}; const organization = toNonEmptyString(candidateFilters?.organization) ?? toNonEmptyString(previousFilters?.organization); const warehouse = toNonEmptyString(candidateFilters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse); - const asOfDate = toNonEmptyString(candidateFilters?.as_of_date) ?? toNonEmptyString(previousFilters?.as_of_date); - const periodFrom = toNonEmptyString(candidateFilters?.period_from) ?? toNonEmptyString(previousFilters?.period_from); - const periodTo = toNonEmptyString(candidateFilters?.period_to) ?? toNonEmptyString(previousFilters?.period_to); + const asOfDate = toNonEmptyString(previousFilters?.as_of_date) ?? toNonEmptyString(candidateFilters?.as_of_date); + const periodFrom = toNonEmptyString(previousFilters?.period_from) ?? toNonEmptyString(candidateFilters?.period_from); + const periodTo = toNonEmptyString(previousFilters?.period_to) ?? toNonEmptyString(candidateFilters?.period_to); if (organization) { nextFilters.organization = organization; } @@ -4514,6 +4514,9 @@ function mergeFollowupContextWithOrganizationScope(followupContext, organization export function resolveSessionOrganizationScopeContextForTests(userMessage, items, addressNavigationState = null) { return resolveSessionOrganizationScopeContext(userMessage, items, addressNavigationState); } +export function buildRootScopedCarryoverFiltersForTests(previousFilters, inventoryRootFrame) { + return buildRootScopedCarryoverFilters(previousFilters, inventoryRootFrame); +} function normalizeGuidValue(value) { const source = normalizeScopeLabel(value); if (!source) { diff --git a/llm_normalizer/backend/src/services/capabilitiesRegistry.ts b/llm_normalizer/backend/src/services/capabilitiesRegistry.ts index 42da52b..02c4ba4 100644 --- a/llm_normalizer/backend/src/services/capabilitiesRegistry.ts +++ b/llm_normalizer/backend/src/services/capabilitiesRegistry.ts @@ -34,14 +34,14 @@ const FALLBACK_REGISTRY: CapabilityRegistry = { { group_code: "vat", group_title: "НДС", - description: "Срезы и расчеты НДС на базе данных 1С.", + description: "Срезы и расчёты НДС на базе данных 1С", risk_level: "high", maturity_status: "partial", supported_operations: ["vat_period_snapshot", "vat_payable_forecast"], unsupported_operations: ["submit_tax_declaration"], required_entities: ["period", "organization"], optional_entities: ["counterparty"], - typical_queries: ["Сколько НДС к уплате за период?"], + typical_queries: ["Сколько НДС к уплате за период?", "Покажи срез НДС на дату"], related_routes: [], safe_alternatives: ["Показать движения по 68/19 за период"], one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] @@ -49,22 +49,71 @@ const FALLBACK_REGISTRY: CapabilityRegistry = { { group_code: "counterparties", group_title: "Контрагенты", - description: "Документы, операции, договоры и срезы по контрагентам.", + description: "Документы, операции, договоры и активность по контрагентам", risk_level: "medium", maturity_status: "production_ready", supported_operations: ["list_documents_by_counterparty", "list_contracts_by_counterparty"], unsupported_operations: ["edit_counterparty_card"], required_entities: ["counterparty_scope_or_contract"], optional_entities: ["period", "organization"], - typical_queries: ["Покажи документы по контрагенту"], + typical_queries: ["Покажи документы по контрагенту", "Какие операции были по банку с контрагентом?"], related_routes: [], - safe_alternatives: ["Уточнить ИНН/наименование контрагента"], + safe_alternatives: ["Уточнить ИНН или наименование контрагента"], one_c_hints: ["Справочник.Контрагенты"] }, + { + group_code: "settlements", + group_title: "Долги и расчёты", + description: "Сальдо, хвосты, незакрытые авансы и аналитика по расчётам", + risk_level: "high", + maturity_status: "production_ready", + supported_operations: ["receivables_confirmed_as_of_date", "open_items_by_counterparty_or_contract"], + unsupported_operations: ["close_period"], + required_entities: ["period_or_date"], + optional_entities: ["organization", "account", "counterparty"], + typical_queries: ["Кто нам должен на дату?", "Хвосты покажи по счёту 60 за период"], + related_routes: [], + safe_alternatives: ["Уточнить период, счёт или организацию"], + one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] + }, + { + group_code: "cash", + group_title: "Деньги", + description: "Остатки и движение по денежным счетам и кассе", + risk_level: "medium", + maturity_status: "production_ready", + supported_operations: ["account_balance_snapshot", "bank_operations_by_counterparty"], + unsupported_operations: ["post_bank_statement"], + required_entities: ["date_or_period"], + optional_entities: ["organization", "account", "counterparty"], + typical_queries: ["Какой остаток по счёту 51 на дату?", "Покажи движение денег за месяц"], + related_routes: [], + safe_alternatives: ["Уточнить счёт или период"], + one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] + }, + { + group_code: "inventory", + group_title: "Склад и товары", + description: "Подтверждённые остатки, происхождение и документы по товарным позициям", + risk_level: "medium", + maturity_status: "production_ready", + supported_operations: [ + "inventory_on_hand_as_of_date", + "inventory_purchase_provenance_for_item", + "inventory_purchase_documents_for_item" + ], + unsupported_operations: ["write_off_inventory"], + required_entities: ["date_or_period"], + optional_entities: ["organization", "warehouse", "item"], + typical_queries: ["Какие товары сейчас лежат на складе?", "Кто поставил эту позицию?"], + related_routes: [], + safe_alternatives: ["Уточнить организацию, дату или позицию"], + one_c_hints: ["РегистрБухгалтерии.Хозрасчетный"] + }, { group_code: "boundaries", group_title: "Ограничения", - description: "Операции, которые ассистент не выполняет.", + description: "Операции, которые ассистент не выполняет в этом рантайме", risk_level: "high", maturity_status: "production_ready", supported_operations: ["explain_boundary", "suggest_safe_next_step"], @@ -73,7 +122,7 @@ const FALLBACK_REGISTRY: CapabilityRegistry = { optional_entities: [], typical_queries: ["Можешь настроить 1С?"], related_routes: [], - safe_alternatives: ["Сформировать план диагностики для 1С/ИТ-админа"], + safe_alternatives: ["Сформировать безопасный план диагностики для 1С или ИТ-админа"], one_c_hints: [] } ] @@ -171,17 +220,21 @@ export function loadCapabilitiesRegistry(): CapabilityRegistry { export function buildCapabilityContractReplyFromRegistry(): string { const registry = loadCapabilitiesRegistry(); - const topGroups = registry.groups.slice(0, 6); + const topGroups = registry.groups.filter((group) => group.group_code !== "boundaries").slice(0, 6); const groupLines = topGroups.map((group, index) => { - const ops = group.supported_operations.slice(0, 3).join(", "); - return `${index + 1}. ${group.group_title}: ${group.description}${ops ? ` (например: ${ops})` : ""}.`; + const examples = group.typical_queries + .slice(0, 2) + .map((query) => query.trim()) + .filter((query) => query.length > 0) + .join("; "); + return `${index + 1}. ${group.group_title}: ${group.description}${examples ? `. Например: ${examples}` : "."}`; }); return [ - "Я ассистент по анализу данных 1С в режиме чтения.", - "Что умею по группам:", + "Могу помочь с вопросами по данным 1С в режиме чтения: НДС, контрагенты, долги, деньги и склад.", + "По основным группам:", ...groupLines, - "Если хотите, раскрою любую группу точечно и дам готовую формулировку запроса.", + "Если нужно, подскажу, как лучше сформулировать запрос под вашу задачу.", "Что не делаю: не настраиваю 1С, не меняю конфигурацию, не создаю и не провожу документы, не выполняю админ-действия на сервере." ].join("\n"); } diff --git a/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts b/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts index 2d6bfaf..2916bda 100644 --- a/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts +++ b/llm_normalizer/backend/tests/addressCounterpartyUtf8Regression.test.ts @@ -52,4 +52,20 @@ describe("address counterparty utf8 regression", () => { expect(result.intent).toBe("list_documents_by_counterparty"); }); + + it("classifies direct company activity-age wording with a colloquial organization anchor", () => { + const result = resolveCounterpartyAddressIntent( + "а по Альтернативе Плюс сколько лет активности в базе 1С?", + 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 direct company activity-age wording", () => { + const result = resolveAddressIntent("а по Альтернативе Плюс сколько лет активности в базе 1С?"); + + expect(result.intent).toBe("counterparty_activity_lifecycle"); + }); }); diff --git a/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts b/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts index f008184..5efe51c 100644 --- a/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts +++ b/llm_normalizer/backend/tests/addressInventoryRootFrameRegression.test.ts @@ -205,4 +205,37 @@ describe("inventory root frame regressions", () => { expect(result?.filters.extracted_filters.period_to).toBe("2019-07-31"); expect(result?.filters.extracted_filters.as_of_date).toBe("2019-07-31"); }); + it("keeps the freshest previous date when inventory root restore follows a receivables step", () => { + const result = runAddressDecomposeStage("остатки по складу на эту же дату", { + previous_intent: "receivables_confirmed_as_of_date", + target_intent: "inventory_on_hand_as_of_date", + previous_filters: { + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31" + }, + previous_anchor_type: "organization", + previous_anchor_value: 'ООО "Альтернатива Плюс"', + root_intent: "inventory_on_hand_as_of_date", + root_filters: { + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + }, + root_anchor_type: "organization", + root_anchor_value: 'ООО "Альтернатива Плюс"', + root_context_only: true, + current_frame_kind: "inventory_root" + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("inventory_on_hand_as_of_date"); + expect(result?.intent.reasons).toContain("intent_restored_to_inventory_root_frame"); + expect(result?.filters.extracted_filters.organization).toBe('ООО "Альтернатива Плюс"'); + 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"); + }); }); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 8ada357..63d1db1 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -3746,6 +3746,29 @@ describe("address query limited taxonomy and stage diagnostics", { timeout: 1500 expect(result?.debug.mcp_call_status).not.toBe("skipped"); }); + it("keeps colloquial follow-up activity-age wording in the lifecycle aggregate recipe", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("а по Альтернативе Плюс сколько лет активности в базе 1С?", { + activeOrganization: 'ООО "Альтернатива Плюс"' + }); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle"); + expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1"); + expect(result?.debug.mcp_call_status).not.toBe("skipped"); + }); + + it("keeps colloquial follow-up activity-age wording grounded to the selected organization", async () => { + const service = new AddressQueryService(); + const result = await service.tryHandle("а по Альтернативе Плюс сколько лет активности в базе 1С?", { + activeOrganization: 'ООО "Альтернатива Плюс"' + }); + expect(result?.handled).toBe(true); + expect(result?.debug.detected_intent).toBe("counterparty_activity_lifecycle"); + expect(result?.debug.selected_recipe).toBe("address_counterparty_activity_lifecycle_v1"); + expect(result?.debug.extracted_filters.counterparty).toBeUndefined(); + expect(result?.debug.mcp_call_status).not.toBe("materialized_but_not_anchor_matched"); + }); + it("routes debt-longevity wording into receivables lane with factual reply", async () => { const service = new AddressQueryService(); const result = await service.tryHandle( diff --git a/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts b/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts index a70df50..e61502e 100644 --- a/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantBoundaryPolicy.test.ts @@ -8,7 +8,10 @@ function createPolicy() { if (value === null || value === undefined) { return null; } - const text = String(value ?? "").trim(); + const text = String(value ?? "") + .replace(/\\/g, "") + .replace(/([А-Яа-яA-Za-z])"([А-Яа-яA-Za-z])/gu, "$1в$2") + .trim(); return text.length > 0 ? text : null; }, toNonEmptyString: (value: unknown) => { @@ -38,6 +41,21 @@ describe("assistantBoundaryPolicy", () => { expect(reply.toLowerCase()).not.toContain("read-only"); }); + it("normalizes noisy organization labels in data-scope reply", () => { + const policy = createPolicy(); + + const reply = policy.buildAssistantDataScopeContractReply({ + status: "resolved", + channel: "default", + organizations: ['ООО \\Альтернати"а Плюс\\', 'ООО \\Лайс"уд\\'] + }); + + expect(reply).toContain('ООО Альтернатива Плюс'); + expect(reply).toContain('ООО Лайсвуд'); + expect(reply).not.toContain('\\"'); + expect(reply).not.toContain("\\"); + }); + it("strips unexpected CJK fragments from live chat reply", () => { const policy = createPolicy(); diff --git a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts index 887e737..ea1d255 100644 --- a/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts +++ b/llm_normalizer/backend/tests/assistantLivingChatMode.test.ts @@ -409,8 +409,13 @@ describe("assistant living chat mode", () => { expect(response.ok).toBe(true); expect(response.reply_type).toBe("factual_with_explanation"); + expect(String(response.assistant_reply).toLowerCase()).toContain("могу помочь"); expect(String(response.assistant_reply).toLowerCase()).toContain("\u0440\u0435\u0436\u0438\u043c\u0435 \u0447\u0442\u0435\u043d\u0438\u044f"); expect(String(response.assistant_reply).toLowerCase()).toContain("\u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u044e 1\u0441"); + expect(String(response.assistant_reply)).not.toContain("vat_period_snapshot"); + expect(String(response.assistant_reply)).not.toContain("inventory_on_hand_as_of_date"); + expect(String(response.assistant_reply)).not.toContain("suggest_safe_next_step"); + expect(String(response.assistant_reply)).not.toContain("explain_boundary"); expect(response.debug?.tool_gate_reason).toBe("assistant_capability_query_detected"); expect(chatClient.chat).toHaveBeenCalledTimes(0); expect(addressQueryService.tryHandle).toHaveBeenCalledTimes(0); diff --git a/llm_normalizer/backend/tests/assistantOrganizationMatcher.test.ts b/llm_normalizer/backend/tests/assistantOrganizationMatcher.test.ts index 5ef5f94..9138278 100644 --- a/llm_normalizer/backend/tests/assistantOrganizationMatcher.test.ts +++ b/llm_normalizer/backend/tests/assistantOrganizationMatcher.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { mergeKnownOrganizations, normalizeOrganizationScopeSearchText, + normalizeOrganizationScopeValue, + organizationsLikelySameEntity, resolveOrganizationSelectionFromMessage, scoreOrganizationMentionInMessage } from "../src/services/assistantOrganizationMatcher"; @@ -17,8 +19,13 @@ describe("assistant organization matcher", () => { ).toEqual(['ООО "Альтернатива Плюс"', "ООО Лайсвуд"]); }); + it("repairs noisy display labels before exposing them to the user", () => { + expect(normalizeOrganizationScopeValue('ООО \\Альтернати"а Плюс\\')).toBe("ООО Альтернатива Плюс"); + expect(normalizeOrganizationScopeValue('ООО \\Лайс"уд\\')).toBe("ООО Лайсвуд"); + }); + it("matches incomplete or reordered organization mention against live candidates", () => { - const resolved = resolveOrganizationSelectionFromMessage("дай что сегодня на складе в конторе ссыт кот", [ + const resolved = resolveOrganizationSelectionFromMessage("дай что сегодня на складе в конторе кот ссыт", [ "ООО КОТ ССЫТ ВО ДВОРЕ", "ООО Альтернатива Плюс" ]); @@ -34,4 +41,12 @@ describe("assistant organization matcher", () => { expect(score).toBeGreaterThanOrEqual(90); }); + + it("treats minor live label corruption as the same organization entity", () => { + expect(organizationsLikelySameEntity("Альтернатива Плюс", 'ООО "Альтернати"а Плюс"')).toBe(true); + }); + + it("does not merge different organizations with only one shared token", () => { + expect(organizationsLikelySameEntity('ООО "Альтернатива Плюс"', 'ООО "Альтернатива Минус"')).toBe(false); + }); }); diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index a17d0f9..8423ab1 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { createAssistantTransitionPolicy } from "../src/services/assistantTransitionPolicy"; +import { buildRootScopedCarryoverFiltersForTests } from "../src/services/assistantService"; function toNonEmptyString(value: unknown): string | null { if (value === null || value === undefined) { @@ -76,8 +77,20 @@ function buildPolicy(overrides: Record = {}) { isInventoryRootFrameIntent: (intent: unknown) => String(intent ?? "") === "inventory_on_hand_as_of_date", findRecentAddressFilterValue: () => null, hasForeignAccountingPivotOverInventoryMessage: () => false, - buildRootScopedCarryoverFilters: (_previousFilters: Record, inventoryRootFrame: Record) => ({ - ...(inventoryRootFrame?.filters ?? {}) + buildRootScopedCarryoverFilters: ( + previousFilters: Record, + inventoryRootFrame: Record + ) => ({ + organization: + toNonEmptyString(inventoryRootFrame?.filters?.organization) ?? toNonEmptyString(previousFilters?.organization), + warehouse: + toNonEmptyString(inventoryRootFrame?.filters?.warehouse) ?? toNonEmptyString(previousFilters?.warehouse), + as_of_date: + toNonEmptyString(previousFilters?.as_of_date) ?? toNonEmptyString(inventoryRootFrame?.filters?.as_of_date), + period_from: + toNonEmptyString(previousFilters?.period_from) ?? toNonEmptyString(inventoryRootFrame?.filters?.period_from), + period_to: + toNonEmptyString(previousFilters?.period_to) ?? toNonEmptyString(inventoryRootFrame?.filters?.period_to) }), inferDisplayedEntityTypeFromIntent: () => "item", extractDisplayedAddressEntityCandidates: () => [], @@ -109,7 +122,7 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.root_context_only).toBe(true); expect(carryover?.followupContext?.target_intent).toBe("inventory_on_hand_as_of_date"); expect(carryover?.followupContext?.root_intent).toBe("inventory_on_hand_as_of_date"); - expect(carryover?.followupContext?.previous_filters).toEqual({ + expect(carryover?.followupContext?.previous_filters).toMatchObject({ as_of_date: "2020-03-31", organization: 'ООО "Альтернатива Плюс"' }); @@ -131,7 +144,7 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupSelectionMode).toBe("carry_root_context"); expect(carryover?.followupContext?.root_context_only).toBe(true); expect(carryover?.followupContext?.previous_intent).toBeUndefined(); - expect(carryover?.followupContext?.previous_filters).toEqual({ + expect(carryover?.followupContext?.previous_filters).toMatchObject({ as_of_date: "2020-03-31", organization: 'ООО "Альтернатива Плюс"' }); @@ -169,6 +182,7 @@ describe("assistantTransitionPolicy", () => { expect(contract.anchor_type).toBe("item"); expect(contract.anchor_value).toBe("Рабочая станция"); }); + it("prefers carryover target intent over llm contract drift in continuation contract", () => { const policy = buildPolicy(); @@ -257,6 +271,7 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.previous_intent).toBe("list_documents_by_counterparty"); expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); }); + it("keeps root-scoped carryover for foreign accounting pivot over inventory drilldown", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({ @@ -308,4 +323,32 @@ describe("assistantTransitionPolicy", () => { period_to: "2021-03-31" }); }); + + it("prefers the freshest previous date scope over a stale inventory root frame during same-date pivot", () => { + const filters = buildRootScopedCarryoverFiltersForTests( + { + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31" + }, + { + filters: { + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2021-03-31", + period_from: "2021-03-01", + period_to: "2021-03-31" + } + } + ); + + expect(filters).toEqual({ + organization: 'ООО "Альтернатива Плюс"', + warehouse: "Основной склад", + as_of_date: "2020-03-31", + period_from: "2020-03-01", + period_to: "2020-03-31" + }); + }); });