АРЧ АП11 - Архитектура после регресса: + Архитектура: восстановить bridge от provenance выбранной позиции к VAT-периоду и закрыть phase10 replay

This commit is contained in:
dctouch 2026-04-18 11:50:48 +03:00
parent 9872ef5446
commit 31cb4ccbbb
27 changed files with 1655 additions and 49 deletions

View File

@ -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; - 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. - 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 ## Ready Signal
The project can leave the current breakpoint when: The project can leave the current breakpoint when:

View File

@ -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.

View File

@ -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"
]
}
]
}

View File

@ -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; const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null;
if (periodToForQuarter) { if (periodToForQuarter) {
const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter);

View File

@ -1,5 +1,7 @@
"use strict"; "use strict";
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.hasCompactAccountCodeToken = hasCompactAccountCodeToken;
exports.hasAccountNumberAnchor = hasAccountNumberAnchor;
exports.resolveAddressIntent = resolveAddressIntent; exports.resolveAddressIntent = resolveAddressIntent;
const addressCounterpartyIntentSignals_1 = require("./addressCounterpartyIntentSignals"); const addressCounterpartyIntentSignals_1 = require("./addressCounterpartyIntentSignals");
const addressInventoryIntentSignals_1 = require("./addressInventoryIntentSignals"); const addressInventoryIntentSignals_1 = require("./addressInventoryIntentSignals");
@ -1449,11 +1451,21 @@ function hasCustomerRevenueRankingBridgeSignal(text) {
if (!normalized) { if (!normalized) {
return false; 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); 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) { if (!hasMoneyCue) {
return false; 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) { function hasInventoryProvenanceBridgeSignal(text) {
const normalized = String(text ?? "").trim().toLowerCase(); const normalized = String(text ?? "").trim().toLowerCase();
@ -1481,6 +1493,10 @@ function hasColloquialInventoryOnHandBridgeSignal(text) {
if (!normalized) { if (!normalized) {
return false; 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 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); 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) { 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); 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; 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) { function resolveAddressIntent(userMessage) {
const text = String(userMessage ?? "").trim().toLowerCase(); 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 { return {
intent: "customer_revenue_and_payments", intent: "customer_revenue_and_payments",
confidence: "medium", confidence: "medium",
reasons: ["customer_revenue_ranking_bridge_signal_detected"] 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)) { if (hasInventoryDocumentaryChainBridgeSignal(text)) {
return { return {
intent: "inventory_purchase_to_sale_chain", intent: "inventory_purchase_to_sale_chain",

View File

@ -487,14 +487,19 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
} }
if (focus === "top_years_by_total") { if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit); const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (visible.length === 0) { if (visible.length === 0) {
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
} }
else { 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)}`)); 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); return (0, replyContracts_1.buildFactualListReply)(lines);
@ -556,7 +561,14 @@ function composeCounterpartyAnalyticsReply(intent, rows, options = {}, deps) {
const heading = isSupplier const heading = isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:` ? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`; : `Топ-${visible.length} заказчиков по сумме поступлений:`;
const leadingCounterparty = visible[0] ?? null;
lines.unshift(heading); 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) => { lines.push(...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? item.total / item.ops : 0; 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)}`; return `${index + 1}. ${item.name} | сумма: ${deps.formatMoneyRub(item.total)} | операций: ${item.ops} | средний чек: ${deps.formatMoneyRub(avgCheck)} | максимальная разовая сумма: ${deps.formatMoneyRub(item.maxSingle)}`;

View File

@ -7,6 +7,7 @@ exports.hasInventoryPurchaseDateFollowupCue = hasInventoryPurchaseDateFollowupCu
exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFollowupCue; exports.hasBareInventoryPurchaseDateFollowupCue = hasBareInventoryPurchaseDateFollowupCue;
exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue; exports.hasInventorySaleFollowupCue = hasInventorySaleFollowupCue;
exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue; exports.hasInventoryPurchaseToSaleChainFollowupCue = hasInventoryPurchaseToSaleChainFollowupCue;
exports.hasInventoryPurchaseDateVatBridgeCue = hasInventoryPurchaseDateVatBridgeCue;
exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal; exports.hasAddressFollowupContextSignal = hasAddressFollowupContextSignal;
exports.runAddressDecomposeStage = runAddressDecomposeStage; exports.runAddressDecomposeStage = runAddressDecomposeStage;
const addressQueryClassifier_1 = require("../addressQueryClassifier"); const addressQueryClassifier_1 = require("../addressQueryClassifier");
@ -537,6 +538,14 @@ function hasInventorySaleFollowupCue(text) {
function hasInventoryPurchaseToSaleChainFollowupCue(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 ?? "")); 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) { function hasAddressFollowupContextSignal(text) {
const normalized = String(text ?? "").trim(); const normalized = String(text ?? "").trim();
if (!normalized) { if (!normalized) {
@ -563,6 +572,9 @@ function hasAddressFollowupContextSignal(text) {
/(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)) { /(?:ндс|vat|прогноз|к\s+уплате|нул|ноль|\b0(?:[.,]0+)?\b)/iu.test(normalized)) {
return true; 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); const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
if (tokenCount <= 8 && hasPeriodLiteral) { if (tokenCount <= 8 && hasPeriodLiteral) {
return true; return true;
@ -1056,6 +1068,17 @@ function deriveIntentWithFollowupContext(detectedIntent, userMessage, followupCo
const isVatFollowup = hasVatCue(normalizedMessage); const isVatFollowup = hasVatCue(normalizedMessage);
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const inventorySelectedObjectFollowup = hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); 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) { if (detectedIntent.intent === "unknown" && isVatFollowup) {
const vatIntent = hasVatTaxPaymentCue(normalizedMessage) const vatIntent = hasVatTaxPaymentCue(normalizedMessage)
? "vat_liability_confirmed_for_tax_period" ? "vat_liability_confirmed_for_tax_period"

View File

@ -209,8 +209,8 @@ function composeInventoryReply(intent, rows, options, deps) {
.map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`) .map((item) => `${item.item} (${deps.inventoryTraceDateLabel(item.firstPurchasePeriod)})`)
.join("; "); .join("; ");
const directAnswerLine = agingItems.length > 0 const directAnswerLine = agingItems.length > 0
? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` ? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; : "По доступному закупочному следу позиции со старыми закупками не материализованы.";
const lines = [directAnswerLine]; const lines = [directAnswerLine];
(0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [ (0, inventoryReplyPresentation_1.appendInventoryBulletSection)(lines, "Сводка:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,

View File

@ -683,6 +683,7 @@ function createAssistantRoutePolicy(deps) {
const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && const deepSessionContinuationFallbackToDeep = Boolean(!followupContext &&
baseToolGate?.runAddressLane && baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected && !llmRuntimeUnavailableDetected &&
!protectAddressLaneFromFallback &&
hasDeepSessionContinuationSignal({ hasDeepSessionContinuationSignal({
rawUserMessage, rawUserMessage,
repairedRawUserMessage, repairedRawUserMessage,

View File

@ -2642,6 +2642,9 @@ function hasAddressFollowupContextSignal(userMessage) {
!hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) { !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) {
return true; return true;
} }
if (shortFollowup && samples.some((sample) => (0, decomposeStage_1.hasInventoryPurchaseDateVatBridgeCue)(sample))) {
return true;
}
if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
return true; return true;
} }
@ -3458,6 +3461,31 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage); }, 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 sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; 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)({ const semanticContractForCandidate = (0, predecomposeContract_1.buildAddressSemanticExtractionContractV1)({
sourceMessage: String(userMessage ?? ""), sourceMessage: String(userMessage ?? ""),
canonicalMessage: candidate, canonicalMessage: candidate,
predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ predecomposeContract: candidatePredecomposeContract
sourceMessage: String(userMessage ?? ""),
canonicalMessage: candidate,
semanticHints: candidateMeta?.semanticHints ?? null
})
}); });
if (!semanticContractForCandidate.apply_canonical_recommended) { if (!semanticContractForCandidate.apply_canonical_recommended) {
const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected); const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected);

View File

@ -3,6 +3,101 @@
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy; exports.createAssistantTransitionPolicy = createAssistantTransitionPolicy;
function createAssistantTransitionPolicy(deps) { 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) { function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) { if (!hasInventoryRootFrame) {
return false; return false;
@ -159,6 +254,7 @@ function createAssistantTransitionPolicy(deps) {
(sourceIntentHint === "inventory_on_hand_as_of_date" || (sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint))); deps.isInventorySelectedObjectIntent(sourceIntentHint)));
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(userMessage, alternateMessage, sourceIntentHint, hasNavigationInventoryItemFocusHint);
let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && let inventoryShortFollowupPrimary = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage); deps.hasShortInventoryObjectFollowupSignal(userMessage);
let inventoryShortFollowupAlternate = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && let inventoryShortFollowupAlternate = (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
@ -172,11 +268,15 @@ function createAssistantTransitionPolicy(deps) {
? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint) ? deps.resolveDebtRoleSwapFollowupIntent(String(alternateMessage ?? ""), sourceIntentHint)
: null; : null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? 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) let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) || Boolean(debtRoleSwapAlternate) ||
inventoryShortFollowupAlternate inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge
: false; : false;
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
@ -206,6 +306,7 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootTemporalFollowupAlternate || hasInventoryRootTemporalFollowupAlternate ||
hasInventoryRootRestatementPrimary || hasInventoryRootRestatementPrimary ||
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
inventoryPurchaseDateVatBridge ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
deps.hasFollowupMarker(userMessage) || deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) || deps.hasReferentialPointer(userMessage) ||
@ -340,11 +441,13 @@ function createAssistantTransitionPolicy(deps) {
deps.hasAddressFollowupContextSignal(userMessage) || deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) || Boolean(debtRoleSwapPrimary) ||
inventoryShortFollowupPrimary || inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
hasInventoryRootTemporalFollowupPrimary; hasInventoryRootTemporalFollowupPrimary;
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) || Boolean(debtRoleSwapAlternate) ||
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
hasInventoryRootTemporalFollowupAlternate hasInventoryRootTemporalFollowupAlternate
: false; : false;
hasStrongFollowupReference = hasStrongFollowupReference =
@ -356,6 +459,7 @@ function createAssistantTransitionPolicy(deps) {
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate || hasInventoryRootTemporalFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
deps.hasFollowupMarker(userMessage) || deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) || deps.hasReferentialPointer(userMessage) ||
@ -435,6 +539,13 @@ function createAssistantTransitionPolicy(deps) {
if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) {
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" || const shouldBackfillPreviousDateScopeFromNavigation = sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
sourceIntentHint === "inventory_purchase_provenance_for_item" || sourceIntentHint === "inventory_purchase_provenance_for_item" ||
@ -461,7 +572,8 @@ function createAssistantTransitionPolicy(deps) {
previousFilters.period_to = deps.toNonEmptyString(navigationDateScope?.period_to); previousFilters.period_to = deps.toNonEmptyString(navigationDateScope?.period_to);
} }
const rootContextOnlyPivot = Boolean((deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && const rootContextOnlyPivot = Boolean((deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage)); deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) &&
!inventoryPurchaseDateVatBridge);
const inventoryRootTemporalPivot = Boolean(inventoryRootFrame && const inventoryRootTemporalPivot = Boolean(inventoryRootFrame &&
(deps.isInventorySelectedObjectIntent(sourceIntentHint) || (deps.isInventorySelectedObjectIntent(sourceIntentHint) ||
deps.isInventoryRootFrameIntent(sourceIntentHint) || deps.isInventoryRootFrameIntent(sourceIntentHint) ||
@ -567,11 +679,13 @@ function createAssistantTransitionPolicy(deps) {
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
hasSelectedObjectInventorySignalPrimary || hasSelectedObjectInventorySignalPrimary ||
hasSelectedObjectInventorySignalAlternate)); hasSelectedObjectInventorySignalAlternate));
const carryoverTargetIntent = followupSelectionMode === "carry_root_context" const carryoverTargetIntent = inventoryPurchaseDateVatBridge
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined ? "vat_liability_confirmed_for_tax_period"
: explicitInventorySameDatePivot : followupSelectionMode === "carry_root_context"
? "inventory_on_hand_as_of_date" ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined; : explicitInventorySameDatePivot
? "inventory_on_hand_as_of_date"
: displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined;
return { return {
followupContext: { followupContext: {
previous_intent: previousIntent ?? undefined, previous_intent: previousIntent ?? undefined,

View File

@ -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; const periodToForQuarter = filters.period_to ?? vatAsOfDate ?? null;
if (periodToForQuarter) { if (periodToForQuarter) {
const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter); const quarterWindow = deriveQuarterWindowForDate(periodToForQuarter);

View File

@ -534,7 +534,7 @@ function hasFuzzyLexeme(text: string, lexemeRoots: string[]): boolean {
return false; 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 РіРѕРґ". // Match compact account tokens while reducing false positives on short-year literals like "22 РіРѕРґ".
const source = String(text ?? ""); const source = String(text ?? "");
if (!source) { 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); return /(?:account|СЃС‡[её]С|счет)\D{0,12}\d{2}(?:[.,]\d{1,2})?/i.test(text);
} }
@ -1790,6 +1790,20 @@ function hasCustomerRevenueRankingBridgeSignal(text: string): boolean {
if (!normalized) { if (!normalized) {
return false; 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 = 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( /(?:\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 normalized
@ -1797,9 +1811,15 @@ function hasCustomerRevenueRankingBridgeSignal(text: string): boolean {
if (!hasMoneyCue) { if (!hasMoneyCue) {
return false; 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( const hasCustomerRankingCue =
normalized /(?:\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 { function hasInventoryProvenanceBridgeSignal(text: string): boolean {
@ -1845,6 +1865,13 @@ function hasColloquialInventoryOnHandBridgeSignal(text: string): boolean {
if (!normalized) { if (!normalized) {
return false; 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 tokenCount = normalized.split(/\s+/u).filter(Boolean).length;
const hasWarehouseCue = const hasWarehouseCue =
/(?:\u0441\u043a\u043b\u0430\u0434(?:\u0430\u0445|\u0435|\u0443|\u043e\u043c|\u044b)?|\u043e\u0441\u0442\u0430\u0442|warehouse|stock|inventory)/iu.test( /(?:\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; 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 { export function resolveAddressIntent(userMessage: string): AddressIntentResolution {
const text = String(userMessage ?? "").trim().toLowerCase(); 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 { return {
intent: "customer_revenue_and_payments", intent: "customer_revenue_and_payments",
confidence: "medium", 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)) { if (hasInventoryDocumentaryChainBridgeSignal(text)) {
return { return {
intent: "inventory_purchase_to_sale_chain", intent: "inventory_purchase_to_sale_chain",

View File

@ -639,13 +639,18 @@ export function composeCounterpartyAnalyticsReply(
if (focus === "top_years_by_total") { if (focus === "top_years_by_total") {
const visible = rankedByYearTotal.slice(0, limit); const visible = rankedByYearTotal.slice(0, limit);
const heading = isSupplier
? `Топ-${visible.length} лет по сумме выплат:`
: `Топ-${visible.length} лет по сумме поступлений:`;
lines.unshift(heading);
if (visible.length === 0) { if (visible.length === 0) {
lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам."); lines.push("По доступному окну не удалось собрать годовые агрегаты по суммам.");
} else { } 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( lines.push(
...visible.map( ...visible.map(
(item, index) => (item, index) =>
@ -742,7 +747,14 @@ export function composeCounterpartyAnalyticsReply(
const heading = isSupplier const heading = isSupplier
? `Топ-${visible.length} поставщиков по сумме выплат:` ? `Топ-${visible.length} поставщиков по сумме выплат:`
: `Топ-${visible.length} заказчиков по сумме поступлений:`; : `Топ-${visible.length} заказчиков по сумме поступлений:`;
const leadingCounterparty = visible[0] ?? null;
lines.unshift(heading); 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( lines.push(
...visible.map((item, index) => { ...visible.map((item, index) => {
const avgCheck = item.ops > 0 ? item.total / item.ops : 0; const avgCheck = item.ops > 0 ? item.total / item.ops : 0;

View File

@ -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 { export function hasAddressFollowupContextSignal(text: string): boolean {
const normalized = String(text ?? "").trim(); const normalized = String(text ?? "").trim();
if (!normalized) { if (!normalized) {
@ -728,6 +741,9 @@ export function hasAddressFollowupContextSignal(text: string): boolean {
) { ) {
return true; 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); const hasPeriodLiteral = /\b(?:19|20)\d{2}(?:[./-](?:0?[1-9]|1[0-2]))?\b/.test(normalized);
if (tokenCount <= 8 && hasPeriodLiteral) { if (tokenCount <= 8 && hasPeriodLiteral) {
return true; return true;
@ -1314,6 +1330,21 @@ function deriveIntentWithFollowupContext(
const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined); const previousIsInventoryFamily = isInventoryIntent(sourceIntent ?? undefined);
const inventorySelectedObjectFollowup = const inventorySelectedObjectFollowup =
hasSelectedObjectInventorySignal(normalizedMessage) || (previousIsInventoryFamily && hasFollowupSignal); 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) { if (detectedIntent.intent === "unknown" && isVatFollowup) {
const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage) const vatIntent: AddressIntent = hasVatTaxPaymentCue(normalizedMessage)

View File

@ -316,8 +316,8 @@ export function composeInventoryReply(
.join("; "); .join("; ");
const directAnswerLine = const directAnswerLine =
agingItems.length > 0 agingItems.length > 0
? `К старым закупкам на ${deps.formatDateRu(asOfDate)} в первую очередь относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.` ? `К самым старым закупкам в текущем подтвержденном срезе относятся позиции с самой ранней первой закупкой: ${oldestAnswerPreview}.`
: `По доступному закупочному следу на ${deps.formatDateRu(asOfDate)} позиции старых закупок не материализованы.`; : "По доступному закупочному следу позиции со старыми закупками не материализованы.";
const lines: string[] = [directAnswerLine]; const lines: string[] = [directAnswerLine];
appendInventoryBulletSection(lines, "Сводка:", [ appendInventoryBulletSection(lines, "Сводка:", [
`Дата среза: ${deps.formatDateRu(asOfDate)}.`, `Дата среза: ${deps.formatDateRu(asOfDate)}.`,

View File

@ -719,6 +719,7 @@ export function createAssistantRoutePolicy(deps) {
const deepSessionContinuationFallbackToDeep = Boolean(!followupContext && const deepSessionContinuationFallbackToDeep = Boolean(!followupContext &&
baseToolGate?.runAddressLane && baseToolGate?.runAddressLane &&
!llmRuntimeUnavailableDetected && !llmRuntimeUnavailableDetected &&
!protectAddressLaneFromFallback &&
hasDeepSessionContinuationSignal({ hasDeepSessionContinuationSignal({
rawUserMessage, rawUserMessage,
repairedRawUserMessage, repairedRawUserMessage,

View File

@ -2597,6 +2597,9 @@ function hasAddressFollowupContextSignal(userMessage) {
!hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) { !hasAny(/(?:по\s+этому|по\s+тому|по\s+нему|по\s+ней|по\s+ним)/iu)) {
return true; return true;
} }
if (shortFollowup && samples.some((sample) => (0, decomposeStage_1.hasInventoryPurchaseDateVatBridgeCue)(sample))) {
return true;
}
if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) { if (shortFollowup && samples.some((sample) => hasPeriodLiteral(sample))) {
return true; return true;
} }
@ -3413,6 +3416,31 @@ async function runAddressLlmPreDecompose(normalizerService, payload, userMessage
semanticHints: candidateMeta?.semanticHints ?? null semanticHints: candidateMeta?.semanticHints ?? null
}, userMessage); }, 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 sourceAnchorQuality = evaluateAddressAnchorQuality(repairedSourceMessage || userMessage);
const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate); const candidateAnchorQuality = evaluateAddressAnchorQuality(candidate);
const sameIntentForAnchorSafety = sourceAnchorQuality.intent !== "unknown" && sourceAnchorQuality.intent === candidateAnchorQuality.intent; 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)({ const semanticContractForCandidate = (0, predecomposeContract_1.buildAddressSemanticExtractionContractV1)({
sourceMessage: String(userMessage ?? ""), sourceMessage: String(userMessage ?? ""),
canonicalMessage: candidate, canonicalMessage: candidate,
predecomposeContract: (0, predecomposeContract_1.buildAddressLlmPredecomposeContractV1)({ predecomposeContract: candidatePredecomposeContract
sourceMessage: String(userMessage ?? ""),
canonicalMessage: candidate,
semanticHints: candidateMeta?.semanticHints ?? null
})
}); });
if (!semanticContractForCandidate.apply_canonical_recommended) { if (!semanticContractForCandidate.apply_canonical_recommended) {
const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected); const sourceDataSignalDetected = Boolean(semanticContractForCandidate?.guard_hints?.source_data_signal_detected);

View File

@ -1,6 +1,118 @@
// @ts-nocheck // @ts-nocheck
export function createAssistantTransitionPolicy(deps) { 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) { function hasInventoryRootRestatementLikeSignal(userMessage, sourceIntentHint, hasInventoryRootFrame) {
if (!hasInventoryRootFrame) { if (!hasInventoryRootFrame) {
return false; return false;
@ -197,6 +309,12 @@ export function createAssistantTransitionPolicy(deps) {
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
deps.isInventorySelectedObjectIntent(sourceIntentHint)) deps.isInventorySelectedObjectIntent(sourceIntentHint))
); );
const inventoryPurchaseDateVatBridge = hasInventoryPurchaseDateVatBridgeSignal(
userMessage,
alternateMessage,
sourceIntentHint,
hasNavigationInventoryItemFocusHint
);
let inventoryShortFollowupPrimary = let inventoryShortFollowupPrimary =
(deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) && (deps.isInventorySelectedObjectIntent(sourceIntentHint) || hasNavigationInventoryItemFocusHint) &&
deps.hasShortInventoryObjectFollowupSignal(userMessage); deps.hasShortInventoryObjectFollowupSignal(userMessage);
@ -214,11 +332,15 @@ export function createAssistantTransitionPolicy(deps) {
: null; : null;
const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null; const debtRoleSwapIntent = debtRoleSwapPrimary ?? debtRoleSwapAlternate ?? null;
let hasPrimaryFollowupSignal = let hasPrimaryFollowupSignal =
deps.hasAddressFollowupContextSignal(userMessage) || Boolean(debtRoleSwapPrimary) || inventoryShortFollowupPrimary; deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) ||
inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge;
let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) let hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) || Boolean(debtRoleSwapAlternate) ||
inventoryShortFollowupAlternate inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge
: false; : false;
const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null; const hasPrimaryIndexReferenceSignal = deps.extractDisplayedEntityIndexMention(userMessage) !== null;
const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage) const hasAlternateIndexReferenceSignal = deps.toNonEmptyString(alternateMessage)
@ -265,6 +387,7 @@ export function createAssistantTransitionPolicy(deps) {
hasInventoryRootTemporalFollowupAlternate || hasInventoryRootTemporalFollowupAlternate ||
hasInventoryRootRestatementPrimary || hasInventoryRootRestatementPrimary ||
hasInventoryRootRestatementAlternate || hasInventoryRootRestatementAlternate ||
inventoryPurchaseDateVatBridge ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
deps.hasFollowupMarker(userMessage) || deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) || deps.hasReferentialPointer(userMessage) ||
@ -418,11 +541,13 @@ export function createAssistantTransitionPolicy(deps) {
deps.hasAddressFollowupContextSignal(userMessage) || deps.hasAddressFollowupContextSignal(userMessage) ||
Boolean(debtRoleSwapPrimary) || Boolean(debtRoleSwapPrimary) ||
inventoryShortFollowupPrimary || inventoryShortFollowupPrimary ||
inventoryPurchaseDateVatBridge ||
hasInventoryRootTemporalFollowupPrimary; hasInventoryRootTemporalFollowupPrimary;
hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage) hasAlternateFollowupSignal = deps.toNonEmptyString(alternateMessage)
? deps.hasAddressFollowupContextSignal(alternateMessage) || ? deps.hasAddressFollowupContextSignal(alternateMessage) ||
Boolean(debtRoleSwapAlternate) || Boolean(debtRoleSwapAlternate) ||
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
hasInventoryRootTemporalFollowupAlternate hasInventoryRootTemporalFollowupAlternate
: false; : false;
hasStrongFollowupReference = hasStrongFollowupReference =
@ -434,6 +559,7 @@ export function createAssistantTransitionPolicy(deps) {
inventoryShortFollowupAlternate || inventoryShortFollowupAlternate ||
hasInventoryRootTemporalFollowupPrimary || hasInventoryRootTemporalFollowupPrimary ||
hasInventoryRootTemporalFollowupAlternate || hasInventoryRootTemporalFollowupAlternate ||
inventoryPurchaseDateVatBridge ||
Boolean(debtRoleSwapIntent) || Boolean(debtRoleSwapIntent) ||
deps.hasFollowupMarker(userMessage) || deps.hasFollowupMarker(userMessage) ||
deps.hasReferentialPointer(userMessage) || deps.hasReferentialPointer(userMessage) ||
@ -522,6 +648,13 @@ export function createAssistantTransitionPolicy(deps) {
if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) { if (!deps.toNonEmptyString(previousFilters.organization) && organizationClarificationSelection) {
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 = const shouldBackfillPreviousDateScopeFromNavigation =
sourceIntentHint === "inventory_on_hand_as_of_date" || sourceIntentHint === "inventory_on_hand_as_of_date" ||
sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" || sourceIntentHint === "inventory_supplier_stock_overlap_as_of_date" ||
@ -556,7 +689,8 @@ export function createAssistantTransitionPolicy(deps) {
} }
const rootContextOnlyPivot = Boolean( const rootContextOnlyPivot = Boolean(
(deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") && (deps.isInventorySelectedObjectIntent(sourceIntentHint) || currentFrameKind === "inventory_drilldown") &&
deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) deps.hasForeignAccountingPivotOverInventoryMessage(userMessage, alternateMessage) &&
!inventoryPurchaseDateVatBridge
); );
const inventoryRootTemporalPivot = Boolean( const inventoryRootTemporalPivot = Boolean(
inventoryRootFrame && inventoryRootFrame &&
@ -681,7 +815,9 @@ export function createAssistantTransitionPolicy(deps) {
hasSelectedObjectInventorySignalAlternate) hasSelectedObjectInventorySignalAlternate)
); );
const carryoverTargetIntent = const carryoverTargetIntent =
followupSelectionMode === "carry_root_context" inventoryPurchaseDateVatBridge
? "vat_liability_confirmed_for_tax_period"
: followupSelectionMode === "carry_root_context"
? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined ? inventoryRootFrame?.intent ?? displayedEntityTargetIntent ?? explicitIntent ?? previousIntent ?? undefined
: explicitInventorySameDatePivot : explicitInventorySameDatePivot
? "inventory_on_hand_as_of_date" ? "inventory_on_hand_as_of_date"

View File

@ -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");
});
});

View File

@ -55,7 +55,7 @@ describe("address follow-up temporal regressions", () => {
}); });
expect(result).not.toBeNull(); 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_from).toBe("2017-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31"); expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31");
expect(result?.filters.extracted_filters.as_of_date).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).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_from).toBe("2017-05-01");
expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31"); expect(result?.filters.extracted_filters.period_to).toBe("2017-05-31");
expect(result?.filters.extracted_filters.as_of_date).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?.filters.extracted_filters.warehouse).toBeUndefined();
expect(result?.baseReasons).toContain("as_of_date_from_followup_context"); 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");
});
}); });

View File

@ -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");
});
});

View File

@ -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"); 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( const extracted = extractAddressFilters(
"сколько ндс надо заплатить в налоговую за декабрь 2019", "сколько ндс надо заплатить в налоговую за декабрь 2019",
"vat_liability_confirmed_for_tax_period" "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.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", () => { it("derives VAT forecast quarter-to-date window for explicit day+month+year phrase", () => {

View File

@ -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");
});
});

View File

@ -157,6 +157,53 @@ describe("assistantRoutePolicy", () => {
expect(decision.orchestrationContract?.semantic_route_arbitration?.supported_address_intent_detected).toBe(true); 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", () => { it("routes memory recap follow-up over grounded answer to chat", () => {
const policy = buildPolicy({ const policy = buildPolicy({
resolveRouteMemorySignals: () => ({ resolveRouteMemorySignals: () => ({

View File

@ -254,6 +254,133 @@ describe("assistantTransitionPolicy", () => {
expect(carryover?.followupContext?.root_context_only).toBeUndefined(); 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", () => { it("drops stale carryover for a fresh standalone topic from another intent family", () => {
const policy = buildPolicy({ const policy = buildPolicy({
findLastAddressAssistantItem: () => ({ findLastAddressAssistantItem: () => ({

View File

@ -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"
}
]
}
]
}