From 31cb4ccbbbce03c4a4b371497f143b956beccb33 Mon Sep 17 00:00:00 2001 From: dctouch Date: Sat, 18 Apr 2026 11:50:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D0=A0=D0=A7=20=D0=90=D0=9F11=20-=20?= =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=81=D0=B0:=20+=20=D0=90=D1=80=D1=85=D0=B8?= =?UTF-8?q?=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D0=B0:=20=D0=B2=D0=BE?= =?UTF-8?q?=D1=81=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?bridge=20=D0=BE=D1=82=20provenance=20=D0=B2=D1=8B=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BA=20VAT-=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=D1=83=20=D0=B8=20=D0=B7=D0=B0=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D1=8C=20phase10=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ontinuity_stabilization_plan_2026-04-17.md | 18 ++ ...n_system_analysis_3NilqwT1G2_2026-04-18.md | 240 ++++++++++++++++ ...ase10_manual_bridge_and_aggregate_mix.json | 265 ++++++++++++++++++ .../dist/services/addressFilterExtractor.js | 6 +- .../dist/services/addressIntentResolver.js | 79 +++++- .../counterpartyAnalyticsReplyBuilders.js | 20 +- .../address_runtime/decomposeStage.js | 23 ++ .../address_runtime/inventoryReplyBuilders.js | 4 +- .../dist/services/assistantRoutePolicy.js | 1 + .../backend/dist/services/assistantService.js | 34 ++- .../services/assistantTransitionPolicy.js | 130 ++++++++- .../src/services/addressFilterExtractor.ts | 8 +- .../src/services/addressIntentResolver.ts | 123 +++++++- .../counterpartyAnalyticsReplyBuilders.ts | 20 +- .../address_runtime/decomposeStage.ts | 31 ++ .../address_runtime/inventoryReplyBuilders.ts | 4 +- .../src/services/assistantRoutePolicy.ts | 1 + .../backend/src/services/assistantService.ts | 34 ++- .../src/services/assistantTransitionPolicy.ts | 144 +++++++++- .../addressFilterExtractorRegression.test.ts | 17 ++ .../addressFollowupTemporalRegression.test.ts | 26 +- .../addressIntentResolverRegression.test.ts | 39 +++ .../tests/addressQueryRuntimeM23.test.ts | 7 +- .../addressReplyBuildersRegression.test.ts | 145 ++++++++++ .../tests/assistantRoutePolicy.test.ts | 47 ++++ .../tests/assistantTransitionPolicy.test.ts | 127 +++++++++ ..._saved_session_runtime_job-vc0r4pNwB-.json | 111 ++++++++ 27 files changed, 1655 insertions(+), 49 deletions(-) create mode 100644 docs/ARCH/11 - architecture_turnaround/12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md create mode 100644 docs/orchestration/address_truth_harness_phase10_manual_bridge_and_aggregate_mix.json create mode 100644 llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts create mode 100644 llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts create mode 100644 llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts create mode 100644 llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-vc0r4pNwB-.json 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 4011941..ecc41f3 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 @@ -193,6 +193,24 @@ Still open after the accepted phase9 replay: - business answers are now semantically correct on this path, but some inventory list formatting still feels heavier and more mechanical than the target human style; - the next architecture slice should keep expanding saved-session proof across additional real user chains, while separately tightening answer presentation so exact routes do not feel template-driven even when the truth path is already correct. +Latest phase10 bridge-and-aggregate evidence after the manual replay recovery: + +- live replay `address_truth_harness_phase10_manual_bridge_and_aggregate_mix_live_20260418_rerun8` is accepted end-to-end with `9/9` steps green; +- the previously broken bridge `selected item purchase provenance -> VAT on purchase date` is now explicit instead of implicit: + - the continuity layer derives the purchase month from the grounded provenance evidence; + - the same session keeps `selected object` continuity instead of collapsing into generic root-only VAT arbitration; + - the runtime now routes this follow-up as `vat_liability_confirmed_for_tax_period`, not as `forecast`, `unknown`, or generic clarification; +- the same replay also proves that the neighboring aggregate fixes are live on the real assistant path: + - top-customer-all-time now returns a direct business answer first; + - top-year aggregate now returns a direct business answer first; + - very-old-stock now prefers `inventory_aging_by_purchase_date` over a generic inventory snapshot; +- this matters architecturally because the seam that used to exist only as ambient monolith behavior is now protected as an explicit carryover contract plus replay-backed acceptance path. + +Still open after the accepted phase10 replay: + +- the user-facing VAT explanation block is now correct and grounded, but some long exact answers still feel heavier than the target human product tone; +- the next architecture slice should keep moving from repaired bridge authority into answer-shaping cleanup and broader saved-session replay coverage, not back into isolated wording tweaks. + ## Ready Signal The project can leave the current breakpoint when: diff --git a/docs/ARCH/11 - architecture_turnaround/12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md b/docs/ARCH/11 - architecture_turnaround/12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md new file mode 100644 index 0000000..b2b75e7 --- /dev/null +++ b/docs/ARCH/11 - architecture_turnaround/12 - manual_run_system_analysis_3NilqwT1G2_2026-04-18.md @@ -0,0 +1,240 @@ +# 12 - Manual Run System Analysis `assistant-stage1-3NilqwT1G2` (2026-04-18) + +## Purpose + +This note analyzes the manual saved-session run `assistant-stage1-3NilqwT1G2`. + +The goal is not to review isolated prompts. + +The goal is to determine: + +- which failures should improve as orchestration / continuity authority is repaired; +- which failures are separate route or capability gaps; +- which failures come from answer shaping and presentation rather than from routing. + +## Evidence + +Primary artifacts: + +- `llm_normalizer/reports/assistant-stage1-3NilqwT1G2.md` +- `llm_normalizer/reports/assistant-stage1-3NilqwT1G2.json` +- `llm_normalizer/data/assistant_sessions/assistant-stage1-3NilqwT1G2-SAVED-001.json` + +Relevant architecture context: + +- `10 - regression_breakpoint_analysis_2026-04-17.md` +- `11 - continuity_stabilization_plan_2026-04-17.md` +- `docs/orchestration/active_domain_contract.json` + +## High-Level Verdict + +This run is bad for more than one reason. + +It is not a single orchestration bug. + +It is also not a pure capability-gap run. + +The failure pattern is mixed: + +1. Some turns fail because the right contour already exists, but orchestration or intent selection does not reach it. +2. Some turns fail because extracted neighboring entities no longer share an explicit bridge contract. +3. Some turns fail because the needed business capability family is only partially modeled. +4. Some turns are technically routed but still feel bad because answer shape and presentation are too template-like. + +## What Still Works + +The following contours are still alive in this run: + +- inventory root snapshot and historical date carryover; +- selected-item supplier provenance; +- selected-item sale trace; +- confirmed receivables snapshot on a date; +- VAT confirmed for the carried tax period after a debt-date question; +- documents by counterparty; +- inventory root return on later turns. + +This matters because it means the runtime foundation is not dead. + +The project did not lose all exact routes. + +The main problem is that the system no longer enters the correct contour reliably under mixed real-session wording. + +## Failure Families + +### A. Contour exists, but the runtime does not enter it + +These are the clearest orchestration / intent-resolution failures. + +#### `Q12` - `прикинь какой ндс нам надо заплатить на февраль 2017` + +- Actual behavior: fell into `living_chat`, `living_chat_response_source = llm_chat`. +- Why this is bad: the system already has `vat_liability_confirmed_for_tax_period` as a real contour. +- Why this is architecture, not wording only: the right family exists, but the lane authority does not protect it under colloquial phrasing. + +#### `Q16` - `мы должны комуто денег на сегодня?` + +- Actual behavior: `detected_intent = unknown`, partial refusal. +- Why this is bad: `payables_confirmed_as_of_date` exists in recipe, capability, filter, compose, and navigation layers. +- Why this is architecture: the contour is present, but the resolver / route arbitration fails to bind the phrase to the payables family. + +#### `Q29` - `Есть ли остатки товара, которые закупались очень давно` + +- Actual behavior: routed as `inventory_on_hand_as_of_date`, returned a generic current stock snapshot. +- Why this is bad: the active domain contract explicitly contains the old-stock / aging path, and the runtime has `inventory_aging_by_purchase_date`. +- Why this is architecture: generic inventory root wins over the more specific aging branch because precedence is wrong. + +### B. Cross-domain bridge disappeared after extraction + +These are the most important "neighbor entities used to live together" failures. + +#### `Q10` - `ндс можешь прикинуть на дату покупки рабочей станции?` + +- Actual behavior: `detected_intent = unknown`, no route, organization only, selected-item / purchase-date context dropped. +- What this should have used: selected item -> purchase provenance bundle -> purchase date / period -> VAT contour. +- Why this is architecture: the system can answer selected-item provenance and can answer VAT periods, but the bridge between those adjacent contours is not explicit. + +#### `Q17` - `а нам?` + +- Actual behavior: rewritten into a generic "our company / our base" question and lost the debt polarity follow-up. +- What this should have used: `payables -> receivables` role-swap follow-up over the same date scope. +- Why this is architecture: short polarity pivots need an explicit follow-up contract, not free-form reinterpretation. + +#### `Q23` - `а по свк` + +- Actual behavior: stayed inside `list_documents_by_counterparty`, but the short alias retarget is semantically weak and the answer quality is poor. +- Why this is architecture: short retargets over an existing counterparty lane need a dedicated alias / retarget seam. + +### C. Business family partially exists, but not at the right grain + +These are not fixed by continuity alone. + +#### `Q13` - `кто у нас самый доходный клиент за все время` + +- Actual behavior: `detected_intent = unknown`, partial refusal. +- What exists nearby: `customer_revenue_and_payments`. +- What is missing in practice: stable ranking / all-time aggregate handling for company-level customer revenue questions under colloquial wording. + +#### `Q18` - `какой у нас самый доходный год` + +#### `Q19` - `а за 2017 мы скок заработали?` + +#### `Q20` - `сколько вообще денег мы заработали за все время?` + +- Actual behavior: broad refusal or partial refusal. +- Why this matters: these are not random questions; they are business analytics asks at company/year/all-time aggregate grain. +- Why this is not only orchestration: current nearby revenue contour is not enough to honestly cover company-year and all-time aggregate semantics. + +### D. Route exists nearby, but wording-to-intent binding regressed + +#### `Q11` - `а какой ндс мы должны сгрузить на март 2020?` + +- Actual behavior: `detected_intent = unknown`. +- What exists nearby: VAT confirmed liability contour. +- Why this matters: colloquial accounting wording like `сгрузить НДС` should not fall out of contour if the product claims live accountant dialogue. + +#### `Q25` - `что нам отгружать чепурнов? какой товар или услугу?` + +- Actual behavior: partial refusal. +- What exists nearby: counterparty documents and related shipment evidence. +- Why this matters: the system has adjacent evidence contours but lacks a stable "counterparty -> shipped items/services" interpretation. + +### E. User-facing quality defects even when routing is not fully broken + +#### `Q2` - `расскажи что можешь интересного` + +- The answer is cleaner than before, but still catalog-like and mechanically enumerated. + +#### `Q14` / `Q15` + +- These turns route correctly, but the answer shape still exposes internal "block" thinking and feels less human than the target product tone. + +#### `Q28` + +- The route works, but the result relies on fallback reasoning (`confirmed_balance_unavailable_fallback_to_heuristic_candidates` / `open_items_account_query_override_to_movements`), so business confidence should stay explicit. + +## System Classification + +This run should be read as: + +- `orchestration / continuity authority failures`: `Q10`, `Q12`, `Q16`, `Q17`, `Q23`, `Q29` +- `resolver / wording-to-intent failures`: `Q11`, `Q13`, `Q25` +- `aggregate business contour gaps`: `Q18`, `Q19`, `Q20` +- `answer-shape / presentation defects`: `Q2`, `Q14`, `Q15`, `Q28` + +The important conclusion: + +- a large share of the bad run is still architecture and orchestration; +- but not all of it will heal automatically just by continuing continuity fixes. + +## What The Old Monolith Likely Had + +Before extraction, several neighboring decisions lived in one ambient runtime region: + +- intent guess; +- follow-up carryover; +- selected-object reuse; +- organization/date reuse; +- recipe selection; +- answer packaging. + +That old shape was fragile, but it allowed adjacent capabilities to "borrow" context from one another without explicit contracts. + +After extraction, those same borrow paths became seams. + +The system now needs those seams to be first-class contracts. + +Without that, optimization does not produce a stronger stone. + +It produces split pieces whose joints are filled by ad hoc glue. + +## Architecture Requirement + +The extraction path is still the right direction only if every broken adjacency becomes explicit. + +The missing explicit seams now visible in this run are: + +- `inventory root <-> inventory aging` +- `selected item / provenance bundle <-> VAT period ask` +- `payables <-> receivables polarity flip` +- `counterparty documents <-> short alias retarget` +- `counterparty evidence <-> shipped goods/services summary` +- `organization scope <-> company analytics aggregates` + +Each seam needs: + +1. an explicit transition / carryover contract; +2. one clear intent-family arbitration rule; +3. answer-shape expectations for the user-facing reply; +4. replay coverage inside a saved-session scenario, not only in isolated unit cases. + +## Final Conclusion + +Yes, the architecture can become better than the old monolith. + +But only under one condition: + +- every extracted neighbor relationship that used to work implicitly must now be reintroduced as an explicit contract and guarded by scenario-level acceptance. + +If that is done, the stone becomes better: + +- less magical; +- more inspectable; +- safer to expand across domains. + +If that is not done, the stone does not become better. + +It becomes fragmented and cosmetically re-glued. + +## Next Recommended Slice + +The next phase should not be a random patch sweep. + +It should be one focused replay pack built from this exact saved session with priority order: + +1. `inventory -> selected item -> VAT purchase-date bridge` +2. `direct VAT colloquial liability wording` +3. `payables / receivables polarity swap` +4. `inventory aging vs generic inventory precedence` +5. `company analytics aggregates` + +That pack should become the next acceptance gate before wider domain expansion. diff --git a/docs/orchestration/address_truth_harness_phase10_manual_bridge_and_aggregate_mix.json b/docs/orchestration/address_truth_harness_phase10_manual_bridge_and_aggregate_mix.json new file mode 100644 index 0000000..723e19a --- /dev/null +++ b/docs/orchestration/address_truth_harness_phase10_manual_bridge_and_aggregate_mix.json @@ -0,0 +1,265 @@ +{ + "schema_version": "domain_truth_harness_spec_v1", + "scenario_id": "address_truth_harness_phase10_manual_bridge_and_aggregate_mix", + "domain": "address_phase10_manual_bridge_and_aggregate_mix", + "title": "Phase 10 manual bridge and aggregate replay for orchestration recovery", + "description": "Focused AGENT replay built from the manual saved session assistant-stage1-3NilqwT1G2. The scenario validates broken seams where neighboring contours used to cooperate implicitly: inventory -> selected item -> VAT purchase-date bridge, colloquial VAT liability wording, payables/receivables polarity flip, company-level revenue aggregates, and inventory aging versus generic stock snapshot precedence.", + "bindings": {}, + "steps": [ + { + "step_id": "step_01_inventory_historical_anchor", + "title": "Historical inventory anchor on March 2016", + "question": "остатки на март 2016", + "allowed_reply_types": [ + "factual" + ], + "expected_intents": [ + "inventory_on_hand_as_of_date" + ], + "expected_recipe": "address_inventory_on_hand_as_of_date_v1", + "required_filters": { + "as_of_date": "2016-03-31", + "period_from": "2016-03-01", + "period_to": "2016-03-31" + }, + "required_direct_answer_patterns_any": [ + "31\\.03\\.2016", + "(?i)на складе" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_root", + "historical_date_anchor" + ] + }, + { + "step_id": "step_02_selected_item_purchase_provenance", + "title": "Selected workstation supplier / purchase provenance on the same date", + "question": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "inventory_purchase_provenance_for_item" + ], + "expected_recipe": "address_inventory_purchase_provenance_for_item_v1", + "required_filters": { + "item": "Рабочая станция универсального специалиста (индивидуальное изготовление)", + "as_of_date": "{{step_01_inventory_historical_anchor.filters.as_of_date}}" + }, + "required_direct_answer_patterns_any": [ + "(?i)поставщик|закуп", + "(?i)рабочая станция" + ], + "criticality": "critical", + "semantic_tags": [ + "selected_object", + "inventory_provenance", + "date_scope" + ] + }, + { + "step_id": "step_03_vat_on_purchase_date_bridge", + "title": "VAT approximate on the purchase date of the selected item", + "question": "ндс можешь прикинуть на дату покупки рабочей станции?", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_intents": [ + "vat_liability_confirmed_for_tax_period" + ], + "required_direct_answer_patterns_any": [ + "(?i)ндс", + "(?i)дата покупки|период|налоговый период|не удается точно" + ], + "forbidden_direct_answer_patterns": [ + "(?i)mcp", + "(?i)address_mode", + "(?i)tool_gate_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "bridge_inventory_to_vat", + "selected_object", + "purchase_date_bundle" + ] + }, + { + "step_id": "step_04_colloquial_vat_tax_period", + "title": "Colloquial VAT liability wording on February 2017", + "question": "прикинь какой ндс нам надо заплатить на февраль 2017", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_intents": [ + "vat_liability_confirmed_for_tax_period" + ], + "expected_recipe": "address_vat_liability_confirmed_tax_period_v1", + "required_filters": { + "period_from": "2017-02-01", + "period_to": "2017-02-28" + }, + "required_direct_answer_patterns_any": [ + "(?i)ндс", + "(?i)2017" + ], + "forbidden_direct_answer_patterns": [ + "(?i)mcp", + "(?i)address_mode", + "(?i)tool_gate_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "vat_colloquial_wording", + "tax_period" + ] + }, + { + "step_id": "step_05_payables_today", + "title": "Payables snapshot for today", + "question": "мы должны комуто денег на сегодня?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "payables_confirmed_as_of_date", + "list_payables_counterparties" + ], + "required_direct_answer_patterns_any": [ + "(?i)кредитор|обязатель|долж", + "(?i)сегодня|на .*20" + ], + "forbidden_direct_answer_patterns": [ + "(?i)mcp", + "(?i)address_mode", + "(?i)tool_gate_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "payables", + "debt_polarity" + ] + }, + { + "step_id": "step_06_receivables_role_flip", + "title": "Short polarity flip from payables to receivables", + "question": "а нам?", + "allowed_reply_types": [ + "factual", + "partial_coverage" + ], + "expected_intents": [ + "receivables_confirmed_as_of_date", + "list_receivables_counterparties" + ], + "required_direct_answer_patterns_any": [ + "(?i)дебитор|нам должны|должны нам", + "(?i)сегодня|на .*20|не удается точно" + ], + "forbidden_direct_answer_patterns": [ + "(?i)что относится к вашей компании", + "(?i)mcp", + "(?i)address_mode", + "(?i)tool_gate_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "receivables", + "polarity_flip", + "followup_short" + ] + }, + { + "step_id": "step_07_top_customer_all_time", + "title": "Top customer by all-time revenue", + "question": "кто у нас самый доходный клиент за все время", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage", + "clarification_required" + ], + "expected_intents": [ + "customer_revenue_and_payments" + ], + "required_direct_answer_patterns_any": [ + "(?i)клиент|контрагент|доход|выруч", + "(?i)не удается точно|за все время|самый доходный" + ], + "forbidden_direct_answer_patterns": [ + "(?i)mcp", + "(?i)address_mode", + "(?i)tool_gate_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "customer_analytics", + "aggregate_all_time", + "ranking" + ] + }, + { + "step_id": "step_08_top_year_revenue", + "title": "Most profitable year", + "question": "какой у нас самый доходный год", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage", + "clarification_required" + ], + "expected_intents": [ + "customer_revenue_and_payments" + ], + "required_direct_answer_patterns_any": [ + "(?i)год|доход|выруч|прибыл", + "(?i)не удается точно|самый доходный" + ], + "forbidden_direct_answer_patterns": [ + "(?i)mcp", + "(?i)address_mode", + "(?i)tool_gate_reason" + ], + "criticality": "critical", + "semantic_tags": [ + "company_analytics", + "aggregate_year", + "ranking" + ] + }, + { + "step_id": "step_09_very_old_stock", + "title": "Very old stock should prefer aging contour over generic inventory snapshot", + "question": "Есть ли остатки товара, которые закупались очень давно", + "allowed_reply_types": [ + "factual", + "factual_with_explanation", + "partial_coverage" + ], + "expected_intents": [ + "inventory_aging_by_purchase_date" + ], + "expected_recipe": "address_inventory_aging_by_purchase_date_v1", + "required_direct_answer_patterns_any": [ + "(?i)стар|давно|закуп", + "(?i)остат" + ], + "forbidden_direct_answer_patterns": [ + "(?i)18\\.04\\.2026", + "(?i)на складе подтверждено \\d+ позиц" + ], + "criticality": "critical", + "semantic_tags": [ + "inventory_aging", + "very_old_stock", + "precedence" + ] + } + ] +} diff --git a/llm_normalizer/backend/dist/services/addressFilterExtractor.js b/llm_normalizer/backend/dist/services/addressFilterExtractor.js index 79f2cd5..3dc4d40 100644 --- a/llm_normalizer/backend/dist/services/addressFilterExtractor.js +++ b/llm_normalizer/backend/dist/services/addressFilterExtractor.js @@ -1598,7 +1598,11 @@ function extractAddressFilters(userMessage, intent) { } } } - if (intent === "vat_liability_confirmed_for_tax_period" && !periodRange.period_from && !periodRange.period_to) { + const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase"); + if (intent === "vat_liability_confirmed_for_tax_period" && + !periodRange.period_from && + !periodRange.period_to && + !monthPeriodWasDerived) { const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; if (periodToForQuarter) { const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); diff --git a/llm_normalizer/backend/dist/services/addressIntentResolver.js b/llm_normalizer/backend/dist/services/addressIntentResolver.js index 155589c..6668061 100644 --- a/llm_normalizer/backend/dist/services/addressIntentResolver.js +++ b/llm_normalizer/backend/dist/services/addressIntentResolver.js @@ -1,5 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); +exports.hasCompactAccountCodeToken = hasCompactAccountCodeToken; +exports.hasAccountNumberAnchor = hasAccountNumberAnchor; exports.resolveAddressIntent = resolveAddressIntent; const addressCounterpartyIntentSignals_1 = require("./addressCounterpartyIntentSignals"); const addressInventoryIntentSignals_1 = require("./addressInventoryIntentSignals"); @@ -1449,11 +1451,21 @@ function hasCustomerRevenueRankingBridgeSignal(text) { if (!normalized) { return false; } + const hasSupplierCue = /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|supplier|vendor)/iu.test(normalized); + if (hasSupplierCue) { + return false; + } + const hasDirectRankingCue = /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|customer|client)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u0433\u043e\u0434|(?:\u0430\s+)?(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a)\s+(?:\u0432\u043e\u043e\u0431\u0449\u0435\s+)?(?:\u0434\u0435\u043d\u0435\u0433\s+)?\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438(?:\s+\u0437\u0430\s+\u0432\u0441\u0435\s+\u0432\u0440\u0435\u043c\u044f)?|(?:\u0430\s+)?(?:\u0437\u0430|for)\s+\d{4}\s+\u043c\u044b\s+(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a)\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a)\s+\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438\s+\u0437\u0430\s+\d{4}|(?:\u043e\u0431\u0449\u0430\u044f\s+)?\u0432\u044b\u0440\u0443\u0447\u043a\u0430\s+\u0437\u0430\s+\d{4})/iu.test(normalized); + if (hasDirectRankingCue) { + return true; + } const hasMoneyCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover|money|inflow)/iu.test(normalized); if (!hasMoneyCue) { return false; } - return /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u0434\u0435\u043d\u0435\u0433)|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test(normalized); + const hasCustomerRankingCue = /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u0434\u0435\u043d\u0435\u0433)|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money|(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d\w*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|customer|client)))/iu.test(normalized); + const hasRevenueAggregateCue = /(?:(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d\w*\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|скок)\s+(?:\u0432\u043e\u043e\u0431\u0449\u0435\s+)?(?:\u0434\u0435\u043d\u0435\u0433\s+)?\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|(?:\u0437\u0430|for)\s+\d{4}\s+\u043c\u044b\s+(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|скок)\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u043a\w*\s+\u0437\u0430\s+\d{4})/iu.test(normalized); + return hasCustomerRankingCue || hasRevenueAggregateCue; } function hasInventoryProvenanceBridgeSignal(text) { const normalized = String(text ?? "").trim().toLowerCase(); @@ -1481,6 +1493,10 @@ function hasColloquialInventoryOnHandBridgeSignal(text) { if (!normalized) { return false; } + const hasInventoryAgingCue = /(?:\u043e\u0447\u0435\u043d\u044c\s+\u0434\u0430\u0432\u043d\u043e|\u0434\u0430\u0432\u043d\u043e\s+\u043a\u0443\u043f\u043b|\u0434\u0430\u0432\u043d\u043e\s+\u043f\u0440\u0438\u043e\u0431\u0440\u0435\u0442|\u0441\u0442\u0430\u0440(?:\u044b\u0435|\u044b\u043c|\u044b\u0445)?\s+\u0437\u0430\u043a\u0443\u043f|\u0441\u0442\u0430\u0440\u044b\u0439\s+\u0442\u043e\u0432\u0430\u0440|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test(normalized); + if (hasInventoryAgingCue) { + return false; + } const tokenCount = normalized.split(/\s+/u).filter(Boolean).length; const hasWarehouseCue = /(?:\u0441\u043a\u043b\u0430\u0434(?:\u0430\u0445|\u0435|\u0443|\u043e\u043c|\u044b)?|\u043e\u0441\u0442\u0430\u0442|warehouse|stock|inventory)/iu.test(normalized); if (!hasWarehouseCue) { @@ -1489,15 +1505,74 @@ function hasColloquialInventoryOnHandBridgeSignal(text) { const hasQuestionCue = /(?:\u0447\u0442\u043e|\u0447\u0435|\u0447\u0451|\u043a\u0430\u043a\u0438\u0435|\u043f\u043e\u043a\u0430\u0436\u0438|\u043f\u043e\u043a\u0430\u0437\u0430\u0442\u044c|show|list|what)/iu.test(normalized); return hasQuestionCue && tokenCount <= 8; } +function repairLikelyUtf8Mojibake(text) { + const raw = String(text ?? ""); + if (!raw) { + return ""; + } + try { + const repaired = Buffer.from(raw, "latin1").toString("utf8"); + return repaired || raw; + } + catch { + return raw; + } +} function resolveAddressIntent(userMessage) { const text = String(userMessage ?? "").trim().toLowerCase(); - if (hasCustomerRevenueRankingBridgeSignal(text)) { + const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase(); + const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text; + const hasLooseVatPayableBridge = /(?:\u043d\u0434\u0441|vat)/iu.test(text) && + /(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:\u043d\u0430\u043c\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e)|\u043d\u0430\u043c\s+\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0430\u043c\s+\u043d\u0443\u0436\u043d\u043e\s+\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test(text) && + /(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test(text); + if (hasLooseVatPayableBridge) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "high", + reasons: ["vat_liability_colloquial_bridge_signal_detected"] + }; + } + const hasExplicitReceivablesSnapshotBridge = /(?:\u043d\u0430\u043c\s+\u043a\u0442\u043e-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\w+\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043d\u0430\u043c|receivables?)/iu.test(text); + if (hasExplicitReceivablesSnapshotBridge) { + return { + intent: "receivables_confirmed_as_of_date", + confidence: "high", + reasons: ["receivables_snapshot_bridge_signal_detected"] + }; + } + const hasExplicitPayablesSnapshotBridge = /(?:\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|payables?)/iu.test(text); + if (hasExplicitPayablesSnapshotBridge) { + return { + intent: "payables_confirmed_as_of_date", + confidence: "high", + reasons: ["payables_snapshot_bridge_signal_detected"] + }; + } + const hasDirectInventoryAgingBridge = /(?:\u043e\u0447\u0435\u043d\u044c\s+\u0434\u0430\u0432\u043d\u043e|\u0434\u0430\u0432\u043d\u043e\s+\u043a\u0443\u043f\u043b|\u0434\u0430\u0432\u043d\u043e\s+\u043f\u0440\u0438\u043e\u0431\u0440\u0435\u0442|\u0441\u0442\u0430\u0440(?:\u044b\u0435|\u044b\u043c|\u044b\u0445)?\s+\u0437\u0430\u043a\u0443\u043f|\u0441\u0442\u0430\u0440(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0442\u043e\u0432\u0430\u0440|old\s+stock|old\s+purchase|very\s+old\s+stock|aging\s+by\s+purchase\s+date)/iu.test(bridgeText); + if (hasDirectInventoryAgingBridge || hasInventoryAgingSignal(text) || hasInventoryAgingSignal(repairedText)) { + return { + intent: "inventory_aging_by_purchase_date", + confidence: "high", + reasons: ["inventory_aging_bridge_signal_detected"] + }; + } + const hasDirectRevenueAggregateBridge = /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u043a\u043b\u0438\u0435\u043d\u0442|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a).*(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u0438\u043b\u0438)|\u0432\u044b\u0440\u0443\u0447\u043a\u0430\s+\u0437\u0430\s+\d{4})/iu.test(bridgeText); + if (hasDirectRevenueAggregateBridge || hasCustomerRevenueRankingBridgeSignal(bridgeText)) { return { intent: "customer_revenue_and_payments", confidence: "medium", reasons: ["customer_revenue_ranking_bridge_signal_detected"] }; } + const hasHistoricalInventorySnapshotBridge = [text, repairedText, bridgeText].some((sample) => /(?:\u043e\u0441\u0442\u0430\u0442|inventory|stock|\u0441\u043a\u043b\u0430\u0434|остат|склад)/iu.test(sample) && + /(?:(?:\u043d\u0430|\u0437\u0430|РЅР°|Р·Р°)\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)\S*(?:\s+(?:19|20)\d{2})?|(?:\u043d\u0430|\u0437\u0430|РЅР°|Р·Р°)\s+\d{4}|\b(?:19|20)\d{2}\b)/iu.test(sample)); + if (hasHistoricalInventorySnapshotBridge) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "medium", + reasons: ["inventory_historical_snapshot_bridge_signal_detected"] + }; + } if (hasInventoryDocumentaryChainBridgeSignal(text)) { return { intent: "inventory_purchase_to_sale_chain", diff --git a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js index dd1b471..be94f8e 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/counterpartyAnalyticsReplyBuilders.js @@ -487,14 +487,19 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { } if (focus === "top_years_by_total") { const visible = rankedByYearTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} лет по сумме выплат:` - : `Топ-${visible.length} лет по сумме поступлений:`; - lines.unshift(heading); if (visible.length === 0) { lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); } else { + const strongestYear = visible[0]; + const directAnswerLine = isSupplier + ? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).` + : `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`; + const heading = isSupplier + ? `Топ-${visible.length} лет по сумме выплат:` + : `Топ-${visible.length} лет по сумме поступлений:`; + lines.unshift(heading); + lines.unshift(directAnswerLine); lines.push(...visible.map((item, index) => `${index + 1}. ${item.year} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | контрагентов: ${item.counterparties.size} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`)); } return (0, replyContracts_1.buildFactualListReply)(lines); @@ -556,7 +561,14 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) { const heading = isSupplier ? `Топ-${visible.length} поставщиков по сумме выплат:` : `Топ-${visible.length} заказчиков по сумме поступлений:`; + const leadingCounterparty = visible[0] ?? null; lines.unshift(heading); + if (leadingCounterparty) { + const directAnswerLine = isSupplier + ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`; + lines.unshift(directAnswerLine); + } lines.push(...visible.map((item, index) => { const avgCheck = item.ops > 0 ? item.total / item.ops : 0; return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`; diff --git a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js index bda0760..d88c85b 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js +++ b/llm_normalizer/backend/dist/services/address_runtime/decomposeStage.js @@ -7,6 +7,7 @@ exports.hasInventoryPurchaseDateFollowupCue = hasInventoryPurchaseDateFollowupCu exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFollowupCue; exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue; exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue; +exports.hasInventoryPurchaseDateVatBridgeCue = hasInventoryPurchaseDateVatBridgeCue; exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal; exports.runAddressDecomposeStage = runAddressDecomposeStage; const addressQueryClassifier_1 = require("../addressQueryClassifier"); @@ -537,6 +538,14 @@ function hasInventorySaleFollowupCue(text) { function hasInventoryPurchaseToSaleChainFollowupCue(text) { return /(?:через\s+какие\s+документы\s+прош[её]л\s+путь\s+товара|закупк.*склад.*продаж|purchase[\s-]?to[\s-]?sale|purchase\s*->\s*(?:warehouse|stock)\s*->\s*sale)/iu.test(String(text ?? "")); } +function hasInventoryPurchaseDateVatBridgeCue(text) { + const normalized = String(text ?? "").trim(); + if (!normalized) { + return false; + } + return (/(?:ндс|vat)/iu.test(normalized) && + /(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(normalized)); +} function hasAddressFollowupContextSignal(text) { const normalized = String(text ?? "").trim(); if (!normalized) { @@ -563,6 +572,9 @@ function hasAddressFollowupContextSignal(text) { /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)) { return true; } + if (tokenCount <= 14 && hasInventoryPurchaseDateVatBridgeCue(normalized)) { + return true; + } const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized); if (tokenCount <= 8 && hasPeriodLiteral) { return true; @@ -1056,6 +1068,17 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo const isVatFollowup = hasVatCue(normalizedMessage); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); + const inventoryPurchaseDateVatBridge = inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage); + if (inventoryPurchaseDateVatBridge && + (detectedIntent.intent === "unknown" || + detectedIntent.intent === sourceIntent || + detectedIntent.intent === "vat_payable_confirmed_as_of_date")) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_purchase_date_vat_bridge"] + }; + } if (detectedIntent.intent === "unknown" && isVatFollowup) { const vatIntent = hasVatTaxPaymentCue(normalizedMessage) ? "vat_liability_confirmed_for_tax_period" diff --git a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js index 1aca12c..8255de9 100644 --- a/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js +++ b/llm_normalizer/backend/dist/services/address_runtime/inventoryReplyBuilders.js @@ -209,8 +209,8 @@ function composeInventoryReply(intent, rows, options, deps) { .map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`) .join("; "); const directAnswerLine = agingItems.length > 0 - ? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` - : `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; + ? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` + : "По доступному закупочному следу позиции со старыми закупками не материализованы."; const lines = [directAnswerLine]; (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, diff --git a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js index 41db71d..77b0f46 100644 --- a/llm_normalizer/backend/dist/services/assistantRoutePolicy.js +++ b/llm_normalizer/backend/dist/services/assistantRoutePolicy.js @@ -683,6 +683,7 @@ function createAssistantRoutePolicy(deps) { const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && + !protectAddressLaneFromFallback && hasDeepSessionContinuationSignal({ rawUserMessage, repairedRawUserMessage, diff --git a/llm_normalizer/backend/dist/services/assistantService.js b/llm_normalizer/backend/dist/services/assistantService.js index 290fb34..9879646 100644 --- a/llm_normalizer/backend/dist/services/assistantService.js +++ b/llm_normalizer/backend/dist/services/assistantService.js @@ -2642,6 +2642,9 @@ function hasAddressFollowupContextSignal(userMessage) { !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) { return true; } + if (shortFollowup && samples.some((sample) => (0, decomposeStage_1.hasInventoryPurchaseDateVatBridgeCue)(sample))) { + return true; + } if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { return true; } @@ -3458,6 +3461,31 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage semanticHints: candidateMeta?.semanticHints ?? null }, userMessage); } + const candidatePredecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ + sourceMessage: String(userMessage ?? ""), + canonicalMessage: candidate, + semanticHints: candidateMeta?.semanticHints ?? null + }); + const sourceHasExplicitAccountAnchor = (0, addressIntentResolver_1.hasAccountNumberAnchor)(repairedSourceMessage || userMessage) || + (0, addressIntentResolver_1.hasCompactAccountCodeToken)(repairedSourceMessage || userMessage); + const candidateInjectsAccountAnchor = Boolean(toNonEmptyString(candidatePredecomposeContract?.entities?.account)); + if (sourceIntentResolution.intent === "inventory_on_hand_as_of_date" && + candidateIntentResolution.intent === "inventory_on_hand_as_of_date" && + !sourceHasExplicitAccountAnchor && + candidateInjectsAccountAnchor) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_anchor_injection", + fallbackRuleHit: null, + sanitizedUserMessage, + semanticHints: candidateMeta?.semanticHints ?? null + }, userMessage); + } const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; @@ -3554,11 +3582,7 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const semanticContractForCandidate = (0, predecomposeContract_1.buildAddressSemanticExtractionContractV1)({ sourceMessage: String(userMessage ?? ""), canonicalMessage: candidate, - predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ - sourceMessage: String(userMessage ?? ""), - canonicalMessage: candidate, - semanticHints: candidateMeta?.semanticHints ?? null - }) + predecomposeContract: candidatePredecomposeContract }); if (!semanticContractForCandidate.apply_canonical_recommended) { const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected); diff --git a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js index 3277aca..d59bdfb 100644 --- a/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js +++ b/llm_normalizer/backend/dist/services/assistantTransitionPolicy.js @@ -3,6 +3,101 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy; function createAssistantTransitionPolicy(deps) { + function parseDmyDateToIso(value) { + const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) { + return null; + } + return `${match[3]}-${match[2]}-${match[1]}`; + } + function computeMonthWindowFromIso(isoDate) { + const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) { + return null; + } + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const mm = String(month).padStart(2, "0"); + const dd = String(lastDay).padStart(2, "0"); + return { + purchase_date: isoDate, + period_from: `${year}-${mm}-01`, + period_to: `${year}-${mm}-${dd}`, + as_of_date: `${year}-${mm}-${dd}` + }; + } + function extractEarliestDmyDateFromEntityRefs(entityRefs) { + const dates = []; + for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) { + const value = deps.toNonEmptyString(entityRef?.value); + if (!value) { + continue; + } + const matches = String(value).match(/\b(\d{2}\.\d{2}\.\d{4})\b/g); + if (!matches) { + continue; + } + for (const token of matches) { + const isoDate = parseDmyDateToIso(token); + if (isoDate) { + dates.push(isoDate); + } + } + } + if (dates.length === 0) { + return null; + } + return dates.sort()[0] ?? null; + } + function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) { + const replyText = deps.toNonEmptyString(previousAddressItem?.text); + if (replyText) { + const repairedText = deps.repairAddressMojibake(replyText); + const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu); + const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null; + if (explicitFirstDateIso) { + return computeMonthWindowFromIso(explicitFirstDateIso); + } + } + const sessionContext = addressNavigationState && + typeof addressNavigationState === "object" && + addressNavigationState.session_context && + typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null; + const focusObject = sessionContext && typeof sessionContext.active_focus_object === "object" + ? sessionContext.active_focus_object + : null; + const preferredResultSetId = deps.toNonEmptyString(focusObject?.provenance_result_set_id) ?? + deps.toNonEmptyString(sessionContext?.active_result_set_id); + const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : []; + const preferredResultSet = (preferredResultSetId + ? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null + : null) ?? + resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ?? + null; + const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs); + return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null; + } + function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) { + if (sourceIntentHint !== "inventory_purchase_provenance_for_item" && + !hasInventoryItemFocusHint && + !deps.isInventorySelectedObjectIntent(sourceIntentHint)) { + return false; + } + const samples = [userMessage, alternateMessage] + .map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase())) + .filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + return samples.some((sample) => /(?:ндс|vat)/iu.test(sample) && + /(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test(sample)); + } function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) { if (!hasInventoryRootFrame) { return false; @@ -159,6 +254,7 @@ function createAssistantTransitionPolicy(deps) { (sourceIntentHint === "inventory_on_hand_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || deps.isInventorySelectedObjectIntent(sourceIntentHint))); + const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint); let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && deps.hasShortInventoryObjectFollowupSignal(userMessage); let inventoryShortFollowupAlternate = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && @@ -172,11 +268,15 @@ function createAssistantTransitionPolicy(deps) { ? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) : null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; - let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; + let hasPrimaryFollowupSignal = deps.hasAddressFollowupContextSignal(userMessage) || + Boolean(debtRoleSwapPrimary) || + inventoryShortFollowupPrimary || + inventoryPurchaseDateVatBridge; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || - inventoryShortFollowupAlternate + inventoryShortFollowupAlternate || + inventoryPurchaseDateVatBridge : false; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) @@ -206,6 +306,7 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootTemporalFollowupAlternate || hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate || + inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || @@ -340,11 +441,13 @@ function createAssistantTransitionPolicy(deps) { deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary || + inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupPrimary; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate || + inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupAlternate : false; hasStrongFollowupReference = @@ -356,6 +459,7 @@ function createAssistantTransitionPolicy(deps) { inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || + inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || @@ -435,6 +539,13 @@ function createAssistantTransitionPolicy(deps) { if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { previousFilters.organization = organizationClarificationSelection; } + if (inventoryPurchaseDateVatBridge) { + const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState); + if (purchaseBridgeWindow) { + previousFilters.period_from = purchaseBridgeWindow.period_from; + previousFilters.period_to = purchaseBridgeWindow.period_to; + } + } const shouldBackfillPreviousDateScopeFromNavigation = sourceIntentHint === "inventory_on_hand_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || sourceIntentHint === "inventory_purchase_provenance_for_item" || @@ -461,7 +572,8 @@ function createAssistantTransitionPolicy(deps) { previousFilters.period_to = deps.toNonEmptyString(navigationDateScope?.period_to); } const rootContextOnlyPivot = Boolean((deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && - deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); + deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) && + !inventoryPurchaseDateVatBridge); const inventoryRootTemporalPivot = Boolean(inventoryRootFrame && (deps.isInventorySelectedObjectIntent(sourceIntentHint) || deps.isInventoryRootFrameIntent(sourceIntentHint) || @@ -567,11 +679,13 @@ function createAssistantTransitionPolicy(deps) { hasInventoryRootRestatementAlternate || hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalAlternate)); - const carryoverTargetIntent = followupSelectionMode === "carry_root_context" - ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined - : explicitInventorySameDatePivot - ? "inventory_on_hand_as_of_date" - : displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined; + const carryoverTargetIntent = inventoryPurchaseDateVatBridge + ? "vat_liability_confirmed_for_tax_period" + : followupSelectionMode === "carry_root_context" + ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined + : explicitInventorySameDatePivot + ? "inventory_on_hand_as_of_date" + : displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined; return { followupContext: { previous_intent: previousIntent ?? undefined, diff --git a/llm_normalizer/backend/src/services/addressFilterExtractor.ts b/llm_normalizer/backend/src/services/addressFilterExtractor.ts index 1ab34d1..48c2e72 100644 --- a/llm_normalizer/backend/src/services/addressFilterExtractor.ts +++ b/llm_normalizer/backend/src/services/addressFilterExtractor.ts @@ -1848,7 +1848,13 @@ export function extractAddressFilters(userMessage: string, intent: AddressIntent } } } - if (intent === "vat_liability_confirmed_for_tax_period" && !periodRange.period_from && !periodRange.period_to) { + const monthPeriodWasDerived = warnings.includes("period_derived_from_month_phrase"); + if ( + intent === "vat_liability_confirmed_for_tax_period" && + !periodRange.period_from && + !periodRange.period_to && + !monthPeriodWasDerived + ) { const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null; if (periodToForQuarter) { const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); diff --git a/llm_normalizer/backend/src/services/addressIntentResolver.ts b/llm_normalizer/backend/src/services/addressIntentResolver.ts index 6655f24..521700b 100644 --- a/llm_normalizer/backend/src/services/addressIntentResolver.ts +++ b/llm_normalizer/backend/src/services/addressIntentResolver.ts @@ -534,7 +534,7 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean { return false; } -function hasCompactAccountCodeToken(text: string): boolean { +export function hasCompactAccountCodeToken(text: string): boolean { // Match compact account tokens while reducing false positives on short-year literals like "22 РіРѕРґ". const source = String(text ?? ""); if (!source) { @@ -1599,7 +1599,7 @@ function hasGenericAddressLookupSignal(text: string): boolean { ); } -function hasAccountNumberAnchor(text: string): boolean { +export function hasAccountNumberAnchor(text: string): boolean { return /(?:account|СЃС‡[её]С‚|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text); } @@ -1790,6 +1790,20 @@ function hasCustomerRevenueRankingBridgeSignal(text: string): boolean { if (!normalized) { return false; } + const hasSupplierCue = + /(?:\u043f\u043e\u0441\u0442\u0430\u0432\u0449\u0438\u043a|\u0432\u0435\u043d\u0434\u043e\u0440|supplier|vendor)/iu.test( + normalized + ); + if (hasSupplierCue) { + return false; + } + const hasDirectRankingCue = + /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|customer|client)|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u0433\u043e\u0434|(?:\u0430\s+)?(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a)\s+(?:\u0432\u043e\u043e\u0431\u0449\u0435\s+)?(?:\u0434\u0435\u043d\u0435\u0433\s+)?\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438(?:\s+\u0437\u0430\s+\u0432\u0441\u0435\s+\u0432\u0440\u0435\u043c\u044f)?|(?:\u0430\s+)?(?:\u0437\u0430|for)\s+\d{4}\s+\u043c\u044b\s+(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a)\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a)\s+\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438\s+\u0437\u0430\s+\d{4}|(?:\u043e\u0431\u0449\u0430\u044f\s+)?\u0432\u044b\u0440\u0443\u0447\u043a\u0430\s+\u0437\u0430\s+\d{4})/iu.test( + normalized + ); + if (hasDirectRankingCue) { + return true; + } const hasMoneyCue = /(?:\u0434\u0435\u043d\u044c\u0433|\u0434\u0435\u043d\u0435\u0433|\u0432\u044b\u0440\u0443\u0447|\u0434\u043e\u0445\u043e\u0434|\u043e\u0431\u043e\u0440\u043e\u0442|revenue|turnover|money|inflow)/iu.test( normalized @@ -1797,9 +1811,15 @@ function hasCustomerRevenueRankingBridgeSignal(text: string): boolean { if (!hasMoneyCue) { return false; } - return /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u0434\u0435\u043d\u0435\u0433)|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money)/iu.test( - normalized - ); + const hasCustomerRankingCue = + /(?:\u043a\u0442\u043e\s+(?:\u043d\u0430\u043c\s+)?(?:\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u043f\u0440\u0438\u043d\u0435\u0441(?:\s+\u0434\u0435\u043d\u0435\u0433)?|\u043f\u0440\u0438\u043d\u0435\u0441\s+\u0431\u043e\u043b\u044c\u0448\u0435(?:\s+\u0432\u0441\u0435\u0433\u043e)?\s+\u0434\u0435\u043d\u0435\u0433)|who\s+brought\s+(?:us\s+)?(?:the\s+)?most\s+money|(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d\w*\s+(?:\u043a\u043b\u0438\u0435\u043d\u0442|customer|client)))/iu.test( + normalized + ); + const hasRevenueAggregateCue = + /(?:(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d\w*\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|скок)\s+(?:\u0432\u043e\u043e\u0431\u0449\u0435\s+)?(?:\u0434\u0435\u043d\u0435\u0433\s+)?\u043c\u044b\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|(?:\u0437\u0430|for)\s+\d{4}\s+\u043c\u044b\s+(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|скок)\s+\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u043a\w*\s+\u0437\u0430\s+\d{4})/iu.test( + normalized + ); + return hasCustomerRankingCue || hasRevenueAggregateCue; } function hasInventoryProvenanceBridgeSignal(text: string): boolean { @@ -1845,6 +1865,13 @@ function hasColloquialInventoryOnHandBridgeSignal(text: string): boolean { if (!normalized) { return false; } + const hasInventoryAgingCue = + /(?:\u043e\u0447\u0435\u043d\u044c\s+\u0434\u0430\u0432\u043d\u043e|\u0434\u0430\u0432\u043d\u043e\s+\u043a\u0443\u043f\u043b|\u0434\u0430\u0432\u043d\u043e\s+\u043f\u0440\u0438\u043e\u0431\u0440\u0435\u0442|\u0441\u0442\u0430\u0440(?:\u044b\u0435|\u044b\u043c|\u044b\u0445)?\s+\u0437\u0430\u043a\u0443\u043f|\u0441\u0442\u0430\u0440\u044b\u0439\s+\u0442\u043e\u0432\u0430\u0440|old\s+stock|old\s+purchase|aging\s+by\s+purchase\s+date)/iu.test( + normalized + ); + if (hasInventoryAgingCue) { + return false; + } const tokenCount = normalized.split(/\s+/u).filter(Boolean).length; const hasWarehouseCue = /(?:\u0441\u043a\u043b\u0430\u0434(?:\u0430\u0445|\u0435|\u0443|\u043e\u043c|\u044b)?|\u043e\u0441\u0442\u0430\u0442|warehouse|stock|inventory)/iu.test( @@ -1860,10 +1887,81 @@ function hasColloquialInventoryOnHandBridgeSignal(text: string): boolean { return hasQuestionCue && tokenCount <= 8; } +function repairLikelyUtf8Mojibake(text: string): string { + const raw = String(text ?? ""); + if (!raw) { + return ""; + } + try { + const repaired = Buffer.from(raw, "latin1").toString("utf8"); + return repaired || raw; + } catch { + return raw; + } +} + export function resolveAddressIntent(userMessage: string): AddressIntentResolution { const text = String(userMessage ?? "").trim().toLowerCase(); + const repairedText = repairLikelyUtf8Mojibake(text).trim().toLowerCase(); + const bridgeText = repairedText && repairedText !== text ? `${text} ${repairedText}` : text; - if (hasCustomerRevenueRankingBridgeSignal(text)) { + const hasLooseVatPayableBridge = + /(?:\u043d\u0434\u0441|vat)/iu.test(text) && + /(?:\u043a\u0430\u043a\u043e\u0439\s+\u043d\u0434\u0441\s+(?:\u043d\u0430\u043c\s+)?(?:\u043d\u0430\u0434\u043e|\u043d\u0443\u0436\u043d\u043e)|\u043d\u0430\u043c\s+\u043d\u0430\u0434\u043e\s+(?:\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u0441\u0433\u0440\u0443\u0437\u0438\u0442\u044c)|\u043d\u0430\u043c\s+\u043d\u0443\u0436\u043d\u043e\s+\u0437\u0430\u043f\u043b\u0430\u0442\u0438\u0442\u044c|\u043d\u0434\u0441\s+\u043a\s+\u0443\u043f\u043b\u0430\u0442\u0435)/iu.test( + text + ) && + /(?:\u0437\u0430\s+(?:\d{4}|(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?)|\u043d\u0430\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440)\S*(?:\s+(?:19|20)\d{2})?|\b[1-4]\s*(?:\u043a\u0432\u0430\u0440\u0442\u0430\u043b|\u043a\u0432\.?)\b)/iu.test( + text + ); + if (hasLooseVatPayableBridge) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "high", + reasons: ["vat_liability_colloquial_bridge_signal_detected"] + }; + } + + const hasExplicitReceivablesSnapshotBridge = + /(?:\u043d\u0430\u043c\s+\u043a\u0442\u043e-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043d\u0430\u043c\s+\u043a\u0442\u043e\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u043a\u0442\u043e\s+\u043d\u0430\u043c\s+\u0434\u043e\u043b\u0436\u0435\u043d|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0434\u0435\u0431\u0438\u0442\u043e\u0440\u0441\u043a\w+\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043d\u0430\u043c|receivables?)/iu.test( + text + ); + if (hasExplicitReceivablesSnapshotBridge) { + return { + intent: "receivables_confirmed_as_of_date", + confidence: "high", + reasons: ["receivables_snapshot_bridge_signal_detected"] + }; + } + + const hasExplicitPayablesSnapshotBridge = + /(?:\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b\s+\u043a\u043e\u043c\u0443\u0442\u043e\s+\u0434\u0435\u043d\u0435\u0433|\u043c\u044b\s+\u043a\u043e\u043c\u0443-\u0442\u043e\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u043a\u043e\u043c\u0443\s+\u043c\u044b\s+\u0434\u043e\u043b\u0436\u043d\u044b|\u0435\u0441\u0442\u044c\s+\u043b\u0438\s+\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|\u0437\u0430\u0434\u043e\u043b\u0436\u0435\u043d\u043d\u043e\u0441\u0442\u044c\s+\u043f\u0435\u0440\u0435\u0434\s+\u043a\u043e\u043d\u0442\u0440\u0430\u0433\u0435\u043d\u0442\u0430\u043c\u0438|payables?)/iu.test( + text + ); + if (hasExplicitPayablesSnapshotBridge) { + return { + intent: "payables_confirmed_as_of_date", + confidence: "high", + reasons: ["payables_snapshot_bridge_signal_detected"] + }; + } + + const hasDirectInventoryAgingBridge = + /(?:\u043e\u0447\u0435\u043d\u044c\s+\u0434\u0430\u0432\u043d\u043e|\u0434\u0430\u0432\u043d\u043e\s+\u043a\u0443\u043f\u043b|\u0434\u0430\u0432\u043d\u043e\s+\u043f\u0440\u0438\u043e\u0431\u0440\u0435\u0442|\u0441\u0442\u0430\u0440(?:\u044b\u0435|\u044b\u043c|\u044b\u0445)?\s+\u0437\u0430\u043a\u0443\u043f|\u0441\u0442\u0430\u0440(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0442\u043e\u0432\u0430\u0440|old\s+stock|old\s+purchase|very\s+old\s+stock|aging\s+by\s+purchase\s+date)/iu.test( + bridgeText + ); + if (hasDirectInventoryAgingBridge || hasInventoryAgingSignal(text) || hasInventoryAgingSignal(repairedText)) { + return { + intent: "inventory_aging_by_purchase_date", + confidence: "high", + reasons: ["inventory_aging_bridge_signal_detected"] + }; + } + + const hasDirectRevenueAggregateBridge = + /(?:\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u043a\u043b\u0438\u0435\u043d\u0442|(?:\u043a\u0430\u043a\u043e\u0439|\u043a\u0430\u043a\u0430\u044f)\s+(?:\u0443\s+\u043d\u0430\u0441\s+)?\u0441\u0430\u043c(?:\u044b\u0439|\u0430\u044f|\u043e\u0435)?\s+\u0434\u043e\u0445\u043e\u0434\u043d(?:\u044b\u0439|\u0430\u044f|\u043e\u0435|\u044b\u0435|\u043e\u0433\u043e|\u043e\u043c\u0443|\u044b\u043c|\u044b\u0445)?\s+\u0433\u043e\u0434|(?:\u0441\u043a\u043e\u043b\u044c\u043a\u043e|\u0441\u043a\u043e\u043a).*(?:\u0437\u0430\u0440\u0430\u0431\u043e\u0442\u0430\u043b\u0438|\u0432\u044b\u0440\u0443\u0447\u0438\u043b\u0438)|\u0432\u044b\u0440\u0443\u0447\u043a\u0430\s+\u0437\u0430\s+\d{4})/iu.test( + bridgeText + ); + if (hasDirectRevenueAggregateBridge || hasCustomerRevenueRankingBridgeSignal(bridgeText)) { return { intent: "customer_revenue_and_payments", confidence: "medium", @@ -1871,6 +1969,19 @@ export function resolveAddressIntent(userMessage: string): AddressIntentResoluti }; } + const hasHistoricalInventorySnapshotBridge = [text, repairedText, bridgeText].some( + (sample) => + /(?:\u043e\u0441\u0442\u0430\u0442|inventory|stock|\u0441\u043a\u043b\u0430\u0434|остат|склад)/iu.test(sample) && + /(?:(?:\u043d\u0430|\u0437\u0430|РЅР°|Р·Р°)\s+(?:\u044f\u043d\u0432\u0430\u0440|\u0444\u0435\u0432\u0440\u0430\u043b|\u043c\u0430\u0440\u0442|\u0430\u043f\u0440\u0435\u043b|\u043c\u0430[\u0439\u044f]|\u0438\u044e\u043d|\u0438\u044e\u043b|\u0430\u0432\u0433\u0443\u0441\u0442|\u0441\u0435\u043d\u0442\u044f\u0431\u0440|\u043e\u043a\u0442\u044f\u0431\u0440|\u043d\u043e\u044f\u0431\u0440|\u0434\u0435\u043a\u0430\u0431\u0440|март|апрел|май|РёСЋРЅ|РёСЋР»|август|сентябр|октябр|РЅРѕСЏР±СЂ|декабр)\S*(?:\s+(?:19|20)\d{2})?|(?:\u043d\u0430|\u0437\u0430|РЅР°|Р·Р°)\s+\d{4}|\b(?:19|20)\d{2}\b)/iu.test(sample) + ); + if (hasHistoricalInventorySnapshotBridge) { + return { + intent: "inventory_on_hand_as_of_date", + confidence: "medium", + reasons: ["inventory_historical_snapshot_bridge_signal_detected"] + }; + } + if (hasInventoryDocumentaryChainBridgeSignal(text)) { return { intent: "inventory_purchase_to_sale_chain", diff --git a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts index cc35e6f..a72f4f3 100644 --- a/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/counterpartyAnalyticsReplyBuilders.ts @@ -639,13 +639,18 @@ export function composeCounterpartyAnalyticsReply( if (focus === "top_years_by_total") { const visible = rankedByYearTotal.slice(0, limit); - const heading = isSupplier - ? `Топ-${visible.length} лет по сумме выплат:` - : `Топ-${visible.length} лет по сумме поступлений:`; - lines.unshift(heading); if (visible.length === 0) { lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); } else { + const strongestYear = visible[0]; + const directAnswerLine = isSupplier + ? `Самый крупный год по подтвержденным выплатам: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям).` + : `Самый доходный год по подтвержденным поступлениям: ${strongestYear.year} (${deps.formatMoneyRub(strongestYear.total)} по ${strongestYear.ops} операциям). Это денежный поток, а не чистая прибыль.`; + const heading = isSupplier + ? `Топ-${visible.length} лет по сумме выплат:` + : `Топ-${visible.length} лет по сумме поступлений:`; + lines.unshift(heading); + lines.unshift(directAnswerLine); lines.push( ...visible.map( (item, index) => @@ -742,7 +747,14 @@ export function composeCounterpartyAnalyticsReply( const heading = isSupplier ? `Топ-${visible.length} поставщиков по сумме выплат:` : `Топ-${visible.length} заказчиков по сумме поступлений:`; + const leadingCounterparty = visible[0] ?? null; lines.unshift(heading); + if (leadingCounterparty) { + const directAnswerLine = isSupplier + ? `Крупнейший поставщик по подтвержденным выплатам за доступное время: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям).` + : `Самый доходный клиент за доступное время по подтвержденным поступлениям: ${leadingCounterparty.name} (${deps.formatMoneyRub(leadingCounterparty.total)} по ${leadingCounterparty.ops} операциям). Это денежный поток, а не чистая прибыль.`; + lines.unshift(directAnswerLine); + } lines.push( ...visible.map((item, index) => { const avgCheck = item.ops > 0 ? item.total / item.ops : 0; diff --git a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts index 3eada3b..9ec70ce 100644 --- a/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts +++ b/llm_normalizer/backend/src/services/address_runtime/decomposeStage.ts @@ -691,6 +691,19 @@ export function hasInventoryPurchaseToSaleChainFollowupCue(text: string): boolea ); } +export function hasInventoryPurchaseDateVatBridgeCue(text: string): boolean { + const normalized = String(text ?? "").trim(); + if (!normalized) { + return false; + } + return ( + /(?:ндс|vat)/iu.test(normalized) && + /(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test( + normalized + ) + ); +} + export function hasAddressFollowupContextSignal(text: string): boolean { const normalized = String(text ?? "").trim(); if (!normalized) { @@ -728,6 +741,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean { ) { return true; } + if (tokenCount <= 14 && hasInventoryPurchaseDateVatBridgeCue(normalized)) { + return true; + } const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized); if (tokenCount <= 8 && hasPeriodLiteral) { return true; @@ -1314,6 +1330,21 @@ function deriveIntentWithFollowupContext( const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); + const inventoryPurchaseDateVatBridge = + inventorySelectedObjectFollowup && hasInventoryPurchaseDateVatBridgeCue(normalizedMessage); + + if ( + inventoryPurchaseDateVatBridge && + (detectedIntent.intent === "unknown" || + detectedIntent.intent === sourceIntent || + detectedIntent.intent === "vat_payable_confirmed_as_of_date") + ) { + return { + intent: "vat_liability_confirmed_for_tax_period", + confidence: "low", + reasons: [...detectedIntent.reasons, "intent_adjusted_to_inventory_purchase_date_vat_bridge"] + }; + } if (detectedIntent.intent === "unknown" && isVatFollowup) { const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage) diff --git a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts index 3813cb7..2580d49 100644 --- a/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts +++ b/llm_normalizer/backend/src/services/address_runtime/inventoryReplyBuilders.ts @@ -316,8 +316,8 @@ export function composeInventoryReply( .join("; "); const directAnswerLine = agingItems.length > 0 - ? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` - : `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; + ? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` + : "По доступному закупочному следу позиции со старыми закупками не материализованы."; const lines: string[] = [directAnswerLine]; appendInventoryBulletSection(lines, "Сводка:", [ `Дата среза: ${deps.formatDateRu(asOfDate)}.`, diff --git a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts index 92a1433..1501081 100644 --- a/llm_normalizer/backend/src/services/assistantRoutePolicy.ts +++ b/llm_normalizer/backend/src/services/assistantRoutePolicy.ts @@ -719,6 +719,7 @@ export function createAssistantRoutePolicy(deps) { const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && baseToolGate?.runAddressLane && !llmRuntimeUnavailableDetected && + !protectAddressLaneFromFallback && hasDeepSessionContinuationSignal({ rawUserMessage, repairedRawUserMessage, diff --git a/llm_normalizer/backend/src/services/assistantService.ts b/llm_normalizer/backend/src/services/assistantService.ts index 46b9be0..693f257 100644 --- a/llm_normalizer/backend/src/services/assistantService.ts +++ b/llm_normalizer/backend/src/services/assistantService.ts @@ -2597,6 +2597,9 @@ function hasAddressFollowupContextSignal(userMessage) { !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) { return true; } + if (shortFollowup && samples.some((sample) => (0, decomposeStage_1.hasInventoryPurchaseDateVatBridgeCue)(sample))) { + return true; + } if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { return true; } @@ -3413,6 +3416,31 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage semanticHints: candidateMeta?.semanticHints ?? null }, userMessage); } + const candidatePredecomposeContract = (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ + sourceMessage: String(userMessage ?? ""), + canonicalMessage: candidate, + semanticHints: candidateMeta?.semanticHints ?? null + }); + const sourceHasExplicitAccountAnchor = (0, addressIntentResolver_1.hasAccountNumberAnchor)(repairedSourceMessage || userMessage) || + (0, addressIntentResolver_1.hasCompactAccountCodeToken)(repairedSourceMessage || userMessage); + const candidateInjectsAccountAnchor = Boolean(toNonEmptyString(candidatePredecomposeContract?.entities?.account)); + if (sourceIntentResolution.intent === "inventory_on_hand_as_of_date" && + candidateIntentResolution.intent === "inventory_on_hand_as_of_date" && + !sourceHasExplicitAccountAnchor && + candidateInjectsAccountAnchor) { + return attachAddressPredecomposeContract({ + ...baseMeta, + attempted: true, + applied: false, + traceId: normalized?.trace_id ?? null, + llmCanonicalCandidateDetected: true, + effectiveMessage: userMessage, + reason: "normalized_fragment_rejected_anchor_injection", + fallbackRuleHit: null, + sanitizedUserMessage, + semanticHints: candidateMeta?.semanticHints ?? null + }, userMessage); + } const sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; @@ -3509,11 +3537,7 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage const semanticContractForCandidate = (0, predecomposeContract_1.buildAddressSemanticExtractionContractV1)({ sourceMessage: String(userMessage ?? ""), canonicalMessage: candidate, - predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ - sourceMessage: String(userMessage ?? ""), - canonicalMessage: candidate, - semanticHints: candidateMeta?.semanticHints ?? null - }) + predecomposeContract: candidatePredecomposeContract }); if (!semanticContractForCandidate.apply_canonical_recommended) { const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected); diff --git a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts index 858d711..866e9f2 100644 --- a/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts +++ b/llm_normalizer/backend/src/services/assistantTransitionPolicy.ts @@ -1,6 +1,118 @@ // @ts-nocheck export function createAssistantTransitionPolicy(deps) { + function parseDmyDateToIso(value) { + const match = String(value ?? "").trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) { + return null; + } + return `${match[3]}-${match[2]}-${match[1]}`; + } + + function computeMonthWindowFromIso(isoDate) { + const match = String(isoDate ?? "").match(/^(\d{4})-(\d{2})-(\d{2})$/); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) { + return null; + } + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const mm = String(month).padStart(2, "0"); + const dd = String(lastDay).padStart(2, "0"); + return { + purchase_date: isoDate, + period_from: `${year}-${mm}-01`, + period_to: `${year}-${mm}-${dd}`, + as_of_date: `${year}-${mm}-${dd}` + }; + } + + function extractEarliestDmyDateFromEntityRefs(entityRefs) { + const dates = []; + for (const entityRef of Array.isArray(entityRefs) ? entityRefs : []) { + const value = deps.toNonEmptyString(entityRef?.value); + if (!value) { + continue; + } + const matches = String(value).match(/\b(\d{2}\.\d{2}\.\d{4})\b/g); + if (!matches) { + continue; + } + for (const token of matches) { + const isoDate = parseDmyDateToIso(token); + if (isoDate) { + dates.push(isoDate); + } + } + } + if (dates.length === 0) { + return null; + } + return dates.sort()[0] ?? null; + } + + function extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState) { + const replyText = deps.toNonEmptyString(previousAddressItem?.text); + if (replyText) { + const repairedText = deps.repairAddressMojibake(replyText); + const explicitFirstDateMatch = repairedText.match(/первая\s+найденная\s+дата\s+закупки:\s*(\d{2}\.\d{2}\.\d{4})/iu); + const explicitFirstDateIso = explicitFirstDateMatch ? parseDmyDateToIso(explicitFirstDateMatch[1]) : null; + if (explicitFirstDateIso) { + return computeMonthWindowFromIso(explicitFirstDateIso); + } + } + + const sessionContext = + addressNavigationState && + typeof addressNavigationState === "object" && + addressNavigationState.session_context && + typeof addressNavigationState.session_context === "object" + ? addressNavigationState.session_context + : null; + const focusObject = + sessionContext && typeof sessionContext.active_focus_object === "object" + ? sessionContext.active_focus_object + : null; + const preferredResultSetId = + deps.toNonEmptyString(focusObject?.provenance_result_set_id) ?? + deps.toNonEmptyString(sessionContext?.active_result_set_id); + const resultSets = Array.isArray(addressNavigationState?.result_sets) ? addressNavigationState.result_sets : []; + const preferredResultSet = + (preferredResultSetId + ? resultSets.find((item) => deps.toNonEmptyString(item?.result_set_id) === preferredResultSetId) ?? null + : null) ?? + resultSets.find((item) => deps.toNonEmptyString(item?.intent) === "inventory_purchase_provenance_for_item") ?? + null; + const earliestIsoDate = extractEarliestDmyDateFromEntityRefs(preferredResultSet?.entity_refs); + return earliestIsoDate ? computeMonthWindowFromIso(earliestIsoDate) : null; + } + + function hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasInventoryItemFocusHint) { + if ( + sourceIntentHint !== "inventory_purchase_provenance_for_item" && + !hasInventoryItemFocusHint && + !deps.isInventorySelectedObjectIntent(sourceIntentHint) + ) { + return false; + } + const samples = [userMessage, alternateMessage] + .map((item) => deps.compactWhitespace(deps.repairAddressMojibake(String(item ?? "")).toLowerCase())) + .filter((item) => item.length > 0); + if (samples.length === 0) { + return false; + } + return samples.some( + (sample) => + /(?:ндс|vat)/iu.test(sample) && + /(?:на\s+дат[ауеы]\s+покупк|на\s+дат[ауеы]\s+закупк|по\s+дат[еу]\s+покупк|по\s+дат[еу]\s+закупк|дата\s+покупк|дата\s+закупк|purchase\s+date)/iu.test( + sample + ) + ); + } + function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) { if (!hasInventoryRootFrame) { return false; @@ -197,6 +309,12 @@ export function createAssistantTransitionPolicy(deps) { sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || deps.isInventorySelectedObjectIntent(sourceIntentHint)) ); + const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal( + userMessage, + alternateMessage, + sourceIntentHint, + hasNavigationInventoryItemFocusHint + ); let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && deps.hasShortInventoryObjectFollowupSignal(userMessage); @@ -214,11 +332,15 @@ export function createAssistantTransitionPolicy(deps) { : null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; let hasPrimaryFollowupSignal = - deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; + deps.hasAddressFollowupContextSignal(userMessage) || + Boolean(debtRoleSwapPrimary) || + inventoryShortFollowupPrimary || + inventoryPurchaseDateVatBridge; let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || - inventoryShortFollowupAlternate + inventoryShortFollowupAlternate || + inventoryPurchaseDateVatBridge : false; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) @@ -265,6 +387,7 @@ export function createAssistantTransitionPolicy(deps) { hasInventoryRootTemporalFollowupAlternate || hasInventoryRootRestatementPrimary || hasInventoryRootRestatementAlternate || + inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || @@ -418,11 +541,13 @@ export function createAssistantTransitionPolicy(deps) { deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary || + inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupPrimary; hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) ? deps.hasAddressFollowupContextSignal(alternateMessage) || Boolean(debtRoleSwapAlternate) || inventoryShortFollowupAlternate || + inventoryPurchaseDateVatBridge || hasInventoryRootTemporalFollowupAlternate : false; hasStrongFollowupReference = @@ -434,6 +559,7 @@ export function createAssistantTransitionPolicy(deps) { inventoryShortFollowupAlternate || hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupAlternate || + inventoryPurchaseDateVatBridge || Boolean(debtRoleSwapIntent) || deps.hasFollowupMarker(userMessage) || deps.hasReferentialPointer(userMessage) || @@ -522,6 +648,13 @@ export function createAssistantTransitionPolicy(deps) { if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { previousFilters.organization = organizationClarificationSelection; } + if (inventoryPurchaseDateVatBridge) { + const purchaseBridgeWindow = extractPurchaseDateBridgeWindow(previousAddressItem, addressNavigationState); + if (purchaseBridgeWindow) { + previousFilters.period_from = purchaseBridgeWindow.period_from; + previousFilters.period_to = purchaseBridgeWindow.period_to; + } + } const shouldBackfillPreviousDateScopeFromNavigation = sourceIntentHint === "inventory_on_hand_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || @@ -556,7 +689,8 @@ export function createAssistantTransitionPolicy(deps) { } const rootContextOnlyPivot = Boolean( (deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && - deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) + deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) && + !inventoryPurchaseDateVatBridge ); const inventoryRootTemporalPivot = Boolean( inventoryRootFrame && @@ -681,7 +815,9 @@ export function createAssistantTransitionPolicy(deps) { hasSelectedObjectInventorySignalAlternate) ); const carryoverTargetIntent = - followupSelectionMode === "carry_root_context" + inventoryPurchaseDateVatBridge + ? "vat_liability_confirmed_for_tax_period" + : followupSelectionMode === "carry_root_context" ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined : explicitInventorySameDatePivot ? "inventory_on_hand_as_of_date" diff --git a/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts b/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts new file mode 100644 index 0000000..6128ec2 --- /dev/null +++ b/llm_normalizer/backend/tests/addressFilterExtractorRegression.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { extractAddressFilters } from "../src/services/addressFilterExtractor"; + +describe("address filter extractor regressions", () => { + it("keeps explicit month window for confirmed VAT tax-period intent", () => { + const extracted = extractAddressFilters( + "сколько ндс надо заплатить в налоговую за декабрь 2019", + "vat_liability_confirmed_for_tax_period" + ); + + expect(extracted.extracted_filters.period_from).toBe("2019-12-01"); + expect(extracted.extracted_filters.period_to).toBe("2019-12-31"); + expect(extracted.warnings).toContain("period_derived_from_month_phrase"); + expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts index 290c220..cd07837 100644 --- a/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts +++ b/llm_normalizer/backend/tests/addressFollowupTemporalRegression.test.ts @@ -55,7 +55,7 @@ describe("address follow-up temporal regressions", () => { }); expect(result).not.toBeNull(); - expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period"); expect(result?.filters.extracted_filters.period_from).toBe("2017-05-01"); expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31"); expect(result?.filters.extracted_filters.as_of_date).toBe("2017-05-31"); @@ -76,7 +76,7 @@ describe("address follow-up temporal regressions", () => { }); expect(result).not.toBeNull(); - expect(result?.intent.intent).toBe("vat_payable_confirmed_as_of_date"); + expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period"); expect(result?.filters.extracted_filters.period_from).toBe("2017-05-01"); expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31"); expect(result?.filters.extracted_filters.as_of_date).toBe("2017-05-31"); @@ -116,4 +116,26 @@ describe("address follow-up temporal regressions", () => { expect(result?.filters.extracted_filters.warehouse).toBeUndefined(); expect(result?.baseReasons).toContain("as_of_date_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", + target_intent: "vat_liability_confirmed_for_tax_period", + previous_filters: { + item: "Рабочая станция универсального специалиста", + organization: 'ООО "Альтернатива Плюс"', + period_from: "2015-02-01", + period_to: "2015-02-28", + as_of_date: "2016-03-31" + }, + previous_anchor_type: "item", + previous_anchor_value: "Рабочая станция универсального специалиста" + }); + + expect(result).not.toBeNull(); + expect(result?.intent.intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(result?.filters.extracted_filters.period_from).toBe("2015-02-01"); + expect(result?.filters.extracted_filters.period_to).toBe("2015-02-28"); + expect(result?.baseReasons).toContain("period_from_followup_context"); + }); }); diff --git a/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts new file mode 100644 index 0000000..e51b673 --- /dev/null +++ b/llm_normalizer/backend/tests/addressIntentResolverRegression.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { resolveAddressIntent } from "../src/services/addressIntentResolver"; + +describe("addressIntentResolver regression bridges", () => { + it("detects colloquial VAT liability for a month period", () => { + const result = resolveAddressIntent("прикинь какой ндс нам надо заплатить на февраль 2017"); + + expect(result.intent).toBe("vat_liability_confirmed_for_tax_period"); + }); + + it("detects payables snapshot wording in plain human form", () => { + const result = resolveAddressIntent("мы должны комуто денег на сегодня?"); + + expect(result.intent).toBe("payables_confirmed_as_of_date"); + }); + + it("detects top customer all-time revenue wording", () => { + const result = resolveAddressIntent("кто у нас самый доходный клиент за все время"); + + expect(result.intent).toBe("customer_revenue_and_payments"); + }); + + it("detects top-year company revenue wording", () => { + const result = resolveAddressIntent("какой у нас самый доходный год"); + + expect(result.intent).toBe("customer_revenue_and_payments"); + }); + + it("does not collapse very old stock request into generic inventory snapshot", () => { + const result = resolveAddressIntent("Есть ли остатки товара, которые закупались очень давно"); + + expect(result.intent).toBe("inventory_aging_by_purchase_date"); + }); + it("detects bare historical inventory root with explicit month-year", () => { + const result = resolveAddressIntent("остатки РЅР° март 2016"); + + expect(result.intent).toBe("inventory_on_hand_as_of_date"); + }); +}); diff --git a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts index 63d1db1..05b693f 100644 --- a/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts +++ b/llm_normalizer/backend/tests/addressQueryRuntimeM23.test.ts @@ -2747,14 +2747,15 @@ describe("address filter extraction for balance drilldown", () => { expect(extracted.warnings).toContain("period_to_derived_from_as_of_date_for_vat_forecast"); }); - it("derives full tax quarter window for confirmed VAT tax-period intent from month phrase", () => { + it("keeps explicit month window for confirmed VAT tax-period intent from month phrase", () => { const extracted = extractAddressFilters( "сколько ндс надо заплатить в налоговую за декабрь 2019", "vat_liability_confirmed_for_tax_period" ); - expect(extracted.extracted_filters.period_from).toBe("2019-10-01"); + expect(extracted.extracted_filters.period_from).toBe("2019-12-01"); expect(extracted.extracted_filters.period_to).toBe("2019-12-31"); - expect(extracted.warnings).toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); + expect(extracted.warnings).toContain("period_derived_from_month_phrase"); + expect(extracted.warnings).not.toContain("period_derived_from_tax_quarter_for_confirmed_vat_liability"); }); it("derives VAT forecast quarter-to-date window for explicit day+month+year phrase", () => { diff --git a/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts new file mode 100644 index 0000000..f7c0c60 --- /dev/null +++ b/llm_normalizer/backend/tests/addressReplyBuildersRegression.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; + +import { composeCounterpartyAnalyticsReply } from "../src/services/address_runtime/counterpartyAnalyticsReplyBuilders"; +import { composeInventoryReply } from "../src/services/address_runtime/inventoryReplyBuilders"; + +describe("address reply builders regressions", () => { + it("starts top customer aggregate reply with a direct business answer", () => { + const result = composeCounterpartyAnalyticsReply( + "customer_revenue_and_payments", + [ + { + amount: 250000, + period: "2020-03-31", + registrator: "Поступление 1" + } as any + ], + { + userMessage: "кто у нас самый доходный клиент за все время?" + }, + { + formatPercent: () => null, + formatDateRu: (value: string) => value, + formatMoneyRub: (value: number) => `${value} ₽`, + extractYearFromIso: (value: string | null) => (value ? Number(value.slice(0, 4)) : null), + detectCounterpartyProfileFocus: () => "full_profile", + detectCounterpartyLifecycleFocus: () => "active_customers_all_time", + hasCounterpartyLifecycleLongevityQuestion: () => false, + hasCounterpartyActivityAgeQuestion: () => false, + detectRankingLimit: () => 5, + detectValueRankingFocus: () => "top_by_total", + detectContractValueFocus: () => "top_by_turnover", + detectMinOpsForAvgCheck: () => 1, + extractRequestedYearFromQuestion: () => null, + extractCounterpartyName: () => "Чапурнов", + extractContractName: () => null, + counterpartyLookupMatches: () => false, + toUtcDayTimestamp: () => null, + formatAgeYearsMonthsDays: () => "0 дней", + normalizeQuestionText: (value: string | null | undefined) => String(value ?? "") + } + ); + + expect(result?.text.split("\n")[0]).toContain("Самый доходный клиент"); + expect(result?.text.split("\n")[0]).toContain("Чапурнов"); + }); + + it("starts top year aggregate reply with a direct business answer", () => { + const result = composeCounterpartyAnalyticsReply( + "customer_revenue_and_payments", + [ + { + amount: 320000, + period: "2021-05-12", + registrator: "Поступление 2" + } as any + ], + { + userMessage: "какой у нас самый доходный год" + }, + { + formatPercent: () => null, + formatDateRu: (value: string) => value, + formatMoneyRub: (value: number) => `${value} ₽`, + extractYearFromIso: (value: string | null) => (value ? Number(value.slice(0, 4)) : null), + detectCounterpartyProfileFocus: () => "full_profile", + detectCounterpartyLifecycleFocus: () => "active_customers_all_time", + hasCounterpartyLifecycleLongevityQuestion: () => false, + hasCounterpartyActivityAgeQuestion: () => false, + detectRankingLimit: () => 5, + detectValueRankingFocus: () => "top_years_by_total", + detectContractValueFocus: () => "top_by_turnover", + detectMinOpsForAvgCheck: () => 1, + extractRequestedYearFromQuestion: () => null, + extractCounterpartyName: () => "Чапурнов", + extractContractName: () => null, + counterpartyLookupMatches: () => false, + toUtcDayTimestamp: () => null, + formatAgeYearsMonthsDays: () => "0 дней", + normalizeQuestionText: (value: string | null | undefined) => String(value ?? "") + } + ); + + expect(result?.text.split("\n")[0]).toContain("Самый доходный год"); + expect(result?.text.split("\n")[0]).toContain("2021"); + }); + + it("keeps very old stock answer free of explicit as-of date in the first line", () => { + const result = composeInventoryReply( + "inventory_aging_by_purchase_date", + [ + { + amount: 1000, + period: "2015-02-05", + registrator: "Поступление 3" + } as any + ], + { + userMessage: "Есть ли остатки товара, которые закупались очень давно", + asOfDate: "2026-04-18" + }, + { + resolvePayablesAsOfDate: () => "2026-04-18", + buildInventoryOnHandAggregate: () => [], + uniqueStrings: (values: string[]) => values, + formatDateRu: (value: string) => value, + formatNumberWithDots: (value: number) => String(value), + formatMoneyRub: (value: number) => `${value} ₽`, + isInventoryPurchaseMovement: () => true, + summarizeInventoryTraceRows: () => ({ + item: "Рабочая станция", + warehouses: ["Основной склад"], + organizations: ["ООО Альтернатива Плюс"], + counterparties: ["Чапурнов"], + documents: ["Поступление 3"], + firstPeriod: "2015-02-05", + lastPeriod: "2015-02-05", + totalAmount: 1000 + }), + formatInventoryTraceRows: () => [], + hasInventoryPurchaseDateActionFocus: () => false, + inventoryTraceDateLabel: (value: string | null) => value ?? "дата не указана", + extractInventoryCounterpartyCandidates: () => ["Чапурнов"], + buildInventoryAgingByItemAggregate: () => [ + { + item: "Рабочая станция", + warehouse: "Основной склад", + organization: "ООО Альтернатива Плюс", + firstPurchasePeriod: "2015-02-05", + lastPurchasePeriod: "2015-02-05", + operations: 1, + documentCount: 1, + counterparties: ["Чапурнов"], + ageDays: 4089 + } + ], + formatInventoryAgingRows: () => ["1. Рабочая станция | первая закупка: 2015-02-05"], + isInventorySaleMovement: () => false + } + ); + + const firstLine = result?.text.split("\n")[0] ?? ""; + expect(firstLine).toContain("К самым старым закупкам"); + expect(firstLine).not.toContain("2026-04-18"); + }); +}); diff --git a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts index 9316df1..0c03958 100644 --- a/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantRoutePolicy.test.ts @@ -157,6 +157,53 @@ describe("assistantRoutePolicy", () => { expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true); }); + it("does not let deep session continuation override an exact VAT period route", () => { + const policy = buildPolicy({ + detectAddressQuestionMode: () => ({ mode: "address_query", confidence: "high" }), + resolveAddressIntent: () => ({ intent: "vat_liability_confirmed_for_tax_period", confidence: "high" }), + resolveAddressToolGateDecision: () => ({ + runAddressLane: true, + decision: "run_address_lane", + reason: "address_mode_classifier_detected" + }), + hasDeepSessionContinuationSignal: () => true + }); + + const decision = policy.resolveAssistantOrchestrationDecision({ + rawUserMessage: "Р° какой НДС РјС‹ должны сгрузить РЅР° март 2020?", + effectiveAddressUserMessage: "Р° какой НДС РјС‹ должны сгрузить РЅР° март 2020?", + followupContext: null, + llmPreDecomposeMeta: { + applied: true, + llmCanonicalCandidateDetected: true, + predecomposeContract: { + mode: "address_query", + mode_confidence: "high", + intent: "vat_liability_confirmed_for_tax_period", + intent_confidence: "high" + }, + semanticExtractionContract: { + valid: true, + apply_canonical_recommended: true, + extraction: { + query_shape: "UNKNOWN", + aggregation_profile: "unknown" + }, + guard_hints: { + deep_investigation_signal_detected: false + } + } + } as any, + useMock: false + }); + + expect(decision.runAddressLane).toBe(true); + expect(decision.toolGateDecision).toBe("run_address_lane"); + expect(decision.toolGateReason).toBe("address_mode_classifier_detected"); + expect(decision.livingMode).toBe("address_data"); + expect(decision.orchestrationContract?.deep_session_continuation_fallback_to_deep).toBe(false); + }); + it("routes memory recap follow-up over grounded answer to chat", () => { const policy = buildPolicy({ resolveRouteMemorySignals: () => ({ diff --git a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts index e3d68af..937e4e9 100644 --- a/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts +++ b/llm_normalizer/backend/tests/assistantTransitionPolicy.test.ts @@ -254,6 +254,133 @@ describe("assistantTransitionPolicy", () => { expect(carryover?.followupContext?.root_context_only).toBeUndefined(); }); + it("bridges selected-item purchase provenance into a VAT period follow-up", () => { + const item = "Рабочая станция универсального специалиста"; + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: [ + `По позиции ${item} однозначный поставщик не подтвержден.`, + "Подтверждение:", + "- Первая найденная дата закупки: 05.02.2015.", + "- Последняя найденная дата закупки: 22.07.2015." + ].join("\n"), + debug: { + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item, + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2016-03-31" + }, + anchor_type: "item", + anchor_value_resolved: item + } + }), + hasAddressFollowupContextSignal: () => false, + findRecentInventoryRootFrame: () => null, + resolveAddressIntent: () => ({ intent: "unknown" }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "ндс можешь прикинуть на дату покупки рабочей станции?", + [], + null, + null, + { + session_context: { + active_focus_object: { + object_type: "item", + label: item, + provenance_result_set_id: "rs-provenance" + }, + active_result_set_id: "rs-provenance" + }, + result_sets: [ + { + result_set_id: "rs-provenance", + intent: "inventory_purchase_provenance_for_item", + entity_refs: [ + { + index: 1, + entity_type: "item", + value: "Поступление товаров и услуг 00000000023 от 05.02.2015 0:00:00" + } + ] + } + ] + } + ); + + expect(carryover?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item"); + expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period"); + expect(carryover?.followupContext?.previous_filters).toMatchObject({ + item, + organization: 'ООО "Альтернатива Плюс"', + period_from: "2015-02-01", + period_to: "2015-02-28" + }); + }); + + it("keeps selected-object continuity for purchase-date VAT bridge even when an inventory root frame exists", () => { + const item = "Рабочая станция универсального специалиста"; + const policy = buildPolicy({ + findLastAddressAssistantItem: () => ({ + text: [ + `По позиции ${item} однозначный поставщик не подтвержден.`, + "Подтверждение:", + "- Первая найденная дата закупки: 05.02.2015." + ].join("\n"), + debug: { + detected_intent: "inventory_purchase_provenance_for_item", + extracted_filters: { + item, + organization: 'ООО "Альтернатива Плюс"', + as_of_date: "2016-03-31" + }, + anchor_type: "item", + anchor_value_resolved: item + } + }), + hasAddressFollowupContextSignal: () => false, + hasForeignAccountingPivotOverInventoryMessage: () => true, + resolveAddressIntent: () => ({ intent: "unknown" }) + }); + + const carryover = policy.resolveAddressFollowupCarryoverContext( + "ндс можешь прикинуть на дату покупки рабочей станции?", + [], + null, + null, + { + session_context: { + active_focus_object: { + object_type: "item", + label: item, + provenance_result_set_id: "rs-provenance" + }, + active_result_set_id: "rs-provenance" + }, + result_sets: [ + { + result_set_id: "rs-provenance", + intent: "inventory_purchase_provenance_for_item", + entity_refs: [ + { + index: 1, + entity_type: "item", + value: "Поступление товаров и услуг 00000000023 от 05.02.2015 0:00:00" + } + ] + } + ] + } + ); + + expect(carryover?.followupSelectionMode).toBe("carry_previous_intent"); + expect(carryover?.followupContext?.root_context_only).toBeUndefined(); + expect(carryover?.followupContext?.previous_intent).toBe("inventory_purchase_provenance_for_item"); + expect(carryover?.followupContext?.target_intent).toBe("vat_liability_confirmed_for_tax_period"); + }); + it("drops stale carryover for a fresh standalone topic from another intent family", () => { const policy = buildPolicy({ findLastAddressAssistantItem: () => ({ diff --git a/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-vc0r4pNwB-.json b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-vc0r4pNwB-.json new file mode 100644 index 0000000..6a0731e --- /dev/null +++ b/llm_normalizer/data/eval_cases/assistant_saved_session_runtime_job-vc0r4pNwB-.json @@ -0,0 +1,111 @@ +{ + "suite_id": "assistant_saved_session_runtime_job-vc0r4pNwB-", + "suite_version": "0.1.0", + "schema_version": "assistant_saved_session_runtime_v0_1", + "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", + "scenario_count": 1, + "case_ids": [ + "SAVED-001" + ], + "cases": [ + { + "case_id": "SAVED-001", + "scenario_tag": "saved_user_sessions_runtime", + "title": "БОЛЬШОЙ ОБЩИЙ Ручная сессия 16.04.2026, 21:26:06", + "question_type": "followup", + "broadness_level": "medium", + "turns": [ + { + "user_message": "приветик - че как там дела" + }, + { + "user_message": "расскажи что можешь интересного" + }, + { + "user_message": "кайф - что там на складе по остаткам?" + }, + { + "user_message": "а исторические остатки на другие даты умеешь?" + }, + { + "user_message": "давай на июль 2017" + }, + { + "user_message": "март 2016" + }, + { + "user_message": "По выбранному объекту \"Рабочая станция универсального специалиста (индивидуальное изготовление)\": где взяли это?" + }, + { + "user_message": "а кому продали?" + }, + { + "user_message": "у тебя написано кто контрагент: рабочая станция - это ошибка?" + }, + { + "user_message": "ндс можешь прикинуть на дату покупки рабочей станции?" + }, + { + "user_message": "а какой ндс мы должны сгрузить на март 2020?" + }, + { + "user_message": "прикинь какой ндс нам надо заплатить на февраль 2017" + }, + { + "user_message": "кто у нас самый доходный клиент за все время" + }, + { + "user_message": "кто нам должен денег на май 2017" + }, + { + "user_message": "а какой ндс мы должны примерно заплатить за этот период?" + }, + { + "user_message": "мы должны комуто денег на сегодня?" + }, + { + "user_message": "а нам?" + }, + { + "user_message": "какой у нас самый доходный год" + }, + { + "user_message": "а за 2017 мы скок заработали?" + }, + { + "user_message": "сколько вообще денег мы заработали за все время?" + }, + { + "user_message": "ты умеешь считать дельту по договорам?" + }, + { + "user_message": "по чепурнову покажи все доки" + }, + { + "user_message": "а по свк" + }, + { + "user_message": "а сейчас у нас есть что на складе?" + }, + { + "user_message": "что нам отгружать чепурнов? какой товар или услугу?" + }, + { + "user_message": "какие остатки на складе на сегодня" + }, + { + "user_message": "остатки на март 2016" + }, + { + "user_message": "хвосты покажи по счету 60 на август 2022" + }, + { + "user_message": "Есть ли остатки товара, которые закупались очень давно" + }, + { + "user_message": "Какие конкретно номенклатуры формируют остаток по складу на май 2020" + } + ] + } + ] +} \ No newline at end of file